diff --git a/AGENTS.md b/AGENTS.md index 066cad3597..6126d4887f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ If you find an asset that breaks these rules, stop and remove it from the reposi ## 3. Agent control (automation and MCP) - **Everything is reachable headlessly.** Every user-facing feature must be callable without the GUI: an engine API, then a tool in `crates/automation` (`pdfcraft-cli run` and MCP use the same table). A feature is not done until its tool exists and has an end-to-end test (`crates/automation/README.md`, "Adding a tool"). -- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file. Never enable it by default, widen the bind address, or log the token. +- **The UI control channel is opt-in too.** It runs only when the app is launched with `--control `. It binds to loopback, uses a fresh random token per launch, and writes it to an owner-only file (mode 0600 on Unix; on Windows an access list of the user and SYSTEM only, which `pdfcraft_platform::owner_only` sets and checks, and which the CLI checks again before reading the token). Never enable it by default, widen the bind address, or log the token. - **The MCP server is opt-in.** Never make the app, the CLI or an installer start it, register it with an agent, or listen on a port by default. It runs only when a user (or an agent the user configured) launches `pdfcraft-cli mcp`. Any future network transport must be off by default, bound to loopback and authenticated with a token. - **Safe by default.** Tools must never discard unsaved work silently, must write atomically, must honour `--root`, and must return errors an agent can act on (say what was wrong and what is allowed). - **Track parity honestly.** Every user-facing feature has an entry in `parity/acrobat-features.toml`. Mark it `shipped` only when it works end to end and the entry cites a passing test (`evidence`); `cargo xtask parity` (part of `xtask ci`) rejects claims whose commands, tools or tests don't exist. diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 67b588f25e..be935422ce 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3244,4 +3244,3 @@ licence_file = "assets/icons/LICENSE-lucide.txt" kind = "icon" usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs" sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7" - diff --git a/Cargo.lock b/Cargo.lock index a08f650e80..52333f20ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -4268,6 +4268,7 @@ dependencies = [ "fmv-macos-events", "log", "pdfcraft-engine", + "pdfcraft-platform", "pdfcraft-ui-egui", "pollster 1.0.1", "raw-window-handle", @@ -4696,6 +4697,7 @@ dependencies = [ "kurbo 0.13.1", "log", "lopdf", + "pdfcraft-content", "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-geom", @@ -4767,6 +4769,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "fontconfig-parser", "getrandom 0.4.3", "image", @@ -8766,7 +8769,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8781,18 +8783,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index d919a90383..b0f3411624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } diff --git a/README.md b/README.md index 7ca789c6e0..4b534f6e17 100644 --- a/README.md +++ b/README.md @@ -331,7 +331,7 @@ Edits stay in memory, undoable, until `doc_save`. Saving to the same file append Start the desktop app with `pdfcraft --control ~/.pdfcraft-control.json` and an agent can see and operate the real interface: the widget tree with labels and positions (from the accessibility tree), clicks, typing, keys, commands, view options and screenshots. This is also off by default. It listens only on loopback, and every connection must present the random token written to that file, which only you can read. -Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, and on macOS, Linux and FreeBSD one that another user owns or can read or write. The app doesn't start if it can't write the file. +Keep the control file in a folder only you can write, not a shared one such as `/tmp`: another user could create the file there first and receive your commands. `pdfcraft-cli ui` refuses a control file that is a symbolic link, or one that another user owns, or that another user or a group such as Administrators can read or write: on macOS, Linux and FreeBSD by its owner and mode (the app writes it with mode 600), on Windows by its owner and access list (the app gives it one with only you and SYSTEM on it). The app doesn't start if it can't write the file. On Windows, cmd.exe doesn't expand `~`: use `%USERPROFILE%\.pdfcraft-control.json` there. ```sh pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect query=rotate # find widgets @@ -472,11 +472,14 @@ Installers and executables are code-signed. The portable zip runs from any folder, a USB stick included. Its `portable.txt` keeps the settings, logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so nothing is written to `%APPDATA%`; delete that file to use the normal per-user folders. +Windows doesn't offer the portable copy as a default PDF app on its own, since it registers nothing; +`portable.txt` has the PowerShell lines that register it for your user only, and how to undo them. The MSI installs for your account only by default, without administrator privileges. Choose "Everyone on this computer" on its first page, or pass `ALLUSERS=1` from an elevated terminal, to install for all users. It adds "Edit with PdfCraft" -to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of +and "Combine files in PdfCraft" (select several PDFs: they land in the Combine files tab of the +running PdfCraft) to the right-click menu of PDF files and "Create PDF with PdfCraft…" to the right-click menu of images (.png .jpg .jpeg .tif .tiff .gif .bmp .jp2 .j2k .jpx) in File Explorer (on Windows 11, under "Show more options"), without changing which app opens them by default. For unattended deployment without a desktop shortcut (add `ALLUSERS=1`, from an elevated terminal, for an all-users install): @@ -488,7 +491,7 @@ msiexec /i "pdfcraft--windows-x64.msi" /qn /norestart INSTALLDESKTOPSHORTCU | Property | `=0` leaves out | |---|---| | `INSTALLDESKTOPSHORTCUT` | the desktop shortcut | -| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" on PDF files | +| `INSTALLPDFCONTEXTMENU` | "Edit with PdfCraft" and "Combine files in PdfCraft" on PDF files | | `INSTALLIMAGECONTEXTMENU` | "Create PDF with PdfCraft…" on images | All three are on for a first install. An upgrade keeps what the installed version has: a shortcut diff --git a/ROADMAP.md b/ROADMAP.md index c2c7c6ad62..1fb7459f49 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -313,6 +313,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **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 (M4, #3923):** A recovered document's original path is only a suggestion: the first Save after recovering opens Save As at that path instead of writing it, since anyone who can write the recovery folder (a shared portable `PdfCraftData`, for example) can name any file in a sidecar. The file chosen is the document's file from then on. Sidecars are read through a 64 KiB cap; a longer one is not read, and its snapshot recovers without metadata. Estimates unchanged. - **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. @@ -442,6 +443,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-16 (M11, #1201):** Optimize decodes a JPEG with its colour transform: an Adobe APP14 marker's flag, else the filter's `/ColorTransform` (ISO 32000-2 §7.4.8). An RGB JPEG stored untransformed (`/ColorTransform 0`, no marker) used to be converted from YCbCr anyway and came out in other colours, recompressed or merged from strips; it now keeps them. A JPEG whose marker and `/ColorTransform` disagree, or whose components are named R, G and B without `/ColorTransform 0`, is left as it is, since readers differ on it. Estimates unchanged. - **2026-10-15 (M2, gap 12):** `cargo xtask text-oracle` scores reading order as well as word F1: the longest common subsequence of the two word sequences over the longer one. F1 is a bag of words and cannot see order at all -- measuring #2431, a change that moves text and nothing else, it called 26 of the 29 files whose output changed identical, where the order score separated 18 improvements from 6 regressions. Reports, does not gate; a disagreement with Poppler, not a verdict. Estimates unchanged. - **2026-10-15 (M2, #2305 follow-up):** WinAnsiEncoding 0xA0 and 0xAD and MacRomanEncoding 0xCA extract as the ordinary space and hyphen Annex D.2 names them, in the vendored hayro tables (patch 30) as well as `crates/fonts`, so search, copy and redaction see characters that were hidden behind U+00A0 and U+00AD. Across the pdf.js corpus the soft hyphens we extract fall 117 to 13 and the non-breaking spaces 30 to 26; the rest come from `/ToUnicode`. An explicit `/Differences` name keeps its glyph list value, as pdftotext does. Estimates unchanged. +- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged. - **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged. - **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged. - **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged. @@ -452,6 +454,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-14 (M12, reading colours, #2059):** Pending page backgrounds use the document's reading colour immediately, preventing white flashes while scrolling to uncached pages. A headless canvas regression covers three pending pages in every preset and a custom background. Estimates unchanged. - **2026-10-15 (community, M6, #2608):** In Prepare a form the selected field moves with the arrow keys: a point per press, ten with Shift, as the object nudge and dragging it do. Each press is one undo step ("Change field properties"), and the keys no longer turn the page while a field is selected. Estimates unchanged. - **2026-10-15 (M3, closable community card):** The ArtCraft community card on Home has a close button that hides it for the rest of the run; it returns on the next launch, and its links stay in the top bar's Discord button and the Help menu. A UI regression covers closing and the fresh-launch default. Estimates unchanged. +- **2026-10-15 (M14, Combine files from File Explorer, #585):** The Windows MSI adds "Combine files in PdfCraft" to PDF files' right-click menu (with the Edit verb, same switch); each selected file starts `pdfcraft --combine`, and the single-instance hand-off (`"combine": true`, `OsEvent::Combine`) collects them into the running PdfCraft's Combine files tab. Tests: the request round trip and hand-off in `single_instance.rs`, the OS event in `tests/ui.rs`, the verb rows in `test-msi.ps1`. Estimates unchanged. - **2026-10-15 (M14, MSI install folder, #1139):** The Windows installer's welcome dialog has an "Install to" row with a native folder browser, starting on the folder chosen for the per-user or per-machine scope (the recorded one on an update). `test-msi.ps1` checks the dialog wiring: both Install buttons set the folder before the dialog closes, and Browse's OK and Cancel. Estimates unchanged. - **2026-10-13 (community, M3, #1569 review):** Pixel-only Wayland wheel, wheel-tilt and continuous input use standalone moved events, so Ctrl zoom does not remain latched after the modifier is released (#991); vendored winit patch (13), with a policy test. Estimates unchanged. - **2026-10-18 (M1, PR #1143 repair policy):** A live in-file reference resolves when its generation matches the xref row or a same-number header at that indexed offset. Neither-match and free slots remain null, including when old object bytes remain. Opening records bounded xref/header disagreements without parsing object bodies or expanding compact xref rows. Synthetic controls cover read order, clones, overlays, dictionary edits, full classic/packed saves and an independent reader of incremental output. **Correction to the 2026-10-15 note below:** its async-opening-test sentence was not evidenced by this COS change; no fresh async-opening or GUI validation is claimed. The full-reference stream-length recursion key is defensive hardening, not an independently isolated regression fix. Estimates unchanged. @@ -523,6 +526,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md - **2026-10-15 (M3, #1248):** A fresh install follows the desktop's appearance: the theme preference defaults to System (dark on a dark desktop, Light where the desktop says nothing); saved settings and `--theme` still win. Dark page rendering is Reading colours, and the page reading themes are #1732. - **2026-10-15 (M3, #1767):** Restore last view settings when reopening documents, as Acrobat's preference does: a file opened again shows the page, zoom and page display it was last shown with (remembered for the last 100 files when a tab closes or settings are saved), over the file's own initial view; on by default, Preferences ▸ Documents and view turns it off. +- **2026-10-14 (M2, #623):** A page whose content was cut at the renderer's limits no longer looks complete: a notice names it (and how many), `ui.state` lists the affected pages under `active.cut_pages`, and `text_extract` marks its text `truncated`, since the cut part's text can't be found or copied. The renderer already reported the cut; nothing showed it. - **2026-10-13 (M3, native-opening candidate):** Chosen tools survive preparation, conversion and password retries; startup intake waits for pending preparation. Regression tests cover completion, cancellation and document identity. Estimates unchanged. - **2026-10-13 (community, M3, #531 touchpad momentum):** On Wayland a two-finger swipe keeps gliding after the fingers lift and slows to a stop, measured from the gesture's last 0.1 s and decaying with a 0.35 s time constant through the #759 1:1 path. Any wheel input, click, key, zoom or edge ends it (fingers resting on the touchpad don't yet: the vendored winit binds no Wayland hold gestures); single-page paging, macOS (which sends its own momentum), the web, and Animation effects Off never glide. Unit tests for the physics and two headless view tests. Estimates unchanged. - **2026-10-13 (M3, native-open admission):** Later prepared replies stay queued while a modal or native picker owns the foreground. Picker completion, cancellation and abandonment wake retained opens. Regressions cover recipient ownership, exactly-once admission, source/history preservation and one-shot repaint requests. Estimates unchanged. diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 62993ad6dc..4521181b6f 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -43,7 +43,18 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { let path = *positional(args, CHECK_ONE_OPTIONS).first().ok_or("check-one: missing file")?; let dpi = parse_dpi(flag(args, "--dpi").unwrap_or("36"))?; let start = Instant::now(); - let bytes = read(path)?; + // A file that can't be read (past the input cap, say) is this file's record, not a crash of the child. + let bytes = match read(path) { + Ok(bytes) => bytes, + Err(e) => { + let status = if e.contains("larger than") { "input-too-large" } else { "read-error" }; + let line = serde_json::json!({ "file": path, "status": status, "pages": 0, "failed_pages": 0, "warnings": 0, + "ms": start.elapsed().as_millis() as u64, "detail": e.chars().take(400).collect::() }); + stdout_line(format_args!("{line}"))?; + // (the record is the answer `check` reads; the error and exit status are for a direct run) + return Err(e.into()); + } + }; let (status, pages, failed, detail, warnings) = match inspect(bytes.clone(), None) { Err(e) => (format!("open-error:{}", short(&e.to_string())), 0, 0, e.to_string(), 0), Ok(info) => { @@ -65,7 +76,7 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { // `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net, // so a panic in cos shows up as a crash of this process. if args.iter().any(|a| a == "--edit") { - edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?); + edit_round_trip(&read(path)?); } let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings, "ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::() }); @@ -76,13 +87,19 @@ pub(crate) fn check_one(args: &[String]) -> Result<(), CliError> { /// Open with cos, touch every object, edit the catalog, save incrementally and in full (both /// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs. fn edit_round_trip(bytes: &[u8]) { - use pdfcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental}; + use pdfcraft_cos::{DecodeBudget, Document, Object, SaveOptions, write_full, write_incremental}; let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return }; + // One allowance for every stream, charged for what each decode did (failed ones too), so + // many small bombs can't each inflate to the per-stream limit. + let mut budget = DecodeBudget::page(); for num in doc.object_numbers().into_iter().take(20_000) { + if budget.exhausted() { + break; + } if let Ok(o) = doc.try_get(num) && let Object::Stream(s) = &*o { - let _ = s.decoded(); + let _ = budget.decode(s); } } if let Some(root) = doc.root() { @@ -113,7 +130,7 @@ fn collect(paths: &[&str]) -> Vec { let identity = match p.canonicalize() { Ok(identity) => identity, Err(error) => { - eprintln!("{}: could not resolve directory: {error}", p.display()); + eprintln!("{}: could not resolve directory: {error}", p.display().to_string().escape_debug()); continue; } }; @@ -158,12 +175,16 @@ pub(crate) fn check(args: &[String]) -> Result<(), CliError> { let status = r["status"].as_str().unwrap_or("?").to_string(); *counts.entry(status.split(':').next().unwrap_or("?").to_string()).or_default() += 1; if status != "ok" { + // One record per line: a file name or detail with a line break or a terminal control in + // it is written escaped, so it can't forge another record (the JSON report keeps both). + let detail: String = r["detail"].as_str().unwrap_or("").chars().take(160).collect(); eprintln!( - "[{}/{}] {status:<28} {} {}", + "[{}/{}] {:<28} {} {}", n + 1, files.len(), - f.display(), - r["detail"].as_str().unwrap_or("").chars().take(160).collect::() + status.escape_debug().to_string(), + f.display().to_string().escape_debug(), + detail.escape_debug() ); } results.push(r); diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 86b31bafd3..5a74f19f56 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -4,8 +4,19 @@ use super::options::{ }; use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; +/// Largest input file read into memory (512 MiB, as automation's `doc_open` reads). +pub(crate) const MAX_INPUT: u64 = 512 * 1024 * 1024; + +/// The file at `path`, read no further than [`MAX_INPUT`]: a name that leads to something +/// endless (a device, a pipe nobody closes) fails at the cap instead of filling memory. pub(crate) fn read(path: &str) -> Result>, String> { - std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) + use std::io::Read as _; + let mut bytes = Vec::new(); + std::fs::File::open(path).and_then(|f| f.take(MAX_INPUT + 1).read_to_end(&mut bytes)).map_err(|e| format!("{path}: {e}"))?; + if bytes.len() as u64 > MAX_INPUT { + return Err(format!("{path}: the file is larger than 512 MiB")); + } + Ok(Arc::new(bytes)) } /// `info FILE`: a JSON summary of the document as the app opens it (pages, fields, metadata, the @@ -387,6 +398,14 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { // The renderer takes only the user password of R2–R4 files; `Session::open` and the display // copy take either. let render_password = pdfcraft_engine::render_password_for(bytes.clone(), password); + // A page image is an image taken out of the document, as a snapshot or Export is in the app: + // like `text`, it needs the document to allow copying (or the permissions password). + let mut session = pdfcraft_engine::Session::new(); + if let Some(doc) = session.open(path, None, bytes.clone(), password).ok().and_then(|id| session.get(id)) { + doc.check_copying().map_err( + |_| "render: the document's permissions don't allow copying its pages as images; pass the owner password with --password to render it", + )?; + } let display = if inspect(bytes.clone(), render_password.as_deref()).is_ok_and(|info| info.xfa == Some(pdfcraft_render::Xfa::Dynamic)) { let mut session = pdfcraft_engine::Session::new(); let id = session.open(path, None, bytes, password).map_err(|e| e.to_string())?; diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index dc419171b0..0ad18c154b 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -50,18 +50,23 @@ pub(crate) fn ui(args: &[String]) -> Result<(), CliError> { /// Most a control file may hold. The app writes under 100 bytes. const CONTROL_FILE_MAX: u64 = 64 * 1024; +#[cfg(not(windows))] const CONTROL_FILE_ADVICE: &str = "Start the app with a control file in a folder only you can write, such as `pdfcraft --control ~/.pdfcraft-control.json`"; +// cmd.exe doesn't expand `~` (#3922). +#[cfg(windows)] +const CONTROL_FILE_ADVICE: &str = + r"Start the app with a control file in a folder only you can write, such as `pdfcraft --control %USERPROFILE%\.pdfcraft-control.json`"; /// Read the file written by `pdfcraft --control FILE`. /// /// It names the port that receives the token and every command, typed text included, so it must /// be the user's own. In a shared folder such as `/tmp`, another local user can create the file /// first; the app then can't write it, and the CLI would talk to the other user's listener. -/// Refused: a symbolic link, anything but a regular file, and on Unix a file owned by someone -/// else or open to other users (the app writes it with mode 600). +/// Refused: a symbolic link, anything but a regular file, and a file owned by someone else or +/// open to other users: on Unix by its owner and mode (the app writes it with mode 600), on +/// Windows by its owner and access list (the app gives it one of its user and SYSTEM, #3922). fn read_control_file(file: &str) -> Result { - use std::io::Read as _; let at_path = std::fs::symlink_metadata(file).map_err(|e| format!("{file}: {e}"))?; if at_path.file_type().is_symlink() { return Err(format!("{file}: is a symbolic link, which could point at another user's file. {CONTROL_FILE_ADVICE}")); @@ -69,21 +74,41 @@ fn read_control_file(file: &str) -> Result { if !at_path.is_file() { return Err(format!("{file}: not a regular file. {CONTROL_FILE_ADVICE}")); } - let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. - let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; - if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { - return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + #[cfg(not(windows))] + let text = { + use std::io::Read as _; + let f = std::fs::File::open(file).map_err(|e| format!("{file}: {e}"))?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + // Checked on the open file, so nothing swapped in after `symlink_metadata` gets through. + let opened = f.metadata().map_err(|e| format!("{file}: {e}"))?; + if (opened.dev(), opened.ino()) != (at_path.dev(), at_path.ino()) { + return Err(format!("{file}: was replaced while being opened. {CONTROL_FILE_ADVICE}")); + } + if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); + } } - if let Some(problem) = control_file_problem(opened.uid(), opened.mode(), current_uid()) { + let mut text = String::new(); + f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + text + }; + // Windows: the same owner and access check, on the security descriptor of the very handle the + // contents are read through, so nothing swapped in at the path gets through (#3922). A file + // planted by another user, or one other users can read, is refused; if the check itself can't + // run, the file isn't trusted either. + #[cfg(windows)] + let text = { + use pdfcraft_platform::owner_only; + let me = owner_only::current_user_sid().map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + let (sddl, bytes) = owner_only::read_with_sddl(std::path::Path::new(file), CONTROL_FILE_MAX) + .map_err(|e| format!("{file}: can't check who owns it: {e}. {CONTROL_FILE_ADVICE}"))?; + if let Some(problem) = owner_only::foreign_access(&sddl, &me) { return Err(format!("{file}: {problem}. {CONTROL_FILE_ADVICE}")); } - } - let mut text = String::new(); - f.take(CONTROL_FILE_MAX.saturating_add(1)).read_to_string(&mut text).map_err(|e| format!("{file}: {e}"))?; + String::from_utf8(bytes).map_err(|e| format!("{file}: {e}"))? + }; if u64::try_from(text.len()).unwrap_or(u64::MAX) > CONTROL_FILE_MAX { return Err(format!("{file}: too large for a control file (over {} KiB)", CONTROL_FILE_MAX / 1024)); } @@ -112,6 +137,38 @@ fn current_uid() -> u32 { mod tests { use super::control_file_problem; + /// #3922: on Windows the CLI checks the control file's owner and access list. A file other + /// users can read is refused; the same file restricted to its owner is accepted. + #[cfg(windows)] + #[test] + fn on_windows_a_control_file_other_users_can_read_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-cli-control-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + let file = dir.join("ctl.json"); + std::fs::write(&file, r#"{"port":4242,"token":"t0ken"}"#).unwrap(); + let path = file.to_string_lossy().into_owned(); + let refused = super::read_control_file(&path).unwrap_err(); + assert!(refused.contains("another account") && refused.contains("%USERPROFILE%"), "{refused}"); + pdfcraft_platform::owner_only::restrict_to_owner(&file, &pdfcraft_platform::owner_only::current_user_sid().unwrap()).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["token"].as_str(), Some("t0ken")); + // The contents come with the check, capped: exactly the limit is read, one byte more is refused. + let padded = |len: usize| { + let json = r#"{"port":4242,"token":"t0ken"}"#; + json.to_string() + &" ".repeat(len - json.len()) + }; + std::fs::write(&file, padded(64 * 1024)).unwrap(); + assert_eq!(super::read_control_file(&path).unwrap()["port"].as_u64(), Some(4242)); + std::fs::write(&file, padded(64 * 1024 + 1)).unwrap(); + assert!(super::read_control_file(&path).unwrap_err().contains("too large")); + // Valid JSON once the byte is replaced by U+FFFD, so only a strict UTF-8 read refuses it. + std::fs::write(&file, b"{\"port\":4242,\"token\":\"\xFF\"}").unwrap(); + assert!(super::read_control_file(&path).is_err(), "not UTF-8"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_control_file_must_be_yours_and_private() { // Regular file type bits (0o100000) don't matter, only the owner and the permission bits. diff --git a/apps/pdfcraft-cli/tests/it/check.rs b/apps/pdfcraft-cli/tests/it/check.rs index 7a6236419c..3f32459089 100644 --- a/apps/pdfcraft-cli/tests/it/check.rs +++ b/apps/pdfcraft-cli/tests/it/check.rs @@ -129,3 +129,18 @@ fn a_bad_timeout_or_dpi_is_refused_before_scanning() { ); } } + +/// A failing file's name goes to stderr escaped: a line break in it can't start a second, +/// forged record. The JSON report keeps the name as it is. +#[cfg(unix)] +#[test] +fn a_file_name_cannot_forge_a_stderr_record() { + let dir = Workdir::new("escaped-name"); + let name = "bad\n[9] ok forged.pdf"; + std::fs::write(dir.0.join("input").join(name), b"not a pdf").unwrap(); + let out = Command::new(BIN).arg("check").arg(dir.0.join("input")).args(["--dpi", "36", "--timeout", "5"]).output().unwrap(); + let stderr = String::from_utf8_lossy(&out.stderr); + let records: Vec<&str> = stderr.lines().filter(|l| l.starts_with('[')).collect(); + assert_eq!(records.len(), 1, "{stderr}"); + assert!(records[0].contains("bad\\n[9] ok forged.pdf"), "{stderr}"); +} diff --git a/apps/pdfcraft-cli/tests/it/options.rs b/apps/pdfcraft-cli/tests/it/options.rs index 7955157e28..d414922998 100644 --- a/apps/pdfcraft-cli/tests/it/options.rs +++ b/apps/pdfcraft-cli/tests/it/options.rs @@ -113,3 +113,14 @@ fn an_argument_that_is_not_utf8_is_an_error_not_a_panic() { assert!(!stderr.contains("panicked"), "{stderr}"); } } + +/// A file `check-one` cannot read is a record on stdout with its own status and the reason, which +/// `check` relays, not an empty stdout that reads as a crash. +#[test] +fn check_one_reports_an_unreadable_file_as_a_record() { + let out = Command::new(BIN).args(["check-one", "missing.pdf"]).output().expect("run cli"); + let stdout = String::from_utf8_lossy(&out.stdout); + let record: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{e}: {stdout:?}")); + assert_eq!(record["status"], "read-error"); + assert!(record["detail"].as_str().is_some_and(|d| d.contains("missing.pdf")), "{record}"); +} diff --git a/apps/pdfcraft-cli/tests/it/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs index 6796980982..5a3e061728 100644 --- a/apps/pdfcraft-cli/tests/it/owner_password.rs +++ b/apps/pdfcraft-cli/tests/it/owner_password.rs @@ -130,6 +130,12 @@ fn render_accepts_the_owner_password() { .output() .unwrap(); let stderr = String::from_utf8_lossy(&out.stderr); + if pw == "user" { + // The user password opens it, but a page image is a copy the permissions forbid. + assert!(!out.status.success() && stderr.contains("don't allow copying"), "{alg:?} with {pw:?}: {stderr}"); + assert!(!png.exists(), "{alg:?}: no image is written"); + continue; + } assert!(out.status.success(), "{alg:?} with {pw:?}: {stderr}"); assert_rendered_page(&std::fs::read(&png).unwrap(), DPI, &format!("{alg:?} with {pw:?}")); } diff --git a/apps/pdfcraft-cli/tests/it/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs index f49771b001..082faf3426 100644 --- a/apps/pdfcraft-cli/tests/it/ui_control.rs +++ b/apps/pdfcraft-cli/tests/it/ui_control.rs @@ -86,6 +86,24 @@ fn write_private(path: &Path, text: &str) { opts.mode(0o600); } opts.open(path).unwrap().write_all(text.as_bytes()).unwrap(); + // On Windows the file inherits its folder's access list, as the app's would before it + // restricts it (#3922). + #[cfg(windows)] + { + use pdfcraft_platform::owner_only; + owner_only::restrict_to_owner(path, &owner_only::current_user_sid().unwrap()).unwrap(); + // The file the CLI is handed must already pass its check, or a refusal below would be + // the helper's fault, not the CLI's. + let sddl = owner_only::sddl_of(path).unwrap(); + let listing = |p: &Path| Command::new("icacls").arg(p).output().map(|o| String::from_utf8_lossy(&o.stdout).into_owned()); + let folder = path.parent().map(listing); + assert_eq!( + owner_only::foreign_access(&sddl, &owner_only::current_user_sid().unwrap()), + None, + "restricted file: {sddl}; icacls: {:?}; folder: {folder:?}", + listing(path) + ); + } } fn ui(control: &Path, method: &str) -> Output { diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 966af354d1..5e4010fcad 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,6 +14,8 @@ workspace = true pdfcraft-ui-egui = { workspace = true } # `build_info`: the version and compile-time build label for `--version` and the log. pdfcraft-engine = { workspace = true } +# `owner_only`: the --control file is restricted to its user on Windows (#3922). +pdfcraft-platform = { workspace = true } # wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or # driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] } @@ -62,6 +64,10 @@ windows = { version = "0.62", default-features = false, features = ["std", "UI_V # socket. `std` has no Unix sockets on Windows, and calling Winsock directly needs unsafe, which # our workspace forbids. Already in the lockfile through zbus; pinned exactly, review before bumping. uds_windows = "=1.2.1" +# Full-resolution taskbar and Alt+Tab icon (`ICON_BIG`, issue #3818). Set both window icons +# directly so eframe cannot replace ICON_BIG with a system-sized copy. Already in the tree +# through eframe and covered by the workspace's winit patch. +winit = { version = "0.30", default-features = false } [build-dependencies] # Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows. diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 4de423db7f..837a803e22 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -12,6 +12,8 @@ //! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page| --always-default-view on|off //! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50 //! --combine-view grid|list --combine-zoom 60..200` +//! `--combine files…` adds the files to the Combine files tab (File Explorer's "Combine files in +//! PdfCraft", #585), of the PdfCraft already running when there is one. //! //! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files //! hands them to the PdfCraft already running, where they open as tabs (`single_instance`). @@ -69,6 +71,7 @@ Options: --two-paged open command-line PDFs in noncontinuous two-page view --new-window open a window of its own instead of passing the files to a running PdfCraft --create-images [images…] put the images in one PDF and ask for the page DPI + --combine files… add the files to the Combine files tab, of a running PdfCraft if there is one --control enable the UI control channel (pdfcraft-cli ui --control …) --page N, --zoom 150, --layout continuous, --theme dark, … view settings applied once the files are open @@ -231,7 +234,7 @@ fn main() -> eframe::Result { } startup.files = rejoin_split_paths(startup.files, |p| std::path::Path::new(p).is_file()); let may_hand_off = startup.may_hand_off(); - let startup::Arguments { files, options, control_file, create_images, two_paged, .. } = startup; + let startup::Arguments { files, options, control_file, create_images, combine, two_paged, .. } = startup; let integrated = cfg!(target_os = "macos"); // GNOME on Wayland leaves the window frame to the app (src/decorations.rs). let window_controls = if cfg!(any(target_os = "macos", target_os = "windows")) { None } else { decorations::app_drawn() }; @@ -255,13 +258,13 @@ fn main() -> eframe::Result { // Absolute for this instance too: links relative to a document resolve against its folder, // never the working directory. let files = single_instance::absolute(&files); - let instance = match single_instance::claim(&files, may_hand_off) { + let instance = match single_instance::claim(&files, may_hand_off, combine) { single_instance::Claim::HandedOff => return Ok(()), single_instance::Claim::Primary(server) => Some(server), single_instance::Claim::Alone => None, }; let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); - let launch = Launch { files, options, control_file, create_images, integrated, window_controls, two_paged }; + let launch = Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged }; // Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one // that launched us as well as later ones. Lives until the event loop returns. #[cfg(target_os = "macos")] @@ -337,7 +340,15 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer) .with_drag_and_drop(true) // Wayland app id: matches packaging/linux/ai.storyteller.pdfcraft.desktop. .with_app_id(APP_ID); + // eframe replaces ICON_BIG with a system-sized icon on its first active frame. + // An explicitly empty icon disables that setter (None selects eframe's default). + // Windows installs both sizes directly through winit in app_creator instead. + #[cfg(target_os = "windows")] + { + viewport = viewport.with_icon(egui::IconData::default()); + } // Dock, taskbar, Alt-Tab and launcher icon when running unbundled. + #[cfg(not(target_os = "windows"))] match eframe::icon_data::from_png_bytes(APP_ICON_PNG) { Ok(icon) => viewport = viewport.with_icon(icon), Err(e) => log::warn!("app icon: {e}"), @@ -368,6 +379,8 @@ struct Launch { options: Vec<(String, String)>, control_file: Option, create_images: bool, + /// `--combine`: the files go to the Combine files tab. + combine: bool, integrated: bool, /// The window buttons the app draws, when the desktop leaves the frame to it. window_controls: Option, @@ -386,13 +399,55 @@ fn native_wayland(_: &eframe::CreationContext<'_>) -> bool { false } +/// Decode `png` as a [`winit::window::Icon`] for the Windows taskbar and Alt+Tab (`ICON_BIG`). +#[cfg(target_os = "windows")] +fn taskbar_icon(png: &[u8]) -> Option { + let icon = match eframe::icon_data::from_png_bytes(png) { + Ok(icon) => icon, + Err(e) => { + log::warn!("taskbar icon: {e}"); + return None; + } + }; + match winit::window::Icon::from_rgba(icon.rgba, icon.width, icon.height) { + Ok(icon) => Some(icon), + Err(e) => { + log::warn!("taskbar icon: {e}"); + None + } + } +} + +/// Install the decoded [`winit::window::Icon`] from `png` via `set` when decoding succeeds. +#[cfg(target_os = "windows")] +fn install_taskbar_icon(png: &[u8], set: impl FnOnce(winit::window::Icon)) { + if let Some(icon) = taskbar_icon(png) { + set(icon); + } +} + +/// Install both window icons before the first frame. eframe's icon setter is disabled in +/// `native_options`, so it cannot replace the full-resolution taskbar/Alt+Tab icon (#3818). +#[cfg(target_os = "windows")] +fn set_taskbar_icon(window: &winit::window::Window) { + use winit::platform::windows::{IconExtWindows as _, WindowExtWindows as _}; + install_taskbar_icon(APP_ICON_PNG, |icon| window.set_taskbar_icon(Some(icon))); + // The title bar's icon: the small image drawn for this scale in the exe's own icon (build.rs). + let side = (16.0 * window.scale_factor()).round().clamp(16.0, 256.0) as u32; + match winit::window::Icon::from_resource(1, Some(winit::dpi::PhysicalSize::new(side, side))) { + Ok(icon) => window.set_window_icon(Some(icon)), + Err(e) => log::warn!("window icon: {e}"), + } +} + +/// Build the [`eframe::AppCreator`] closure that initializes [`PdfCraftApp`] once the window and renderer are ready. fn app_creator<'a>( launch: Launch, started: Rc>, instance: Option<&'a single_instance::Server>, #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, ) -> eframe::AppCreator<'a> { - let Launch { files, options, control_file, create_images, integrated, window_controls, two_paged } = launch; + let Launch { files, options, control_file, create_images, combine, integrated, window_controls, two_paged } = launch; Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); @@ -413,6 +468,9 @@ fn app_creator<'a>( // it is set to On or Off. #[cfg(target_os = "windows")] { + if let Some(window) = cc.winit_window() { + set_taskbar_icon(window); + } app.motion.source = Some(motion::source()); } app.set_startup_update_check(true); @@ -466,6 +524,9 @@ fn app_creator<'a>( if let Err(e) = app.begin_image_import_paths(&files) { app.notify(e); } + } else if combine { + app.reopen_last_files(&[]); + app.combine_paths(&files); } else { // With the preference on, last session's files come back first; files named on // the command line open after them, in front (#442). @@ -506,7 +567,9 @@ fn open_control_channel(file: &str, endpoint: std::io::Result { - let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as ~/.pdfcraft-control.json"); + // cmd.exe doesn't expand `~` (#3922). + let example = if cfg!(windows) { r"%USERPROFILE%\.pdfcraft-control.json" } else { "~/.pdfcraft-control.json" }; + let message = format!("--control {file}: {e}. Use a control file in a folder only you can write, such as {example}"); log::error!("{message}"); Err(message) } @@ -515,15 +578,25 @@ fn open_control_channel(file: &str, endpoint: std::io::Result std::io::Result<()> { - use std::io::{ErrorKind, Write}; + use std::io::ErrorKind; let json = serde_json::json!({ "port": port, "token": token, "pid": std::process::id() }).to_string(); let path = std::path::Path::new(path); let name = path.file_name().ok_or_else(|| std::io::Error::new(ErrorKind::InvalidInput, "not a file name"))?; + // Windows: the user's SID, read once for the private folder and for the file in it, since each + // lookup starts `whoami` (review on #3956). + #[cfg(windows)] + let me = pdfcraft_platform::owner_only::current_user_sid()?; + #[cfg(windows)] + let restrict = |file: &std::path::Path| pdfcraft_platform::owner_only::restrict_to_owner(file, &me); + #[cfg(not(windows))] + let restrict = |_: &std::path::Path| Ok::<(), std::io::Error>(()); // Unpredictable suffixes, so the name can't be planted in advance. `RandomState` is keyed from // the operating system's random source. let random = std::hash::RandomState::new(); @@ -532,35 +605,79 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> staged.push(name); staged.push(format!(".{:016x}.tmp", std::hash::BuildHasher::hash_one(&random, i))); let staged = path.with_file_name(staged); - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let file = match opts.open(&staged) { - Ok(f) => f, - Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, - // Windows refuses `create_new` on a folder with "access denied"; the name is taken all - // the same. A folder we can't write to, with nothing at the name, still fails here. - Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, - Err(e) => return Err(e), + // Windows: a file created next to `path` would inherit that folder's access list, which may + // let other users read it, and restricting it afterwards leaves a window in which one of + // them opens a handle; Windows checks access only when a handle is opened, so a later + // change of the list doesn't revoke it. The file is created in a private folder instead, + // and that folder is removed once the file has left it. + #[cfg(windows)] + let (staging, staged) = { + match pdfcraft_platform::owner_only::create_private_dir(&staged, &me) { + Ok(()) => {} + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + // As for a file below: something at the name we may not open still takes it. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => continue, + Err(e) => return Err(e), + } + let file = staged.join(name); + (staged, file) }; - // The block closes the file before it is renamed. - let written = { - let mut file = file; - file.write_all(json.as_bytes()) - }; - let done = written.and_then(|()| std::fs::rename(&staged, path)); - if done.is_err() { - let _ = std::fs::remove_file(&staged); + let done = stage_control_file(&staged, path, &json, &restrict); + #[cfg(windows)] + let _ = std::fs::remove_dir(&staging); + match done { + Some(done) => return done, + None => continue, } - return done; } Err(std::io::Error::new(ErrorKind::AlreadyExists, "no free name for the control file's temporary copy")) } +/// Create `staged` fresh and owner-only, `restrict` it, write `json` to it and rename it to `path`. +/// `None` when something already has the name, so the caller tries another. On any other failure +/// the new file is removed and nothing is left at `path`'s side. +fn stage_control_file( + staged: &std::path::Path, + path: &std::path::Path, + json: &str, + restrict: &dyn Fn(&std::path::Path) -> std::io::Result<()>, +) -> Option> { + use std::io::{ErrorKind, Write}; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let file = match opts.open(staged) { + Ok(f) => f, + Err(e) if e.kind() == ErrorKind::AlreadyExists => return None, + // Windows refuses `create_new` on a folder with "access denied"; the name is taken all + // the same. A folder we can't write to, with nothing at the name, still fails here. + Err(e) if e.kind() == ErrorKind::PermissionDenied && staged.symlink_metadata().is_ok() => return None, + Err(e) => return Some(Err(e)), + }; + // Windows: the file inherited the private folder's list. It also gets a list of its own, so it + // stays private wherever the rename puts it, and that is checked before the token is written; + // nothing is written if that fails (#3922). Unix has mode 0600 above, and `restrict` does nothing. + if let Err(e) = restrict(staged) { + drop(file); + let _ = std::fs::remove_file(staged); + return Some(Err(e)); + } + // The block closes the file before it is renamed. + let written = { + let mut file = file; + file.write_all(json.as_bytes()) + }; + let done = written.and_then(|()| std::fs::rename(staged, path)); + if done.is_err() { + let _ = std::fs::remove_file(staged); + } + Some(done) +} + /// How wgpu finds a GPU. Each choice yields to its wgpu environment variable. /// /// - Draw on the integrated GPU unless `WGPU_POWER_PREF` says otherwise. A PDF viewer has no use diff --git a/apps/pdfcraft/src/single_instance.rs b/apps/pdfcraft/src/single_instance.rs index 448eb40d68..60f5d1d493 100644 --- a/apps/pdfcraft/src/single_instance.rs +++ b/apps/pdfcraft/src/single_instance.rs @@ -14,7 +14,9 @@ //! intercept a launch or open files in this user's window. Inside a Flatpak, `$XDG_RUNTIME_DIR` is //! the app's own and is shared by every run of it, so the handoff works there too. The exchange is //! one line each way, a JSON request naming absolute paths and `ok`, and the running app opens the -//! paths exactly as File ▸ Open would. +//! paths exactly as File ▸ Open would. A `--combine` launch (File Explorer's "Combine files in +//! PdfCraft", #585, one launch per selected file) hands them over the same way with +//! `"combine": true`, and the running app adds them to its Combine files tab instead. //! //! Every failure falls back to the old behaviour: a launch that can't hand its files over within //! a few seconds opens its own window. `--new-window`, any other option, or no files at all also @@ -58,7 +60,10 @@ pub enum Claim { /// Files that later launches handed over, and how to wake the UI for them. #[derive(Default)] struct Inbox { - paths: Vec, + /// The requests in the order they arrived: [`OsEvent::Open`] or, for `--combine`, + /// [`OsEvent::Combine`]. One queue, so the UI handles an Open that came after a Combine after + /// it, and that Open comes to the front instead of opening behind the Combine files tab. + events: Vec, wake: Option>, } @@ -74,11 +79,20 @@ impl Server { Self { inbox: Arc::default() } } - /// Queue files from another launch and wake the UI. + /// Queue files from another launch (to open, or to combine) and wake the UI. #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] - fn deliver(&self, paths: Vec) { + fn deliver(&self, paths: Vec, combine: bool) { + if paths.is_empty() { + return; + } let mut inbox = self.inbox.lock().unwrap_or_else(PoisonError::into_inner); - inbox.paths.extend(paths); + // A run of requests of one kind (File Explorer starts one launch per selected file) stays + // one event; a request of the other kind starts a new one after it. + match (inbox.events.last_mut(), combine) { + (Some(OsEvent::Combine(queued)), true) | (Some(OsEvent::Open(queued)), false) => queued.extend(paths), + (_, true) => inbox.events.push(OsEvent::Combine(paths)), + (_, false) => inbox.events.push(OsEvent::Open(paths)), + } if let Some(wake) = &inbox.wake { wake(); } @@ -95,8 +109,8 @@ impl Server { let inbox = Arc::clone(&self.inbox); let ctx = ctx.clone(); Box::new(move || { - let paths = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).paths); - if paths.is_empty() { + let events = std::mem::take(&mut inbox.lock().unwrap_or_else(PoisonError::into_inner).events); + if events.is_empty() { return Vec::new(); } ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); @@ -104,27 +118,37 @@ impl Server { if native_wayland { ctx.send_viewport_cmd(egui::ViewportCommand::RequestUserAttention(egui::UserAttentionType::Informational)); } - vec![OsEvent::Open(paths)] + events }) } } -/// The request a launch sends: `{"open":[…]}` and a newline. +/// The request a launch sends: `{"open":[…]}` (with `"combine":true` for `--combine`) and a +/// newline. #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] -pub fn request(paths: &[String]) -> String { - let mut line = serde_json::json!({ "open": paths }).to_string(); +pub fn request(paths: &[String], combine: bool) -> String { + let mut value = serde_json::json!({ "open": paths }); + if combine { + value["combine"] = serde_json::Value::Bool(true); + } + let mut line = value.to_string(); line.push('\n'); line } -/// The paths a request names, or `None` when it isn't a request: anything but one JSON object -/// whose `open` is a non-empty list of non-empty strings. +/// The paths a request names and whether they are for Combine files, or `None` when it isn't a +/// request: anything but one JSON object whose `open` is a non-empty list of non-empty strings +/// (`combine`, when there, must be a boolean). #[cfg_attr(not(any(windows, all(unix, not(target_os = "macos")))), allow(dead_code))] -pub fn parse_request(line: &str) -> Option> { +pub fn parse_request(line: &str) -> Option<(Vec, bool)> { let value: serde_json::Value = serde_json::from_str(line.trim_end()).ok()?; let open = value.get("open")?.as_array()?; let paths = open.iter().map(|p| p.as_str().filter(|s| !s.is_empty()).map(str::to_string)).collect::>>()?; - (!paths.is_empty()).then_some(paths) + let combine = match value.get("combine") { + None => false, + Some(c) => c.as_bool()?, + }; + (!paths.is_empty()).then_some((paths, combine)) } /// File arguments made absolute against this launch's working folder, which the running app @@ -134,18 +158,19 @@ pub fn absolute(paths: &[String]) -> Vec { } /// Decide what this launch does. `files` are absolute ([`absolute`]); `may_hand_off` is false -/// when the launch asked for anything besides opening files, so it gets its own window. -pub fn claim(files: &[String], may_hand_off: bool) -> Claim { +/// when the launch asked for anything besides opening (or, with `combine`, combining) files, so +/// it gets its own window. +pub fn claim(files: &[String], may_hand_off: bool, combine: bool) -> Claim { #[cfg(any(windows, all(unix, not(target_os = "macos"))))] { match socket_path() { - Some(path) => claim_at(&path, files, may_hand_off), + Some(path) => claim_at(&path, files, may_hand_off, combine), None => Claim::Alone, } } #[cfg(not(any(windows, all(unix, not(target_os = "macos")))))] { - let _ = (files, may_hand_off); + let _ = (files, may_hand_off, combine); Claim::Alone } } @@ -239,7 +264,7 @@ fn election(socket: &std::path::Path) -> Option { /// [`claim`] with the socket at `path`. #[cfg(any(windows, all(unix, not(target_os = "macos"))))] -fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Claim { +fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool, combine: bool) -> Claim { // Released when this function returns: by then this launch listens, or has handed over. let _election = election(path); // Two launches can start together: the one that loses the race to bind hands over to the @@ -250,7 +275,7 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla if !may_hand_off { return Claim::Alone; } - return match hand_off(&stream, files) { + return match hand_off(&stream, files, combine) { Ok(()) => Claim::HandedOff, Err(e) => { log::warn!("couldn't hand the files to the running PdfCraft ({e}); opening a new window"); @@ -296,12 +321,12 @@ fn claim_at(path: &std::path::Path, files: &[String], may_hand_off: bool) -> Cla /// Send `files` over `stream` and wait for the running app's `ok`. #[cfg(any(windows, all(unix, not(target_os = "macos"))))] -fn hand_off(stream: &UnixStream, files: &[String]) -> std::io::Result<()> { +fn hand_off(stream: &UnixStream, files: &[String], combine: bool) -> std::io::Result<()> { use std::io::{BufRead, Read, Write}; stream.set_read_timeout(Some(TIMEOUT))?; stream.set_write_timeout(Some(TIMEOUT))?; let mut writer = stream; - writer.write_all(request(files).as_bytes())?; + writer.write_all(request(files, combine).as_bytes())?; let mut reply = String::new(); std::io::BufReader::new(stream.take(16)).read_line(&mut reply)?; if reply == "ok\n" { Ok(()) } else { Err(std::io::Error::other("the running PdfCraft didn't confirm")) } @@ -343,8 +368,8 @@ fn receive(stream: &UnixStream, server: &Server) -> std::io::Result<()> { if line.is_empty() { return Ok(()); } - let paths = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?; - server.deliver(paths); + let (paths, combine) = parse_request(&line).ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "not an open request"))?; + server.deliver(paths, combine); let mut writer = stream; writer.write_all(b"ok\n") } @@ -356,16 +381,31 @@ mod tests { #[test] fn a_request_names_the_paths_and_round_trips() { let paths = vec![r"C:\Users\me\Documents\Quote.pdf".to_string(), r"D:\Scans\Plan „A“ 1.pdf".to_string()]; - let line = request(&paths); + let line = request(&paths, false); assert!(line.ends_with('\n') && !line.trim_end().contains('\n'), "one line"); - assert_eq!(parse_request(&line), Some(paths)); + assert_eq!(parse_request(&line), Some((paths.clone(), false))); + assert!(!line.contains("combine"), "an open request says nothing about combining"); + let line = request(&paths, true); + assert_eq!(parse_request(&line), Some((paths, true)), "a --combine launch's request"); } #[test] fn anything_but_a_list_of_paths_is_refused() { - for line in ["", "ok\n", "{}", r#"{"open":[]}"#, r#"{"open":[""]}"#, r#"{"open":["a.pdf",3]}"#, r#"{"open":"a.pdf"}"#, "[\"a.pdf\"]"] { + for line in [ + "", + "ok\n", + "{}", + r#"{"open":[]}"#, + r#"{"open":[""]}"#, + r#"{"open":["a.pdf",3]}"#, + r#"{"open":"a.pdf"}"#, + "[\"a.pdf\"]", + r#"{"open":["a.pdf"],"combine":"yes"}"#, + r#"{"open":["a.pdf"],"combine":1}"#, + ] { assert_eq!(parse_request(line), None, "{line:?}"); } + assert_eq!(parse_request(r#"{"open":["a.pdf"],"combine":false}"#), Some((vec!["a.pdf".to_string()], false))); } #[test] @@ -398,13 +438,31 @@ mod tests { fn delivered_files_reach_the_ui_once_wake_it_and_bring_the_window_forward() { let server = Server::new(); // Before the window exists: queued, nothing to wake yet. - server.deliver(vec!["a.pdf".into()]); + server.deliver(vec!["a.pdf".into()], false); let ctx = egui::Context::default(); let mut poll = server.connect(&ctx, false); - server.deliver(vec!["b.pdf".into()]); + server.deliver(vec!["b.pdf".into()], false); let events = poll(); assert!(matches!(events.as_slice(), [OsEvent::Open(p)] if p == &["a.pdf", "b.pdf"])); assert!(poll().is_empty(), "each file is delivered once"); + // Files to combine (one launch each, from File Explorer) arrive as one Combine event, + // apart from files to open. + server.deliver(vec!["c.pdf".into()], true); + server.deliver(vec!["e.pdf".into()], true); + let events = poll(); + assert!(matches!(events.as_slice(), [OsEvent::Combine(c)] if c == &["c.pdf", "e.pdf"]), "{events:?}"); + // #2503 review: requests reach the UI in the order they arrived, so an Open after a + // Combine is handled after it and comes to the front instead of opening behind the + // Combine files tab. + server.deliver(vec!["f.pdf".into()], true); + server.deliver(vec!["g.pdf".into()], false); + server.deliver(vec!["h.pdf".into()], true); + let events = poll(); + assert!( + matches!(events.as_slice(), [OsEvent::Combine(f), OsEvent::Open(g), OsEvent::Combine(h)] if f == &["f.pdf"] && g == &["g.pdf"] && h == &["h.pdf"]), + "{events:?}" + ); + assert!(poll().is_empty()); } #[test] @@ -442,12 +500,15 @@ mod tests { #[test] fn a_second_launch_hands_its_files_to_the_first() { let path = socket("hand-off"); - let Claim::Primary(first) = claim_at(&path, &[], true) else { panic!("the first launch listens") }; + let Claim::Primary(first) = claim_at(&path, &[], true, false) else { panic!("the first launch listens") }; let mut poll = first.connect(&egui::Context::default(), false); - assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::HandedOff)); + assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::HandedOff)); assert!(matches!(poll().as_slice(), [OsEvent::Open(p)] if p == &["C:\\a.pdf"])); + // A --combine launch hands its file over for the Combine files tab (#585). + assert!(matches!(claim_at(&path, &["C:\\c.pdf".into()], true, true), Claim::HandedOff)); + assert!(matches!(poll().as_slice(), [OsEvent::Combine(p)] if p == &["C:\\c.pdf"])); // A launch with options keeps its own window and doesn't take over the socket. - assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false), Claim::Alone)); + assert!(matches!(claim_at(&path, &["C:\\b.pdf".into()], false, false), Claim::Alone)); assert!(poll().is_empty()); } @@ -462,7 +523,7 @@ mod tests { let (path, barrier) = (path.clone(), Arc::clone(&barrier)); std::thread::spawn(move || { barrier.wait(); - claim_at(&path, &[format!("C:\\{i}.pdf")], true) + claim_at(&path, &[format!("C:\\{i}.pdf")], true, false) }) }) .collect(); @@ -483,6 +544,6 @@ mod tests { })); // `symlink_metadata`, not `exists`, which follows the socket's reparse point and can fail. assert!(std::fs::symlink_metadata(&path).is_ok(), "the socket file stays behind"); - assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true), Claim::Primary(_))); + assert!(matches!(claim_at(&path, &["C:\\a.pdf".into()], true, false), Claim::Primary(_))); } } diff --git a/apps/pdfcraft/src/startup.rs b/apps/pdfcraft/src/startup.rs index be25da45b6..23db00a7b7 100644 --- a/apps/pdfcraft/src/startup.rs +++ b/apps/pdfcraft/src/startup.rs @@ -7,6 +7,8 @@ pub(super) struct Arguments { pub options: Vec<(String, String)>, pub control_file: Option, pub create_images: bool, + /// `--combine`: the files go to the Combine files tab. + pub combine: bool, pub new_window: bool, pub two_paged: bool, pub version: bool, @@ -33,6 +35,9 @@ impl Arguments { } "--control" => parsed.control_file = args.next(), "--create-images" => parsed.create_images = true, + // File Explorer's "Combine files in PdfCraft" (#585): the files go to the Combine + // files tab, of the running PdfCraft when there is one. + "--combine" => parsed.combine = true, "--new-window" => parsed.new_window = true, "--two-paged" => parsed.two_paged = true, flag if flag.starts_with("--two-paged=") => { diff --git a/apps/pdfcraft/src/tests.rs b/apps/pdfcraft/src/tests.rs index 3512f46b36..7c1f111b96 100644 --- a/apps/pdfcraft/src/tests.rs +++ b/apps/pdfcraft/src/tests.rs @@ -137,6 +137,31 @@ fn the_desktop_frame_is_turned_off_only_when_the_app_draws_one() { assert_eq!(native_options(false, true, eframe::Renderer::Glow).viewport.decorations, Some(false)); } +#[test] +fn the_window_and_windows_taskbar_icons_load_from_the_bundled_png() { + let icon = eframe::icon_data::from_png_bytes(super::APP_ICON_PNG).expect("the bundled PNG decodes as the window icon"); + assert!(icon.width > 0 && icon.height > 0); + assert_eq!(icon.rgba.len(), (icon.width as usize) * (icon.height as usize) * 4); + #[cfg(not(target_os = "windows"))] + assert_eq!(super::native_options(false, false, eframe::Renderer::Glow).viewport.icon.as_deref(), Some(&icon)); + #[cfg(target_os = "windows")] + { + // None would load eframe's default icon; only the explicit empty sentinel prevents + // its first-frame setter from overwriting our full-resolution ICON_BIG. + for renderer in [eframe::Renderer::Glow, eframe::Renderer::Wgpu] { + let viewport = super::native_options(false, false, renderer).viewport; + assert_eq!(viewport.icon.as_deref(), Some(&egui::IconData::default())); + } + assert_eq!((icon.width, icon.height), (256, 256)); + let mut installed = None; + super::install_taskbar_icon(super::APP_ICON_PNG, |icon| installed = Some(icon)); + assert!(installed.is_some(), "issue #3818: ICON_BIG loads and installs from APP_ICON_PNG"); + let mut bad = None; + super::install_taskbar_icon(b"not a png", |icon| bad = Some(icon)); + assert!(bad.is_none()); + } +} + #[test] fn a_software_renderer_is_shown_to_the_user() { use eframe::wgpu::DeviceType; @@ -654,6 +679,42 @@ fn the_control_file_replaces_the_one_a_previous_run_left() { let _ = std::fs::remove_dir_all(&dir); } +/// #3922: a control file created in a folder that lets every user read what's in it is still +/// readable by its user and SYSTEM only, like mode 0600 on Unix. +#[cfg(windows)] +#[test] +fn the_control_file_is_owner_only_in_a_folder_other_users_can_read() { + use pdfcraft_platform::owner_only; + let dir = scratch(); + let users = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(users.status.success()); + // What the folder hands down, for comparison: a plain file there is readable by Users. + std::fs::write(dir.join("plain.txt"), "x").unwrap(); + let me = owner_only::current_user_sid().unwrap(); + assert!(owner_only::foreign_access(&owner_only::sddl_of(&dir.join("plain.txt")).unwrap(), &me).is_some()); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + let sddl = owner_only::sddl_of(&dir.join("ctl.json")).unwrap(); + assert_eq!(owner_only::foreign_access(&sddl, &me), None, "{sddl}"); + let (json, names) = written(&dir); + assert_eq!(json["token"].as_str(), Some("t0ken")); + assert_eq!(names, ["ctl.json", "plain.txt"], "no private staging folder left behind"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Review on #3956: writing the control file reads the user's SID once, for both the private +/// folder and the file in it, rather than starting `whoami` for each. +#[cfg(windows)] +#[test] +fn writing_the_control_file_looks_up_the_users_sid_once() { + use pdfcraft_platform::owner_only::sid_lookups_on_this_thread; + let dir = scratch(); + let before = sid_lookups_on_this_thread(); + super::write_control_file(&dir.join("ctl.json").to_string_lossy(), 4242, "t0ken").unwrap(); + assert_eq!(sid_lookups_on_this_thread().saturating_sub(before), 1, "whoami runs once per control file"); + assert_eq!(written(&dir).0["token"].as_str(), Some("t0ken")); + let _ = std::fs::remove_dir_all(&dir); +} + /// X11 startup notification and docks match a window to its launcher by `StartupWMClass`, /// so it has to be the class the window carries: the app id, on Wayland and X11 alike. #[test] diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs index 55c0857878..29e48409ba 100644 --- a/crates/a11y/src/alt.rs +++ b/crates/a11y/src/alt.rs @@ -106,7 +106,13 @@ pub(crate) fn elements(doc: &Document) -> Vec { if !seen.insert(r) { continue; } - let Some(d) = doc.get(r).as_dict().cloned() else { continue }; + let target = doc.get(r); + if matches!(&*target, Object::Array(_) | Object::Ref(_)) { + // An indirect /K array is a container, not an element or a parent. + stack.push(((*target).clone(), pg, parent, depth)); + continue; + } + let Some(d) = target.as_dict().cloned() else { continue }; if d.contains(b"S") { let pg = d.get(b"Pg").and_then(Object::as_ref).or(pg); if let Some(kids) = d.get(b"K") { diff --git a/crates/a11y/src/autotag.rs b/crates/a11y/src/autotag.rs index 5c0dc2bd19..1b898fdfda 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -9,6 +9,10 @@ use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; const MAX_PAGES: usize = 1_000; const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; const MAX_OPERATORS: usize = 100_000; +/// What all pages together may decode and keep: pages can share one stream, so the per-page limits +/// alone let a small file retain a plan for every page. Real documents stay far under these. +const MAX_TOTAL_BYTES: usize = 128 * 1024 * 1024; +const MAX_TOTAL_OPERATORS: usize = 2_000_000; const MAX_TEXT_OBJECTS: usize = 4_096; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -186,6 +190,7 @@ fn plan(doc: &Document) -> Result, AutoTagError> { } let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; + let (mut total_bytes, mut total_ops) = (0usize, 0usize); for (index, page) in pages.iter().enumerate() { if page.dict.contains(b"StructParents") { return Err(unsupported(index, "page already has a structure parent key")); @@ -197,10 +202,15 @@ fn plan(doc: &Document) -> Result, AutoTagError> { return Err(unsupported(index, "annotations need separate structure tags")); } let bytes = content_bytes(doc, page, index)?; + total_bytes = total_bytes.saturating_add(bytes.len()); let parsed = parse(&bytes); if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { return Err(unsupported(index, "content has malformed or too many operators")); } + total_ops = total_ops.saturating_add(parsed.ops.len()); + if total_bytes > MAX_TOTAL_BYTES || total_ops > MAX_TOTAL_OPERATORS { + return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); + } let spans = text_spans(&parsed.ops, index)?; total = total.saturating_add(spans.len()); out.push(Plan { page: page.obj, ops: parsed.ops, spans }); diff --git a/crates/a11y/src/autotag_fields.rs b/crates/a11y/src/autotag_fields.rs index 3203aca376..df15b3420d 100644 --- a/crates/a11y/src/autotag_fields.rs +++ b/crates/a11y/src/autotag_fields.rs @@ -10,6 +10,23 @@ fn reference(object: Option<&Object>, what: &str) -> Result { object.and_then(Object::as_ref).ok_or_else(|| format!("the {what} must be an indirect reference")) } +fn root_document_ref(doc: &Document, k: Option<&Object>) -> Result { + let Some(k) = k else { + return Err("the structure tree root K must be an indirect reference".into()); + }; + if let Some(r) = k.as_ref() + && doc.get(r).as_dict().is_some() + { + return Ok(r); + } + if let Some([item]) = doc.resolve(k).as_array().map(Vec::as_slice) + && let Some(r) = item.as_ref() + { + return Ok(r); + } + Err("the structure tree root K must be an indirect reference".into()) +} + fn nums(doc: &Document, parent_tree: ObjRef) -> Result, String> { let tree = doc.get(parent_tree); let tree = tree.as_dict().ok_or("the structure parent tree is invalid")?; @@ -31,10 +48,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let root = doc.root().ok_or("the PDF has no catalog")?; let catalog_object = doc.get(root); let catalog = catalog_object.as_dict().ok_or("the PDF catalog is invalid")?; - if !catalog.contains(b"AcroForm") { + if !catalog.get(b"AcroForm").is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) { return Err("the PDF has no AcroForm fields".into()); } - let existing_root = catalog.get(b"StructTreeRoot").cloned(); + let existing_root = catalog.get(b"StructTreeRoot").filter(|v| !matches!(*doc.resolve(v), Object::Null)).cloned(); let mark_info = catalog.get(b"MarkInfo").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_dict).cloned().unwrap_or_default(); let pages = pdfcraft_model::pages(doc); let mut widgets = Vec::new(); @@ -46,7 +63,9 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let Some(reference) = annot.as_ref() else { continue }; let resolved = doc.get(reference); let Some(dict) = resolved.as_dict() else { continue }; - if dict.name(b"Subtype") == Some(b"Widget") && !dict.contains(b"StructParent") && seen.insert(reference) { + let is_widget = dict.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Widget"); + let has_parent = dict.get(b"StructParent").map(|v| doc.resolve(v)).and_then(|v| v.as_int()).is_some_and(|k| k >= 0); + if is_widget && !has_parent && seen.insert(reference) { widgets.push((page.obj, reference)); } } @@ -54,16 +73,29 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { if widgets.is_empty() { return Err("the AcroForm has no untagged page widgets".into()); } + let count = i64::try_from(widgets.len()).map_err(|_| "too many form widgets")?; - let (struct_root, document_ref, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root { + let (struct_root, document_ref, mut existing_kids, parent_tree, mut parent_nums, first_key, new_tree) = if let Some(existing_root) = existing_root + { let struct_root = reference(Some(&existing_root), "structure tree root")?; let structure_object = doc.get(struct_root); let structure = structure_object.as_dict().ok_or("the structure tree root is invalid")?; - let document_ref = reference(structure.get(b"K"), "structure tree root K")?; - let document = doc.get(document_ref); - if document.as_dict().is_none_or(|d| d.name(b"S") != Some(b"Document")) { + let document_ref = root_document_ref(doc, structure.get(b"K"))?; + let document_obj = doc.get(document_ref); + let Some(document_dict) = document_obj.as_dict() else { + return Err("the structure tree root must point directly to a Document element".into()); + }; + if document_dict.get(b"S").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Document") { return Err("the structure tree root must point directly to a Document element".into()); } + let existing_kids = match document_dict.get(b"K") { + None => Vec::new(), + Some(k) => match &*doc.resolve(k) { + Object::Null => Vec::new(), + Object::Array(items) => items.clone(), + _ => vec![k.clone()], + }, + }; let parent_tree = reference(structure.get(b"ParentTree"), "structure parent tree")?; let parent_nums = nums(doc, parent_tree)?; if parent_nums.len() % 2 != 0 { @@ -71,14 +103,26 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { } let mut greatest = -1_i64; for pair in parent_nums.as_chunks::<2>().0 { - let Some(key) = pair[0].as_int() else { return Err("the structure parent tree has a non-integer key".into()) }; + let Some(key) = doc.resolve(&pair[0]).as_int() else { + return Err("the structure parent tree has a non-integer key".into()); + }; if key < 0 { return Err("the structure parent tree has a negative key".into()); } greatest = greatest.max(key); } - let first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; - (struct_root, document_ref, parent_tree, parent_nums, first_key, false) + let mut first_key = greatest.checked_add(1).ok_or("the structure parent tree has no available key")?; + if let Some(next_obj) = structure.get(b"ParentTreeNextKey") { + let resolved_next = doc.resolve(next_obj); + if !matches!(*resolved_next, Object::Null) { + let next_key = resolved_next.as_int().ok_or("the structure parent tree next key is invalid")?; + if next_key < 0 { + return Err("the structure parent tree next key is negative".into()); + } + first_key = first_key.max(next_key); + } + } + (struct_root, document_ref, existing_kids, parent_tree, parent_nums, first_key, false) } else { let mut root_dict = Dict::new(); root_dict.set(b"Type".to_vec(), Object::Name(b"StructTreeRoot".to_vec())); @@ -88,9 +132,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { document.set(b"P".to_vec(), Object::Ref(struct_root)); let document_ref = doc.add(Object::Dict(document)); let parent_tree = doc.add(Object::Dict(Dict::new())); - (struct_root, document_ref, parent_tree, Vec::new(), 0, true) + (struct_root, document_ref, Vec::new(), parent_tree, Vec::new(), 0, true) }; - let mut kids = Vec::with_capacity(widgets.len()); + // ISO 32000-1 14.7.4.4: reserve the next unused key before adding any parent-tree entries. + let next_key = first_key.checked_add(count).ok_or("the structure parent tree has no available next key")?; for (offset, (page, widget)) in widgets.iter().copied().enumerate() { let key = first_key.checked_add(i64::try_from(offset).map_err(|_| "too many form widgets")?).ok_or("too many form widgets")?; let mut objr = Dict::new(); @@ -105,16 +150,10 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { let form_ref = doc.add(Object::Dict(form)); doc.update_dict(widget, |d| d.set(b"StructParent".to_vec(), Object::Int(key))).map_err(|e| e.to_string())?; parent_nums.extend([Object::Int(key), Object::Ref(form_ref)]); - kids.push(Object::Ref(form_ref)); + existing_kids.push(Object::Ref(form_ref)); } doc.update_dict(document_ref, |d| { - let old = d.remove(b"K").unwrap_or(Object::Array(Vec::new())); - let mut all = match old { - Object::Array(items) => items, - item => vec![item], - }; - all.extend(kids); - d.set(b"K".to_vec(), Object::Array(all)); + d.set(b"K".to_vec(), Object::Array(existing_kids)); }) .map_err(|e| e.to_string())?; doc.update_dict(parent_tree, |d| d.set(b"Nums".to_vec(), Object::Array(parent_nums))).map_err(|e| e.to_string())?; @@ -122,6 +161,12 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { doc.update_dict(struct_root, |d| { d.set(b"K".to_vec(), Object::Ref(document_ref)); d.set(b"ParentTree".to_vec(), Object::Ref(parent_tree)); + d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key)); + }) + .map_err(|e| e.to_string())?; + } else { + doc.update_dict(struct_root, |d| { + d.set(b"ParentTreeNextKey".to_vec(), Object::Int(next_key)); }) .map_err(|e| e.to_string())?; } @@ -136,3 +181,87 @@ pub fn tag_form_fields(doc: &mut Document) -> Result { .map_err(|e| e.to_string())?; Ok(widgets.len()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::tags; + + fn pdf(bodies: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn existing_tree(next: &str) -> Document { + pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [4 0 R] >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] >>".into(), + format!("<< /Type /StructTreeRoot /K 6 0 R /ParentTree 7 0 R {next} >>"), + "<< /S /Document /P 5 0 R /K [] >>".into(), + "<< /Nums [7 []] >>".into(), + "null".into(), + ]) + } + + #[test] + fn extending_tree_creates_missing_next_key() { + for next in ["", "/ParentTreeNextKey null", "/ParentTreeNextKey 8 0 R"] { + let mut doc = existing_tree(next); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + assert_eq!(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().int(b"StructParent"), Some(8)); + assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(9)); + } + } + + #[test] + fn exhausted_next_key_rejects_before_mutation() { + let mut doc = existing_tree(&format!("/ParentTreeNextKey {}", i64::MAX)); + let before = doc.get(ObjRef::new(4, 0)); + let objects = doc.object_numbers().len(); + assert!(tag_form_fields(&mut doc).is_err()); + assert_eq!(doc.get(ObjRef::new(4, 0)), before); + assert_eq!(doc.object_numbers().len(), objects); + } + + #[test] + fn extends_existing_tree_with_array_root_k_indirect_document_kids_and_indirect_nums_keys() { + let mut doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 0 R] >> /MarkInfo << /Marked true >> /StructTreeRoot 6 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] /StructParents 0 >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /Rect [20 60 120 80] /StructParent null >>".into(), + "<< /Type /StructTreeRoot /K [7 0 R] /ParentTree 8 0 R /ParentTreeNextKey 5 >>".into(), + "<< /S /Document /P 6 0 R /K 10 0 R >>".into(), + "<< /Nums [11 0 R [9 0 R]] >>".into(), + "<< /S /P /P 7 0 R /Pg 3 0 R /K 0 >>".into(), + "[9 0 R]".into(), + "0".into(), + ]); + assert_eq!(tag_form_fields(&mut doc).unwrap(), 1); + // Existing paragraph (9 0 R) and new Form tag remain flat siblings under /Document (7 0 R). + let doc_k = doc.get(ObjRef::new(7, 0)).as_dict().unwrap().get(b"K").unwrap().as_array().unwrap().clone(); + assert_eq!(doc_k.len(), 2); + assert_eq!(doc_k[0].as_ref(), Some(ObjRef::new(9, 0))); + let listed: Vec<_> = tags(&doc).into_iter().map(|t| t.kind).collect(); + assert_eq!(listed, ["Document", "P", "Form"]); + // Widget got key 5 from /ParentTreeNextKey, and /ParentTreeNextKey advanced to 6. + assert_eq!(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().int(b"StructParent"), Some(5)); + assert_eq!(doc.get(ObjRef::new(6, 0)).as_dict().unwrap().int(b"ParentTreeNextKey"), Some(6)); + } +} diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs index d548c57f9d..118b7af4e5 100644 --- a/crates/a11y/src/content.rs +++ b/crates/a11y/src/content.rs @@ -73,7 +73,8 @@ impl Walk<'_> { // Marked-content stack: whether each level is tagged or an artifact. let mut stack: Vec = Vec::new(); let mut untagged = 0; - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: the walk keeps counts, never the stream's operators. + for op in pdfcraft_content::ops(data) { let inside = covered || stack.last().copied().unwrap_or(false); match op.op.as_slice() { b"BMC" => stack.push(inside || op.name(0) == Some(b"Artifact")), diff --git a/crates/a11y/src/layout_table.rs b/crates/a11y/src/layout_table.rs new file mode 100644 index 0000000000..6b2bab2d09 --- /dev/null +++ b/crates/a11y/src/layout_table.rs @@ -0,0 +1,359 @@ +//! A layout table as plain blocks: a table used only to place content (label and value pairs, a +//! letterhead grid) is read as a data table, with headers and cell navigation it doesn't have. One +//! edit turns the table, its row groups and rows into Div elements and its cells into P elements +//! (Div when a cell holds tags of its own), keeping the content, its order and everything inside +//! the cells, and drops the Table attributes (summary, scope, spans) they no longer have (#4105). + +use std::collections::HashSet; + +use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object}; + +use crate::alt; + +/// The most structure elements one table may have: past it the edit is refused. +const MAX_ELEMENTS: usize = 100_000; +/// The deepest nesting of direct (inline) elements followed. +const MAX_DEPTH: usize = 256; + +#[derive(Debug, thiserror::Error, PartialEq)] +pub enum LayoutTableError { + #[error("object {0} is not a reachable indirect structure tag")] + NotATag(ObjRef), + #[error("the tag is not a table")] + NotATable, + #[error("the table has too many tags, or tags nested too deeply")] + TooLarge, + #[error(transparent)] + Cos(#[from] CosError), +} + +/// What a table part becomes; `None` for anything else (which keeps its type and contents). +fn block_type(ty: &[u8], has_tags: bool, root: bool) -> Option<&'static str> { + match ty { + b"Table" if root => Some("Div"), + b"THead" | b"TBody" | b"TFoot" | b"TR" => Some("Div"), + b"TH" | b"TD" | b"Caption" => Some(if has_tags { "Div" } else { "P" }), + _ => None, + } +} + +struct Retag<'a> { + doc: &'a Document, + role_map: Dict, + /// Indirect elements under the table still to look at. + queue: Vec, + /// How many elements changed. + changed: usize, +} + +impl Retag<'_> { + fn standard(&self, d: &Dict) -> Option> { + Some(alt::standard(self.doc, &self.role_map, d.name(b"S")?)) + } + + /// Whether children `k` include structure elements (indirect or direct, also in an indirect + /// array of children). + fn has_tags(&self, k: &Object, depth: usize) -> bool { + if depth > MAX_DEPTH { + return false; + } + match k { + Object::Array(items) => items.iter().any(|i| self.has_tags(i, depth + 1)), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) => d.contains(b"S"), + array @ Object::Array(_) => self.has_tags(array, depth + 1), + _ => false, + }, + Object::Dict(d) => d.contains(b"S"), + _ => false, + } + } + + /// Element `d` as a block when it is a part of the table (`root`: the table itself), with its + /// direct children converted the same way and its indirect ones queued; `None` otherwise. + fn convert(&mut self, d: &Dict, root: bool, depth: usize) -> Result, LayoutTableError> { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + let Some(ty) = self.standard(d) else { return Ok(None) }; + // As written: a single child is often a reference to it, which must stay one. + let kids = d.get(b"K").cloned(); + let has_tags = kids.as_ref().is_some_and(|k| self.has_tags(k, 0)); + let Some(block) = block_type(&ty, has_tags, root) else { return Ok(None) }; + let mut out = d.clone(); + out.set(b"S".to_vec(), Object::name(block)); + self.drop_table_attributes(&mut out); + if let Some(kids) = kids { + let kids = self.convert_kids(&kids, depth + 1)?; + out.set(b"K".to_vec(), kids); + } + self.changed = self.changed.saturating_add(1); + Ok(Some(out)) + } + + /// Children `k` with direct table parts converted and indirect elements queued (references + /// stay references; an indirect array of children is written out only when a direct table + /// part in it changed). + fn convert_kids(&mut self, k: &Object, depth: usize) -> Result { + if depth > MAX_DEPTH { + return Err(LayoutTableError::TooLarge); + } + Ok(match k { + Object::Array(items) => Object::Array(items.iter().map(|i| self.convert_kids(i, depth)).collect::>()?), + Object::Ref(r) => match &*self.doc.get(*r) { + Object::Dict(d) if d.contains(b"S") => { + self.queue.push(*r); + k.clone() + } + array @ Object::Array(_) => { + let converted = self.convert_kids(array, depth + 1)?; + if converted == *array { k.clone() } else { converted } + } + _ => k.clone(), + }, + Object::Dict(d) if d.contains(b"S") => match self.convert(d, false, depth)? { + Some(block) => Object::Dict(block), + None => k.clone(), + }, + _ => k.clone(), + }) + } + + /// Remove the Table attribute objects (`/O /Table`) from `d`'s `/A`, keeping the others and + /// their revision numbers; an `/A` left empty goes. + fn drop_table_attributes(&self, d: &mut Dict) { + let Some(a) = d.get(b"A").cloned() else { return }; + let is_table = |o: &Object| self.doc.resolve(o).as_dict().is_some_and(|x| x.name(b"O") == Some(b"Table")); + let resolved = self.doc.resolve(&a); + let Object::Array(items) = &*resolved else { + if is_table(&a) { + d.remove(b"A"); + } + return; + }; + let mut kept = Vec::with_capacity(items.len()); + let mut dropping = false; + for item in items { + match item { + // A revision number goes with the attribute object before it. + Object::Int(_) if dropping => {} + _ => { + dropping = is_table(item); + if !dropping { + kept.push(item.clone()); + } + } + } + } + if kept.is_empty() { + d.remove(b"A"); + } else if kept.len() != items.len() { + d.set(b"A".to_vec(), Object::Array(kept)); + } + } +} + +/// Turn layout table `table` (a reachable indirect tag whose type is Table after the role map) into +/// Div and P elements as the module documentation says; returns how many elements changed. Tables +/// nested in its cells stay tables. +pub fn layout_table_to_blocks(doc: &mut Document, table: ObjRef) -> Result { + let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == table) else { + return Err(LayoutTableError::NotATag(table)); + }; + let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); + let mut retag = Retag { doc: &*doc, role_map, queue: Vec::new(), changed: 0 }; + if retag.standard(&element.dict).as_deref() != Some(b"Table".as_slice()) { + return Err(LayoutTableError::NotATable); + } + // Read everything first, then write: a refused table changes nothing. + let mut updates: Vec<(ObjRef, Dict)> = Vec::new(); + let mut seen = HashSet::new(); + if let Some(block) = retag.convert(&element.dict, true, 0)? { + seen.insert(table); + updates.push((table, block)); + } + while let Some(r) = retag.queue.pop() { + if !seen.insert(r) { + continue; + } + if seen.len() > MAX_ELEMENTS { + return Err(LayoutTableError::TooLarge); + } + let Some(d) = retag.doc.get(r).as_dict().cloned() else { continue }; + if let Some(block) = retag.convert(&d, false, 0)? { + updates.push((r, block)); + } + } + let changed = retag.changed; + for (r, block) in updates { + doc.update_dict(r, |d| *d = block)?; + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::{Document, ObjRef, Object}; + + use super::*; + + /// A tagged document: the catalog (1), the structure tree root (2, `root` its other entries) + /// over the Document element (3), then `objects` from 4 on. + fn doc(root: &str, objects: &[&str]) -> Document { + let mut bodies = vec!["<< /Type /Catalog /StructTreeRoot 2 0 R >>".to_string(), format!("<< /Type /StructTreeRoot /K 3 0 R {root} >>")]; + bodies.extend(objects.iter().map(|s| s.to_string())); + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn r(n: u32) -> ObjRef { + ObjRef::new(n, 0) + } + + fn dict(doc: &Document, n: u32) -> Dict { + doc.get(r(n)).as_dict().cloned().unwrap() + } + + fn ty(doc: &Document, n: u32) -> Vec { + dict(doc, n).name(b"S").unwrap().to_vec() + } + + #[test] + fn a_label_and_value_table_becomes_divs_and_paragraphs() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + // 4: the table, with a layout attribute and a summary (revision 1). + "<< /S /Table /K [5 0 R 8 0 R] /A [ << /O /Layout /Placement /Block >> << /O /Table /Summary (s) >> 1 ] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + "<< /S /TH /K 0 /A << /O /Table /Scope /Row >> >>", + "<< /S /TD /K 1 >>", + "<< /S /TR /K [9 0 R 10 0 R] >>", + "<< /S /TD /K 2 >>", + "<< /S /TD /K 3 /A [ << /O /Table /ColSpan 1 >> << /O /Layout /TextAlign /End >> ] >>", + ], + ); + let before: Vec = crate::tags(&d).iter().map(|t| t.object).collect(); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(7)); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P"), (7, "P"), (8, "Div"), (9, "P"), (10, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + // The content and the order stay; Table attributes go, layout ones stay. + assert_eq!(crate::tags(&d).iter().map(|t| t.object).collect::>(), before); + assert_eq!(dict(&d, 6).get(b"K"), Some(&Object::Int(0))); + assert_eq!(dict(&d, 6).get(b"A"), None); + let Some(Object::Array(a)) = dict(&d, 4).get(b"A").cloned() else { panic!("{:?}", dict(&d, 4)) }; + assert_eq!(a.len(), 1, "the summary and its revision number go: {a:?}"); + assert_eq!(a[0].as_dict().and_then(|x| x.name(b"O")), Some(&b"Layout"[..])); + let Some(Object::Array(a)) = dict(&d, 10).get(b"A").cloned() else { panic!() }; + assert_eq!(a.len(), 1); + } + + #[test] + fn what_the_cells_hold_stays_and_a_table_in_a_cell_stays_a_table() { + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Table /K [5 0 R] >>", + "<< /S /TR /K [6 0 R 7 0 R] >>", + // 6: a cell holding a paragraph; 7: a cell holding a table of its own. + "<< /S /TD /K [8 0 R] >>", + "<< /S /TD /K [9 0 R] >>", + "<< /S /P /K 0 >>", + "<< /S /Table /K [10 0 R] >>", + "<< /S /TR /K [11 0 R] >>", + "<< /S /TD /K 1 >>", + ], + ); + layout_table_to_blocks(&mut d, r(4)).unwrap(); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "Div"), (7, "Div"), (8, "P"), (9, "Table"), (10, "TR"), (11, "TD")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + } + + #[test] + fn direct_rows_and_cells_are_converted_in_place() { + let mut d = doc( + "/RoleMap << /Grid /Table /Row /TR >>", + &[ + "<< /S /Document /K [4 0 R] >>", + "<< /S /Grid /K [ << /S /Row /K [ << /S /TD /K 0 >> << /S /TD /K [5 0 R] >> ] >> ] >>", + "<< /S /Span /K 1 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(4)); + let table = dict(&d, 4); + assert_eq!(table.name(b"S"), Some(&b"Div"[..]), "the role map makes /Grid a table"); + let Some(Object::Array(rows)) = table.get(b"K").cloned() else { panic!() }; + let row = rows[0].as_dict().unwrap(); + assert_eq!(row.name(b"S"), Some(&b"Div"[..])); + let Some(Object::Array(cells)) = row.get(b"K").cloned() else { panic!() }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1].as_dict().and_then(|c| c.name(b"S")), Some(&b"Div"[..]), "it holds a Span tag"); + assert_eq!(ty(&d, 5), b"Span"); + } + + #[test] + fn only_a_reachable_table_is_changed() { + let mut d = doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /P /K 0 >>", "<< /S /Table >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Err(LayoutTableError::NotATable)); + assert_eq!(layout_table_to_blocks(&mut d, r(5)), Err(LayoutTableError::NotATag(r(5))), "not in the structure tree"); + assert_eq!(layout_table_to_blocks(&mut d, ObjRef::new(4, 1)), Err(LayoutTableError::NotATag(ObjRef::new(4, 1)))); + assert_eq!(ty(&d, 5), b"Table"); + } + + #[test] + fn a_single_child_written_as_a_reference_stays_its_own_object() { + // /K 5 0 R: the table's one row, the row's one cell; 8: a row whose cells are an indirect + // array (object 9) holding a direct cell and a reference. + let mut d = doc( + "", + &[ + "<< /S /Document /K [4 0 R 7 0 R] >>", + "<< /S /Table /K 5 0 R >>", + "<< /S /TR /K 6 0 R >>", + "<< /S /TD /K 0 >>", + "<< /S /Table /K 8 0 R >>", + "<< /S /TR /K 9 0 R >>", + "[ << /S /TD /K 1 >> 10 0 R ]", + "<< /S /TD /K 2 >>", + ], + ); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!(dict(&d, 4).get(b"K"), Some(&Object::Ref(r(5))), "the row stays a reference"); + assert_eq!(dict(&d, 5).get(b"K"), Some(&Object::Ref(r(6)))); + for (n, want) in [(4, "Div"), (5, "Div"), (6, "P")] { + assert_eq!(ty(&d, n), want.as_bytes(), "object {n}"); + } + assert!(crate::tags(&d).iter().any(|t| t.object == r(6)), "the cell is still listed"); + assert_eq!(layout_table_to_blocks(&mut d, r(7)), Ok(4)); + let Some(Object::Array(cells)) = dict(&d, 8).get(b"K").cloned() else { panic!("{:?}", dict(&d, 8)) }; + assert_eq!(cells[0].as_dict().and_then(|c| c.name(b"S")), Some(&b"P"[..])); + assert_eq!(cells[1], Object::Ref(r(10))); + assert_eq!(ty(&d, 10), b"P"); + } + + #[test] + fn rows_that_point_at_each_other_end() { + // A row listed twice and a cell that lists its own row: each is converted once. + let mut d = + doc("", &["<< /S /Document /K [4 0 R] >>", "<< /S /Table /K [5 0 R 5 0 R] >>", "<< /S /TR /K [6 0 R] >>", "<< /S /TD /K [5 0 R] >>"]); + assert_eq!(layout_table_to_blocks(&mut d, r(4)), Ok(3)); + assert_eq!((ty(&d, 5), ty(&d, 6)), (b"Div".to_vec(), b"Div".to_vec())); + } +} diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs index 280f4e845f..50e632dec8 100644 --- a/crates/a11y/src/lib.rs +++ b/crates/a11y/src/lib.rs @@ -17,6 +17,7 @@ mod autotag; mod autotag_fields; mod content; mod headings; +mod layout_table; mod report; mod structure; mod tags; @@ -41,6 +42,7 @@ pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_ta pub use autotag::{AutoTagError, AutoTagResult, autotag_text}; pub use autotag_fields::tag_form_fields; pub use headings::{Heading, headings}; +pub use layout_table::{LayoutTableError, layout_table_to_blocks}; pub use report::{REPORT_WORDS, report_html}; pub use tags::{TagContentItem, TagError, TagInfo, TagPlacement, TagProperty, move_tag, set_tag_property, tag_content, tags}; diff --git a/crates/a11y/src/tags.rs b/crates/a11y/src/tags.rs index ba9d639018..ab38f8aece 100644 --- a/crates/a11y/src/tags.rs +++ b/crates/a11y/src/tags.rs @@ -101,6 +101,8 @@ pub enum TagError { NotSiblings, #[error("the parent must list each tag exactly once in its /K array")] AmbiguousChildren, + #[error("the tag has too much or too deeply nested marked content")] + ContentLimit, #[error("the tag is already at the requested position")] AlreadyThere, #[error("{0} applies only to {1} tags")] @@ -118,7 +120,13 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T return Err(TagError::NotSiblings); } let reachable = alt::elements(doc); - if !reachable.iter().any(|e| e.object == object) || !reachable.iter().any(|e| e.object == anchor) { + let Some(obj_elem) = reachable.iter().find(|e| e.object == object) else { + return Err(TagError::NotSiblings); + }; + let Some(anc_elem) = reachable.iter().find(|e| e.object == anchor) else { + return Err(TagError::NotSiblings); + }; + if obj_elem.parent != anc_elem.parent { return Err(TagError::NotSiblings); } let parent_of = |r| doc.get(r).as_dict().and_then(|d| d.get(b"P").and_then(Object::as_ref)); @@ -126,8 +134,23 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T if parent_of(anchor) != Some(parent) { return Err(TagError::NotSiblings); } + let valid_parent = match obj_elem.parent { + Some(expected) => parent == expected, + None => doc + .root() + .and_then(|r| doc.get(r).as_dict().and_then(|c| c.get(b"StructTreeRoot").and_then(Object::as_ref))) + .is_some_and(|root_ref| parent == root_ref), + }; + if !valid_parent { + return Err(TagError::NotSiblings); + } let owner = doc.get(parent); - let Some(children) = owner.as_dict().and_then(|d| d.get(b"K").and_then(Object::as_array)) else { + let mut k_obj = owner.as_dict().and_then(|d| d.get(b"K")).cloned(); + for _ in 0..16 { + let Some(Object::Ref(r)) = k_obj else { break }; + k_obj = Some((*doc.get(r)).clone()); + } + let Some(children) = k_obj.and_then(|k| k.as_array().cloned()) else { return Err(TagError::AmbiguousChildren); }; let one_position = |child| { @@ -135,7 +158,7 @@ pub fn move_tag(doc: &mut Document, object: ObjRef, anchor: ObjRef, placement: T matches.next().and_then(|(index, _)| matches.next().is_none().then_some(index)) }; let (Some(from), Some(to)) = (one_position(object), one_position(anchor)) else { return Err(TagError::AmbiguousChildren) }; - let mut reordered = children.clone(); + let mut reordered = children; let moved = reordered.remove(from); let target = if from < to { to - 1 } else { to }; let destination = target + usize::from(placement == TagPlacement::After); @@ -151,6 +174,10 @@ fn text(doc: &Document, d: &pdfcraft_cos::Dict, key: &[u8]) -> Option { d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(PdfString::to_text)) } +fn tag_type_name(doc: &Document, d: &Dict) -> Option> { + d.get(b"S").map(|s| doc.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec)) +} + /// List reachable, indirect tags in structure-tree order. pub fn tags(doc: &Document) -> Vec { let pages = pdfcraft_model::pages(doc); @@ -162,7 +189,7 @@ pub fn tags(doc: &Document) -> Vec { object: e.object, parent: e.parent, depth: e.depth, - kind: String::from_utf8_lossy(e.dict.name(b"S")?).into_owned(), + kind: String::from_utf8_lossy(&tag_type_name(doc, &e.dict)?).into_owned(), title: text(doc, &e.dict, b"T"), actual_text: text(doc, &e.dict, b"ActualText"), alt: text(doc, &e.dict, b"Alt"), @@ -176,13 +203,16 @@ pub fn tags(doc: &Document) -> Vec { } /// List marked-content references directly owned by an indirect tag. +/// Returns `ContentLimit` rather than a partial list beyond 65,536 marks or 512 child/reference +/// levels. Child structure elements do not count as direct marks. pub fn tag_content(doc: &Document, object: ObjRef) -> Result, TagError> { let Some(element) = alt::elements(doc).into_iter().find(|e| e.object == object) else { return Err(TagError::NotATag(object)) }; let pages = pdfcraft_model::pages(doc); let page_index: std::collections::HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect(); let mut marks = Vec::new(); + let mut seen = std::collections::HashSet::new(); if let Some(kids) = element.dict.get(b"K") { - direct_marks(doc, kids, element.page, &mut marks); + direct_marks(doc, kids, element.page, &mut seen, &mut marks)?; } // Each page's content is parsed once, however many of its MCIDs the tag owns. let mut texts: std::collections::HashMap> = std::collections::HashMap::new(); @@ -206,25 +236,63 @@ pub fn tag_content(doc: &Document, object: ObjRef) -> Result .collect()) } -fn direct_marks(doc: &Document, item: &Object, page: Option, out: &mut Vec<(Option, i64)>) { - match item { - Object::Int(mcid) => out.push((page, *mcid)), - Object::Array(items) => items.iter().for_each(|item| direct_marks(doc, item, page, out)), - Object::Dict(dict) if dict.name(b"Type") == Some(b"MCR") => { - if let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) { - out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); - } +/// Bound both graph work and the queued array entries. Following references never uses the +/// call stack; the depth limit also rejects pathologically nested direct content explicitly. +fn direct_marks( + doc: &Document, + item: &Object, + page: Option, + seen: &mut std::collections::HashSet, + out: &mut Vec<(Option, i64)>, +) -> Result<(), TagError> { + const MAX_DEPTH: usize = 512; + const MAX_MARKS: usize = 65_536; + // Account for the root `/K` container separately so an array of 65,536 entries passes. + let mut remaining = MAX_MARKS + 1; + let mut stack = vec![(std::sync::Arc::new(item.clone()), 0usize)]; + while let Some((item, depth)) = stack.pop() { + remaining = remaining.checked_sub(1).ok_or(TagError::ContentLimit)?; + if depth > MAX_DEPTH { + return Err(TagError::ContentLimit); } - Object::Ref(reference) => { - if let Some(dict) = doc.get(*reference).as_dict() - && dict.name(b"Type") == Some(b"MCR") - && let Some(mcid) = dict.get(b"MCID").and_then(Object::as_int) - { - out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + match &*item { + Object::Int(mcid) => { + if out.len() >= MAX_MARKS { + return Err(TagError::ContentLimit); + } + out.push((page, *mcid)); } + Object::Array(items) => { + if items.len() > remaining.saturating_sub(stack.len()) { + return Err(TagError::ContentLimit); + } + stack.extend(items.iter().rev().map(|item| (std::sync::Arc::new(item.clone()), depth + 1))); + } + Object::Dict(dict) if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) == Some(b"MCR") => { + if let Some(mcid) = dict.get(b"MCID").map(|m| doc.resolve(m)).and_then(|m| m.as_int()) { + if out.len() >= MAX_MARKS { + return Err(TagError::ContentLimit); + } + out.push((dict.get(b"Pg").and_then(Object::as_ref).or(page), mcid)); + } + } + Object::Ref(reference) if seen.insert(*reference) => { + let target = doc.get(*reference); + // Resolved non-MCR dictionaries are child structure elements, not direct content, + // so do not push them onto the stack or charge them a second time. + let is_non_mcr_dict = matches!( + &*target, + Object::Dict(dict) + if dict.get(b"Type").map(|t| doc.resolve(t)).as_deref().and_then(Object::as_name) != Some(b"MCR") + ); + if !is_non_mcr_dict { + stack.push((target, depth + 1)); + } + } + _ => {} } - _ => {} } + Ok(()) } /// The attribute objects of a tag's `/A` entry (one object, or an array of them each optionally @@ -236,8 +304,8 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option)> let Object::Array(items) = &*resolved else { return (vec![(a.clone(), None)], false) }; let mut out: Vec<(Object, Option)> = Vec::new(); for item in items { - match (item, out.last_mut()) { - (Object::Int(_), Some((_, revision @ None))) => *revision = Some(item.clone()), + match (doc.resolve(item).as_int(), out.last_mut()) { + (Some(_), Some((_, revision @ None))) => *revision = Some(item.clone()), _ => out.push((item.clone(), None)), } } @@ -245,13 +313,18 @@ fn attribute_entries(doc: &Document, d: &Dict) -> (Vec<(Object, Option)> } /// Whether attribute object `o` (resolved) is a Table attribute object. -fn is_table_attributes(o: &Object) -> bool { - o.as_dict().is_some_and(|d| d.name(b"O") == Some(b"Table")) +fn is_table_attributes(doc: &Document, o: &Object) -> bool { + o.as_dict().and_then(|d| d.get(b"O")).map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) == Some(b"Table") } /// The value of `key` in the first Table attribute object of tag `d` that has it. fn table_attribute(doc: &Document, d: &Dict, key: &[u8]) -> Option { - attribute_entries(doc, d).0.iter().map(|(o, _)| doc.resolve(o)).filter(|o| is_table_attributes(o)).find_map(|o| o.as_dict()?.get(key).cloned()) + attribute_entries(doc, d) + .0 + .iter() + .map(|(o, _)| doc.resolve(o)) + .filter(|o| is_table_attributes(doc, o)) + .find_map(|o| o.as_dict()?.get(key).map(|v| (*doc.resolve(v)).clone()).filter(|v| !matches!(v, Object::Null))) } /// Set `key` in the Table attribute object of `object`'s `/A` entry that holds it (else the first @@ -266,11 +339,12 @@ fn set_table_attribute(doc: &mut Document, object: ObjRef, key: &[u8], value: Op let mut entries: Vec<(Object, Option, Option)> = entries .into_iter() .map(|(o, r)| { - let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(a)).and_then(|a| a.as_dict().cloned()); + let table = Some(doc.resolve(&o)).filter(|a| is_table_attributes(doc, a)).and_then(|a| a.as_dict().cloned()); (o, r, table) }) .collect(); - let holding = |e: &(Object, Option, Option)| e.2.as_ref().is_some_and(|d| d.contains(key)); + let holding = + |e: &(Object, Option, Option)| e.2.as_ref().and_then(|d| d.get(key)).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)); match value { Some(v) => match entries.iter().position(holding).or_else(|| entries.iter().position(|e| e.2.is_some())) { Some(i) => { @@ -331,7 +405,7 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert if let Some(owner) = property.table_attribute_of() { let role_map = alt::tree_root(doc).and_then(|r| r.get(b"RoleMap").map(|o| doc.resolve(o))).and_then(|o| o.as_dict().cloned()).unwrap_or_default(); - let ty = element.dict.name(b"S").map(|s| alt::standard(doc, &role_map, s)).unwrap_or_default(); + let ty = tag_type_name(doc, &element.dict).map(|s| alt::standard(doc, &role_map, &s)).unwrap_or_default(); if ty != owner.as_bytes() { let name = if property == TagProperty::Summary { "summary" } else { "scope" }; return Err(TagError::WrongTagType(name, owner)); @@ -357,3 +431,168 @@ pub fn set_tag_property(doc: &mut Document, object: ObjRef, property: TagPropert })?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn pdf(bodies: &[String]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn tag_content_and_table_attributes_resolve_indirect_entries() { + let content = "/P << /MCID 0 >> BDC BT /F1 12 Tf (Hello) Tj ET EMC /P << /MCID 1 >> BDC BT /F1 12 Tf (World) Tj ET EMC"; + let doc = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /StructTreeRoot /K 6 0 R >>".into(), + "<< /S /Document /P 5 0 R /K [7 0 R 8 0 R] >>".into(), + "<< /S /Table /P 6 0 R /Pg 3 0 R /A << /O 11 0 R /Summary 12 0 R >> /K 9 0 R >>".into(), + "<< /S /TH /P 6 0 R /Pg 3 0 R /A [ << /O 11 0 R /Scope 13 0 R >> 14 0 R ] /K 10 0 R >>".into(), + "[15 0 R << /Type /MCR /MCID 16 0 R >>]".into(), + "15 0 R".into(), + "/Table".into(), + "(Indirect summary)".into(), + "/Column".into(), + "1".into(), + "0".into(), + "1".into(), + ]); + let listed = tags(&doc); + let table = listed.iter().find(|t| t.object == ObjRef::new(7, 0)).unwrap(); + let th = listed.iter().find(|t| t.object == ObjRef::new(8, 0)).unwrap(); + assert_eq!(table.summary.as_deref(), Some("Indirect summary")); + assert_eq!(th.scope.as_deref(), Some("Column")); + + let table_marks = tag_content(&doc, ObjRef::new(7, 0)).unwrap(); + assert_eq!( + table_marks, + [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }, TagContentItem { page: Some(0), mcid: 1, text: "World".into() },] + ); + let th_marks = tag_content(&doc, ObjRef::new(8, 0)).unwrap(); + assert_eq!(th_marks, [TagContentItem { page: Some(0), mcid: 0, text: "Hello".into() }]); + } + + #[test] + fn move_tag_resolves_indirect_array_children_and_cycles() { + let mut doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /Document /P 2 0 R /K 7 0 R >>".into(), + "<< /S /P /P 3 0 R >>".into(), + "<< /S /P /P 3 0 R >>".into(), + "[4 0 R 6 0 R 5 0 R]".into(), + "6 0 R".into(), + ]); + let first = ObjRef::new(4, 7); + let second = ObjRef::new(5, 9); + doc.set(first, (*doc.get(ObjRef::new(4, 0))).clone()); + doc.set(second, (*doc.get(ObjRef::new(5, 0))).clone()); + doc.set(ObjRef::new(6, 0), Object::Array(vec![Object::Ref(first), Object::Ref(ObjRef::new(6, 0)), Object::Ref(second)])); + assert_eq!(tags(&doc).iter().map(|t| t.object).collect::>(), [ObjRef::new(3, 0), first, second]); + move_tag(&mut doc, second, first, TagPlacement::Before).unwrap(); + assert_eq!(tags(&doc).iter().map(|t| t.object).collect::>(), [ObjRef::new(3, 0), second, first]); + } + + #[test] + fn tag_content_rejects_excessive_indirect_depth() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + let mut kid = doc.add(Object::Int(42)); + for _ in 0..128 { + kid = doc.add(Object::Ref(kid)); + } + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [42]); + for _ in 0..1024 { + kid = doc.add(Object::Ref(kid)); + } + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Ref(kid))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit)); + } + + #[test] + fn tag_content_rejects_excessive_mark_count() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_537]))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)), Err(TagError::ContentLimit)); + } + + #[test] + fn tag_content_accepts_full_mark_limit() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Int(1); 65_536]))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().len(), 65_536); + } + + #[test] + fn tag_content_skips_many_child_elements_without_spending_mark_budget() { + let mut doc = + pdf(&["<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), "<< /Type /StructTreeRoot /K 3 0 R >>".into(), "<< /S /P /P 2 0 R >>".into()]); + let mut kids = Vec::new(); + for _ in 0..33_000 { + let mut child = Dict::new(); + child.set(b"S".to_vec(), Object::name("P")); + kids.push(Object::Ref(doc.add(Object::Dict(child)))); + } + kids.push(Object::Int(42)); + doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"K".to_vec(), Object::Array(kids))).unwrap(); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [42]); + } + + #[test] + fn tag_content_keeps_order_and_stops_indirect_cycles() { + let doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /P /P 2 0 R /K 4 0 R >>".into(), + "[1 5 0 R 4 0 R 3]".into(), + "<< /Type /MCR /MCID 2 >>".into(), + ]); + assert_eq!(tag_content(&doc, ObjRef::new(3, 0)).unwrap().iter().map(|m| m.mcid).collect::>(), [1, 2, 3]); + } + + #[test] + fn move_tag_rejects_non_sibling_tags_sharing_stale_parent_ref() { + // 8 0 R is a child of 6 0 R, while 9 0 R is a child of 7 0 R, even though both point /P at 6 0 R. + let mut doc = pdf(&[ + "<< /Type /Catalog /StructTreeRoot 2 0 R >>".into(), + "<< /Type /StructTreeRoot /K 3 0 R >>".into(), + "<< /S /Document /P 2 0 R /K [6 0 R 7 0 R] >>".into(), + "<< /S /P >>".into(), + "<< /S /P >>".into(), + "<< /S /Sect /P 3 0 R /K [8 0 R 9 0 R] >>".into(), + "<< /S /Sect /P 3 0 R /K [9 0 R] >>".into(), + "<< /S /P /P 6 0 R >>".into(), + "<< /S /P /P 6 0 R >>".into(), + ]); + // Give 6 0 R only [8 0 R] in the reachable tree, and put [8 0 R 9 0 R] on an unreachable object 4 0 R. + doc.update_dict(ObjRef::new(6, 0), |d| d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))).unwrap(); + doc.update_dict(ObjRef::new(4, 0), |d| { + d.set(b"K".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0)), Object::Ref(ObjRef::new(9, 0))])); + }) + .unwrap(); + doc.update_dict(ObjRef::new(8, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap(); + doc.update_dict(ObjRef::new(9, 0), |d| d.set(b"P".to_vec(), Object::Ref(ObjRef::new(4, 0)))).unwrap(); + assert_eq!(move_tag(&mut doc, ObjRef::new(9, 0), ObjRef::new(8, 0), TagPlacement::Before), Err(TagError::NotSiblings)); + } +} diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs index 66ae06fce4..e5bc3e425a 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -1763,3 +1763,20 @@ fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() { assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge)); assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content"); } + +#[test] +fn autotag_text_refuses_pages_that_share_content_past_the_total_operator_allowance() { + // Each page is just under the per-page limit; sharing one stream makes the total 21 times it. + let content = format!("BT (a) Tj ET {}", "q Q ".repeat(49_990)); + let pages = 21; + let kids: Vec = (0..pages).map(|i| format!("{} 0 R", 4 + i)).collect(); + let mut bodies = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 200 200] >>", kids.join(" ")), + stream(&content), + ]; + bodies.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_string())); + let mut doc = pdf(&bodies, ""); + let Err(AutoTagError::Unsupported { reason, .. }) = autotag_text(&mut doc) else { panic!("expected a refusal") }; + assert!(reason.contains("too much content"), "{reason}"); +} diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 011656c7e7..0083761288 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -3,8 +3,8 @@ //! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the //! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with //! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box. -//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line -//! endings), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` +//! It returns `None` for anything it cannot draw faithfully (unknown line endings, a cloudy +//! callout), so callers never replace an appearance with a worse one. It resolves `/QuadPoints` //! itself; for the other values, callers that hold the document follow references first //! (`draw::with_direct_values`). //! @@ -55,7 +55,9 @@ pub fn arrow_size(w: f64) -> f64 { 6.0 + 3.0 * w.max(0.0) } -/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). +/// Nominal radius of a cloudy border's bumps for a line of width `w` (intensity 1). ISO 32000-2 +/// Table 169 leaves the size open; this is the size PdfCraft's polygon clouds already had, so +/// squares, circles and text boxes match them. pub fn cloud_radius(w: f64) -> f64 { 6.0 + 1.5 * w.max(0.0) } @@ -180,6 +182,80 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String { /// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow. pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000; +/// A cloudy border's intensity (ISO 32000-2 §12.5.4, Table 169: `/BE /I`, "in the range 0 to 2. +/// Default value: 0"), or `None` for no cloud: no `/BE`, a style other than `/C`, or intensity 0, +/// which draws the border plain. +pub(crate) fn cloud_intensity(d: &Dict) -> Option { + let be = d.get(b"BE").and_then(|b| b.as_dict())?; + if be.name(b"S") != Some(b"C") { + return None; + } + let i = be.get(b"I").and_then(|i| i.as_f64()).unwrap_or(0.0); + (i.is_finite() && i > 0.0).then(|| i.min(2.0)) +} + +/// The bump radius for a cloudy border of width `w` at `intensity` (0–2]. +pub(crate) fn cloud_bump(w: f64, intensity: f64) -> f64 { + cloud_radius(w) * intensity.clamp(0.5, 2.0) +} + +/// How far past its outline a cloud with bumps of radius `bump` around an outline `perimeter` +/// long can reach. `cloud_path` rounds each edge to whole bumps, so one may span up to three +/// radii and bulge 1.5 radii; it also grows the radius to keep within its bump budget. +pub(crate) fn cloud_reach(perimeter: f64, bump: f64) -> f64 { + 1.5 * bump.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64)) +} + +/// `/RD` when it is four finite, non-negative numbers (Tables 170 and 180). +fn rd_of(d: &Dict) -> Option> { + nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| x.is_finite() && *x >= 0.0)) +} + +/// `rect` less `rd`, whose four numbers are the left, top, right and bottom differences. +fn less_rd(rect: [f64; 4], rd: &[f64]) -> [f64; 4] { + let at = |i: usize| rd.get(i).copied().unwrap_or(0.0); + [rect[0] + at(0), rect[1] + at(3), rect[2] - at(2), rect[3] - at(1)] +} + +/// The box a plain (not cloudy) square or circle is drawn in: `/Rect` less a usable `/RD` (Table +/// 180: the differences between `/Rect` and "the actual boundaries of the underlying square or +/// circle"), or `None` when there is no `/RD`, it isn't four finite non-negative numbers, or it +/// leaves less than a point either way, so the caller draws in `/Rect` as without one. +fn rect_less_rd(d: &Dict, rect: [f64; 4]) -> Option<[f64; 4]> { + let inner = less_rd(rect, &rd_of(d)?); + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// The shape a cloudy square, circle or text box draws its bumps around: `/Rect` less `/RD` +/// (Table 180: the differences between "the left, top, right, and bottom coordinates of Rect and +/// those of the square or circle, respectively"), but at least the bumps' greatest reach and half +/// the line from each side, so they stay inside `/Rect`; without a usable `/RD`, that margin. +fn cloud_inner(d: &Dict, rect: [f64; 4], bump: f64, w: f64) -> Option<[f64; 4]> { + // The cloud's reach and half the line keep it inside `/Rect`; an `/RD` leaving more is + // followed, one leaving less is not. + let perimeter = 2.0 * ((rect[2] - rect[0]) + (rect[3] - rect[1])); + let m = cloud_reach(perimeter, bump) + w / 2.0; + let safe = [rect[0] + m, rect[1] + m, rect[2] - m, rect[3] - m]; + let inner = match rd_of(d) { + Some(rd) => { + let r = less_rd(rect, &rd); + [r[0].max(safe[0]), r[1].max(safe[1]), r[2].min(safe[2]), r[3].min(safe[3])] + } + None => safe, + }; + (inner[2] - inner[0] >= 1.0 && inner[3] - inner[1] >= 1.0).then_some(inner) +} + +/// Points around the ellipse inscribed in `r`, counter-clockwise, one per bump of radius `bump` +/// (each edge of the outline becomes one bump), for a cloudy circle. +fn ellipse_points(r: [f64; 4], bump: f64) -> Vec<(f64, f64)> { + let (cx, cy, rx, ry) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, (r[2] - r[0]) / 2.0, (r[3] - r[1]) / 2.0); + // Ramanujan's approximation of the perimeter; the count stays within the bump budget. + let perimeter = std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).max(0.0).sqrt()); + let count = (perimeter / (2.0 * bump.max(0.5))).round().clamp(8.0, MAX_CLOUD_BUMPS as f64) as u32; + (0..count).map(|i| f64::from(i) * std::f64::consts::TAU / f64::from(count)).map(|a| (cx + rx * a.cos(), cy + ry * a.sin())).collect() +} + /// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of /// half-circle bumps of about `r`, bulging outwards. pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String { @@ -536,7 +612,8 @@ struct AppearanceContext<'a> { dict: &'a Dict, subtype: &'a [u8], rect: [f64; 4], - cloudy: bool, + /// A cloudy border's intensity ([`cloud_intensity`]), or `None` for a plain one. + cloud: Option, /// The line's opacity (`parts.line`). opacity: f64, parts: PartOpacity, @@ -599,11 +676,13 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> return None; } let subtype = d.name(b"Subtype")?; - let rect = normalised_rect(d)?; + // A number too long for a double reads as infinite, and two near its limit are an infinite + // width apart: nothing is drawn rather than "inf" written into the appearance (review on #4195). + let rect = normalised_rect(d).filter(|r| r.iter().all(|x| x.is_finite()) && (r[2] - r[0]).is_finite() && (r[3] - r[1]).is_finite())?; let rect = page.and_then(|page| point_reach(doc, d, rect, page)).unwrap_or(rect); - // Cloudy borders (§12.5.4) are drawn for polygons only. - let cloudy = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - if cloudy && subtype != b"Polygon" { + // Cloudy borders (§12.5.4, Table 169) apply to squares, circles, polygons and text boxes. + let cloud = cloud_intensity(d); + if cloud.is_some() && !matches!(subtype, b"Square" | b"Circle" | b"Polygon" | b"FreeText") { return None; } Some(AppearanceContext { @@ -611,7 +690,7 @@ fn appearance_context<'a>(doc: &'a Document, d: &'a Dict, page: Option<[f64; 4]> dict: d, subtype, rect, - cloudy, + cloud, opacity: part_opacity(d).line, parts: part_opacity(d), stroke: color(d, b"C")?, @@ -698,14 +777,18 @@ fn drawn_points(doc: &Document, d: &Dict) -> Option<(Vec, f64)> { if count < 4 || count % 2 != 0 || count / 2 > MAX_REACH_POINTS { return None; } - let cloudy = d.get(b"BE").and_then(Object::as_dict).is_some_and(|b| b.name(b"S") == Some(b"C")); - // A cloud's bumps are drawn at up to twice their nominal radius (`draw_polygon`). - let reach = match subtype { - b"Polygon" if cloudy => 2.0 * cloud_radius(w), - b"PolyLine" if has_ending() => arrow_size(w), + let vertices = nums(d, b"Vertices")?; + // A cloud's bumps (`draw_polygon`) reach as far past the outline as `cloud_reach` says. + let reach = match (subtype, cloud_intensity(d)) { + (b"Polygon", Some(intensity)) => { + let pts: Vec<(f64, f64)> = vertices.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum(); + cloud_reach(if perimeter.is_finite() { perimeter } else { 0.0 }, cloud_bump(w, intensity)) + } + (b"PolyLine", _) if has_ending() => arrow_size(w), _ => 0.0, }; - (nums(d, b"Vertices")?, w / 2.0 + reach) + (vertices, w / 2.0 + reach) } b"Line" => { let [x0, y0, x1, y1] = <[f64; 4]>::try_from(nums(d, b"L")?).ok()?; @@ -921,15 +1004,27 @@ fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> if stroke.is_none() && fill.is_none() { return Some(()); } + // A plain square or circle lies within /Rect less /RD (Table 180), its stroke centred + // half the width inside that box. + let shape = rect_less_rd(d, rect).unwrap_or(rect); let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + let [x0, y0, x1, y1] = [shape[0] + inset, shape[1] + inset, shape[2] - inset, shape[3] - inset]; if let Some(f) = fill { c.push_str(&rg(f)); } if let Some(s) = stroke { c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); } - if subtype == b"Square" { + if let Some(intensity) = context.cloud { + let bump = cloud_bump(w, intensity); + let inner = cloud_inner(d, rect, bump, w)?; + let pts = if subtype == b"Square" { + vec![(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])] + } else { + ellipse_points(inner, bump) + }; + c.push_str(&format!("1 j\n{}", cloud_path(&pts, bump))); + } else if subtype == b"Square" { c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); } else { c.push_str(&ellipse(x0, y0, x1, y1)); @@ -990,7 +1085,6 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing let subtype = context.subtype; let stroke = context.stroke; let w = context.width; - let cloudy = context.cloudy; let c = &mut drawing.content; let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); @@ -1018,9 +1112,8 @@ fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing if let Some(s) = col { c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + if let Some(intensity) = context.cloud { + c.push_str(&cloud_path(&pts, cloud_bump(w, intensity))); } else { for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); @@ -1332,7 +1425,20 @@ fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawin // `/RD` and keeps its place: background and border take the shape, the text is laid out // in the text box as in a rectangle. let shape = crate::BorderShape::of(d); - if shape != crate::BorderShape::Rectangle { + // A cloudy box (not a callout): its background and border follow the cloud around + // the text, which keeps to the cloud's inner rectangle. + if let Some(intensity) = context.cloud.filter(|_| !d.contains(b"CL")) { + let cw = if bw > 0.0 { bw } else { 1.0 }; + let bump = cloud_bump(cw, intensity); + let inner = cloud_inner(d, rect, bump, cw)?; + let pts = [(inner[0], inner[1]), (inner[2], inner[1]), (inner[2], inner[3]), (inner[0], inner[3])]; + c.push_str(&format!("{}{}{} w 1 j\n{}", rg_stroke(line_color), rg(bg.unwrap_or([1.0; 3])), n(cw), dash(d))); + c.push_str(&cloud_path(&pts, bump)); + c.push_str(if bg.is_some() { "B\n[] 0 d\n" } else { "S\n[] 0 d\n" }); + rect = inner; + } else if context.cloud.is_some() { + return None; + } else if shape != crate::BorderShape::Rectangle { rect = shaped_border(d, shape, full, turn, bw, bg, line_color, &mut c)?; } else { if let Some(bg) = bg { diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index 6dbb7842e6..1b55e48932 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -103,12 +103,8 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr } grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0) } - Shape::Polygon { vertices, cloud } => { - let b = bounds(vertices.iter().copied()) - .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); - let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; - grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) - } + // A new cloud has intensity 1 (`/BE /I 1`). + Shape::Polygon { vertices, cloud } => polygon_rect(vertices, style.width, cloud.then_some(1.0))?, Shape::PolyLine { vertices, start, end } => { let b = bounds(vertices.iter().copied()) .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); @@ -135,6 +131,19 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr Ok(r) } +/// Validate a polygon's corners and compute its `/Rect`: room for half the line, and with a +/// cloud of `cloud` intensity, for the farthest its bumps reach (`appearance::cloud_reach`). +pub(crate) fn polygon_rect(vertices: &[[f64; 2]], width: f64, cloud: Option) -> Result<[f64; 4], AnnotError> { + let b = bounds(vertices.iter().copied()) + .filter(|b| vertices.len() >= 3 && vertices.iter().all(|p| finite(p)) && b[2] - b[0] >= 1.0 && b[3] - b[1] >= 1.0); + let b = b.ok_or_else(|| AnnotError::Invalid("invalid polygon (it needs three points)".into()))?; + let reach = cloud.map_or(0.0, |intensity| { + let perimeter: f64 = vertices.iter().zip(vertices.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum(); + appearance::cloud_reach(perimeter, appearance::cloud_bump(width, intensity)) + }); + Ok(grow(b, width.max(0.0) / 2.0 + 1.0 + reach)) +} + pub(crate) fn base_dict(subtype: &str, rect: [f64; 4], page: ObjRef, contents: &str, author: &str, meta: &Meta) -> Dict { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("Annot")); diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index c86b9f8f78..71a4fbfa32 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -154,7 +154,13 @@ pub fn summaries(doc: &Document) -> Vec { grouped: d.name(b"RT") == Some(b"Group"), rect, color, - state: text_value(doc, d, b"State"), + // A reply that gives only `/StateModel` has the model's default state (ISO 32000-2 + // Table 175), as `last_state_reply` and the inspector read it. + state: text_value(doc, d, b"State").or_else(|| match text_value(doc, d, b"StateModel").as_deref() { + Some("Marked") => Some("Unmarked".into()), + Some("Review") => Some("None".into()), + _ => None, + }), quads, locked: annotation_flags(doc, d) & FLAG_LOCKED != 0, intent: d.get(b"IT").and_then(|o| doc.resolve(o).as_name().map(|n| String::from_utf8_lossy(n).into_owned())), diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs index e2199e4d23..88ac097b74 100644 --- a/crates/annot/src/links.rs +++ b/crates/annot/src/links.rs @@ -503,10 +503,24 @@ fn email_at(chars: &[char], start: usize) -> Option<(usize, String)> { Some((end, format!("mailto:{address}"))) } +/// The longest web address [`find_urls`] reads, in characters (browsers and servers commonly +/// stop near 2,000). Each address it might be is read this far at most, so text whose brackets +/// never close (`https://a/(https://a/(…`, without a space) costs this much per address in it, +/// not a pass over the rest of the page each. +const MAX_URL_CHARS: usize = 2048; + /// Find web addresses in text: `http(s)://…`, `www.…` and bare e-mail addresses become /// `mailto:`. Returns character ranges and the URI for each. +/// +/// No other scheme is ever found, and a `www.` or e-mail address that is part of one written with +/// another scheme (`ftp://www.example.com`, `smb://admin@example.org`) is not taken out of it: +/// it would open with a scheme the text doesn't say. Brackets inside a web address belong to it +/// when they pair up (`…/wiki/Mercury_(planet)`); an unpaired one ends it. pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { let url_char = |c: char| !c.is_whitespace() && !"<>\"'()[]{}".contains(c); + // Characters found inside an address but not between words: a `www.` after one is part of + // a longer address, and so is an e-mail address after a slash. + let inside_address = |i: usize, set: &str| i > 0 && chars.get(i - 1).is_some_and(|c| set.contains(*c)); // Case-insensitive prefix test that never steps past the end. Comparing against the original // characters keeps every offset in `chars`'s own index space: case-folding a copy can change // its length (U+0130 becomes "i" plus a combining dot), so a folded copy must never supply @@ -517,11 +531,38 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { let mut i = 0; while i < chars.len() { let boundary = i == 0 || chars.get(i - 1).is_some_and(|c| !c.is_alphanumeric()); - if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || starts_with(i, "www.")) { + let www = starts_with(i, "www.") && !inside_address(i, "/\\:@.-_~%+=&#"); + if boundary && (starts_with(i, "http://") || starts_with(i, "https://") || www) { let mut j = i; - while chars.get(j).is_some_and(|c| url_char(*c)) { + // Brackets pair up inside the address: `depth` are open, the outermost at `opened`. + let (mut depth, mut opened) = (0usize, i); + let mut too_long = false; + loop { + if j.saturating_sub(i) >= MAX_URL_CHARS { + too_long = true; + break; + } + match chars.get(j) { + Some('(') => { + if depth == 0 { + opened = j; + } + depth = depth.saturating_add(1); + } + Some(')') if depth > 0 => depth -= 1, + Some(c) if url_char(*c) => {} + _ => break, + } j += 1; } + // A bracket left open isn't the address's: it ends before it. A run of address + // characters longer than any address is none, and the search goes on after it. + if depth > 0 { + j = opened; + } else if too_long { + i = j; + continue; + } // Trailing punctuation belongs to the sentence. while j > i && chars.get(j - 1).is_some_and(|c| ".,;:!?".contains(*c)) { j -= 1; @@ -534,7 +575,10 @@ pub fn find_urls(chars: &[char]) -> Vec<(std::ops::Range, String)> { continue; } } - if boundary && let Some((end, uri)) = email_at(chars, i) { + if boundary + && !inside_address(i, "/\\") + && let Some((end, uri)) = email_at(chars, i) + { out.push((i..end, uri)); i = end; continue; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 78200f5c16..153be15346 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -1,5 +1,6 @@ use super::create::{ - add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb, + add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, polygon_rect, + rect_for, rgb, }; use super::draw::{draw_appearance, redraw_or_restore, set_appearance}; use super::info::{line_endings_of, text_value}; @@ -260,8 +261,9 @@ pub fn add_reply(doc: &mut Document, page: usize, index: usize, text: &str, auth reply(doc, page, index, text, author, meta, None) } -/// Set a comment's review status (Acrobat: "Set status ▸ Accepted"…). Like Acrobat this adds a -/// state reply (`/State`, `/StateModel /Review`) by `author`; the latest one wins. +/// Set a comment's review status ("Set status ▸ Accepted"…). This adds a hidden state reply +/// (`/State`, `/StateModel /Review`) by `author`, in reply to `author`'s previous one when there is +/// one; each reviewer's latest is their status. pub fn set_review_state(doc: &mut Document, page: usize, index: usize, state: ReviewState, author: &str, meta: &Meta) -> Result { let text = format!("{} set by {}", state.name(), if author.is_empty() { "unknown" } else { author }); reply(doc, page, index, &text, author, meta, Some(state)) @@ -294,6 +296,11 @@ fn reply( d.set(b"State".to_vec(), PdfString::text(s.name())); d.set(b"StateModel".to_vec(), PdfString::text("Review")); d.set(b"Subj".to_vec(), PdfString::text("Status")); + // Hidden (Table 167, bit 2): the state "is not specified in the annotation itself but in a + // separate text annotation" (ISO 32000-2 §12.5.6.3), so this reply is a state record, not + // a note, and is neither drawn nor listed as one. + d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Review").unwrap_or(parent))); } // An empty form: nothing is drawn for the reply itself. let mut fd = Dict::new(); @@ -330,8 +337,9 @@ pub fn set_locked(doc: &mut Document, page: usize, index: usize, locked: bool) - Ok(()) } -/// Mark or unmark a comment with a checkmark. Like Acrobat this adds a hidden state reply with -/// `/StateModel /Marked` by `author`; the latest one wins. It's private bookkeeping, not a status. +/// Mark or unmark a comment with a checkmark. This adds a hidden state reply with `/StateModel +/// /Marked` by `author`, in reply to `author`'s previous one when there is one; their latest one +/// wins. It's each reviewer's private bookkeeping, not a status. pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, author: &str, meta: &Meta) -> Result { let (p, parent) = annot_ref(doc, page, index)?; let pd = annot_dict(doc, parent); @@ -342,7 +350,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a let text = format!("{state} set by {}", if author.is_empty() { "unknown" } else { author }); let mut d = base_dict("Text", [0.0; 4], p, &text, author, meta); d.set(b"Rect".to_vec(), pd.get(b"Rect").cloned().unwrap_or_else(|| num_array(&[0.0; 4]))); - d.set(b"IRT".to_vec(), Object::Ref(parent)); + d.set(b"IRT".to_vec(), Object::Ref(last_state_reply(doc, p, parent, author, "Marked").unwrap_or(parent))); d.set(b"F".to_vec(), Object::Int(FLAG_HIDDEN | FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); d.set(b"State".to_vec(), PdfString::text(state)); d.set(b"StateModel".to_vec(), PdfString::text("Marked")); @@ -354,6 +362,56 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a Ok(i) } +/// `author`'s latest `model` state reply about `target` on page `p`, for the next change to reply +/// to: ISO 32000-2 §12.5.6.3, "Additional state changes shall be made by adding text annotations +/// in reply to the previous reply for a given user". A state reply is about the +/// annotation its `/IRT` chain reaches past other state replies; a loop reaches nothing. Each +/// reply's answer is kept, so a long history is walked once. +fn last_state_reply(doc: &Document, p: ObjRef, target: ObjRef, author: &str, model: &str) -> Option { + use std::collections::{HashMap, HashSet}; + // As the readers see it: a readable `/State` (not null, empty or another type), or a known + // `/StateModel` whose default state applies. + let is_state = |r: ObjRef| { + doc.get(r).as_dict().is_some_and(|d| { + text_value(doc, d, b"State").is_some() || matches!(text_value(doc, d, b"StateModel").as_deref(), Some("Marked" | "Review")) + }) + }; + let irt = |r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"IRT").and_then(Object::as_ref)); + let mut reaches: HashMap> = HashMap::new(); + let mut mine = Vec::new(); + for r in annots(doc, p).iter().filter_map(Object::as_ref).filter(|&r| r != target) { + let d = annot_dict(doc, r); + if text_value(doc, &d, b"StateModel").as_deref() != Some(model) || text_value(doc, &d, b"T").unwrap_or_default() != author.trim() { + continue; + } + let (mut path, mut seen, mut at) = (vec![r], HashSet::from([r]), irt(r)); + let about = loop { + let Some(up) = at else { break None }; + if let Some(&known) = reaches.get(&up) { + break known; + } + if !is_state(up) { + break Some(up); + } + if !seen.insert(up) { + break None; + } + path.push(up); + at = irt(up); + }; + for x in path { + reaches.insert(x, about); + } + if about == Some(target) { + mine.push(r); + } + } + // The end of the chain, whatever order the page lists it in: a reply none of the others + // replies to; among several (changes that don't chain), the last listed. + let answered: HashSet = mine.iter().filter_map(|&r| irt(r)).collect(); + mine.iter().rev().copied().find(|r| !answered.contains(r)) +} + /// Move a comment (and its pop-up) by `(dx, dy)` points. The appearance moves with `/Rect`. pub fn move_annotation(doc: &mut Document, page: usize, index: usize, dx: f64, dy: f64, meta: &Meta) -> Result<(), AnnotError> { if !finite(&[dx, dy]) { @@ -568,22 +626,21 @@ pub fn set_points(doc: &mut Document, page: usize, index: usize, points: &[[f64; if points.len() > MAX_POINTS { return Err(AnnotError::Invalid(format!("at most {MAX_POINTS} points"))); } - let style = Style { width: border_width_of(&d), ..Style::default() }; + // The width the appearance draws with (`/BS`, else `/Border`), so the room is the drawn stroke's. + let style = Style { width: appearance::border_width(&d), ..Style::default() }; let ends = line_endings_of(&d).unwrap_or_default(); let (start, end) = (ends.first().copied().unwrap_or(LineEnding::None), ends.get(1).copied().unwrap_or(LineEnding::None)); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let (shape, key): (Shape, &[u8]) = match (subtype.as_str(), points) { - ("Line", [from, to]) => (Shape::Line { from: *from, to: *to, start, end }, b"L"), + // Each checks the points too: finite, and far enough apart to draw. + let (rect, key): ([f64; 4], &[u8]) = match (subtype.as_str(), points) { + ("Line", [from, to]) => (rect_for(&Shape::Line { from: *from, to: *to, start, end }, &style)?, b"L"), ("Line", _) => return Err(AnnotError::Invalid("a line has two ends".into())), - ("PolyLine", _) => (Shape::PolyLine { vertices: points.to_vec(), start, end }, b"Vertices"), - ("Polygon", _) => { - let cloud = d.get(b"BE").and_then(|b| b.as_dict()).is_some_and(|b| b.name(b"S") == Some(b"C")); - (Shape::Polygon { vertices: points.to_vec(), cloud }, b"Vertices") - } + ("PolyLine", _) => (rect_for(&Shape::PolyLine { vertices: points.to_vec(), start, end }, &style)?, b"Vertices"), + // Room for the cloud as the appearance draws it: `/BE /S /C` at its own intensity + // (Table 169), which may reach further than a new cloud's intensity 1. + ("Polygon", _) => (polygon_rect(points, style.width, appearance::cloud_intensity(&d))?, b"Vertices"), (other, _) => return Err(AnnotError::Invalid(format!("{other} comments have no points to move"))), }; - // Checks the points too: finite, and far enough apart to draw. - let rect = rect_for(&shape, &style)?; let before = d; doc.update_dict(r, |d| { d.set(key.to_vec(), num_array(&points.concat())); diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b0220b1023..9651263171 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -213,6 +213,122 @@ fn replies_and_status_thread_onto_the_parent() { assert_eq!(text(&all[2], b"Contents"), "Accepted set by Bob"); } +/// A state reply records a state, not a note (ISO 32000-2 §12.5.6.3: the state is kept "in a +/// separate text annotation"), so it is written Hidden; a reviewer's next change replies to their +/// previous state reply ("Additional state changes shall be made by adding text annotations in +/// reply to the previous reply for a given user"). Another reviewer's state, and another state +/// model, start their own chain on the comment. +#[test] +fn status_and_checkmark_replies_are_hidden_and_chain_per_reviewer() { + let mut doc = fixture(); + let first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let carol = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Carol", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let mark = set_marked(&mut doc, 0, 0, true, "Bob", &meta("m1")).unwrap(); + let unmark = set_marked(&mut doc, 0, 0, false, "Bob", &meta("m2")).unwrap(); + let doc = reopen(&doc); + let p = page_refs(&doc).unwrap()[0]; + let entries = annots(&doc, p); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + for i in [first, carol, second, mark, unmark] { + assert_eq!(all[i].int(b"F"), Some(30), "hidden, printable, no zoom, no rotate: {i}"); + } + assert_eq!(all[first].reference(b"IRT"), Some(at(0))); + assert_eq!(all[carol].reference(b"IRT"), Some(at(0)), "another reviewer starts on the comment"); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "Bob's change replies to Bob's last status"); + assert_eq!(all[mark].reference(b"IRT"), Some(at(0)), "a checkmark is its own chain"); + assert_eq!(all[unmark].reference(b"IRT"), Some(at(mark))); + // A reply that isn't a state stays visible. + let mut doc = doc; + let r = add_reply(&mut doc, 0, 0, "Agreed", "Bob", &meta("r")).unwrap(); + assert_eq!(list(&doc, 0)[r].int(b"F"), Some(28)); +} + +/// Review on #4126: a history has no length limit. The 70th change still replies to the 69th, +/// and a reviewer whose name was typed with spaces continues their own chain. +#[test] +fn a_long_status_history_keeps_chaining_to_the_latest() { + let mut doc = fixture(); + let mut prev: Option = None; + for i in 0..70 { + let state = if i % 2 == 0 { ReviewState::Accepted } else { ReviewState::Rejected }; + let at = set_review_state(&mut doc, 0, 0, state, "Bob", &meta(&format!("s{i}"))).unwrap(); + if let Some(p) = prev { + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[at].reference(b"IRT"), entries[p].as_ref(), "change {i}"); + } + prev = Some(at); + } + let padded = set_review_state(&mut doc, 0, 0, ReviewState::Completed, " Bob ", &meta("pad")).unwrap(); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + assert_eq!(list(&doc, 0)[padded].reference(b"IRT"), prev.and_then(|p| entries[p].as_ref())); + // Listed newest first, the first reply read walks the whole chain at once. + let latest = entries[padded].as_ref(); + let p = page_refs(&doc).unwrap()[0]; + let mut reversed = entries.clone(); + reversed.reverse(); + set_annots(&mut doc, p, reversed).unwrap(); + let next = set_review_state(&mut doc, 0, 71, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), latest); +} + +/// Review on #4126: a reply with `/StateModel` but no `/State` has the model's default state +/// (ISO 32000-2 Table 175), for the reader as for the writer: it is Bob's latest status, None, +/// and his next change replies to it. +#[test] +fn a_state_model_without_a_state_is_the_default_state() { + let mut doc = fixture(); + let bare = add_reply(&mut doc, 0, 0, "", "Bob", &meta("bare")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let r = annots(&doc, p)[bare].as_ref().unwrap(); + doc.update_dict(r, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Review")); + d.set(b"F".to_vec(), Object::Int(30)); + }) + .unwrap(); + let summary = summaries(&doc).into_iter().find(|s| s.index == bare).unwrap(); + assert_eq!(summary.state.as_deref(), Some("None")); + let next = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("next")).unwrap(); + assert_eq!(list(&doc, 0)[next].reference(b"IRT"), Some(r)); + + // A model PdfCraft doesn't know isn't a state: a status replying to such a reply is about + // that reply, not about the comment, so Bob's next change on the comment starts afresh. + let mut doc = fixture(); + let other = add_reply(&mut doc, 0, 0, "", "Ann", &meta("other")).unwrap(); + let p = page_refs(&doc).unwrap()[0]; + let o = annots(&doc, p)[other].as_ref().unwrap(); + // Nor is a `/State` the readers can't read, such as a number (review on #4126). + doc.update_dict(o, |d| { + d.set(b"StateModel".to_vec(), PdfString::text("Other")); + d.set(b"State".to_vec(), Object::Int(5)); + }) + .unwrap(); + assert_eq!(summaries(&doc).into_iter().find(|s| s.index == other).unwrap().state, None); + set_review_state(&mut doc, 0, other, ReviewState::Rejected, "Bob", &meta("on-other")).unwrap(); + let fresh = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("fresh")).unwrap(); + assert_eq!(list(&doc, 0)[fresh].reference(b"IRT"), annots(&doc, p)[0].as_ref()); +} + +/// A third change replies to the second, not the first; and Bob's status on another comment +/// is never the one his next change on this comment replies to. +#[test] +fn a_reviewers_chain_continues_from_their_latest_status_on_that_comment() { + 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 first = set_review_state(&mut doc, 0, 0, ReviewState::Accepted, "Bob", &meta("s1")).unwrap(); + let elsewhere = set_review_state(&mut doc, 0, note, ReviewState::Cancelled, "Bob", &meta("s2")).unwrap(); + let second = set_review_state(&mut doc, 0, 0, ReviewState::Rejected, "Bob", &meta("s3")).unwrap(); + let third = set_review_state(&mut doc, 0, 0, ReviewState::Completed, "Bob", &meta("s4")).unwrap(); + let doc = reopen(&doc); + let entries = annots(&doc, page_refs(&doc).unwrap()[0]); + let at = |i: usize| entries[i].as_ref().unwrap(); + let all = list(&doc, 0); + assert_eq!(all[elsewhere].reference(b"IRT"), Some(at(note))); + assert_eq!(all[second].reference(b"IRT"), Some(at(first)), "not Bob's status on the note"); + assert_eq!(all[third].reference(b"IRT"), Some(at(second)), "the latest, not the first"); +} + #[test] fn delete_takes_popup_and_reply_chain() { let mut doc = fixture(); @@ -1314,6 +1430,44 @@ fn urls_are_found_after_length_changing_lowercase() { assert_eq!(range, 21..39, "range is in source-character indices"); } +/// Review on #4472: an address inside one written with another scheme isn't taken out of it +/// (`ftp://www.example.com` opened as `http://www.example.com`), and brackets that pair up +/// inside an address belong to it. +#[test] +fn urls_keep_their_written_scheme_and_paired_brackets() { + let found = |s: &str| -> Vec { crate::links::find_urls(&s.chars().collect::>()).into_iter().map(|(_, u)| u).collect() }; + for s in + ["ftp://www.example.com/pub", "file://www.example.com/x", "javascript:www.example.com", "ftp://user@example.com/x", "smb://a@example.org/s"] + { + assert_eq!(found(s), Vec::::new(), "{s}"); + } + assert_eq!(found("mailto:someone@example.com and (www.example.com)"), ["mailto:someone@example.com", "http://www.example.com"]); + assert_eq!(found("See https://en.wikipedia.org/wiki/Mercury_(planet)."), ["https://en.wikipedia.org/wiki/Mercury_(planet)"]); + assert_eq!(found("(https://example.com/a_(b)_c)"), ["https://example.com/a_(b)_c"]); + assert_eq!(found("https://example.com/a(b c"), ["https://example.com/a"]); + // Deeply nested or unbalanced brackets end the address without trouble. + let deep = format!("https://example.com/{}x{}", "(".repeat(10_000), ")".repeat(9_999)); + assert_eq!(found(&deep), ["https://example.com/"]); +} + +/// Review on #4472: brackets that never close in text without spaces (`https://a/(` again and +/// again) made each address in it read the rest of the text, then start again one character on, +/// a pass over the page per address. Each is read 2,048 characters at most now, and the +/// addresses around them are still found. A run of address characters longer than that is no +/// address. +#[test] +fn unclosed_brackets_in_text_without_spaces_are_read_once_per_address() { + let found = |s: &str| -> Vec { crate::links::find_urls(&s.chars().collect::>()).into_iter().map(|(_, u)| u).collect() }; + let text = format!("www.before.org {} www.after.org", "https://a/(".repeat(60_000)); + let started = std::time::Instant::now(); + assert_eq!(found(&text), ["http://www.before.org", "http://www.after.org"]); + assert!(started.elapsed().as_secs() < 5, "{:?}", started.elapsed()); + let long = format!("https://example.com/{} www.after.org", "a".repeat(3000)); + assert_eq!(found(&long), ["http://www.after.org"]); + let fits = format!("https://example.com/{}", "a".repeat(2000)); + assert_eq!(found(&fits), std::slice::from_ref(&fits)); +} + #[test] fn url_scheme_and_www_match_case_insensitively() { let t: Vec = "HTTP://Example.COM".chars().collect(); @@ -3012,6 +3166,366 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() { assert!(!long.contains(&"w".repeat(257))); } +/// A comment's built appearance for the dictionary `body` (no `/AP`), as text, or `None` when +/// PdfCraft can't draw it. +fn built(body: &str) -> Option { + let doc = document_of(&[ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 600] >>", + b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + body.as_bytes(), + ]); + let d = list(&doc, 0).into_iter().next().unwrap(); + crate::appearance::build(&doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()) +} + +/// The x and y range a drawing's path covers: its points, and each curve's middle (a curve's +/// control points lie outside it, so they aren't counted). +fn extent(ops: &str) -> [f64; 4] { + let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + let mut at = (0.0, 0.0); + let add = |p: (f64, f64), b: &mut [f64; 4]| *b = [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]; + for line in ops.lines() { + let t: Vec<&str> = line.split_whitespace().collect(); + let Some((op, nums)) = t.split_last() else { continue }; + let v: Vec = nums.iter().filter_map(|n| n.parse().ok()).collect(); + match (*op, v.as_slice()) { + ("m" | "l", [x, y]) => { + at = (*x, *y); + add(at, &mut b); + } + ("c", [x1, y1, x2, y2, x3, y3]) => { + let mid = ((at.0 + 3.0 * x1 + 3.0 * x2 + x3) / 8.0, (at.1 + 3.0 * y1 + 3.0 * y2 + y3) / 8.0); + add(mid, &mut b); + at = (*x3, *y3); + add(at, &mut b); + } + _ => {} + } + } + b +} + +/// ISO 32000-2 §12.5.4, Table 169: a cloudy border (`/BE /S /C`) applies to squares, circles, +/// polygons and (PDF 1.6) text boxes, honours the `/BS` width and dashes, and its intensity +/// `/I` defaults to 0. Squares, circles and text boxes without an appearance were not drawn at +/// all, so a reviewer's marks vanished; intensity 0 (or none) drew a cloud on polygons. +#[test] +fn cloudy_borders_are_drawn_on_squares_circles_polygons_and_text_boxes() { + let base = "/Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 >>"; + for kind in ["Square", "Circle"] { + let ops = built(&format!("<< {base} /Subtype /{kind} /BE << /S /C /I 1 >> >>")).unwrap_or_else(|| panic!("{kind} is drawn")); + assert!(ops.matches(" c\n").count() > 8 && !ops.contains(" re\n"), "{kind} as bumps: {ops}"); + let e = extent(&ops); + assert!(e[0] >= 100.0 && e[1] >= 100.0 && e[2] <= 300.0 && e[3] <= 250.0, "{kind} stays in /Rect: {e:?}"); + } + let dashed = built(&format!("<< {base} /Subtype /Square /BS << /W 2 /S /D /D [6 3] >> /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(dashed.contains("[6 3] 0 d") && dashed.contains(" c\n"), "dashes honoured: {dashed}"); + let text = built(&format!("<< {base} /Subtype /FreeText /DA (0 0 1 rg /Helv 10 Tf) /Contents (Look here) /BE << /S /C /I 1 >> >>")).unwrap(); + assert!(text.contains(" c\n") && text.contains("(Look here) Tj"), "a cloud around the text: {text}"); + let dashed_text = built( + "<< /Type /Annot /Rect [100 100 300 250] /C [1 0 0] /BS << /W 2 /S /D /D [4 2] >> /Subtype /FreeText /DA (0 g /Helv 10 Tf) /Contents (x) /BE << /S /C /I 1 >> >>", + ) + .unwrap(); + assert!(dashed_text.contains("[4 2] 0 d"), "a text box's cloud is dashed too: {dashed_text}"); + // Intensity 0, or none: a plain border (the default is 0). + for be in ["<< /S /C /I 0 >>", "<< /S /C >>", "<< /S /S /I 2 >>"] { + let square = built(&format!("<< {base} /Subtype /Square /BE {be} >>")).unwrap(); + assert!(square.contains(" re\n") && !square.contains(" c\n"), "{be}: {square}"); + let polygon = + built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE {be} >>")) + .unwrap(); + assert!(!polygon.contains(" c\n"), "{be}: polygon edges stay straight: {polygon}"); + } + let cloudy_polygon = + built("<< /Type /Annot /Subtype /Polygon /Rect [0 0 100 100] /C [1 0 0] /Vertices [10 10 90 10 90 90 10 90] /BE << /S /C /I 1 >> >>") + .unwrap(); + assert!(cloudy_polygon.contains(" c\n")); + // A cloudy callout is still left to its own appearance. + assert!( + built(&format!("<< {base} /Subtype /FreeText /DA (0 g /Helv 10 Tf) /CL [10 10 100 100] /RD [5 5 5 5] /BE << /S /C /I 1 >> >>")).is_none() + ); +} + +/// Table 180: `/RD` gives the differences between "the left, top, right, and bottom coordinates +/// of Rect and those of the square or circle, respectively"; a cloudy square's bumps sit on that +/// inner square. +#[test] +fn a_cloudy_square_follows_rd_left_top_right_bottom() { + let ops = built("<< /Type /Annot /Subtype /Square /Rect [100 100 300 300] /C [1 0 0] /BS << /W 1 >> /BE << /S /C /I 1 >> /RD [20 40 30 25] >>") + .unwrap(); + // The corners of the inner square are path points: left 120, bottom 125, right 270, top 260. + for corner in ["120 125", "270 125", "270 260", "120 260"] { + assert!(ops.contains(corner), "corner {corner} in {ops}"); + } +} + +/// Review on #4195: without `/RD`, a small cloudy square still keeps its bumps inside `/Rect`. +/// Its edges are short enough that each rounds to a single bump about three radii long, which +/// bulges further than one radius. An `/RD` leaving less than that margin is widened to it, so +/// the cloud stays inside `/Rect` too. +#[test] +fn a_small_cloudy_square_without_rd_stays_inside_its_rect() { + for side in [40.0, 48.8, 60.0, 82.0] { + let ops = built(&format!( + "<< /Type /Annot /Subtype /Square /Rect [100 100 {x} {x}] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> >>", + x = 100.0 + side + )) + .unwrap_or_else(|| panic!("side {side} is drawn")); + let e = extent(&ops); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 101.0 + side && e[3] <= 101.0 + side, "side {side}: {e:?}"); + } + let small_rd = + built("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 2 >> /BE << /S /C /I 1 >> /RD [1 1 1 1] >>").unwrap(); + let e = extent(&small_rd); + assert!(e[0] >= 99.0 && e[1] >= 99.0 && e[2] <= 201.0 && e[3] <= 201.0, "/RD [1 1 1 1] keeps the cloud inside /Rect: {e:?}"); +} + +/// ISO 32000-2 Table 180: `/RD` gives the differences between `/Rect` and "the actual boundaries +/// of the underlying square or circle", so a plain square or circle (no cloudy border) is drawn +/// inside `/Rect` less `/RD` (left, top, right, bottom) too, its stroke centred half the width +/// inside that box. An `/RD` that isn't four finite non-negative numbers, or leaves no box, is +/// ignored and the shape fills `/Rect` as before. +#[test] +fn plain_squares_and_circles_are_drawn_inside_rect_less_rd() { + let square = |rd: &str| built(&format!("<< /Type /Annot /Subtype /Square /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> {rd} >>")).unwrap(); + // Rect + 15 + 1.5 on every side: a 67 × 67 square from 116.5. + assert!(square("/RD [15 15 15 15]").contains("116.5 116.5 67 67 re"), "{}", square("/RD [15 15 15 15]")); + // Left 10, top 40, right 20, bottom 30: the box is 110..180 × 130..160. + assert!(square("/RD [10 40 20 30]").contains("111.5 131.5 67 27 re"), "{}", square("/RD [10 40 20 30]")); + for ignored in ["", "/RD [-1 0 0 0]", "/RD [1 2 3]", "/RD [60 0 60 0]", "/RD [0 50 0 49.5]", "/RD (x)"] { + assert!(square(ignored).contains("101.5 101.5 97 97 re"), "{ignored}: {}", square(ignored)); + } + let circle = built("<< /Type /Annot /Subtype /Circle /Rect [100 100 200 200] /C [1 0 0] /BS << /W 3 >> /RD [15 15 15 15] >>").unwrap(); + // The ellipse starts at its rightmost point: 200 - 15 - 1.5, at mid-height. + assert!(circle.starts_with("1 0 0 RG\n3 w\n183.5 150 m"), "{circle}"); +} + +/// The circles and rectangles PdfCraft draws write no `/RD`, so their appearance is unchanged by +/// it: the stroke still sits half its width inside `/Rect`. +#[test] +fn pdfcraft_rectangles_and_ellipses_still_fill_their_rect() { + let mut doc = fixture(); + let rect = [10.0, 10.0, 110.0, 60.0]; + for shape in [Shape::Rectangle { rect }, Shape::Oval { rect }] { + let oval = matches!(shape, Shape::Oval { .. }); + let i = add_annotation(&mut doc, &new(0, shape), &meta("r")).unwrap(); + let d = list(&doc, 0).remove(i); + assert!(!d.contains(b"RD")); + let w = appearance::border_width(&d); + let ap = ap_content(&doc, &d); + let r: Vec = d.get(b"Rect").and_then(Object::as_array).unwrap().iter().filter_map(Object::as_f64).collect(); + let expected = if oval { + format!("{} {} m", n(r[2] - w / 2.0), n((r[1] + r[3]) / 2.0)) + } else { + format!("{} {} {} {} re", n(r[0] + w / 2.0), n(r[1] + w / 2.0), n(r[2] - r[0] - w), n(r[3] - r[1] - w)) + }; + assert!(ap.contains(&expected), "{expected} in {ap}"); + } +} + +/// Review on #4195: a polygon reshaped with `/BE /S /C` and no `/I` (or `/I 0`) is a plain one, +/// as its appearance is drawn (Table 169: intensity 0 is no cloud), so its `/Rect` gets no room +/// for bumps; with an intensity above 0 it does. +#[test] +fn a_reshaped_polygon_pads_its_rect_only_for_a_drawn_cloud() { + // `/BE << /S /C >>` with `/I` when given; no `/BE` at all for `None`. + let reshaped = |be: Option>| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: false }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + if let Some(intensity) = be { + let mut b = pdfcraft_cos::Dict::new(); + b.set(b"S".to_vec(), Object::name("C")); + if let Some(i) = intensity { + b.set(b"I".to_vec(), Object::Int(i)); + } + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(b))).unwrap(); + } + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let plain = reshaped(None); + for be in [None, Some(0)] { + assert_eq!(reshaped(Some(be)), plain, "/I {be:?}: no cloud drawn, no room for one"); + } + let cloudy = reshaped(Some(Some(1))); + assert!(cloudy[0] < plain[0] && cloudy[2] > plain[2], "room for the bumps: {cloudy:?} around {plain:?}"); +} + +/// Review on #4195: a reshaped polygon whose cloud has intensity 2 gets room for its bumps at +/// that intensity. With room only for intensity 1, the bumps on a 200 pt edge reached past +/// `/Rect` and the appearance's `/BBox` clipped them. +#[test] +fn a_reshaped_intensity_two_cloud_stays_inside_its_rect() { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + let mut be = pdfcraft_cos::Dict::new(); + be.set(b"S".to_vec(), Object::name("C")); + be.set(b"I".to_vec(), Object::Int(2)); + doc.update_dict(r, |d| d.set(b"BE".to_vec(), Object::Dict(be))).unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + let d = &list(&doc, 0)[i]; + let bbox = rect(d); + // Every point along the drawn bumps, with half the 2 pt line, is inside `/Rect`. + let content = ap_content(&doc, d); + let (mut at, mut lowest, mut leftmost) = ((0.0, 0.0), f64::MAX, f64::MAX); + for line in content.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + for step in 0..=20 { + let t = f64::from(step) / 20.0; + let u = 1.0 - t; + let (a, b, c, e) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + lowest = lowest.min(a * at.1 + b * v[1] + c * v[3] + e * v[5]); + leftmost = leftmost.min(a * at.0 + b * v[0] + c * v[2] + e * v[4]); + } + at = (v[4], v[5]); + } + } + assert!(lowest < 100.0 - 8.0, "the bumps bulge below the bottom edge: {lowest}"); + assert!(lowest - 1.0 >= bbox[1], "the bottom bumps ({lowest}) and the line fit in /Rect {bbox:?}"); + assert!(leftmost - 1.0 >= bbox[0], "the left bumps ({leftmost}) and the line fit in /Rect {bbox:?}"); +} + +/// Review on #4195: a reshaped polygon gets room for the border it is drawn with. With no `/BS`, +/// a `/Border [0 0 5]` is drawn 5 pt wide (`appearance::border_width`), so the new `/Rect` is the +/// one `/BS /W 5` gets; it was sized for a 1-pt border, and the cloud's outer bumps were clipped. +#[test] +fn a_reshaped_polygon_has_room_for_the_border_it_is_drawn_with() { + let reshaped = |border: &[u8], width: Object| { + let mut doc = fixture(); + let shape = Shape::Polygon { vertices: vec![[100.0, 100.0], [200.0, 100.0], [150.0, 200.0]], cloud: true }; + let i = add_annotation(&mut doc, &new(0, shape), &meta("p")).unwrap(); + let (_, r) = annot_ref(&mut doc, 0, i).unwrap(); + doc.update_dict(r, |d| { + d.remove(b"BS"); + if border == b"BS" { + let mut bs = pdfcraft_cos::Dict::new(); + bs.set(b"W".to_vec(), width); + d.set(b"BS".to_vec(), Object::Dict(bs)); + } else { + d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), width])); + } + }) + .unwrap(); + set_points(&mut doc, 0, i, &[[100.0, 100.0], [300.0, 100.0], [200.0, 250.0]], &meta("")).unwrap(); + rect(&list(&doc, 0)[i]) + }; + let bs = reshaped(b"BS", Object::Int(5)); + assert_eq!(reshaped(b"Border", Object::Int(5)), bs, "the same 5-pt border, the same room"); + assert_ne!(reshaped(b"BS", Object::Int(1)), bs, "a 1-pt border needs less"); +} + +/// Every number a drawing writes, which must all be finite. +fn drawn_numbers(ops: &str) -> Vec { + ops.split_whitespace().filter(|t| t.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '.')).filter_map(|t| t.parse().ok()).collect() +} + +/// Review on #4195: a cloud around a zero-size, inverted, sliver or huge `/Rect`, at any border +/// width and any `/BE /I` (tiny, negative, far above Table 169's 2), is either drawn inside +/// `/Rect` with finite numbers and a bounded number of bumps, or not built at all, never a +/// panic, a division by zero or a NaN. +#[test] +fn hostile_cloud_sizes_widths_and_intensities_draw_inside_rect_or_not_at_all() { + let rects = [ + "0 0 0 0", + "100 100 100 100", + "100 100 100.5 300", + "100 100 101 101", + "300 250 100 100", + "100 100 300 250", + "100 100 110 110", + "-1000000000000000 -1000000000000000 1000000000000000 1000000000000000", + "0 0 99999999999999999999999 1", + &format!("0 0 {} 100", "9".repeat(400)), + &format!("-{n} -{n} {n} {n}", n = "9".repeat(308)), + ]; + let (mut drawn, mut refused) = (0, 0); + for rect in rects { + let r: Vec = rect.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + let bounds = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + for width in ["0", "0.001", "2", "1000000", "-3"] { + for intensity in ["0.0000001", "2", "1000000000000000000000000000000", "-1"] { + for kind in ["Square", "Circle", "FreeText"] { + let body = format!( + "<< /Type /Annot /Subtype /{kind} /Rect [{rect}] /C [1 0 0] /BS << /W {width} >> /BE << /S /C /I {intensity} >> /DA (0 g /Helv 10 Tf) /Contents (x) >>" + ); + let Some(ops) = built(&body) else { + refused += 1; + continue; + }; + drawn += 1; + assert!(!ops.contains("NaN") && !ops.contains("inf"), "{body}: {ops}"); + assert!(drawn_numbers(&ops).iter().all(|v| v.is_finite()), "{body}"); + assert!(ops.matches(" c\n").count() <= crate::appearance::MAX_CLOUD_BUMPS + 8, "{body}: bumps are capped"); + // A cloud (intensity above 0); a negative one is a plain border (Table 169). + if !intensity.starts_with('-') && ops.contains(" c\n") { + let e = extent(&ops); + let slack = 0.5 + 1e-9 * (bounds[2] - bounds[0]).abs().max((bounds[3] - bounds[1]).abs()); + assert!( + e[0] >= bounds[0] - slack && e[1] >= bounds[1] - slack && e[2] <= bounds[2] + slack && e[3] <= bounds[3] + slack, + "{body}: the cloud {e:?} stays in /Rect" + ); + } + } + } + } + } + assert!(drawn > 0 && refused > 0, "both outcomes are exercised: {drawn} drawn, {refused} refused"); +} + +/// Review on #4195: a cloud around a rotated rectangle (a polygon turned 45°, its corners in +/// either order) bulges outwards the same way and stays inside its `/Rect`, and `cloud_path` +/// itself takes a zero, negative, infinite or NaN bump size and repeated corners without +/// dividing by zero. +#[test] +fn a_cloud_around_a_rotated_rectangle_bulges_outwards_either_way_round() { + let ccw = "150 100 200 150 150 200 100 150"; + let cw = "150 100 100 150 150 200 200 150"; + let extents: Vec<[f64; 4]> = [ccw, cw] + .iter() + .map(|v| { + let ops = built(&format!("<< /Type /Annot /Subtype /Polygon /Rect [80 80 220 220] /C [1 0 0] /Vertices [{v}] /BE << /S /C /I 1 >> >>")) + .unwrap(); + assert!(ops.contains(" c\n"), "{ops}"); + let e = extent(&ops); + assert!(e[0] >= 80.0 && e[1] >= 80.0 && e[2] <= 220.0 && e[3] <= 220.0, "inside /Rect: {e:?}"); + // Each bump's middle lies outside the turned square |x − 150| + |y − 150| = 50. + let mut at = (0.0, 0.0); + for line in ops.lines() { + let v: Vec = line.split_whitespace().filter_map(|t| t.parse().ok()).collect(); + if line.ends_with(" m") && v.len() == 2 { + at = (v[0], v[1]); + } else if line.ends_with(" c") && v.len() == 6 { + let mid = ((at.0 + 3.0 * v[0] + 3.0 * v[2] + v[4]) / 8.0, (at.1 + 3.0 * v[1] + 3.0 * v[3] + v[5]) / 8.0); + assert!((mid.0 - 150.0).abs() + (mid.1 - 150.0).abs() > 50.0 + 1.0, "the bump at {mid:?} bulges outwards"); + at = (v[4], v[5]); + } + } + e + }) + .collect(); + for k in 0..4 { + assert!((extents[0][k] - extents[1][k]).abs() < 0.5, "the same cloud either way round: {extents:?}"); + } + let square = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + for r in [0.0, -1.0, f64::INFINITY, f64::NAN, 1e-300, 1e300] { + let path = crate::appearance::cloud_path(&square, r); + assert!(!path.contains("NaN") && !path.contains("inf"), "r {r}: {path}"); + assert!(path.ends_with("h\n")); + } + for pts in [vec![], vec![(5.0, 5.0)], vec![(5.0, 5.0); 4], vec![(0.0, 0.0), (10.0, 0.0), (10.0, 0.0), (0.0, 0.0)]] { + let path = crate::appearance::cloud_path(&pts, 2.0); + assert!(!path.contains("NaN") && !path.contains("inf"), "{pts:?}: {path}"); + } +} + #[test] fn freehand_highlight_is_compatible_ink_with_marker_blending() { let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] }; @@ -3503,15 +4017,16 @@ fn polyline_whose_vertices_reach_past_its_rect_is_drawn_through_them() { fn polygon_whose_vertices_reach_past_its_rect_is_drawn_through_them() { let (_, display) = displayed(&[ b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 1 >> /C [0 0 1] /IC [1 1 0] /Vertices [100 100 200 100 150 180] >>", - b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C >> /Vertices [100 100 200 100 150 180] >>", + b"<< /Type /Annot /Subtype /Polygon /Rect [10 10 20 20] /BS << /W 2 >> /C [0 0 1] /BE << /S /C /I 1 >> /Vertices [100 100 200 100 150 180] >>", ]); let shown = list(&display, 0); let rect = |d: &Dict| nums_of(d, b"Rect"); assert_eq!(rect(&shown[0]), [10.0, 10.0, 200.5, 180.5], "the polygon's /Rect takes in its vertices"); assert_eq!(ap_bbox(&display, &shown[0]), rect(&shown[0])); assert!(ap_content(&display, &shown[0]).contains("100 100 m"), "the polygon is drawn"); - // Half the width (1) and twice the cloud radius (2 × (6 + 1.5 × 2)) past the vertices. - assert_eq!(rect(&shown[1]), [10.0, 10.0, 219.0, 199.0], "and its cloudy border"); + // Half the width (1) and the cloud's reach (`cloud_reach`: 1.5 bumps of 6 + 1.5 × 2 at + // intensity 1) past the vertices. + assert_eq!(rect(&shown[1]), [10.0, 10.0, 214.5, 194.5], "and its cloudy border"); } /// A line's `/L` (Table 178) outside its `/Rect` is drawn, with its line ending and its leader diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8fb1e0fd52..c5692ff9a7 100644 --- a/crates/automation/src/a11y.rs +++ b/crates/automation/src/a11y.rs @@ -213,25 +213,25 @@ const MAX_TAG_PAGE: usize = 1000; impl Automation { pub(crate) fn accessibility_autotag_apply(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::AutoTagText).map_err(failed)?; + let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagText)?; let tags = self.accessibility_tags(a)?; - Ok(json!({ + let out = json!({ "tags": tags["tags"], "count": tags["count"], "note": "Text objects were tagged as paragraphs. Review heading roles, reading order and remaining accessibility findings manually.", - })) + }); + Ok(with_warnings(out, warnings)) } pub(crate) fn accessibility_autotag_form_fields(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::AutoTagFormFields).map_err(failed)?; + let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::AutoTagFormFields)?; let report = self .doc(a)? .accessibility_check(&Options { rules: [Rule::TaggedFormFields].into(), pages: None }) .ok_or_else(|| failed("the document can't be read"))?; let result = report.result(Rule::TaggedFormFields).ok_or_else(|| failed("the form field check is unavailable"))?; - Ok( - json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }), - ) + let out = json!({ "status": status_id(result.status), "remaining_findings": result.findings.len(), "note": "Only form widgets were tagged. Review text, figures, links, reading order, and other document content separately." }); + Ok(with_warnings(out, warnings)) } pub(crate) fn accessibility_autotag_preview(&self, a: &Args) -> Result { @@ -432,10 +432,11 @@ impl Automation { let rule = Rule::from_id(id).ok_or_else(|| ToolError::InvalidArgs(format!("unknown rule {id:?}")))?; let edit = self.doc(a)?.accessibility_fix(rule, a.opt_str("value")?).map_err(failed)?; let doc = self.doc(a)?.id; - self.session.apply(doc, edit).map_err(failed)?; + let warnings = self.apply_with_warnings(doc, edit)?; let r = self.doc(a)?.accessibility_check(&Options { rules: [rule].into(), pages: None }).ok_or_else(|| failed("the document can't be read"))?; - Ok(json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) })) + let out = json!({ "rule": id, "status": status_id(r.results.iter().find(|x| x.rule == rule).map_or(Status::Skipped, |x| x.status)) }); + Ok(with_warnings(out, warnings)) } pub(crate) fn accessibility_figures(&self, a: &Args) -> Result { @@ -466,8 +467,8 @@ impl Automation { pdfcraft_engine::Edit::SetAltText { figure, alt: a.opt_str("alt")?.map(str::to_owned) } }; let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.accessibility_figures(a) + let warnings = self.apply_with_warnings(id, edit)?; + Ok(with_warnings(self.accessibility_figures(a)?, warnings)) } /// The structure tags, one page at a time (`offset`, `limit`), optionally only some types, pages @@ -551,7 +552,22 @@ impl Automation { let generation = u16::try_from(a.int("generation")?) .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) }).map_err(failed)?; + let warnings = + self.apply_with_warnings(id, pdfcraft_engine::Edit::MarkTagArtifact { object: pdfcraft_cos::ObjRef::new(object, generation) })?; + Ok(with_warnings(self.accessibility_tags(a)?, warnings)) + } + + pub(crate) fn accessibility_layout_table_to_blocks(&mut self, a: &Args) -> Result { + let object = u32::try_from(a.int("object")?).map_err(|_| ToolError::InvalidArgs("object must be a positive tag object number".into()))?; + if object == 0 { + return Err(ToolError::InvalidArgs("object must be a positive tag object number".into())); + } + let generation = u16::try_from(a.int("generation")?) + .map_err(|_| ToolError::InvalidArgs("generation must be a PDF object generation from accessibility_tags".into()))?; + let id = self.doc(a)?.id; + self.session + .apply(id, pdfcraft_engine::Edit::LayoutTableToBlocks { object: pdfcraft_cos::ObjRef::new(object, generation) }) + .map_err(failed)?; self.accessibility_tags(a) } @@ -573,9 +589,32 @@ impl Automation { other => return Err(ToolError::InvalidArgs(format!("placement must be before or after, not {other:?}"))), }; let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement }).map_err(failed)?; - self.accessibility_tags(a) + let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::MoveTag { object, anchor, placement })?; + Ok(with_warnings(self.accessibility_tags(a)?, warnings)) } + + /// Apply `edit` to document `id` and take out the errors it added to the document's script + /// output, such as the values of an XFA form that could not be written to its data. + fn apply_with_warnings(&mut self, id: DocId, edit: pdfcraft_engine::Edit) -> Result> { + let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?; + Ok(out.errors) + } +} + +/// `out` with `warnings` added to its `warnings` list (made when it has none). +fn with_warnings(mut out: Value, warnings: Vec) -> Value { + if warnings.is_empty() { + return out; + } + if let Some(map) = out.as_object_mut() { + match map.get_mut("warnings").and_then(Value::as_array_mut) { + Some(list) => list.extend(warnings.into_iter().map(Value::from)), + None => { + map.insert("warnings".into(), json!(warnings)); + } + } + } + out } #[cfg(test)] diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index ceb520e9d4..137c98c5d4 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { @@ -103,8 +103,8 @@ impl Automation { pub(crate) fn pdfa_convert(&mut self, a: &Args) -> Result { let level = self.pdfa_level(a)?; let id = self.doc(a)?.id; - self.session.apply(id, pdfcraft_engine::Edit::ConvertPdfA { level }).map_err(failed)?; - self.pdfa_verify(a) + let warnings = self.apply_with_warnings(id, pdfcraft_engine::Edit::ConvertPdfA { level })?; + Ok(with_warnings(self.pdfa_verify(a)?, warnings)) } pub(crate) fn action_list(&self) -> Result { @@ -209,7 +209,7 @@ impl Automation { pub(crate) fn doc_compare_mark(&mut self, a: &Args) -> Result { let (old, new) = self.compare_ids(a)?; - let n = self.session.mark_differences(old, new).map_err(failed)?; - Ok(json!({ "comments": n })) + let (n, out) = self.session.with_own_js_output(new, |s| s.mark_differences(old, new)).map_err(failed)?; + Ok(with_warnings(json!({ "comments": n }), out.errors)) } } diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 2420a44372..5f8a7021c4 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,4 +1,4 @@ -use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; +use super::{Args, Automation, Result, ToolError, Value, failed, json, with_warnings, write_atomic}; /// A script's request to the viewer as the JSON that running a script reports; a side panel /// goes by its fixed id. @@ -28,10 +28,18 @@ impl Automation { /// messages, console lines, requests, error, the failing document scripts' errors and result. pub(crate) fn js_run(&mut self, a: &Args) -> Result { let id = self.doc(a)?.id; - let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?; + let (script, field) = (a.str("script")?, a.opt_str("field")?); + // Applying the script's field changes runs the field scripts again; their output is reported with the script's. + let (mut o, edit) = self.session.with_own_js_output(id, |s| s.run_javascript(id, script, field)).map_err(failed)?; + o.alerts.extend(edit.alerts); + o.console.extend(edit.console); + o.requests.extend(edit.requests); let reqs: Vec = o.requests.iter().map(request_json).collect(); let alerts: Vec = o.alerts.iter().map(alert_json).collect(); - Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result })) + Ok(with_warnings( + json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }), + edit.errors, + )) } pub(crate) fn js_document_scripts(&self, a: &Args) -> Result { @@ -42,8 +50,8 @@ impl Automation { pub(crate) fn js_set_document_script(&mut self, a: &Args) -> Result { let edit = pdfcraft_engine::Edit::SetDocumentScript { name: a.str("name")?.into(), script: a.opt_str("script")?.map(str::to_string) }; let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.js_document_scripts(a) + let warnings = self.apply_with_warnings(id, edit)?; + Ok(with_warnings(self.js_document_scripts(a)?, warnings)) } pub(crate) fn form_set_script(&mut self, a: &Args) -> Result { @@ -53,8 +61,7 @@ impl Automation { script: a.opt_str("script")?.map(str::to_string), }; let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - let out = self.session.take_js_output(id); + let ((), out) = self.session.with_own_js_output(id, |s| s.apply(id, edit)).map_err(failed)?; Ok(json!({ "field": a.str("field")?, "event": a.str("event")?, "console": out.console, "errors": out.errors })) } @@ -90,10 +97,12 @@ impl Automation { v }) .collect(); + let mut warnings = Vec::new(); if a.opt_bool("add")?.unwrap_or(true) && !found.is_empty() { - self.session.auto_detect_fields(id, &pages).map_err(failed)?; + let (_, out) = self.session.with_own_js_output(id, |s| s.auto_detect_fields(id, &pages)).map_err(failed)?; + warnings = out.errors; } - Ok(json!({ "fields": list })) + Ok(with_warnings(json!({ "fields": list }), warnings)) } pub(crate) fn form_actions(&self, a: &Args) -> Result { @@ -157,8 +166,8 @@ impl Automation { let edit = pdfcraft_engine::Edit::SetFieldProps { name, props: Box::new(pdfcraft_engine::FieldProps { actions: Some(acts), ..Default::default() }) }; let id = self.doc(a)?.id; - self.session.apply(id, edit).map_err(failed)?; - self.form_actions(a) + let warnings = self.apply_with_warnings(id, edit)?; + Ok(with_warnings(self.form_actions(a)?, warnings)) } pub(crate) fn js_enabled(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index 2846aa44a6..6d1eaaa389 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -1,4 +1,4 @@ -use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, with_warnings, write_atomic}; impl Automation { /// The settings every OCR tool shares: dpi, language, the engine/strategy choice, the four @@ -258,9 +258,10 @@ impl Automation { let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?; match out { None => { - let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?; + let (found, out) = self.session.with_own_js_output(id, |s| s.recognize_text(id, &pages, settings)).map_err(failed)?; + let warnings = out.errors; let words = found.iter().map(|p| p.words.len()).sum::(); - Ok(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) })) + Ok(with_warnings(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }), warnings)) } Some(out) => { let job = self.session.ocr_job(id, &pages, settings).ok_or_else(|| failed("the document could not be read"))?; diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index deb2efa18a..5fd9d9b0be 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -36,8 +36,9 @@ impl Automation { } (None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())), }; - // The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with - // its line) by `pdfcraft_create::from_text`, as for every other caller. + // The page font is Helvetica/WinAnsi, or the bundled Inter face for Latin, Greek and + // Cyrillic letters WinAnsi lacks: text neither can draw is refused (with its line) + // by `pdfcraft_create::from_text`, as for every other caller. let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e { pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m), e => failed(e), diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 390e3b4d1a..3174e68dbd 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -224,24 +224,27 @@ impl Automation { }; // Every reply under the comment its thread starts from, at any depth, in document order: // a reply to a reply keeps its `/IRT` on the direct parent, so following one level would - // drop it. Parents are found by reference, as `/NM` need not be unique. A status or - // checkmark belongs to the annotation its `/IRT` names (ISO 32000-2 §12.5.6.2), as in the - // Comments panel: one set on a reply is not the comment's. + // drop it. Parents are found by reference, as `/NM` need not be unique. Status and + // checkmark replies are read through `review` instead. let tops = pdfcraft_render::thread_tops(&all); let mut threads: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); - for r in &all { + for r in all.iter().filter(|r| r.state.is_none()) { let Some(&top) = tops.get(&(r.page, r.index)) else { continue }; - let key = match r.parent { - Some(parent) if r.state.is_some() => parent, - _ => top, - }; - threads.entry(key).or_default().push(r); + threads.entry(top).or_default().push(r); } - // An annotation's review status and checkmark: the latest of each set on it. + // An annotation's review states. A state reply's `/IRT` refers to the annotation whose + // state it sets, and a reviewer's later change replies to their previous one (ISO 32000-2 + // §12.5.6.3), so a status on a reply is that reply's, as in the Comments panel. `status` + // is the latest set by anyone; `statuses` has each reviewer's latest (a state is "for the + // specified user"). Checkmarks are each reviewer's own too: `marked_by` names who has one + // on, and `marked` whether anyone has. + let review = pdfcraft_render::ReviewStates::new(&all); let states = |c: &Annotation, v: &mut Value| { - let set = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); - v["status"] = json!(set.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone())); - v["marked"] = json!(set.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked"))); + v["status"] = json!(review.latest_status(c)); + v["statuses"] = json!(review.statuses(c).iter().map(|(who, state)| json!({ "author": who, "status": state })).collect::>()); + let marked_by = review.marked(c); + v["marked"] = json!(!marked_by.is_empty()); + v["marked_by"] = json!(marked_by); }; let roots: Vec = all .iter() @@ -253,7 +256,6 @@ impl Automation { let thread = threads.get(&(c.page, c.index)).map(Vec::as_slice).unwrap_or(&[]); v["replies"] = thread .iter() - .filter(|r| r.state.is_none()) .map(|r| { let mut reply = view(r); states(r, &mut reply); diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 642c4b5b11..43eacc7f95 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -747,6 +747,7 @@ impl Automation { "content_delete" => self.content_delete(&a)?, "content_copy" => self.content_copy(&a)?, "doc_print" => self.doc_print(&a)?, + "doc_fix_hairlines" => self.fix_hairlines(&a)?, "attachment_add" => { let path = self.resolve(a.str("path")?, false)?; let bytes = read_input(&path)?; @@ -834,6 +835,7 @@ impl Automation { "accessibility_tag_content" => self.accessibility_tag_content(&a)?, "accessibility_set_tag_property" => self.accessibility_set_tag_property(&a)?, "accessibility_mark_tag_artifact" => self.accessibility_mark_tag_artifact(&a)?, + "accessibility_layout_table_to_blocks" => self.accessibility_layout_table_to_blocks(&a)?, "accessibility_move_tag" => self.accessibility_move_tag(&a)?, "sign_id_create" => self.sign_id_create(&a)?, "sign_document" => self.sign_document(&a)?, diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 8741b2829f..823bc078c0 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -361,6 +361,7 @@ impl Automation { ("discard_alternate_images", &mut settings.discard_alternate_images), ("discard_tags", &mut settings.discard_tags), ("discard_print_settings", &mut settings.discard_print_settings), + ("discard_form_actions", &mut settings.discard_form_actions), ("flate_unencoded", &mut settings.flate_unencoded), ("object_streams", &mut settings.object_streams), ("lzw_to_flate", &mut settings.lzw_to_flate), @@ -471,6 +472,8 @@ impl Automation { "thumbnails": o.thumbnails, "alternate_images": o.alternate_images, "tags_removed": o.tags_removed, + "print_settings": o.print_settings, + "form_actions": o.form_actions, "streams_compressed": o.streams_compressed, "lzw_streams": o.lzw_streams, "invalid_links": o.invalid_links, diff --git a/crates/automation/src/file_access.rs b/crates/automation/src/file_access.rs index dfc33b4e7d..9ffdea083a 100644 --- a/crates/automation/src/file_access.rs +++ b/crates/automation/src/file_access.rs @@ -15,8 +15,8 @@ //! - A path on another network share or in a device namespace (`\\host\share`, `\\.\pipe\x`) //! than the root's is refused by name, before anything is resolved: resolving it would contact //! the host. -//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside -//! `/work`), without regard to case on Windows. +//! - The result must lie inside the root, compared by whole canonical components (`/work2` isn't +//! inside `/work`), with the drive letter compared without regard to case on Windows. //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Without roots nothing is checked. A path is checked, then opened: a process that can change the @@ -204,22 +204,23 @@ pub(crate) fn follow(shown: &str, path: &Path, root: &Path) -> Toward { if inside(&at, root) { Toward::Inside } else { Toward::Outside } } -/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on -/// Windows, whose file systems ignore it)? +/// Is `path` (resolved) `root` or inside it, component by component (the drive letter without +/// regard to case on Windows)? fn inside(path: &Path, root: &Path) -> bool { let mut path = path.components(); root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) } fn same_component(a: Component, b: Component) -> bool { + use std::path::Prefix; if a == b { return true; } - if !cfg!(windows) { - return false; - } - match (a.as_os_str().to_str(), b.as_os_str().to_str()) { - (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + match (a, b) { + (Component::Prefix(a), Component::Prefix(b)) => match (a.kind(), b.kind()) { + (Prefix::Disk(a) | Prefix::VerbatimDisk(a), Prefix::Disk(b) | Prefix::VerbatimDisk(b)) => a.eq_ignore_ascii_case(&b), + _ => false, + }, _ => false, } } @@ -466,6 +467,37 @@ mod tests { let _ = std::fs::remove_dir_all(base); } + /// #4003: canonical path components are compared as the file system gave them, with only the + /// Windows drive letter matched ASCII case-insensitively, so a sibling whose name `to_lowercase` + /// folds onto the root's (`K` U+212A → `k`) or a differently-cased canonical component is not + /// taken as inside the root. + #[test] + fn canonical_components_are_compared_exactly() { + let (base, _) = folders("canonical-exact"); + std::fs::create_dir_all(base.join("ink")).unwrap(); + let kelvin = base.join("in\u{212A}"); + std::fs::create_dir_all(&kelvin).unwrap(); + std::fs::write(kelvin.join("secret.pdf"), "x").unwrap(); + // U+212A folds to K. A case-insensitive volume stores both names as one directory, so + // `check` would be asked to reject a path inside its own root. The component comparison + // itself does not need that volume. + let distinct = base.join("ink").canonicalize().ok() != kelvin.canonicalize().ok(); + if distinct { + let roots = AutomationRoots::new(Some(&base.join("ink")), None).unwrap().unwrap(); + let e = roots.check(&s(kelvin.join("secret.pdf")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + } + assert!(!inside(Path::new("/work/in\u{212A}/secret.pdf"), Path::new("/work/ink"))); + assert!(!inside(Path::new(r"\\?\C:\work\IN\a.pdf"), Path::new(r"\\?\C:\work\in"))); + #[cfg(windows)] + { + assert!(inside(Path::new(r"\\?\c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + assert!(inside(Path::new(r"c:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + assert!(!inside(Path::new(r"\\?\D:\work\in\a.pdf"), Path::new(r"\\?\C:\work\in"))); + } + let _ = std::fs::remove_dir_all(base); + } + /// Every file a tool reads or writes goes through `Automation::resolve`, which holds it to the /// roots. A function that reaches for the file system without resolving a path first would /// bypass them, so each use of `std::fs` or `File::open` must come after a `resolve` call in diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index adf0bd0884..15c8975dd6 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -213,6 +213,16 @@ fn value_for(f: &FormField, v: &Value) -> Result { let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); Ok(match (f.kind, v) { (FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b), + // {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it + // off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229). + (FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => { + let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?; + let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?; + if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() { + return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len()))); + } + FieldValue::Widget(i) + } (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( @@ -294,6 +304,11 @@ impl Automation { } FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); + // Buttons sharing an on state have the same value: which is on (1-based). + o.insert( + "on_widgets".into(), + json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::>()), + ); } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); diff --git a/crates/automation/src/hairlines.rs b/crates/automation/src/hairlines.rs new file mode 100644 index 0000000000..60e9dfe475 --- /dev/null +++ b/crates/automation/src/hairlines.rs @@ -0,0 +1,40 @@ +//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a +//! width (`doc_fix_hairlines`). + +use pdfcraft_engine::{Edit, Hairlines}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Strokes thinner than `narrower_than` points on the page (after every transformation; + /// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on + /// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere` + /// counts the glyphs and patterns changed that something outside `pages` also uses. + pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let settings = Hairlines { + narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0), + width: a.opt_num("width")?.unwrap_or(0.01 * 72.0), + type3: a.opt_bool("type3")?.unwrap_or(false), + patterns: a.opt_bool("patterns")?.unwrap_or(false), + }; + if !settings.in_range() { + return Err(ToolError::InvalidArgs(format!( + "narrower_than must be 0 or more and width above 0, both at most {} (points)", + Hairlines::MAX_POINTS + ))); + } + let id = self.doc(a)?.id; + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 })); + } + let r = self.apply(a, Edit::FixHairlines { pages, settings })?; + Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 594a129d8b..7fa63d1a1d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -22,6 +22,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod hairlines; mod layers; mod links; #[cfg(feature = "mcp")] diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index a3bf9cd28b..546413f6fc 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -113,7 +113,41 @@ impl Automation { v }) .collect(); - Ok(json!({ "count": items.len(), "links": items })) + let mut out = json!({ "count": items.len(), "links": items }); + // Addresses written as plain page text, which the viewer makes clickable (#4207). Reading + // the pages' text costs more than the list above, so only on request. + if a.opt_bool("text_links")?.unwrap_or(false) { + let id = doc.id; + // One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based + // `text_links_from`; `text_links_next_page` says where the next share starts. + let pages = doc.info.pages.len().max(1); + let from = match a.opt_int("text_links_from")? { + None => 0, + Some(p) => usize::try_from(p) + .ok() + .filter(|p| (1..=pages).contains(p)) + .map(|p| p - 1) + .ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?, + }; + let scan = self.session.text_links(id, from); + if let Some(next) = scan.next_page { + out["text_links_next_page"] = json!(next.saturating_add(1)); + } + let found: Vec = scan + .links + .into_iter() + .map(|(page, l)| { + let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri }); + // What the viewer does on a click: the same check every document link gets. + if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) { + v["blocked"] = json!(e.to_string()); + } + v + }) + .collect(); + out["text_links"] = json!(found); + } + Ok(out) } pub(crate) fn link_open(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 04ab8dace4..f9ddec40a3 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -235,6 +235,10 @@ impl Automation { let (mut html_left, mut spent, mut cut) = (html_budget, false, false); for (&p, t) in pages.iter().zip(texts) { let mut page = json!({ "page": p + 1, "text": text(&t) }); + // Content cut at the renderer's limits (#623): the text is incomplete, and said so. + if t.truncated { + page["truncated"] = json!(true); + } if html { // Copy with formatting: the same glyphs as an HTML fragment, within one budget for // the whole call; a page past it gets none and the call says so. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 2f1cf5e136..8a6f825e6a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -38,8 +38,14 @@ fn bad(m: impl Into) -> ToolError { } /// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size; US Letter when none is given. +/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when +/// none is given. fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + // A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in + // `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it. + if let Some(size) = name.and_then(paper_size) { + return Ok((PAPERS.len(), size)); + } let mut papers = PAPERS.iter().enumerate(); let found = match name { None => papers.next(), @@ -51,7 +57,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { }; found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { bad(format!( - "unknown paper {:?}; choose one of {}", + "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", name.unwrap_or_default(), PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") )) @@ -237,6 +243,19 @@ impl Automation { } } +/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 +/// inches (PDF's own page limit). +fn paper_size(spec: &str) -> Option<(f64, f64)> { + let s = spec.trim().to_ascii_lowercase(); + let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)] + .into_iter() + .find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?; + let (w, h) = body.split_once('x')?; + let side = |t: &str| t.trim().parse::().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v)); + let (w, h) = (side(w)?, side(h)?); + Some((w.min(h), w.max(h))) +} + #[cfg(test)] mod tests { use super::*; @@ -289,7 +308,19 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + // A size of its own is laid out as asked and names no media. + assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0)))); + assert!(job(json!({}), Some("8.5x14in")).options.is_empty()); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); } + + #[test] + fn a_paper_size_reads_its_numbers_and_needs_a_unit() { + assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0))); + assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0))); + for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] { + assert_eq!(paper_size(spec), None, "{spec}"); + } + } } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 6ec66ce680..a793487d9f 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -2,7 +2,7 @@ //! (removing what they cover for good) or clear them. use pdfcraft_engine::{ - Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, + Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, }; use serde_json::{Value, json}; @@ -235,6 +235,9 @@ impl Automation { Ok(json!({ "findings": items, "count": items.len() })) } + /// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them + /// (Sanitize Document) when none are listed, and report how many items the listed + /// categories lost as `removed`. pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { let which = match a.get("categories") { None => None, @@ -253,8 +256,11 @@ impl Automation { // One scan, for this check and for what was removed. The removal refuses when it counts // every listed category as 0 (an AcroForm with no fields counts 1), with an error about // redaction marks; this reads the same scan, so it refuses exactly then. A document that - // can't be edited has no scan: `apply` says why. - let info = self.doc(a)?.hidden_info(); + // can't be edited has no scan: `apply` says why. Only the listed categories are counted, + // here and after: the others would read every page's content and every object for + // nothing (#5873). + let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec()); + let info = self.doc(a)?.hidden_info_of(&listed); let edit = match which { None => Edit::Sanitize, Some(which) => { @@ -266,7 +272,7 @@ impl Automation { }; let before: usize = info.iter().map(|c| c.1).sum(); let mut out = self.apply(a, edit)?; - let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum(); out["removed"] = json!(before.saturating_sub(after)); Ok(out) } diff --git a/crates/automation/src/signing.rs b/crates/automation/src/signing.rs index df0fd643fb..c78baf25e9 100644 --- a/crates/automation/src/signing.rs +++ b/crates/automation/src/signing.rs @@ -310,6 +310,9 @@ impl Automation { pub(crate) fn sign_trust(&mut self, a: &Args) -> Result { let mut certs: Vec = if a.opt_bool("clear")?.unwrap_or(false) { Vec::new() } else { self.session.trusted_certificates().to_vec() }; + // Which certificates are already in, by their bytes: a set, so a bundle of many distinct + // certificates isn't compared against every one before it. + let mut known: std::collections::HashSet> = certs.iter().map(|c| c.raw.clone()).collect(); let paths: Vec = match a.get("paths") { None => Vec::new(), Some(v) => v @@ -330,7 +333,7 @@ impl Automation { sign::x509::load_certificates(&bytes).map_err(failed)? }; for c in found { - if !certs.iter().any(|t| t.raw == c.raw) { + if known.insert(c.raw.clone()) { certs.push(c); } } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 0cade83abd..27ce2d8b7c 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -154,6 +154,17 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.") + .cmd("prepress.hairlines") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to fix (default all)"), + "narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." }, + "width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." }, + "type3": { "type": "boolean" }, "patterns": { "type": "boolean" }, + }), + &["doc"], + )), t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") .cmd("edit.attach") .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), @@ -187,7 +198,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - 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.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() @@ -235,7 +246,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") .ro() .cmd("edit.column_select") .with(schema( @@ -497,13 +508,13 @@ pub fn tools() -> Vec { }), &["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. 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).") + 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). Radio groups include on_widgets, the 1-based numbers of the buttons that are on (buttons sharing an option tell apart only there).") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( "form_fill", "Fill in form fields", - "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", + "Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists; {\"widget\": n} turns on button n (1-based) of a check box or radio group and never turns it off (to clear, use null for a radio group that allows it, false for a check box); of radio buttons sharing an option only that one is on unless the group has RadiosInUnison. Appearances are regenerated so every viewer shows the values. Thai the field's font can't hold is drawn from PdfCraft's built-in Thai face (a build without one refuses it). Any other value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.", ) .with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])), t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.") @@ -708,7 +719,7 @@ pub fn tools() -> Vec { t( "doc_print", "Print", - "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", + "Print with Acrobat's Print dialog options, or save the print-ready PDF. pages: a range such as \"1-3, 6, 9-\" (page labels allowed, \"(n)\" for the nth page whatever its label, unless a page is labelled exactly \"(n)\", since labels are looked up first; default all); subset odd/even (by page number, as Acrobat counts them: pages 2, 7-10 with even prints 2, 8 and 10, not every second page of the range); reverse. layout: fit (default), actual, shrink, custom (scale %), multiple (per_sheet 2/4/6/9/16, which turns the sheet to suit the grid, or a custom grid of cols across by rows down, each 1-99, on the sheet as oriented (auto prints portrait); order, border, auto_rotate; cut-stack order arranges single-sided sheets for cutting into piles and stacking left to right, top to bottom, keeping sheet order within each pile; duplex must be off), booklet (booklet_subset both/front/back, binding left/right; a booklet sets two-sided itself, as Acrobat does: both flips on the short edge, front or back prints one-sided), poster (scale %, overlap pt, cut_marks). orientation auto/portrait/landscape; comments_forms document / document-and-markups (default) / document-and-stamps / form-fields-only; paper Letter/Legal/Tabloid, ARCH A/B/C/D/E1/E, ANSI C/D/E, A0/A1/A2/A3/A4/A5/A6, or US #10 Envelope (short names or full preset labels); use layout actual for 100% in the print-ready PDF; printer drivers may still substitute paper or scale. Then path (save) or printer (a name or \"default\") with copies, collate, duplex off/long-edge/short-edge, grayscale, and options (driver options from printer_options, CUPS).", ) .with(schema( json!({ @@ -730,7 +741,7 @@ pub fn tools() -> Vec { "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, - "paper": { "type": "string" }, + "paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." }, "path": { "type": "string" }, "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, @@ -813,9 +824,9 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])), t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), @@ -846,7 +857,7 @@ pub fn tools() -> Vec { t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.") + t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links, leaving out text on a layer the viewer hides (layer_set). Returns the URLs. Undoable.") .cmd("edit.links_from_urls") .with(schema(json!({ "doc": doc() }), &["doc"])), t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.") @@ -1321,6 +1332,11 @@ pub fn tools() -> Vec { "doc": doc(), "object": { "type": "integer", "minimum": 1 }, "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, }), &["doc", "object", "generation"])), + t("accessibility_layout_table_to_blocks", "Change layout table to blocks", "Turn a table used only for layout (label and value pairs, a letterhead grid) into plain blocks in one step: the Table tag, its THead/TBody/TFoot and TR tags become Div, its TH, TD and Caption tags P (Div when they hold tags of their own). Content, reading order and the tags inside cells stay; a table nested in a cell stays a table; Table attributes (Summary, Scope, spans) are removed, other attributes kept. The tag's type must be Table after the role map; use object and generation from accessibility_tags. Undoable. Returns the tags as accessibility_tags does.") + .with(schema(json!({ + "doc": doc(), "object": { "type": "integer", "minimum": 1 }, + "generation": { "type": "integer", "minimum": 0, "maximum": 65535 }, + }), &["doc", "object", "generation"])), t("accessibility_move_tag", "Move structure tag", "Move a reachable indirect structure tag before or after a direct sibling in their parent's /K array, changing tagged reading order without changing page appearance. Use object and generation values from accessibility_tags. Rejects distinct parents, duplicate references and positions that are already current. Undoable.") .with(schema(json!({ "doc": doc(), "object": { "type": "integer", "minimum": 1 }, @@ -1332,7 +1348,7 @@ pub fn tools() -> Vec { t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.") .cmd("form.merge_data") .with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])), - t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).") + t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output and requests (print, page, url, submit) of the script and of the field scripts its changes ran, the script's error, the errors of document-level scripts that failed (the rest still ran), and in warnings the errors of applying the changes, such as values of an XFA form that could not be written to its data.") .cmd("tools.js_console") .with(schema( json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }), @@ -1364,7 +1380,7 @@ pub fn tools() -> Vec { t("pdfa_convert", "Save as PDF/A", "Standards ▸ Save as PDF/A: fix what can be fixed for PDF/A-2b or 3b (XMP identification and metadata, an sRGB output intent, forbidden actions and JavaScript, annotation print flags, image interpolation, encryption, the file header, the file identifier). Returns what was fixed and what remains (e.g. fonts that aren't embedded). Undoable; save the document to keep it.") .cmd("standards.pdfa") .with(schema(json!({ "doc": doc(), "level": { "type": "string", "enum": ["2b", "3b"] } }), &["doc"])), - t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") + t("standards_remove_id", "Remove PDF/A or PDF/X identification", "Remove what identifies the document as PDF/A (its pdfaid: XMP properties) or PDF/X (GTS_PDFXVersion and GTS_PDFXConformance, in the document information and as pdfxid: or pdfx: XMP properties), e.g. after edits it no longer meets. Output intents and other metadata stay. Fails when the document declares nothing of the kind. Undoable.") .with(schema(json!({ "doc": doc(), "standard": { "type": "string", "enum": ["pdfa", "pdfx"] } }), &["doc", "standard"])), t("action_list", "List actions", "Action Wizard: the built-in actions (name, description, steps) and every step an action can use (id, label, whether it takes a text argument).") .ro() @@ -1532,7 +1548,7 @@ pub fn tools() -> Vec { t( "doc_create", "Create a PDF", - "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; Western European (WinAnsi) characters only, others are refused). Save it with doc_save and a path.", + "Create a new, unsaved document and return it like doc_open: `blank` (pages, width, height in points; default 1 US Letter page), `images` (paths of PNG, JPEG, TIFF (every page), GIF or BMP files, one page each at the image's resolution) or `text` (a .txt path, or `text` directly; set in Helvetica, or in the bundled Inter face when it has Latin, Greek or Cyrillic letters outside Western European (WinAnsi); other characters are refused). Save it with doc_save and a path.", ) .with(schema( json!({ @@ -1662,7 +1678,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc() }), &["doc"])), t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.") .with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])), - t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). 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, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 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, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. 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. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. 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, up to 16 JBIG2 images sharing one symbol dictionary (/JBIG2Globals) where that is smaller still (image masks and DeviceGray images named directly, of at most 64 Mi pixels, which redaction can store again without it), Group 3 fax images, JBIG2 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, a JBIG2 image JBIG2 and any other becoming Flate). Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings (entries removed: print_settings), form_actions (submit, import and reset actions removed: form_actions; the fields and links stay, and the actions chained after one still run); 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. target_bytes (optional): if the copy is larger, retry with up to 5 progressively stronger image settings (downsample to at most 110, 96, 72, 54, 36 ppi, applied to colour and grey images; images set to JPEG also get JPEG quality at most 45, 35, 28, 20, 12, while zip and retain images keep their encoding and only get the lower ppi) and stop at the first copy that fits; reports target_reached, attempts and target_settings, and writes the smallest copy with target_reached false when none fits. Without it nothing changes. The open document is unchanged.") .cmd("optimize.advanced") .with(schema( json!({ @@ -1679,6 +1695,7 @@ pub fn tools() -> Vec { "discard_alternate_images": { "type": "boolean" }, "discard_tags": { "type": "boolean" }, "discard_print_settings": { "type": "boolean" }, + "discard_form_actions": { "type": "boolean" }, "flate_unencoded": { "type": "boolean" }, "object_streams": { "type": "boolean" }, "lzw_to_flate": { "type": "boolean" }, diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 9a042320ee..e209d3102a 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2107,6 +2107,162 @@ fn destinations_are_listed_with_their_position() { assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None)); } +/// Web, `www.` and email addresses written as plain page text, which the viewer opens on a click +/// (#4207), are listed by link_list with text_links: true; one under a link annotation is not. +#[test] +fn addresses_written_as_page_text_are_listed_with_text_links() { + let dir = workdir("text-links"); + let content = [ + (700, "Visit https://example.com/plain-text-url."), + (650, "or www.example.com/no-scheme"), + (600, "Write to someone@example.com, please"), + (550, "https://example.org/covered"), + (500, "Version 1.2.3 and file:///etc/passwd"), + ] + .iter() + .map(|(y, s)| format!("BT /F1 14 Tf 72 {y} Td ({s}) Tj ET\n")) + .collect::(); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Annot /Subtype /Link /Rect [60 545 400 565] /Border [0 0 0] /A << /S /URI /URI (https://example.org/annotation) >> >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("text.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "text.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc })).get("text_links"), None, "only on request"); + let listed = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = listed["text_links"].as_array().unwrap(); + let urls: Vec<&str> = found.iter().map(|l| l["url"].as_str().unwrap()).collect(); + assert_eq!(urls, ["https://example.com/plain-text-url", "https://www.example.com/no-scheme", "mailto:someone@example.com"], "{listed}"); + assert!(found.iter().all(|l| l["page"] == 1 && l.get("blocked").is_none()), "{listed}"); + // Rectangles from the top-left of the page: the first line's baseline is 792 − 700 = 92 down. + let r = found[0]["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!(r[0] > 100.0 && r[0] < 140.0 && r[1] < 92.0 && r[3] > 80.0, "{r:?}"); +} + +/// One PDF of `pages` pages that all draw `content` in Helvetica. +fn same_text_pages(pages: usize, content: &str) -> Vec { + let kids: String = (0..pages).map(|k| format!("{} 0 R ", k + 5)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 612 792] >>"), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 3 0 R >>".to_string())); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// Review on #4472: link_list with text_links reads a share of a large document's pages per +/// call and says where to go on, so a 10,000-page document is never read all at once; a call +/// also stops after the page that brings it to 2,000 addresses. +#[test] +fn text_links_of_a_large_document_are_read_a_share_at_a_time() { + let dir = workdir("text-links-large"); + std::fs::write(dir.join("long.pdf"), same_text_pages(10_000, "BT /F1 14 Tf 72 700 Td (See https://example.com/page now) Tj ET\n")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = first["text_links"].as_array().unwrap(); + assert_eq!(found.len(), 100, "the first 100 pages"); + assert_eq!((found[0]["page"].clone(), found[99]["page"].clone()), (json!(1), json!(100))); + assert_eq!(first["text_links_next_page"], json!(101)); + let last = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 9_951 })); + let found = last["text_links"].as_array().unwrap(); + assert_eq!((found.len(), found[0]["page"].clone()), (50, json!(9_951))); + assert_eq!(last.get("text_links_next_page"), None, "nothing after the last page"); + for from in [0, -1, 10_001, i64::MAX] { + err(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": from })); + } + + // 52 lines of 10 addresses: 520 on a page, kept to 512; the fourth page brings a call to + // 2,048, so it stops there. + let line = "x@example.org ".repeat(10); + let content: String = (0..52).map(|k| format!("BT /F1 6 Tf 20 {} Td ({line}) Tj ET\n", 760 - k * 7)).collect(); + std::fs::write(dir.join("dense.pdf"), same_text_pages(6, &content)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "dense.pdf" }))["doc"].as_u64().unwrap(); + let dense = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + assert_eq!(dense["text_links"].as_array().unwrap().len(), 4 * 512); + assert_eq!(dense["text_links_next_page"], json!(5)); + let rest = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 5 })); + assert_eq!((rest["text_links"].as_array().unwrap().len(), rest.get("text_links_next_page")), (2 * 512, None)); +} + +/// An address on a layer the viewer hides is not a text link, and Create links from URLs makes +/// no link there: link_list and links_from_urls read the page text with the layers as layer_set +/// left them (ISO 32000-2 §8.11.3.1: hidden optional content "shall not be drawn"). +#[test] +fn text_links_leave_out_addresses_on_hidden_layers() { + let dir = workdir("text-links-layers"); + let content = + "BT /F1 14 Tf 72 700 Td (https://example.com/shown) Tj ET\n/OC /L1 BDC BT /F1 14 Tf 72 650 Td (https://example.com/layered) Tj ET EMC\n"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /OCG /Name (Addresses) >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("layers.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let urls = |a: &mut Automation| -> Vec { + let listed = ok(a, "link_list", json!({ "doc": doc, "text_links": true })); + listed["text_links"].as_array().unwrap().iter().map(|l| l["url"].as_str().unwrap().to_owned()).collect() + }; + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": false })); + assert_eq!(urls(&mut a), ["https://example.com/shown"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown"]), "no link over the hidden address: {created}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": true })); + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown", "https://example.com/layered"]), "{created}"); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -2155,6 +2311,98 @@ fn numbering_pages_through_tools() { assert!(matches!(a.call("page_number", &json!({ "doc": doc, "from": 1, "to": 1, "style": "klingon" })), Err(ToolError::InvalidArgs(_)))); } +/// ISO 32000-2 §12.5.6.3: a state is "for the specified user" (`/T`), so there is one status per +/// reviewer (their latest), and a checkmark ("marked by the user", Table 174) belongs only to the +/// reviewer who set it. `comment_list` reports each reviewer's status and who has a checkmark on; +/// the replies written are hidden (a state reply records a state, not a note) and a reviewer's +/// later change replies to their earlier one. +#[test] +fn review_statuses_are_per_reviewer_through_tools() { + let dir = workdir("statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + for (status, author) in [("accepted", "Bob"), ("completed", "Carol"), ("rejected", "Bob")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": status, "author": author })); + } + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Bob" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol" })); + ok(&mut a, "comment_mark", json!({ "doc": doc, "id": id, "author": "Carol", "marked": false })); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + assert_eq!(list["count"], 1, "status replies aren't comments: {list}"); + let c = &list["comments"][0]; + assert_eq!(c["statuses"], json!([{ "author": "Bob", "status": "Rejected" }, { "author": "Carol", "status": "Completed" }])); + assert_eq!(c["status"], "Rejected", "the latest set by anyone"); + assert_eq!((c["marked"].clone(), c["marked_by"].clone()), (json!(true), json!(["Bob"]))); + assert_eq!(c["replies"], json!([])); + + let bytes = std::fs::read(dir.join("statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let replies: Vec = saved + .object_numbers() + .into_iter() + .filter_map(|n| saved.get(pdfcraft_cos::ObjRef { num: n, generation: 0 }).as_dict().cloned()) + .filter(|d| d.contains(b"StateModel")) + .collect(); + assert_eq!(replies.len(), 6); + assert!(replies.iter().all(|d| d.int(b"F") == Some(30)), "every state reply is hidden: {replies:?}"); +} + +/// ISO 32000-2 §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", which +/// for a status set on a reply is that reply. The reviewer's next change replies to their previous +/// state reply, and the chain, found by reference, stays the reply's: it is reported on the reply +/// and never on its comment. +#[test] +fn statuses_set_on_a_reply_chain_on_that_reply_through_tools() { + let dir = workdir("reply-statuses"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "Check", "author": "Ada" }))["comment"] + ["id"] + .as_str() + .unwrap() + .to_string(); + ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "ANSWER", "author": "Bob" })); + let listed = ok(&mut a, "comment_list", json!({ "doc": doc })); + let reply_id = listed["comments"][0]["replies"][0]["id"].as_str().unwrap().to_string(); + for (status, author) in [("accepted", "Carol"), ("completed", "Dan"), ("rejected", "Carol")] { + ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": reply_id, "status": status, "author": author })); + } + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "reply-statuses.pdf" })); + let doc = ok(&mut a, "doc_open", json!({ "path": "reply-statuses.pdf" }))["doc"].as_u64().unwrap(); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let c = &list["comments"][0]; + assert_eq!((&c["status"], &c["statuses"]), (&Value::Null, &json!([])), "not the comment's: {c}"); + assert_eq!(c["replies"].as_array().map(Vec::len), Some(1), "{c}"); + let reply = &c["replies"][0]; + assert_eq!(reply["statuses"], json!([{ "author": "Carol", "status": "Rejected" }, { "author": "Dan", "status": "Completed" }]), "{reply}"); + + // Carol's second status replies to her first, which replies to the reply. + let bytes = std::fs::read(dir.join("reply-statuses.pdf")).unwrap(); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let dicts: Vec<(pdfcraft_cos::ObjRef, pdfcraft_cos::Dict)> = saved + .object_numbers() + .into_iter() + .map(|n| pdfcraft_cos::ObjRef { num: n, generation: 0 }) + .filter_map(|r| Some((r, saved.get(r).as_dict()?.clone()))) + .collect(); + let find = |text: &str| -> pdfcraft_cos::ObjRef { + dicts.iter().find(|(_, d)| d.get(b"Contents").and_then(|o| o.as_string()).is_some_and(|s| s.to_text().contains(text))).unwrap().0 + }; + let irt = |r: pdfcraft_cos::ObjRef| dicts.iter().find(|(x, _)| *x == r).and_then(|(_, d)| d.get(b"IRT").and_then(|o| o.as_ref())); + let (answer, first, second) = (find("ANSWER"), find("Accepted set by Carol"), find("Rejected set by Carol")); + assert_eq!(irt(first), Some(answer)); + assert_eq!(irt(second), Some(first)); +} + /// Typed text and text boxes take an oval or a circle for their border through the tools; /// comment_list reports it (a rectangle is not reported). Only typed text, dates and text boxes /// have one, and only the three shapes. @@ -2818,6 +3066,31 @@ fn form_fill_warns_when_a_value_does_not_fit() { assert!(r.get("warnings").is_none(), "{r}"); } +/// `form_fill` with {"widget": n} turns on that radio button alone when others share its on +/// state (ISO 32000-2 Table 229, RadiosInUnison clear), and `form_fields` says which is on. +#[test] +fn form_fill_picks_one_of_radio_buttons_sharing_an_on_state() { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>", + "<< /FT /Btn /T (pick) /Ff 49152 /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [10 250 26 266] /P 3 0 R /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [40 250 56 266] /P 3 0 R /AP << /N << /Yes 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("radio-shared-state"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pick": { "widget": 2 } } })); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!((&after["fields"][0]["value"], &after["fields"][0]["on_widgets"]), (&json!("Yes"), &json!([2])), "{after}"); + for bad in [json!({ "widget": 0 }), json!({ "widget": 3 }), json!({ "widget": -1 }), json!({ "widget": "1" }), json!({})] { + assert!(matches!(a.call("form_fill", &json!({ "doc": doc, "values": { "pick": bad } })), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} /// A form whose fields are only page widgets (the `/Fields` list is empty) still lists, fills and /// resets through the tools, and the leniency shows up as a repair note. #[test] @@ -3380,6 +3653,59 @@ trailer << /Root 1 0 R >> assert_eq!(saved.iter().find(|t| t.kind == "TH").and_then(|t| t.scope.as_deref()), Some("Column")); } +/// A layout table becomes plain blocks through `accessibility_layout_table_to_blocks` (#4105): the +/// table findings go, the content keeps its order, and the change is undone and saved. +#[test] +fn a_layout_table_becomes_blocks_through_automation() { + let dir = workdir("a11y-layout-table"); + let content = "/TD << /MCID 0 >> BDC 10 150 40 20 re f EMC /TD << /MCID 1 >> BDC 60 150 40 20 re f EMC"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /MarkInfo << /Marked true >> /StructTreeRoot 5 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /StructParents 0 >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +5 0 obj << /Type /StructTreeRoot /K 6 0 R /ParentTree 11 0 R >> endobj +6 0 obj << /S /Document /P 5 0 R /K [7 0 R] >> endobj +7 0 obj << /S /Table /P 6 0 R /K [8 0 R] >> endobj +8 0 obj << /S /TR /P 7 0 R /K [9 0 R 10 0 R] >> endobj +9 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 0 >> endobj +10 0 obj << /S /TD /P 8 0 R /Pg 3 0 R /K 1 >> endobj +11 0 obj << /Nums [0 [9 0 R 10 0 R]] >> endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + std::fs::write(dir.join("layout.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let fd = ok(&mut a, "doc_open", json!({ "path": "layout.pdf" }))["doc"].as_u64().unwrap(); + let table_rules = |a: &mut Automation| -> Vec { + let r = ok(a, "accessibility_check", json!({ "doc": fd, "rules": ["table-summary", "table-headers"] })); + ["table-summary", "table-headers"] + .map(|id| r["results"].as_array().unwrap().iter().find(|x| x["rule"] == id).unwrap()["status"].as_str().unwrap().to_owned()) + .to_vec() + }; + assert_eq!(table_rules(&mut a), ["failed", "failed"]); + let types = |tags: &Value| tags["tags"].as_array().unwrap().iter().map(|t| t["type"].as_str().unwrap().to_owned()).collect::>(); + let tags = ok(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 7, "generation": 0 })); + assert_eq!(types(&tags), ["Document", "Div", "Div", "P", "P"]); + assert_eq!(table_rules(&mut a), ["passed", "passed"]); + let content = |a: &mut Automation, object: u32| { + ok(a, "accessibility_tag_content", json!({ "doc": fd, "object": object, "generation": 0 }))["content"][0]["mcid"].clone() + }; + assert_eq!((content(&mut a, 9), content(&mut a, 10)), (json!(0), json!(1)), "each cell keeps its content"); + // Only a table: a cell is refused. + assert!(matches!(err(&mut a, "accessibility_layout_table_to_blocks", json!({ "doc": fd, "object": 9, "generation": 0 })), ToolError::Failed(_))); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": fd }))["undone"], "Change tag properties"); + assert_eq!(types(&ok(&mut a, "accessibility_tags", json!({ "doc": fd }))), ["Document", "Table", "TR", "TD", "TD"]); + ok(&mut a, "edit_redo", json!({ "doc": fd })); + ok(&mut a, "doc_save", json!({ "doc": fd, "path": "blocks.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("blocks.pdf")).unwrap())).unwrap(); + assert_eq!(pdfcraft_engine::a11y::tags(&saved).into_iter().map(|t| t.kind).collect::>(), ["Document", "Div", "Div", "P", "P"]); +} + #[test] fn marking_a_leaf_tag_as_an_artifact_through_automation_is_undoable_and_saved() { let dir = workdir("a11y-artifact"); @@ -5059,6 +5385,40 @@ fn removing_hidden_information_through_tools() { assert!(matches!(&refused, Err(ToolError::Failed(m)) if m.contains("can't be edited")), "{refused:?}"); } +/// `removed` counts what went from the listed categories. Flattening a field whose appearance +/// draws invisible text puts that text on the page, where hidden text (not listed) counts it; +/// a count over every category took it off what was removed (#5873). +#[test] +fn removed_hidden_information_counts_the_listed_categories() { + let appearance = "BT /Helv 12 Tf 2 4 Td (Ada) Tj 3 Tr (Secret) Tj ET"; + let appearance = format!( + "<< /Type /XObject /Subtype /Form /BBox [0 0 140 18] /Resources << /Font << /Helv 7 0 R >> >> /Length {} >>\nstream\n{appearance}\nendstream", + appearance.len() + ); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>", + "<< /Type /Page /Parent 2 0 R /Annots [5 0 R] /Resources << /Font << /Helv 7 0 R >> >> >>", + "<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V (Ada) /Rect [10 250 150 268] /P 3 0 R /AP << /N 6 0 R >> >>", + &appearance, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]; + let dir = workdir("hidden-listed"); + std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap(); + let count = |a: &mut Automation, c: &str| { + let info = ok(a, "doc_hidden_info", json!({ "doc": doc })); + info["categories"].as_array().unwrap().iter().find(|x| x["category"] == c).unwrap()["count"].as_u64().unwrap() + }; + assert_eq!((count(&mut a, "form-fields"), count(&mut a, "hidden-text")), (1, 0)); + let r = ok(&mut a, "doc_remove_hidden", json!({ "doc": doc, "categories": ["form-fields"] })); + assert_eq!(r["removed"], 1, "{r}"); + assert_eq!(count(&mut a, "form-fields"), 0); + assert_eq!(count(&mut a, "hidden-text"), 6, "the flattened appearance's invisible word is page content now"); +} + /// A one-page PDF whose text "TOP SECRET" sits under an opaque black rectangle. fn faux_redacted_fixture() -> Vec { helvetica_page("BT /F1 24 Tf 100 700 Td (TOP SECRET) Tj ET\n0 g\n90 688 220 32 re f") @@ -7350,6 +7710,32 @@ fn optimizing_through_tools() { assert!(matches!(a.call("doc_optimize", &json!({ "doc": doc, "path": "x.pdf", "discard": ["everything"] })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn optimizing_discards_form_actions_through_tools() { + let dir = workdir("optimize-form-actions"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 20, 120, 42], "name": "Send" })); + ok( + &mut a, + "form_set_actions", + json!({ "doc": doc, "field": "Send", "actions": [ + { "trigger": "mouse_up", "submit": "https://example.com/submit" }, + { "trigger": "mouse_down", "reset": [] }, + { "trigger": "mouse_enter", "javascript": "app.alert(1);" } + ] }), + ); + let kept = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "kept.pdf" })); + assert_eq!(kept["form_actions"], 0, "{kept}"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "small.pdf", "discard_form_actions": true })); + assert_eq!(r["form_actions"], 2, "{r}"); + let small = ok(&mut a, "doc_open", json!({ "path": "small.pdf" }))["doc"].as_u64().unwrap(); + let fields = ok(&mut a, "form_fields", json!({ "doc": small })); + assert!(fields.to_string().contains("Send"), "the button stays: {fields}"); + let actions = ok(&mut a, "form_actions", json!({ "doc": small, "field": "Send" })); + assert_eq!(actions["actions"], json!([{ "trigger": "mouse_enter", "javascript": "app.alert(1);" }]), "{actions}"); +} + /// #3227 review: retaining the version, a catalog `/Version` below the 1.5 an object stream /// needs is not what viewers read: the header is raised, and the later of the two is reported. #[test] @@ -7370,6 +7756,35 @@ fn retained_pdf_version_reports_the_header_raised_for_object_streams() { assert_eq!(r["pdf_version"].as_str(), Some("1.5"), "{r}"); } +/// #3227: retaining the version, the later of the written header and the catalog `/Version` is +/// reported: a catalog below the header cannot hide it, and equal versions are reported as is. +#[test] +fn retained_pdf_version_is_the_later_of_header_and_catalog() { + let dir = workdir("optimize-retain-version-later"); + let mut a = auto(&dir); + let blank = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 300, "height": 400 }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_optimize", json!({ "doc": blank, "path": "v14.pdf", "compatibility": "1.4" })); + let mut cos = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("v14.pdf")).unwrap())).unwrap(); + let root = cos.root().unwrap(); + for (label, header, catalog, saved, reported) in [ + ("higher-header", "1.7", "1.4", "1.7", "1.7"), + // PDF versions compare as major/minor numbers: 2.0 is newer than 1.7. + // Retain keeps the header. The catalog /Version only sets the reported version. + ("higher-catalog", "1.7", "2.0", "1.7", "2.0"), + ("equal", "1.4", "1.4", "1.4", "1.4"), + ] { + cos.update_dict(root, |c| c.set(b"Version".to_vec(), pdfcraft_cos::Object::name(catalog))).unwrap(); + cos.set_version(header); + let input = pdfcraft_cos::write_full(&cos, &pdfcraft_cos::SaveOptions { object_streams: false, ..Default::default() }).unwrap(); + std::fs::write(dir.join(format!("{label}-input.pdf")), input).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": format!("{label}-input.pdf") }))["doc"].as_u64().unwrap(); + let out = format!("{label}-output.pdf"); + let r = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": out, "compatibility": "retain", "object_streams": false })); + assert!(std::fs::read(dir.join(&out)).unwrap().starts_with(format!("%PDF-{saved}\n").as_bytes()), "{label}: written header"); + assert_eq!(r["pdf_version"].as_str(), Some(reported), "{label}: {r}"); + } +} + /// `data` as LZW with one 9-bit literal code per byte (clear-table first, EOD last): valid for /// fewer than 250 bytes, before the code width grows. fn lzw_literals(data: &[u8]) -> Vec { @@ -8940,6 +9355,46 @@ fn links_in_xfa_rich_text_are_listed_through_tools() { assert_eq!(urls(&mut a, doc), ["mailto:forms@example.com"], "one link after a save and a fresh open, not two"); } +/// What a date typed into an XFA date field leaves in the form's data, for each kind of picture: +/// what another viewer kept for the same typing on a synthetic form. A date one of the edit +/// picture's patterns reads is kept as an ISO date; anything else, ISO text included, as typed, +/// and nothing is refused. +#[test] +fn xfa_date_fields_keep_typed_dates_as_another_viewer_does() { + let dir = workdir("xfa-typed-dates"); + std::fs::write(dir.join("dates.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::date_pictures_template())).unwrap(); + let mut a = auto(&dir); + // typed, then the data for display, edit, either and bare. + let seen = [ + ("2016-10-18", ["2016-10-18", "2016-10-18", "2016-10-18", "2016-10-18"]), + ("10/18/2016", ["10/18/2016", "2016-10-18", "10/18/2016", "10/18/2016"]), + ("20161018", ["20161018", "20161018", "2016-10-18", "20161018"]), + ("18OCT16", ["18OCT16", "18OCT16", "2016-10-18", "18OCT16"]), + ("26 10 18", ["26 10 18", "26 10 18", "26 10 18", "26 10 18"]), + ("xyz", ["xyz", "xyz", "xyz", "xyz"]), + ]; + for (typed, kept) in seen { + for (field, want) in ["display", "edit", "either", "bare"].into_iter().zip(kept) { + let doc = ok(&mut a, "doc_open", json!({ "path": "dates.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { field: typed } })); + ok(&mut a, "doc_export_data", json!({ "doc": doc, "path": "data.xml", "what": "fields" })); + let xml = std::fs::read_to_string(dir.join("data.xml")).unwrap(); + assert!(xml.contains(&format!("<{field}>{want}")), "{typed} in {field}: want {want}, got {xml}"); + ok(&mut a, "doc_close", json!({ "doc": doc, "discard_changes": true })); + } + } + // What the fields show: an ISO date in the field's own date pattern (MM/DD/YYYY, YYYYMMDD), + // read from the appearances flattening draws into the page. + let doc = ok(&mut a, "doc_open", json!({ "path": "dates.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "edit": "2016-10-18", "either": "2016-10-18" } })); + ok(&mut a, "doc_flatten", json!({ "doc": doc, "comments": false, "fields": true })); + let text = ok(&mut a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap().to_string(); + assert!(text.contains("10/18/2016") && text.contains("20161018") && !text.contains("2016-10-18"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A dynamic XFA form opens laid out from its template through the tools, renders, takes values +/// and saves them. #[test] fn dynamic_xfa_forms_open_render_fill_and_save_through_tools() { let dir = workdir("xfa"); @@ -9016,7 +9471,8 @@ fn cut_stack_printing_through_tools() { ); assert_eq!(r["pages"], 5); let printed = ok(&mut a, "doc_open", json!({"path": "range.pdf"}))["doc"].as_u64().unwrap(); - assert_eq!(page_text(&mut a, printed), ["Page 10\nPage 4", "Page 8\nPage 2", "Page 6"]); + // Two pages side by side: their labels share a baseline, so a line. + assert_eq!(page_text(&mut a, printed), ["Page 10 Page 4", "Page 8 Page 2", "Page 6"]); assert_eq!(page_text(&mut a, doc), before); assert_eq!(ok(&mut a, "doc_info", json!({"doc": doc}))["document"]["dirty"], false); for duplex in ["long-edge", "short-edge"] { @@ -9227,6 +9683,94 @@ fn measurement_snap_tool_covers_all_targets() { let _ = std::fs::remove_dir_all(dir); } +/// An XFA button click through js_run returns only what the click produced, not the +/// alerts of an earlier fill. +#[test] +fn an_xfa_click_through_js_run_does_not_return_an_earlier_fills_alerts() { + let dir = workdir("xfa-click-output"); + std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap(); + // 500 fails qty's validation, which shows its message. + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "500" } })); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" })); + assert_eq!(r["alerts"], json!([{ "message": "Hello 500", "title": null, "icon": "error", "buttons": "ok" }]), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// form_set_script returns only the console lines its own edit produced, not those of an +/// earlier fill. +#[test] +fn form_set_script_does_not_return_an_earlier_fills_console_lines() { + let dir = workdir("set-script-output"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["a", "b"].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 })); + } + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "a", "event": "validate", "script": "console.println('validated ' + event.value);" })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "a": "7" } })); + let r = ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "b", "event": "format", "script": "event.value = event.value;" })); + assert_eq!(r["console"], json!([]), "{r}"); + assert_eq!(r["errors"], json!([]), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// js_run returns the output of the field scripts that its field changes ran, with the +/// script's own. +#[test] +fn js_run_returns_the_output_of_the_calculations_its_changes_ran() { + let dir = workdir("js-run-calc-output"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (i, name) in ["a", "b"].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 calc = "console.println('calculated ' + getField('a').value); event.value = getField('a').value;"; + ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "b", "event": "calculate", "script": calc })); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "getField('a').value = '4'; console.println('set');" })); + let console: Vec<&str> = r["console"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(console.first().is_some_and(|l| l.starts_with("set")), "{r}"); + assert!(console.iter().any(|l| l.starts_with("calculated 4")), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +/// The tools that edit a document without going through form_fill's path report the values of +/// an XFA form that could not be written to its data in `warnings`, once, and leave nothing for +/// the next tool to report as its own. +#[test] +fn edits_report_the_xfa_data_they_could_not_write_once() { + let dir = workdir("xfa-data-warnings"); + // The data holds structured content where the price goes, so its value can't be written. + let tpl = pdfcraft_xfa::fixtures::scripted_template().replace( + "", + "

7

\n", + ); + std::fs::write(dir.join("data.pdf"), pdfcraft_xfa::fixtures::shell(&tpl)).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "data.pdf" }))["doc"].as_u64().unwrap(); + let structured = |out: &Value, key: &str| { + out[key].as_array().map_or(0, |w| w.iter().filter(|w| w.as_str().is_some_and(|w| w.contains("structured content"))).count()) + }; + let r = ok(&mut a, "js_set_document_script", json!({ "doc": doc, "name": "init", "script": "var ready = true;" })); + assert_eq!(structured(&r, "warnings"), 1, "{r}"); + assert_eq!(r["scripts"][0]["name"], "init", "{r}"); + let r = ok( + &mut a, + "form_set_actions", + json!({ "doc": doc, "field": "hello", "actions": [{ "trigger": "mouse_enter", "javascript": "var entered = true;" }] }), + ); + assert_eq!(structured(&r, "warnings"), 1, "{r}"); + let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "getField('qty').value = 3;" })); + assert_eq!(structured(&r, "warnings"), 1, "{r}"); + // form_set_script lists its edit's own warning in errors, and the earlier tools' are not repeated. + let r = ok(&mut a, "form_set_script", json!({ "doc": doc, "field": "qty", "event": "format", "script": "event.value = event.value;" })); + assert_eq!(structured(&r, "errors"), 1, "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn growable_xfa_text_paginates_after_fill_save_and_print() { let dir = workdir("xfa-growable-text"); @@ -10617,6 +11161,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { let _ = std::fs::remove_dir_all(base); } +/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres, +/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is +/// refused. +#[test] +fn doc_print_takes_a_paper_size_of_its_own() { + let dir = workdir("print-paper-size"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (paper, want) in + [("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))] + { + let file = format!("{}.pdf", want.0); + ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file })); + let bytes = std::fs::read(dir.join(&file)).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&out)[0].display_size(&out); + assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}"); + } + for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] { + match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"), + other => panic!("{paper}: {other:?}"), + } + } +} + /// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside /// 72–600 is refused. #[test] @@ -10655,6 +11225,30 @@ fn a_root_less_write_output_is_atomic_too() { let _ = std::fs::remove_dir_all(&dir); } +/// Fix hairlines through the tools: counts what changed, and reports zero without an edit. +#[test] +fn fix_hairlines_through_tools() { + let dir = workdir("hairlines"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + // The fixture's text pages have no strokes. + let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc })); + assert_eq!((r["fixed"].as_u64(), r["undo"].is_null()), (Some(0), true)); + // A zero-width rule drawn by the page content tools counts as a hairline. + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [10, 10], "to": [100, 10], "width": 0 })); + let r = ok(&mut a, "doc_fix_hairlines", json!({ "doc": doc, "pages": [1] })); + // Comments are annotations, not page content: still nothing in the page's own content. + assert_eq!(r["fixed"].as_u64(), Some(0), "{r}"); + assert_eq!(r["elsewhere"].as_u64(), Some(0), "{r}"); + for bad in + [json!({ "width": 0 }), json!({ "width": -1 }), json!({ "width": 10000 }), json!({ "narrower_than": -1 }), json!({ "narrower_than": 10000 })] + { + let mut args = bad.clone(); + args["doc"] = json!(doc); + assert!(matches!(a.call("doc_fix_hairlines", &args), Err(ToolError::InvalidArgs(_))), "{bad}"); + } +} + #[test] fn attaching_and_deleting_files_through_the_tools() { let dir = workdir("attach"); @@ -11594,6 +12188,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { write_full(&doc, &SaveOptions::default()).unwrap() } +/// text_extract says when a page's text is incomplete because its content was cut at the +/// renderer's limits (#623); a page read whole carries no such mark. +#[test] +fn text_extract_says_when_a_pages_content_was_cut() { + use std::io::Write as _; + let dir = workdir("cut-content"); + // 288 MiB of whitespace, past the renderer's 256 MiB per-stream limit, as one Flate stream. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + std::fs::write(dir.join("cut.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let cut = ok(&mut a, "doc_open", json!({ "path": "cut.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": cut })); + assert_eq!(out["pages"][0]["truncated"], json!(true), "{out}"); + let whole = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": whole, "pages": [1] })); + assert!(out["pages"][0].get("truncated").is_none(), "{out}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/automation/tests/it/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs index c017ff78d3..9835231c63 100644 --- a/crates/automation/tests/it/hostile_args.rs +++ b/crates/automation/tests/it/hostile_args.rs @@ -11,9 +11,10 @@ //! //! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so //! every call must come back as `Ok` or as a `ToolError` the agent can read. -//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute -//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and -//! afterwards nothing outside the root may have been created. +//! 2. **Nothing escapes `--root` or the automation roots.** Every path argument is tried with `..` +//! traversal, absolute paths, Windows drive and UNC paths, and a planted symbolic link, under +//! both `--root` and `--automation-read-root`/`--automation-write-root` (#4003). The call must +//! fail, and afterwards nothing outside the root may have been created. //! //! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves //! its own paths and that no tool dispatches another internally; this keeps that true as tools are @@ -22,6 +23,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; +use pdfcraft_automation::file_access::AutomationRoots; use pdfcraft_automation::{Automation, ToolError, tools}; use serde_json::{Value, json}; @@ -84,6 +86,7 @@ fn zoo() -> Vec { /// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link /// to `base`. fn escapes(base: &std::path::Path, linked: bool) -> Vec { + let root = base.join("root"); let mut out: Vec = vec![ "../outside.pdf".into(), "../../outside.pdf".into(), @@ -93,13 +96,16 @@ fn escapes(base: &std::path::Path, linked: bool) -> Vec { base.join("outside.pdf").to_string_lossy().into_owned(), base.join("new-outside.pdf").to_string_lossy().into_owned(), base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(), + root.join("..").join("outside.pdf").to_string_lossy().into_owned(), ]; if linked { out.push("link-out/outside.pdf".into()); + out.push(root.join("link-out").join("outside.pdf").to_string_lossy().into_owned()); } // Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root. if cfg!(windows) { out.push(r"..\..\outside.pdf".into()); + out.push(r"\\.\pipe\pdfcraft-hostile-escape.pdf".into()); } out } @@ -220,6 +226,20 @@ fn empty_dir(dir: &Path) { } } +/// `action_run` keeps going when one input path is refused and reports that refusal on the file. +/// That is still a confinement refusal: every file has an error naming the boundary, and none +/// has an output. +fn per_file_confinement_refusal(content: &[pdfcraft_automation::Content]) -> bool { + let Some(files) = content.iter().find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v.get("files").and_then(Value::as_array), + pdfcraft_automation::Content::Png { .. } => None, + }) else { + return false; + }; + !files.is_empty() + && files.iter().all(|file| file.get("output").is_none() && file.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("outside"))) +} + /// Real values for the arguments a tool checks before the others, so a hostile value reaches the /// argument it is meant for instead of failing on a placeholder. `sign_document` checks its /// digital ID first and its document second: this ID opens with its password, and each call gets @@ -234,6 +254,12 @@ fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map { + let args = json!({ "steps": [{ "step": "set_title", "arg": "Confined" }] }); + args.as_object().cloned().unwrap_or_default() + } _ => serde_json::Map::new(), } } @@ -313,28 +339,34 @@ fn no_tool_escapes_the_root() { } }; - let mut a = Automation::new().with_root(&root).expect("root"); - - // A real document, so tools reach their path arguments instead of failing on `doc` first. - let doc = blank_doc(&mut a); + let roots = AutomationRoots::new(Some(&root), Some(&root)).expect("roots"); + let modes = [("--root", Automation::new().with_root(&root).expect("root")), ("automation roots", Automation::new().with_automation_roots(roots))]; let mut leaked = Vec::new(); let mut tried = 0usize; let mut refused = 0usize; - for def in tools() { - for (prop, is_list) in path_props(&def) { - for escape in escapes(&base, linked) { - let mut obj = plausible_with(&def, Some(doc)); - obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); - let args = Value::Object(obj); - tried += 1; - match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { - // A path that leaves the root must be refused. Succeeding means the tool - // either used it or ignored an argument it declares — both are faults. - Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)), - Ok(Err(_)) => refused += 1, - Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)), + for (mode, mut a) in modes { + // A real document, so tools reach their path arguments instead of failing on `doc` first. + let doc = blank_doc(&mut a); + + for def in tools() { + for (prop, is_list) in path_props(&def) { + for escape in escapes(&base, linked) { + let mut obj = plausible_with(&def, Some(doc)); + obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); + let args = Value::Object(obj); + tried += 1; + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { + // A path that leaves the root must be refused. Succeeding means the tool + // either used it or ignored an argument it declares — both are faults. + // action_run reports a refused input inside an otherwise successful + // result, one error per file, and writes nothing for that file. + Ok(Ok(content)) if per_file_confinement_refusal(&content) => refused += 1, + Ok(Ok(_)) => leaked.push(format!("{mode}: {} accepted {prop}={escape:?}", def.name)), + Ok(Err(_)) => refused += 1, + Err(_) => leaked.push(format!("{mode}: {} panicked on {prop}={escape:?}", def.name)), + } } } } diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 92e54c99a6..f8e3f15f6d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -17,4 +17,5 @@ mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; +mod text_copy; mod unreadable_objects; diff --git a/crates/automation/tests/it/text_copy.rs b/crates/automation/tests/it/text_copy.rs new file mode 100644 index 0000000000..c635fba148 --- /dev/null +++ b/crates/automation/tests/it/text_copy.rs @@ -0,0 +1,106 @@ +//! `text_extract` and `text_find` on a page of the cases text copy has to get right: words drawn out +//! of order, runs of different sizes on one baseline, a ligature glyph named in the encoding and a +//! line turned upwards in a row of text. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// A fresh directory for one test. +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-text-copy-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +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}"), + } +} + +/// One page of labelled rows (`/F1` Helvetica, `/F2` Helvetica with code 128 named `/fi`): +/// 01 `ofce`; 06 `exam-` over `ple`; 07 two columns of two lines; 08 "second" drawn before +/// "first", to its right; 11 a 14 pt invisible word and a 9 pt note on one baseline; 12 letter +/// spacing; 13 "up" set upwards; 14 two words placed by `Td`. +fn copy_pdf() -> Vec { + let rows = [ + ("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"), + ("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"), + ("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"), + ("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"), + ("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"), + ("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"), + ("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"), + ("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"), + ]; + let mut body = String::new(); + let mut y = 740; + for (tag, row) in rows { + body += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", row.replace("{Y}", &y.to_string())); + y -= match tag { + "06" | "13" => 48, + "07" => 44, + _ => 38, + }; + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>" + .to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Copy keeps a baseline on one line in left-to-right order, columns column by column, the +/// ligature as its letters and the turned line in its row; Find matches what Copy reads, and its +/// rectangles cover the glyphs it names. +#[test] +fn text_extract_and_find_read_one_line_per_baseline() { + let dir = tmp("baseline"); + std::fs::write(dir.join("copy.pdf"), copy_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "copy.pdf" }))["doc"].as_u64().unwrap(); + let text = ok(&mut a, "text_extract", json!({ "doc": doc })); + assert_eq!( + text["pages"][0]["text"], + "01 office\n06 exam-\nple\n07 left one\nleft two\nright one\nright two\n08 first second\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words" + ); + let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q })); + let first_second = find(&mut a, "first second"); + assert_eq!(first_second["count"], 1); + assert_eq!(first_second["matches"][0]["text"], "first second"); + // One rectangle across both words, from the left edge of "first" (x = 90) past the right edge of "second". + let rects = first_second["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 1, "{rects:?}"); + let x = |k: usize| rects[0][k].as_f64().unwrap(); + assert!((x(0) - 90.0).abs() < 0.5 && x(2) > 200.0, "{rects:?}"); + let office = find(&mut a, "office"); + assert_eq!(office["count"], 1); + assert_eq!(office["matches"][0]["text"], "office"); + assert_eq!(find(&mut a, "hidden <- invisible")["count"], 1); + assert_eq!(find(&mut a, "left one right one")["count"], 0, "the columns stay apart"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs index 4957f86861..fb17a903d1 100644 --- a/crates/content/src/lib.rs +++ b/crates/content/src/lib.rs @@ -67,8 +67,9 @@ pub struct Parsed { /// Bytes that could not be read as operands or operators. pub skipped: usize, /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it - /// accepts). Their end here is a guess, so the operators after them may not be the ones the - /// page draws. + /// accepts), so their end here is a guess and the operators after them may not be the ones + /// the page draws; and images it reads whose parameters this parser can't, which are missing + /// from `ops` although the page draws them. pub ambiguous_images: usize, /// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before /// and whether one runs out of it into the next. Only boundaries are recorded, so the size @@ -139,8 +140,8 @@ impl<'a> Ops<'a> { self.skipped } - /// Inline images read so far whose end is this parser's guess (as in - /// [`Parsed::ambiguous_images`]). + /// Inline images read so far that are ambiguous (as in [`Parsed::ambiguous_images`]): either + /// their end is this parser's guess, or the renderer reads them and this parser cannot. pub fn ambiguous_images(&self) -> usize { self.ambiguous_images } @@ -291,7 +292,8 @@ struct Inline { image: Option<(Dict, Vec)>, /// The position after its `EI` (or the end of the stream). end: usize, - /// The renderer's tokenizer reads no image here, so `end` is this parser's guess. + /// The renderer's tokenizer reads no image here, so `end` is this parser's guess; or it reads + /// one whose parameters this parser can't, so `image` is `None` for an image the page draws. ambiguous: bool, } @@ -339,7 +341,9 @@ fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline { { samples = rest; } - return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false }; + // The renderer draws an image whose parameters this parser can't read: it is not + // represented here, so a redaction can't inspect or remove it and must not trust the page. + return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: !readable }; } if !readable { // Without an `EI` after it, the image runs to the end of the stream. diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs index 9ac9297cd2..9ca12c4536 100644 --- a/crates/content/src/tests.rs +++ b/crates/content/src/tests.rs @@ -209,6 +209,16 @@ fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() } } +/// The renderer reads an image whose parameters this parser can't (a stray operand before `ID`): +/// the page draws an image `ops` doesn't hold, so it is counted as ambiguous rather than dropped +/// silently. +#[test] +fn an_inline_image_read_by_the_renderer_but_not_here_is_ambiguous() { + let parsed = parse(b"q BI /W 1 /H 1 /BPC 8 /CS /G 7 ID \x80 EI Q"); + assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"Q"]); + assert_eq!(parsed.ambiguous_images, 1); +} + /// A zlib stream (one stored block) for 64 DeviceGray samples whose bytes hold " EI\n" at offset 41, /// the way a stored deflate block copies incompressible samples verbatim (#3579). const FLATE_WITH_EI: &str = "789c014000bfffed746159d50788f7ea604984cd08e00b4c8983207f56fe82c77ef8dfb8b9a3a586382045490a3120\ diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index bef0d4f543..8f9d3a8a5b 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use super::reader::find; use super::state::{ - LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads, + LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member, + overlay_reads, }; use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ @@ -705,23 +706,7 @@ impl Document { match self.entries.lookup(num, &self.data) { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { - let resolve = |r: ObjRef| { - if self.overlay.contains_key(&r.num) { - note_overlay_read(); - } - // A length resolved through this document's edits counts as an edit read - // on every reuse. - if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { - if edits { - note_overlay_read(); - } - return known; - } - let edits_read = overlay_reads(); - let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); - length - }; + let resolve = |r: ObjRef| self.stream_length(r); let off = offset as usize; let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), @@ -795,6 +780,29 @@ impl Document { Ok(stm) } + /// An indirect stream `/Length`, read through the loader's guard against cycles. Kept in + /// [`LENGTHS`] only during a load, which clears it when it ends: a scan of the file from + /// [`Self::objstm`] can come here without one. + fn stream_length(&self, r: ObjRef) -> Option { + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } + let cached = loading(); + // A length resolved through this document's edits counts as an edit read on every reuse. + if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { + if edits { + note_overlay_read(); + } + return known; + } + let edits_read = overlay_reads(); + let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); + if cached { + LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); + } + length + } + /// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss /// reads the whole file, and a file can refer to such an object from everywhere. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { @@ -805,6 +813,8 @@ impl Document { super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1)); let needle = format!("{num} "); let data = &self.data; + let budget = ScanLengths::enter(); + let resolve = |r: ObjRef| if budget.take() { self.stream_length(r) } else { None }; let mut found = None; let mut i = 0; while let Some(p) = find(data, needle.as_bytes(), i, data.len()) { @@ -812,7 +822,11 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) + // An indirect `/Length` is resolved, as at the object's offset, or a stream that ends + // in a misspelt `endstream` is searched on into a later object's. A header that parses + // as far as the `/Length` starts with `num` itself, after white space. A budget shared + // with the scans this leads to bounds how often the file is read again ([`ScanLengths`]). + if let Ok((id, o)) = parse_indirect_shared(data, p, &resolve, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index af71f95359..c5bd85c065 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -11,6 +11,52 @@ thread_local! { pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length` + /// values the outermost scan and those inside it have resolved ([`ScanLengths`]). + static SCAN_LENGTHS: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// The indirect `/Length` values a scan of the file for an object may resolve, shared with the +/// scans it leads to. Each header in the file that starts with the object's number is parsed, and +/// a `/Length` that names an object the cross-reference table cannot place scans the file for that +/// one: a damaged file with many such headers, each naming another missing object, would read the +/// whole file once per header. Past the budget a `/Length` is left unresolved, and the stream is +/// read as before resolution, by its `endstream`. +pub(super) struct ScanLengths; + +impl ScanLengths { + /// A real object is defined once per revision, and its `/Length` once per definition. + pub(super) const MAX: u32 = 32; + + /// Marks a scan as running; the outermost one starts the budget afresh. + pub(super) fn enter() -> Self { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_add(1), if depth == 0 { 0 } else { used })); + }); + ScanLengths + } + + /// Whether one more `/Length` may be resolved, counting it if so. + pub(super) fn take(&self) -> bool { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + if used >= Self::MAX { + return false; + } + c.set((depth, used.saturating_add(1))); + true + }) + } +} + +impl Drop for ScanLengths { + fn drop(&mut self) { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_sub(1), used)); + }); + } } /// Unsaved edits read so far on this thread: a load during which this changes depended on one. @@ -42,6 +88,12 @@ impl LoadGuard { } } +/// Whether an object is being loaded on this thread: [`LENGTHS`] holds only while one is, since +/// it is cleared when the outermost load ends. +pub(super) fn loading() -> bool { + LOADING.with(|l| !l.borrow().is_empty()) +} + /// What loading a file object gave, kept in the document's cache either way. pub(super) type Loaded = Result, CosError>; diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index a6116762ea..274672bc7c 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -30,6 +30,67 @@ fn what_a_scan_of_the_file_did_not_find_is_remembered() { assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file"); } +/// An object found by a scan of the file, because its cross-reference offset is wrong, has its +/// indirect `/Length` resolved as at the right offset. Unresolved, a stream ending in a misspelt +/// `endstream` (`endstrea`) was searched on into a later object's `endstream`, and took that +/// object's header and data as its own. +#[test] +fn a_scanned_stream_resolves_its_indirect_length() { + let mut out = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nHELLO\nendstrea", + "5", + "<< /Length 5 >>\nstream\nWORLD\nendstream", + ], + "/Root 1 0 R", + ); + // Point object 3's entry at the catalog, so only the scan finds it. + let at = |needle: &[u8], out: &[u8]| out.windows(needle.len()).position(|w| w == needle).unwrap(); + let catalog = format!("{:010} 00000 n ", at(b"1 0 obj", &out)); + let entry = at(format!("{:010} 00000 n ", at(b"3 0 obj", &out)).as_bytes(), &out); + out.splice(entry..entry + catalog.len(), catalog.bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + let stream = doc.try_get(3).unwrap(); + let Object::Stream(s) = &*stream else { panic!("stream expected: {stream:?}") }; + assert_eq!(&s.raw[..], b"HELLO"); +} + +/// A scan of the file resolves only so many indirect `/Length` values. Each header that starts +/// with the sought number is parsed, and a `/Length` naming an object the table cannot place scans +/// the file for that one: with a hundred such headers, each naming another misplaced object, one +/// lookup read the whole file a hundred and one times. +#[test] +fn a_scan_of_the_file_resolves_a_bounded_number_of_lengths() { + const HEADERS: u32 = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let catalog = out.len(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + let pages = out.len(); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n"); + // Object 3 is defined only by headers inside this string, each with its own misplaced /Length. + out.extend_from_slice(b"4 0 obj (\n"); + for k in 0..HEADERS { + out.extend_from_slice(format!("3 0 obj << /Length {} 0 R >> stream\nX\nendstream endobj\n", 10 + k).as_bytes()); + } + out.extend_from_slice(b") endobj\n"); + let size = 10 + HEADERS; + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for num in 1..size { + let at = if num == 2 { pages } else { catalog }; + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + FILE_SCANS.with(|scans| scans.set(0)); + let three = doc.try_get(3).unwrap(); + assert!(matches!(&*three, Object::Stream(_)), "{three:?}"); + let scans = FILE_SCANS.with(std::cell::Cell::get); + assert!(scans <= 1 + super::state::ScanLengths::MAX as usize, "{scans} scans of the file"); +} + /// An object can be nothing but a reference to another. A chain of them is followed, and one that /// goes round ends in what isn't a dictionary or a name, without hanging. #[test] diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index d2cb904871..49793d950c 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -437,7 +437,7 @@ pub(crate) type EndstreamIndex = std::sync::OnceLock>; fn endstream_offsets(data: &[u8]) -> Vec { #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); - data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() + data.windows(9).enumerate().filter(|(i, w)| *w == b"endstream" && endstream_at(data, *i)).map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -491,26 +491,24 @@ fn parse_indirect_in( Some(Object::Ref(r)) => resolve_length(*r), _ => None, }; - let valid = |len: i64| -> bool { - if len < 0 { - return false; - } - let end = start.saturating_add(len as usize); - if end > data.len() { - return false; - } - let mut after = Lexer::new(data, end); - after.eat_keyword(b"endstream") - }; - // A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt. + // A /Length that fits in the file (not negative, and no wrap on a 32-bit target). let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len())); - let len = match declared { - Some(l) if valid(l) => l as usize, + // What follows the /Length bytes, white space aside. + let after = |len: usize| { + let mut after = Lexer::new(data, start.saturating_add(len)); + after.skip_ws(); + data.get(after.pos..).unwrap_or_default() + }; + let len = match fits { + // The /Length ends at `endstream`, or where it would be: misspelt (`endstrea`), or + // left out before `endobj` or the end of the file. The stream is the bytes its /Length + // gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 + // §7.3.8.2), as other readers take it. A producer that wrote `endstrea` after every + // stream lost every font and every page's content, and searching on would run into a + // later object's `endstream`. + Some(l) if at_stream_end(after(l)) => l, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none - // left in the file, a /Length that fits is the stream's extent, "how many bytes of the - // PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers - // take it: a producer that wrote `endstrea` after every stream lost every font and - // every page's content. + // left in the file, a /Length that fits is still the stream's extent. _ => match find_endstream(data, start, endstreams) { Some(end) => end - start, None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?, @@ -529,6 +527,45 @@ fn parse_indirect_in( Ok((id, obj)) } +/// Whether `rest`, what follows a stream's /Length bytes with white space skipped, is where the +/// stream ends. A whole `endstream` token is: a token runs on through regular characters (ISO +/// 32000-2 §7.2.3), so payload such as `endstreamer` is not one. A misspelt `endstream` +/// (`endstrea`), an `endobj` with the `endstream` left out, or both, end it only where the object +/// ends as well, before the end of the data, another object's `N G obj` header, or `xref`, +/// `trailer` or `startxref`: payload such as `endobj more` or `endobject`, where a wrong /Length +/// stops, is not, and the search for `endstream` runs instead. So does anything else but the end +/// of the data. +fn at_stream_end(rest: &[u8]) -> bool { + let mut lx = Lexer::new(rest, 0); + if lx.eat_keyword(b"endstream") { + return true; + } + let misspelt = lx.eat_keyword(b"endstrea"); + let endobj = lx.eat_keyword(b"endobj"); + lx.skip_ws(); + if lx.pos >= rest.len() { + return true; + } + if !(misspelt || endobj) { + return false; + } + if [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| Lexer::new(rest, lx.pos).eat_keyword(kw)) { + return true; + } + let number = |lx: &mut Lexer| { + lx.skip_ws(); + let t = lx.token(); + !t.is_empty() && t.iter().all(u8::is_ascii_digit) + }; + number(&mut lx) && number(&mut lx) && lx.eat_keyword(b"obj") +} + +/// Whether a whole `endstream` token starts at `at` in `data`: one followed by a regular +/// character (`endstreamer`) is payload (ISO 32000-2 §7.2.3). +fn endstream_at(data: &[u8], at: usize) -> bool { + data.get(at..).is_some_and(|d| d.starts_with(b"endstream")) && !data.get(at.saturating_add(9)).copied().is_some_and(is_regular) +} + /// Offset where stream data ends (before the EOL preceding "endstream"). fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { let at = match endstreams { @@ -538,7 +575,7 @@ fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) } None => { let hay = data.get(from..)?; - let found = hay.windows(9).position(|w| w == b"endstream"); + let found = hay.windows(9).enumerate().position(|(i, w)| w == b"endstream" && endstream_at(hay, i)); #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); found? + from @@ -683,6 +720,109 @@ mod tests { assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length"); } + /// A stream ending in `endstrea` is its /Length even when a later object does have an + /// `endstream`: the search for one would otherwise run on into that object and take its + /// header and data as this stream's. + #[test] + fn a_stream_without_endstream_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO\r\nendstrea\rendobj\r\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let second = data.windows(7).position(|w| w == b"8 0 obj").unwrap(); + for (offset, want) in [(0, &b"HELLO"[..]), (second, b"WORLD")] { + let (_, o) = parse_indirect(data, offset, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected at {offset}") }; + assert_eq!(&s.raw[..], want, "at {offset}"); + } + // Through the loader's shared index of `endstream` offsets as well. + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(data, None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// The same for a stream whose `endstream` is left out before `endobj`: the later object's + /// `endstream` is not this stream's end. + #[test] + fn a_stream_ending_at_endobj_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\nHELLO\nendobj\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// A wrong /Length that stops before payload bytes beginning like an end marker (`endobject`, + /// `endstreak`, `endstreamer`) is not taken for the stream's end: a token runs on through + /// regular characters (ISO 32000-2 §7.2.3), so the search for `endstream` finds the whole + /// payload, and skips `endstreamer` too, by itself and through the loader's shared index. + #[test] + fn a_length_ending_inside_a_longer_word_is_not_an_end_marker() { + for word in ["endobject", "endstreak", "endstreamer"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {word} more\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}"); + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(pdf.as_bytes(), None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}, indexed"); + } + // Whole markers still end it, followed by white space, a delimiter or the end of the data. + for tail in ["endobj\n", "endstrea%", "endobj"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{tail}"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{tail:?}"); + } + } + + /// A whole `endobj` or `endstrea` token where a wrong /Length stops is payload, not the + /// stream's end, unless the object ends there too: what follows is the end of the data, + /// another object's header, or `xref`, `trailer` or `startxref`. Otherwise the search for + /// `endstream` finds the whole payload. + #[test] + fn an_end_marker_inside_the_payload_does_not_end_the_stream() { + for payload in ["endobj more", "endstrea more", "endstrea endobj more", "endobj 12 more"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {payload}\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {payload}").as_bytes(), "{payload}"); + } + // Where the object does end, the /Length is the stream, also with an `endstream` later. + for next in ["8 0 obj\n<< >>", "%c\n12 0 obj", "xref", "trailer", "startxref"] { + for marker in ["endobj", "endstrea\nendobj", "endstrea"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{marker}\n{next}\nstream\nWORLD\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{marker:?} then {next:?}"); + } + } + } + /// Without an `endstream`, the /Length is trusted as written when it fits, also when it is + /// wrong, and an indirect one is resolved first; one that doesn't fit, a negative one and one + /// too large for any file are errors rather than wrapped into a short read. + #[test] + fn a_stream_without_endstream_takes_its_length_only_when_it_fits() { + let stream = |length: &str, tail: &str| format!("7 0 obj\n<< /Length {length} >>\nstream\nHELLO{tail}"); + let raw = |pdf: &str, lookup: &dyn Fn(ObjRef) -> Option| match parse_indirect(pdf.as_bytes(), 0, lookup) { + Ok((_, Object::Stream(s))) => Ok(s.raw.to_vec()), + Ok((_, other)) => panic!("{pdf:?} is {other:?}"), + Err(e) => Err(e), + }; + let none = |_: ObjRef| None; + // Wrong but fitting: the bytes it gives, not the bytes up to `endstrea`. + assert_eq!(raw(&stream("3", "\nendstrea\nendobj\n"), &none).unwrap(), b"HEL"); + // Exactly to the end of the file, and nothing at all. + assert_eq!(raw(&stream("5", ""), &none).unwrap(), b"HELLO"); + assert_eq!(raw(&stream("0", "\nendstrea\nendobj\n"), &none).unwrap(), b""); + // Indirect, resolved through the lookup. + let lookup = |r: ObjRef| (r == ObjRef::new(9, 0)).then_some(4); + assert_eq!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &lookup).unwrap(), b"HELL"); + assert!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &none).is_err(), "an unresolved /Length"); + // One past the end, negative, the largest integer and one beyond it. + for length in ["6", "-1", "-9223372036854775808", "9223372036854775807", "18446744073709551621"] { + assert!(raw(&stream(length, ""), &none).is_err(), "/Length {length}"); + } + } + #[test] fn indirect_stream_with_a_space_before_the_line_break() { // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the diff --git a/crates/cos/tests/it/writer.rs b/crates/cos/tests/it/writer.rs index 2716b9242e..1dc6275b5f 100644 --- a/crates/cos/tests/it/writer.rs +++ b/crates/cos/tests/it/writer.rs @@ -544,3 +544,74 @@ fn incremental_saves_of_a_file_whose_offsets_count_from_its_first_byte_keep_coun // No qpdf check here: qpdf counts every offset from the header in a file with bytes before // it, so it already reconstructs this input before anything is appended. } + +/// One page whose catalog carries a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 +/// 14.12) and whose structure tree root lists structure namespaces (`/Namespaces`, 14.7.4), +/// both through indirect objects that nothing else references. +fn pdf2_fixture() -> Vec { + Pdf::new() + .version("2.0") + .obj("<< /Type /Catalog /Pages 2 0 R /DPartRoot 6 0 R /StructTreeRoot 8 0 R >>") + .obj("<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>") + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>") + .stream("", "BT /F1 24 Tf 20 150 Td (Part) Tj ET") + .obj("<< /Type /DPartRoot /DPartRootNode 7 0 R /NodeNameList [/Chapter] >>") + .obj("<< /Type /DPart /Parent 6 0 R /Start 4 0 R /End 4 0 R /DPM << /Kind (cover) >> >>") + .obj("<< /Type /StructTreeRoot /K [] /Namespaces [9 0 R] >>") + .obj("<< /Type /Namespace /NS (http://iso.org/pdf2/ssn) /Schema << /Type /Filespec /F (ssn.rnc) >> >>") + .build() +} + +/// What `pdf2_fixture` declares, read back through the catalog. +fn assert_dpart_and_namespaces(doc: &Document) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").expect("catalog keeps /DPartRoot")); + let root = root.as_dict().unwrap(); + let node = doc.resolve(root.get(b"DPartRootNode").expect("/DPartRootNode kept")); + let node = node.as_dict().unwrap(); + assert_eq!(node.name(b"Type"), Some(&b"DPart"[..])); + // Both ends of the part are still the one page, found through /Pages (references are + // renumbered by a full save, so they are compared inside this document). + let pages = doc.resolve(catalog.get(b"Pages").unwrap()); + let kids = pages.as_dict().unwrap().get(b"Kids").unwrap().as_array().unwrap(); + let page = kids.first().and_then(|k| k.as_ref()).expect("the page is still the first kid"); + assert_eq!(node.reference(b"Start"), Some(page), "the part still starts at its page"); + assert_eq!(node.reference(b"End"), Some(page), "the part still ends at its page"); + assert!(doc.resolve(node.get(b"DPM").unwrap()).as_dict().unwrap().get(b"Kind").is_some()); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap()); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").expect("/Namespaces kept")); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + let namespace = namespace.as_dict().unwrap(); + assert_eq!(namespace.get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn"); + assert!(namespace.get(b"Schema").is_some(), "/Schema is retained"); +} + +#[test] +fn document_parts_and_structure_namespaces_survive_full_and_incremental_saves() { + let original = Arc::new(pdf2_fixture()); + let doc = Document::open(original.clone()).unwrap(); + assert_dpart_and_namespaces(&doc); + + for object_streams in [true, false] { + let opts = SaveOptions { object_streams, ..SaveOptions::default() }; + let full = write_full(&doc, &opts).unwrap(); + let back = Document::open(Arc::new(full.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + assert_dpart_and_namespaces(&back); + // A second generation, rewritten from the rewritten file. + let second = Document::open(Arc::new(write_full(&back, &opts).unwrap())).unwrap(); + assert!(second.repair_log().is_empty(), "{:?}", second.repair_log()); + assert_dpart_and_namespaces(&second); + } + + // An incremental save of an unrelated edit leaves the file's bytes, and so both, alone. + let mut edited = Document::open(original.clone()).unwrap(); + edited.add(Object::Int(1)); + let updated = write_incremental(&edited, &SaveOptions::default()).unwrap(); + assert!(updated.starts_with(&original)); + let reopened = Document::open(Arc::new(updated)).unwrap(); + assert!(reopened.repair_log().is_empty(), "{:?}", reopened.repair_log()); + assert_dpart_and_namespaces(&reopened); +} diff --git a/crates/create/README.md b/crates/create/README.md index f72f70aa26..95d47d27a9 100644 --- a/crates/create/README.md +++ b/crates/create/README.md @@ -19,10 +19,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2: for one point per pixel, or `ImageResolution::Embedded` for the same behavior as `from_images`. The largest page side is still capped at 14,400 points; - `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica - can't draw is refused, naming the line and the character, never set as `?`. + can't draw is set in the bundled Inter face instead (Latin, Greek and Cyrillic letters), with + the same page, margins, wrapping and form feeds; text Inter can't draw either is refused, + naming the line and the character, never set as `?`. - `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents - (comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first - line says so (`ESCAPED_NOTE`). + (comment summaries, compare reports): text Inter draws is set in it; otherwise the characters + Helvetica can't draw are written as `[U+XXXX]` and the first line says so (`ESCAPED_NOTE`). Every function returns a `pdfcraft_cos::Document`; the caller writes it. diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 149bd99d97..389173d848 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -5,9 +5,11 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}; mod extract; +mod text_font; mod upright_image; pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within}; -use pdfcraft_fonts::{literal, win_ansi, wrap}; +use pdfcraft_fonts::wrap_with; +use text_font::TextFont; #[derive(Debug, thiserror::Error, PartialEq)] pub enum CreateError { @@ -281,7 +283,8 @@ fn jp2_boxes(b: &[u8]) -> Vec<([u8; 4], &[u8])> { }, n => (8, n), }; - let end = i.saturating_add(len as usize).min(b.len()); + // `len as usize` would truncate on 32-bit targets and put the end before the payload. + let end = i.saturating_add(usize::try_from(len).unwrap_or(usize::MAX)).min(b.len()); if len < head as u64 || end <= i { break; } @@ -428,7 +431,13 @@ fn png_image(name: &str, bytes: &[u8]) -> Result { // The decoded size comes from the header alone, so a file of a few bytes can claim any // size: keep the ceiling, and ask for the memory fallibly (a failed allocation would abort // the whole app, and no `catch_unwind` stops that). - let size = reader.output_buffer_size().filter(|n| *n <= MAX_DECODED_BYTES).ok_or_else(|| bad("image too large".into()))?; + // Flate expands at most 1032:1 and decoding at most 32:1 more (1-bit palette to RGBA), so a + // file cannot honestly hold more than that: refuse a header the bytes cannot back before + // zero-filling a buffer of its size. The slack keeps tiny real images out of the check. + let size = reader + .output_buffer_size() + .filter(|n| *n <= MAX_DECODED_BYTES && *n <= bytes.len().saturating_mul(PNG_MAX_EXPANSION).saturating_add(PNG_SLACK)) + .ok_or_else(|| bad("image too large".into()))?; let mut buf = Vec::new(); buf.try_reserve_exact(size).map_err(|_| bad("not enough memory for this image".into()))?; buf.resize(size, 0); @@ -646,6 +655,11 @@ const MAX_GIF_PIXELS: u64 = 1 << 24; /// twice over. const MAX_DECODED_BYTES: usize = 2 << 30; +/// Most a PNG's decoded size can exceed its file size: Flate's 1032:1 times the 32:1 of +/// expanding a 1-bit palette to RGBA, plus a fixed allowance for small files. +const PNG_MAX_EXPANSION: usize = 1032 * 32; +const PNG_SLACK: usize = 1 << 20; + /// Most decoded bytes all the pages of one TIFF may take together: its pages stay in memory until /// they are embedded, and many small directories can share one compressed strip. Saturates to the /// whole address space on 32-bit targets (wasm32), where 4 GiB doesn't fit in `usize`. @@ -900,14 +914,20 @@ fn turkish_code_page(bytes: &[u8]) -> bool { } /// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't -/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and -/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead. +/// draw (outside Western European) is set in the bundled Inter face instead, whole, when Inter +/// draws all of it (Latin, Greek and Cyrillic letters); anything else is refused with +/// [`CreateError::Invalid`] naming the line and the first character neither face draws. Text +/// PdfCraft generates goes through [`from_text_escaped`] instead. pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { - // Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`. - if let Some((line, c)) = first_undrawable_line(text) { - return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c))); + // Helvetica in WinAnsiEncoding draws it: set as it always was. + if first_undrawable_line(text).is_none() { + return set_text(title, text, page, font_size, false); } - set_text(title, text, page, font_size) + // Otherwise the bundled face, or a refusal with where it is, never `?`. + if let Some((line, c)) = text_font::first_missing(text) { + return Err(CreateError::Invalid(text_font::undrawable_message(line, c))); + } + set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true) } /// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and @@ -921,10 +941,15 @@ fn first_undrawable_line(text: &str) -> Option<(usize, char)> { pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points."; /// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare -/// report), which may quote any language. Each character Helvetica can't draw is written as -/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says -/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way. +/// report), which may quote any language. Text the bundled Inter face draws all of is set as +/// [`from_text`] sets it, nothing escaped. Otherwise each character Helvetica can't draw is +/// written as `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first +/// line says so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written +/// that way. pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> { + if first_undrawable_line(text).is_some() && text_font::first_missing(text).is_none() { + return Ok((set_text(title, &text.chars().map(text_font::drawn_as).collect::(), page, font_size, true)?, 0)); + } let mut escaped = 0usize; let mut out = String::with_capacity(text.len()); for c in text.chars() { @@ -939,29 +964,25 @@ pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f out = format!("{ESCAPED_NOTE}\n\n{out}"); } // The title is a text string in /Info, which holds any character. - Ok((set_text(title, &out, page, font_size)?, escaped)) + Ok((set_text(title, &out, page, font_size, false)?, escaped)) } -fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result { +/// `text` set on pages of `page` size, in Helvetica or, with `inter`, the bundled Inter face. +fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64, inter: bool) -> Result { let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 }; let (w, h) = page; let margin = 72.0; let line_h = size * 1.25; let width = (w - 2.0 * margin).max(36.0); + let mut doc = Document::new_empty(); + let font = if inter { TextFont::inter(&mut doc)? } else { TextFont::helvetica(&mut doc) }; let lines: Vec = text .replace("\r\n", "\n") .replace('\t', " ") .split('\u{c}') - .flat_map(|p| wrap(p, size, width).into_iter().chain(std::iter::once("\u{c}".to_string()))) + .flat_map(|p| wrap_with(p, size, width, |s, size| font.width(s, size)).into_iter().chain(std::iter::once("\u{c}".to_string()))) .collect(); let per_page = (((h - 2.0 * margin) / line_h).floor() as usize).max(1); - let mut doc = Document::new_empty(); - let mut font = Dict::new(); - font.set(b"Type".to_vec(), Object::name("Font")); - font.set(b"Subtype".to_vec(), Object::name("Type1")); - font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); - font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - let fr = doc.add(font); let mut tree = Pages::of(&doc)?; let mut page_lines: Vec> = vec![Vec::new()]; for l in lines.iter().take(lines.len().saturating_sub(1)) { @@ -978,12 +999,12 @@ fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result for lines in page_lines { let mut c = format!("BT /F1 {size} Tf {line_h:.3} TL {margin} {:.3} Td\n", h - margin - size).into_bytes(); for l in lines { - c.extend(literal(&win_ansi(&l))); + c.extend(font.show(&l)?); c.extend_from_slice(b" Tj T*\n"); } c.extend_from_slice(b"ET\n"); let mut fonts = Dict::new(); - fonts.set(b"F1".to_vec(), Object::Ref(fr)); + fonts.set(b"F1".to_vec(), Object::Ref(font.font())); let mut res = Dict::new(); res.set(b"Font".to_vec(), Object::Dict(fonts)); tree.add(&mut doc, w, h, res, Some(c)); diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9d5a70ffcd..d2a8c143e7 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -163,7 +163,7 @@ fn text_keeps_leading_spaces_and_tabs() { fn text_helvetica_cannot_draw_is_refused_or_escaped() { let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err(); let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; - assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}"); + assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Cyrillic letters"), "{msg}"); // Western European text, tabs, CRLF and form feeds included, still converts. from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap(); let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap(); @@ -183,6 +183,99 @@ fn text_helvetica_cannot_draw_is_refused_or_escaped() { assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX")); } +/// The decoded content of each page. +fn page_contents(doc: &Document) -> Vec { + pages(doc) + .iter() + .map(|p| { + let Object::Stream(s) = &*doc.resolve(p.get(b"Contents").unwrap()) else { panic!("no content stream") }; + String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() + }) + .collect() +} + +/// The text each page shows, read back through its font's `ToUnicode`, one line per `Tj`. +fn page_text(doc: &Document, page: &Dict) -> Vec { + let res = doc.resolve(page.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); + let fonts = doc.resolve(res.get(b"Font").unwrap()).as_dict().cloned().unwrap(); + let font = doc.resolve(fonts.get(b"F1").unwrap()).as_dict().cloned().unwrap(); + let metrics = pdfcraft_fonts::pdf::Metrics::from_dict(doc, &font); + let Object::Stream(s) = &*doc.resolve(page.get(b"Contents").unwrap()) else { panic!("no content stream") }; + let content = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(); + content + .split("> Tj") + .filter_map(|part| part.rsplit_once('<').map(|(_, hex)| hex)) + .map(|hex| { + let bytes: Vec = (0..hex.len()).step_by(2).map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()).collect(); + metrics.decode(&bytes) + }) + .collect() +} + +const PANGRAM: &str = "Жълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + +/// Cyrillic text was refused (Helvetica can't draw it). Text with letters Helvetica lacks is set +/// in the bundled Inter face when that draws all of it, its Latin lines too, with Helvetica's +/// page, margins, line height, wrapping, tabs and form feeds; it reads back as typed. +#[test] +fn text_with_cyrillic_is_set_in_the_bundled_face() { + let long: String = (0..120).map(|i| format!("Ред {i}: {PANGRAM}\n")).collect(); + let text = format!("{long}\tТаб, „кавички“, № 5 и Ελληνικά\u{c}Нова страница"); + let doc = reopen(&from_text("бележки", &text, LETTER, 11.0).unwrap()); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert!(fonts.iter().any(|f| f == b"Inter") && !fonts.iter().any(|f| f == b"Helvetica"), "{fonts:?}"); + let programs = doc.scan_objects().filter(|(_, o)| matches!(o.as_ref(), Object::Stream(s) if s.dict.get(b"PCFullFont").is_some())).count(); + assert_eq!(programs, 1, "one copy of the face for every page"); + let p = pages(&doc); + let contents = page_contents(&doc); + assert!(p.len() >= 4, "{} pages", p.len()); + assert_eq!(media(&p[0]), [0.0, 0.0, 612.0, 792.0]); + // The same page set-up as Helvetica's: 11 pt, 1.25 line height, 1-inch margins. + assert!(contents[0].starts_with("BT /F1 11 Tf 13.750 TL 72 709.000 Td\n<"), "{}", contents[0]); + assert!(contents.iter().all(|c| !c.contains('?') && !c.contains("[U+"))); + // Each line fits between the margins in Inter's widths, and long lines wrap. + let lines: Vec = p.iter().flat_map(|page| page_text(&doc, page)).collect(); + assert!(lines.len() > 120, "{} lines", lines.len()); + assert!(lines.iter().all(|l| pdfcraft_fonts::inter_width(l, 11.0) <= 612.0 - 144.0 + 1e-6), "{lines:?}"); + let joined = lines.join(" "); + assert!(joined.contains("Ред 0: Жълтата дюля") && joined.contains("Ред 119:"), "{joined}"); + assert!(joined.contains(" Таб, „кавички“, № 5 и Ελληνικά"), "a tab is four spaces: {joined}"); + assert_eq!(page_text(&doc, p.last().unwrap()), ["Нова страница"], "a form feed starts a page"); + // As many lines on a page as in Helvetica. + let latin: String = (0..120).map(|i| format!("Row {i}\n")).collect(); + let cyrillic: String = (0..120).map(|i| format!("Ред {i}\n")).collect(); + let per_page = + |text: &str| page_contents(&from_text("t", text, LETTER, 11.0).unwrap()).iter().map(|c| c.matches("Tj T*").count()).collect::>(); + assert_eq!(per_page(&cyrillic), per_page(&latin)); +} + +/// Text the bundled face can't draw all of keeps the Helvetica way: refused, naming the first +/// character neither face draws, or, for text PdfCraft generates, escaped as before. Text it draws +/// all of is set in it with nothing escaped, and Western European text stays in Helvetica. +#[test] +fn text_the_bundled_face_cannot_draw_is_refused_or_escaped_as_before() { + let err = from_text("t", "Бележка\nПодпис: 签名", LETTER, 11.0).unwrap_err(); + let CreateError::Invalid(msg) = &err else { panic!("{err:?}") }; + assert!(msg.contains("(line 2)") && msg.contains("\"签\" (U+7B7E)"), "{msg}"); + let (doc, escaped) = from_text_escaped("r", "Бележка: 签名", LETTER, 11.0).unwrap(); + assert_eq!(escaped, 9, "every character Helvetica can't draw, as before"); + let shown = page_contents(&reopen(&doc)).join("\n"); + assert!(shown.contains(&format!("({ESCAPED_NOTE}) Tj")) && shown.contains("([U+0411][U+0435]"), "{shown}"); + let (doc, escaped) = from_text_escaped("Обобщение на коментарите", &format!("Коментар: {PANGRAM}\nPage 2"), LETTER, 11.0).unwrap(); + assert_eq!(escaped, 0); + let doc = reopen(&doc); + let shown = page_contents(&doc).join("\n"); + assert!(!shown.contains("[U+") && !shown.contains(ESCAPED_NOTE), "{shown}"); + assert_eq!(page_text(&doc, &pages(&doc)[0]), [format!("Коментар: {PANGRAM}"), "Page 2".to_string()]); + // A soft hyphen, which Inter lacks, is drawn as the hyphen Helvetica draws for it. + let doc = reopen(&from_text("t", "при\u{ad}мер", LETTER, 11.0).unwrap()); + assert_eq!(page_text(&doc, &pages(&doc)[0]), ["при-мер"]); + // Western European text is still set in Helvetica, exactly as before. + let doc = from_text("t", "Café 5€ ™", LETTER, 11.0).unwrap(); + let fonts: Vec<_> = doc.scan_objects().filter_map(|(_, o)| o.as_dict().and_then(|d| d.name(b"BaseFont").map(<[u8]>::to_vec))).collect(); + assert_eq!(fonts, [b"Helvetica".to_vec()]); +} + fn image_of(doc: &Document, page: usize) -> Dict { let p = &pages(doc)[page]; let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap(); @@ -316,6 +409,13 @@ fn a_tiny_png_claiming_huge_dimensions_is_refused_before_allocating() { } } +#[test] +fn a_small_png_claiming_a_large_frame_is_refused_before_zero_filling() { + // 100 MP RGBA = 400 MB is under the 2 GiB ceiling, but 68 bytes cannot hold it. + let r = from_images(&[("big.png".into(), png_claiming(10_000, 10_000))]); + assert!(matches!(&r, Err(CreateError::Image(_, m)) if m.contains("too large"))); +} + /// Likewise a PNG: the png crate's limits don't cover the frame buffer, which its header sizes /// (60000x60000 RGBA is 14.4 GB). Refused before anything is allocated, not after. #[test] diff --git a/crates/create/src/text_font.rs b/crates/create/src/text_font.rs new file mode 100644 index 0000000000..555ed3be73 --- /dev/null +++ b/crates/create/src/text_font.rs @@ -0,0 +1,108 @@ +//! The font a text document is set in: standard Helvetica, or, for text Helvetica can't draw, the +//! bundled Inter face (`pdfcraft_fonts::document_latin_fonts`), which every build has and which +//! carries the Latin, Greek and Cyrillic letters WinAnsiEncoding lacks. Text Helvetica draws is +//! set in Helvetica exactly as before. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_fonts::pdf::Metrics; +use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; + +use crate::CreateError; + +/// The bundled face, Regular. +fn face() -> Option<&'static pdfcraft_fonts::CraftFont> { + pdfcraft_fonts::document_latin_fonts(false).into_iter().next() +} + +/// Line breaks, tabs and form feeds lay the text out; they are not drawn. +fn layout(c: char) -> bool { + matches!(c, '\n' | '\r' | '\t' | '\u{c}') +} + +/// The character the bundled face draws for `c`: a soft hyphen as a hyphen, as Helvetica draws it +/// (Inter has no glyph for it). +pub(crate) fn drawn_as(c: char) -> char { + if c == '\u{ad}' { '-' } else { c } +} + +/// The line (1-based, lines split at `\n`) and character of the first one the bundled face can't +/// draw, or `None` when it draws all of `text`. +pub(crate) fn first_missing(text: &str) -> Option<(usize, char)> { + let face = face(); + let draws = |s: &str| !s.chars().any(char::is_control) && face.is_some_and(|f| pdfcraft_fonts::embedding::covers(f.bytes, s)); + let mut buf = [0u8; 4]; + text.split('\n').enumerate().find_map(|(i, line)| { + // A whole line at once; the character only for a line the face can't draw. + let drawn: String = line.chars().filter(|c| !layout(*c)).map(drawn_as).collect(); + if draws(&drawn) { + return None; + } + line.chars().find(|&c| !layout(c) && !draws(drawn_as(c).encode_utf8(&mut buf))).map(|c| (i + 1, c)) + }) +} + +/// The refusal for text with `c` on `line`, which neither Helvetica nor the bundled face can draw. +pub(crate) fn undrawable_message(line: usize, c: char) -> String { + format!( + "neither Helvetica nor the bundled Inter face can draw \"{}\" (U+{:04X}) in the text (line {line}); only Latin, Greek and Cyrillic letters can be written there for now", + c.escape_debug(), + u32::from(c) + ) +} + +/// The font a text document is set in. +pub(crate) struct TextFont { + font: ObjRef, + /// The bundled face's metrics; `None` for Helvetica. + inter: Option, +} + +impl TextFont { + /// Standard Helvetica in WinAnsiEncoding, added to `doc`. + pub(crate) fn helvetica(doc: &mut Document) -> Self { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name("Helvetica")); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + Self { font: doc.add(font), inter: None } + } + + /// The bundled face, embedded in `doc` once for every page. + pub(crate) fn inter(doc: &mut Document) -> Result { + let face = face().ok_or_else(|| CreateError::Invalid("this build has no bundled Latin, Greek and Cyrillic face".into()))?; + let mut fonts = Dict::new(); + let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(CreateError::Invalid)?; + let font = fonts.reference(name.as_bytes()).ok_or_else(|| CreateError::Invalid("the bundled face could not be added".into()))?; + let dict = doc.dict(&Object::Ref(font)).ok_or_else(|| CreateError::Invalid("the bundled face could not be read".into()))?; + Ok(Self { font, inter: Some(Metrics::from_dict(doc, &dict)) }) + } + + /// The font object every page names. + pub(crate) fn font(&self) -> ObjRef { + self.font + } + + /// The advance of `s` at `size`; characters the face lacks count as nothing ([`Self::show`] + /// refuses them). + pub(crate) fn width(&self, s: &str, size: f64) -> f64 { + match &self.inter { + None => helvetica_width(s, size), + Some(m) => m.encode(s).map_or(0.0, |codes| m.codes(&codes).iter().map(|(code, _)| m.width(*code)).sum::() * size), + } + } + + /// The string operand that shows `line`. Helvetica writes what WinAnsiEncoding can't hold as + /// `?`, so its callers check first; the bundled face refuses a character it lacks. + pub(crate) fn show(&self, line: &str) -> Result, CreateError> { + let Some(m) = &self.inter else { return Ok(literal(&win_ansi(line))) }; + let codes = m.encode(line).ok_or_else(|| CreateError::Invalid(format!("the bundled Inter face can't draw \"{}\"", line.escape_debug())))?; + let mut out = Vec::with_capacity(codes.len().saturating_mul(2).saturating_add(2)); + out.push(b'<'); + for b in codes { + out.extend_from_slice(format!("{b:02X}").as_bytes()); + } + out.push(b'>'); + Ok(out) + } +} diff --git a/crates/edit/src/flatten.rs b/crates/edit/src/flatten.rs index 73c5389c25..03ed272f3c 100644 --- a/crates/edit/src/flatten.rs +++ b/crates/edit/src/flatten.rs @@ -4,7 +4,8 @@ //! Each visible annotation's normal appearance (for check boxes and radio buttons, the one its //! `/AS` selects) is drawn into the page with the matrix of ISO 32000-2 §12.5.5 (Algorithm 8.1: //! the appearance's bounding box, transformed by its `/Matrix`, mapped onto `/Rect`). Hidden -//! annotations are left alone; links are kept (they have no ink and stay clickable). Flattened +//! annotations and replies are left alone (a reply is not shown on the page: [`is_reply`]); +//! links are kept (they have no ink and stay clickable). Flattened //! comments take their pop-ups and replies with them; flattened widgets are removed from the //! form's field tree. An annotation whose appearance cannot be drawn is not flattened: it stays //! as it is, so no comment or field value is lost with nothing in its place. @@ -19,6 +20,21 @@ const HIDDEN: i64 = 2; const NO_ROTATE: i64 = 16; const NO_VIEW: i64 = 32; +/// A reply of type R: a markup annotation with an `/IRT` to the annotation it answers and an +/// `/RT` other than `/Group` (R is the default, so a missing, unknown or malformed `/RT` reads as +/// R, as the comment list reads it). It is shown in its comment's thread, never on the page +/// ("Interactive PDF processors shall not display replies to an annotation individually", ISO +/// 32000-2 Table 172), so flattening neither draws it nor builds it an appearance; it goes with +/// its comment. A `/RT /Group` member is part of its group's look and is flattened as any other +/// annotation. `/IRT` is a markup annotation's entry: a widget, link or pop-up with one is no +/// reply, and is flattened (or kept) as it would be without. Keep in step with hayro-interpret's +/// PdfCraft patch (81). +fn is_reply(doc: &Document, d: &Dict) -> bool { + !matches!(d.name(b"Subtype"), Some(b"Widget" | b"Link" | b"Popup")) + && d.get(b"IRT").is_some_and(|o| doc.resolve(o).as_dict().is_some()) + && d.get(b"RT").map(|o| doc.resolve(o)).as_deref().and_then(Object::as_name) != Some(b"Group") +} + fn nums(doc: &Document, o: Option<&Object>) -> Option> { let o = doc.resolve(o?); o.as_array()?.iter().map(|x| doc.resolve(x).as_f64()).collect() @@ -319,7 +335,8 @@ fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], whi } let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue }; let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) { + // A reply is never drawn into the page (`is_reply`), so its text can't bake in a `?`. + if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 || !transferable(doc, &d, usable_properties) || is_reply(doc, &d) { continue; } let widget = d.name(b"Subtype") == Some(b"Widget"); @@ -346,13 +363,60 @@ fn prepare_appearances(doc: &mut Document, list: &[Object], which: Which, retain continue; } let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0); - if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() { + // A reply is not shown on the page (`is_reply`), so it gets no appearance either. + if flags & (HIDDEN | NO_VIEW) != 0 || is_reply(doc, &d) || appearance(doc, &d).is_some() { continue; } + // A subtype this build cannot draw fails here and is kept below, so the comment is not + // deleted with nothing drawn in its place. (Text the standard fonts can't draw was + // refused before anything changed: `undrawn_on`.) let _ = pdfcraft_annot::set_appearance(doc, r); } } +/// Adds to `gone` and `gone_inline` the pop-ups and replies of what is in them, at any depth of +/// the thread and in any order of `/Annots`, passing by the entries `stays` keeps. What points at +/// each annotation (a pop-up by `/Parent`, a reply by `/IRT`) is gathered once, then followed +/// from what went. `/Annots` should hold references (ISO 32000-2 Table 31), but a pop-up or +/// reply written inline still goes with what it points at; nothing can point at it in turn. +fn take_threads( + doc: &Document, + list: &[Object], + gone: &mut HashSet, + gone_inline: &mut HashSet, + stays: impl Fn(usize, &Object) -> bool, +) { + let mut pointing: HashMap> = HashMap::new(); + let mut pointing_inline: Vec<(ObjRef, usize)> = Vec::new(); + for (k, entry) in list.iter().enumerate() { + if stays(k, entry) { + continue; + } + let r = entry.as_ref(); + let obj = doc.resolve(entry); + let Some(d) = obj.as_dict() else { continue }; + let popup_of = d.reference(b"Parent").filter(|_| d.name(b"Subtype") == Some(b"Popup")); + // Only a reply goes with what it answers: a `/RT /Group` member was drawn (and went) or + // stays, hidden or undrawable, as any annotation. + let reply_to = d.reference(b"IRT").filter(|_| is_reply(doc, d)); + for p in popup_of.into_iter().chain(reply_to) { + match r { + Some(r) => pointing.entry(p).or_default().push(r), + None => pointing_inline.push((p, k)), + } + } + } + let mut follow: Vec = gone.iter().copied().collect(); + while let Some(p) = follow.pop() { + for &r in pointing.get(&p).into_iter().flatten() { + if gone.insert(r) { + follow.push(r); + } + } + } + gone_inline.extend(pointing_inline.into_iter().filter(|(p, _)| gone.contains(p)).map(|(_, k)| k)); +} + /// Runs [`flatten_in`] on a copy of `doc` (unchanged objects are shared) and keeps it only on /// success, so a page refused part way leaves the pages before it as they were. fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result { @@ -431,7 +495,8 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result Result = list .iter() .enumerate() diff --git a/crates/edit/src/hairlines.rs b/crates/edit/src/hairlines.rs new file mode 100644 index 0000000000..f70fd50d76 --- /dev/null +++ b/crates/edit/src/hairlines.rs @@ -0,0 +1,638 @@ +//! Fix hairlines (Acrobat's Print production ▸ Fix Hairlines; Fix hairlines): strokes thinner +//! than a printer holds - zero-width lines, and lines whose width on the page, after every +//! transformation, is below a threshold - are given a chosen width. Page content and the Form +//! XObjects it draws are rewritten; Type 3 glyphs and tiling patterns on request. +//! +//! A form is rewritten for the scale and line width it is drawn under. One drawn under several +//! (or drawn by a page that is not being fixed) is rewritten as a copy for each use that needs +//! one, and the resources of what draws it point at the copy, so no other use changes. +//! +//! Type 3 glyphs and tiling patterns are each a content stream of their own, reached from the +//! resources of the page and of the forms it draws. They are rewritten in place, so a page +//! outside the run that uses one changes too; [`Fixed::elsewhere`] counts those. + +use pdfcraft_content::{Op, parse, serialize_ops}; +use pdfcraft_cos::{Dict, Document, Name, ObjRef, Object, Stream}; +use std::collections::{HashMap, HashSet}; + +use crate::page_streams::PageStreams; +use crate::{EditError, check, n, page_list}; + +/// The stream `r` holds, if it is one. +fn stream_of(doc: &Document, r: ObjRef) -> Option { + match &*doc.get(r) { + Object::Stream(s) => Some(s.clone()), + _ => None, + } +} + +/// What counts as a hairline and what it becomes, in points on the page. +#[derive(Clone, Debug, PartialEq)] +pub struct Hairlines { + /// Lines narrower than this (points, after the page's transformations) are replaced; + /// zero-width lines always are. Acrobat's default is 0.003 in. + pub narrower_than: f64, + /// The width they are given (points on the page). Acrobat's default is 0.01 in. + pub width: f64, + /// Also the strokes inside Type 3 font glyphs. + pub type3: bool, + /// Also the strokes inside tiling patterns. + pub patterns: bool, +} + +impl Default for Hairlines { + fn default() -> Self { + Self { narrower_than: 0.003 * 72.0, width: 0.01 * 72.0, type3: false, patterns: false } + } +} + +impl Hairlines { + /// The widest either width may be, in points: past this it is no hairline. + pub const MAX_POINTS: f64 = 10.0; + + /// Whether both widths are numbers a run accepts: a width above 0, a threshold of 0 or + /// more, neither past [`Self::MAX_POINTS`]. + pub fn in_range(&self) -> bool { + (0.0..=Self::MAX_POINTS).contains(&self.narrower_than) && self.width > 0.0 && self.width <= Self::MAX_POINTS + } +} + +/// What a run changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Fixed { + /// Strokes given a new width. + pub lines: usize, + /// Pages on which something changed. + pub pages: usize, + /// Type 3 glyphs and tiling patterns changed that a page outside the run, or an + /// annotation's appearance, also uses: what those draw changed too. + pub elsewhere: usize, +} + +/// The uniform scale a matrix applies to lengths (the geometric mean of its two axes). +fn scale_of(m: [f64; 6]) -> f64 { + (m[0] * m[3] - m[1] * m[2]).abs().sqrt() +} + +fn mul(a: [f64; 6], b: [f64; 6]) -> [f64; 6] { + // a then b. + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +/// The graphics state a stream leaves for the next one on the same page: CTM and line width, +/// with the q/Q stack, and whether a path is still being built. +#[derive(Clone, Debug)] +struct State { + ctm: [f64; 6], + width: f64, + stack: Vec<([f64; 6], f64)>, + in_path: bool, +} + +impl State { + /// A stream drawn under `base` with line width `width` in effect. + fn new(base: [f64; 6], width: f64) -> Self { + Self { ctm: base, width, stack: Vec::new(), in_path: false } + } +} + +/// All a form's rewrite depends on: the scale it is drawn under and the line width in effect +/// at its `Do` (as bits, so it can be a map key). +type Key = (u64, u64); + +fn key_of(ctm: [f64; 6], width: f64) -> Key { + (scale_of(ctm).to_bits(), width.to_bits()) +} + +/// A Form XObject a stream draws: the resource name it is drawn by, the object, and the CTM +/// and line width at its `Do`. +struct Drawn { + name: Name, + r: ObjRef, + ctm: [f64; 6], + width: f64, +} + +/// The names a stream's `Do`s are given instead, by the name they had and the key they are +/// drawn under. +type Renames = HashMap<(Name, Key), Name>; + +/// How deep forms drawing forms are followed, and how many form rewrites one run makes; past +/// either, a form is drawn as it is. +const MAX_DEPTH: usize = 32; +const MAX_FORMS: usize = 4096; + +/// The width `/name gs` sets, when its ExtGState has an `/LW`. +fn line_width_of(doc: &Document, gstates: Option<&Dict>, name: &[u8]) -> Option { + let gs = doc.resolve(gstates?.get(name)?); + let lw = gs.as_dict()?.get(b"LW")?; + doc.resolve(lw).as_f64().filter(|w| w.is_finite()) +} + +/// Rewrite one content stream drawn under `state` (the CTM when it starts, scale only +/// matters), leaving in it the state at the stream's end. A thin stroke gets a `w` of its own +/// before its path begins (graphics-state operators are not allowed inside a path) and the +/// old width back after it is painted, so nothing else drawn changes width. +/// `forms` collects the Form XObjects it draws, with the CTM and width each is drawn under, +/// for the caller to rewrite in turn; a `Do` listed in `renames` draws the name given there. +/// Returns the new bytes when anything changed, and the count. +fn rewrite( + data: &[u8], + state: &mut State, + h: &Hairlines, + resources: Option<&Dict>, + doc: &Document, + forms: &mut Vec, + renames: &Renames, +) -> (Option>, usize) { + let parsed = parse(data); + let mut ops: Vec = parsed.ops; + let mut fixed = 0usize; + let mut changed = false; + // Where in `out` the path being built began (None when it began in an earlier stream). + let mut path_start: Option = None; + let mut out: Vec = Vec::with_capacity(ops.len() + 8); + let dict_of = |key: &[u8]| resources.and_then(|r| r.get(key)).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let xobjects = dict_of(&b"XObject"[..]); + let gstates = dict_of(&b"ExtGState"[..]); + for mut op in ops.drain(..) { + match op.op.as_slice() { + b"q" => { + state.stack.push((state.ctm, state.width)); + if state.stack.len() > 512 { + state.stack.remove(0); + } + } + b"Q" => { + if let Some((c, w)) = state.stack.pop() { + state.ctm = c; + state.width = w; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + state.ctm = mul(m, state.ctm); + } + } + b"w" => { + if let Some([w]) = op.nums::<1>() { + state.width = w; + } + } + b"gs" => { + // An ExtGState's /LW sets the width as `w` does (so the width put back after + // a fixed stroke is the right one). + if let Some(Object::Name(name)) = op.operands.first() + && let Some(w) = line_width_of(doc, gstates.as_ref(), name) + { + state.width = w; + } + } + b"m" | b"re" => { + if !state.in_path { + state.in_path = true; + path_start = Some(out.len()); + } + } + b"Do" => { + if let Some(Object::Name(name)) = op.operands.first() + && let Some(x) = xobjects.as_ref().and_then(|x| x.get(name)) + && let Some(r) = x.as_ref() + && matches!(&*doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")) + { + let name = name.clone(); + if !renames.is_empty() + && let Some(to) = renames.get(&(name.clone(), key_of(state.ctm, state.width))) + && let Some(first) = op.operands.first_mut() + { + *first = Object::Name(to.clone()); + changed = true; + } + forms.push(Drawn { name, r, ctm: state.ctm, width: state.width }); + } + } + b"S" | b"s" | b"B" | b"B*" | b"b" | b"b*" => { + let scale = scale_of(state.ctm); + let on_page = state.width * scale; + let thin = state.width <= 0.0 || (scale > 0.0 && on_page < h.narrower_than); + // A path begun in an earlier stream cannot get its `w` before it here; it is + // left as it is rather than given an operator inside the path. + let at = match path_start { + Some(i) => Some(i), + None if !state.in_path => Some(out.len()), + None => None, + }; + state.in_path = false; + path_start = None; + if thin + && scale > 0.0 + && let Some(at) = at + { + let new_w = h.width / scale; + out.insert(at, Op::new("w", vec![Object::Real(new_w)])); + out.push(op); + out.push(Op::new("w", vec![Object::Real(state.width)])); + fixed += 1; + changed = true; + continue; + } + } + b"f" | b"F" | b"f*" | b"n" => { + state.in_path = false; + path_start = None; + } + _ => {} + } + out.push(op); + } + (changed.then(|| serialize_ops(&out)), fixed) +} + +/// What a run keeps across pages. +struct Run { + /// Each form already rewritten under a key, and the object drawn for it there. + done: HashMap<(ObjRef, Key), ObjRef>, + /// The forms drawn where this run does not rewrite: they are never changed in place. + shared: HashSet, + /// The forms already drawn as themselves: `true` when rewritten in place, `false` when + /// left as they were. Any other use that needs something else gets a copy. + used: HashMap, + /// Each form's stream as it was before the run. + original: HashMap, + /// Form rewrites left. + budget: usize, +} + +/// The XObjects (forms and images, and those the forms draw), tiling patterns and Type 3 +/// glyphs that the pages not in `pages` use, and those any page's annotation appearances use +/// (this run does not rewrite them): rewriting one of those in place would change what they +/// show. +fn drawn_elsewhere(doc: &Document, pages: &[usize]) -> HashSet { + let mut seen: HashSet = HashSet::new(); + let mut todo: Vec = Vec::new(); + let xobjects_of = |res: Option<&Object>, todo: &mut Vec| { + let Some(res) = res.map(|r| doc.resolve(r)) else { return }; + let Some(res) = res.as_dict() else { return }; + let dict_at = |key: &[u8]| res.get(key).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + for dict in [dict_at(b"XObject"), dict_at(b"Pattern")].into_iter().flatten() { + todo.extend(dict.iter().map(|(_, o)| o.clone())); + } + for (_, font) in dict_at(b"Font").iter().flat_map(Dict::iter) { + let font = doc.resolve(font); + if let Some(procs) = font.as_dict().and_then(|f| f.get(b"CharProcs")).map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + todo.extend(procs.iter().map(|(_, g)| g.clone())); + } + } + }; + for (i, page) in page_list(doc).into_iter().enumerate() { + if !pages.contains(&i) { + xobjects_of(page.dict.get(b"Resources"), &mut todo); + } + let annots = page.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); + for a in annots { + let Some(ap) = doc.resolve(&a).as_dict().and_then(|d| d.get(b"AP").cloned()) else { continue }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { continue }; + for (_, v) in ap.iter() { + match &*doc.resolve(v) { + Object::Dict(states) => todo.extend(states.iter().map(|(_, s)| s.clone())), + _ => todo.push(v.clone()), + } + } + } + } + while let Some(o) = todo.pop() { + let Some(r) = o.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let res = match &*doc.get(r) { + Object::Stream(s) => s.dict.get(b"Resources").cloned(), + _ => None, + }; + xobjects_of(res.as_ref(), &mut todo); + } + seen +} + +/// A name for another copy drawn by `name` that `xobjects` does not hold yet. +fn fresh_name(xobjects: &Dict, name: &[u8]) -> Name { + let mut i = 1usize; + loop { + let mut candidate = name.to_vec(); + candidate.extend_from_slice(format!("_hl{i}").as_bytes()); + if !xobjects.contains(&candidate) { + return candidate; + } + i = i.saturating_add(1); + } +} + +/// Rewrite one drawing context - a page's streams, or a form's - that starts in `start`, and +/// the forms it draws. Returns each stream's new bytes (`None` where it did not change), the +/// strokes fixed (in the forms too), and the `/XObject` dictionary for the context's resources +/// when its names must now point at other objects. +fn fix_context( + doc: &mut Document, + run: &mut Run, + h: &Hairlines, + data: &[Vec], + start: &State, + resources: Option<&Dict>, + depth: usize, +) -> (Vec>>, usize, Option) { + let none = Renames::new(); + let mut drawn: Vec = Vec::new(); + let mut state = start.clone(); + let mut new: Vec>> = Vec::with_capacity(data.len()); + let mut fixed = 0usize; + for d in data { + let (bytes, count) = rewrite(d, &mut state, h, resources, doc, &mut drawn, &none); + fixed += count; + new.push(bytes); + } + if drawn.is_empty() { + return (new, fixed, None); + } + let Some(xo) = resources.and_then(|r| r.get(b"XObject")).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { + return (new, fixed, None); + }; + // The object each (name, key) is drawn as, and each name's objects in order of first use. + let mut chosen: HashMap<(Name, Key), ObjRef> = HashMap::new(); + let mut targets: Vec<(Name, Vec)> = Vec::new(); + for d in drawn { + let k = key_of(d.ctm, d.width); + if chosen.contains_key(&(d.name.clone(), k)) { + continue; + } + let (to, count) = fix_form(doc, run, h, d.r, d.ctm, d.width, depth + 1); + fixed += count; + chosen.insert((d.name.clone(), k), to); + match targets.iter_mut().find(|(name, _)| *name == d.name) { + Some((_, list)) => { + if !list.contains(&to) { + list.push(to); + } + } + None => targets.push((d.name, vec![to])), + } + } + // The first object keeps the name; each other one gets a name of its own, and the `Do`s + // drawing it are renamed. + let mut repointed = xo; + let mut changed = false; + let mut renames = Renames::new(); + for (name, list) in &targets { + for (i, to) in list.iter().enumerate() { + let at = if i == 0 { name.clone() } else { fresh_name(&repointed, name) }; + if repointed.get(&at).and_then(Object::as_ref) != Some(*to) { + repointed.set(at.clone(), Object::Ref(*to)); + changed = true; + } + if i > 0 { + for ((was, k), t) in &chosen { + if was == name && t == to { + renames.insert((was.clone(), *k), at.clone()); + } + } + } + } + } + if !renames.is_empty() { + // The same rewrite again, with the renamed `Do`s. + let mut state = start.clone(); + let mut again: Vec = Vec::new(); + new = data.iter().map(|d| rewrite(d, &mut state, h, resources, &*doc, &mut again, &renames).0).collect(); + } + (new, fixed, changed.then_some(repointed)) +} + +/// The object `r` is drawn as under `ctm` with line width `width` in effect, rewritten for +/// them, and the strokes that rewrite fixed. It is `r` itself, rewritten in place, unless +/// something else draws `r` differently; then it is a copy. +fn fix_form(doc: &mut Document, run: &mut Run, h: &Hairlines, r: ObjRef, ctm: [f64; 6], width: f64, depth: usize) -> (ObjRef, usize) { + let k = key_of(ctm, width); + if let Some(&to) = run.done.get(&(r, k)) { + return (to, 0); + } + if depth > MAX_DEPTH || run.budget == 0 { + return (r, 0); + } + run.budget -= 1; + // Marked before rewriting, so a form that draws itself ends. + run.done.insert((r, k), r); + let s = match run.original.get(&r) { + Some(s) => s.clone(), + None => { + let Some(s) = stream_of(doc, r) else { return (r, 0) }; + run.original.insert(r, s.clone()); + s + } + }; + let Ok(data) = s.decoded() else { return (r, 0) }; + let matrix = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + let base = match matrix { + Some(m) => mul(m, ctm), + None => ctm, + }; + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let (new, fixed, xo) = fix_context(doc, run, h, std::slice::from_ref(&data), &State::new(base, width), res.as_ref(), depth); + let bytes = new.into_iter().next().flatten(); + let to = if bytes.is_none() && xo.is_none() { + if run.used.get(&r) == Some(&true) { + // `r` was rewritten in place for another use; this one draws it as it was. + doc.add(Object::Stream(s)) + } else { + run.used.insert(r, false); + r + } + } else { + let mut form = s; + if let Some(xo) = xo { + let mut res = res.unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + form.dict.set(b"Resources".to_vec(), Object::Dict(res)); + } + if let Some(bytes) = bytes { + let mut dict = form.dict; + for key in [&b"Length"[..], b"Filter", b"DecodeParms"] { + dict.remove(key); + } + form = Stream::flate(dict, &bytes); + } + if run.shared.contains(&r) || run.used.contains_key(&r) { + doc.add(Object::Stream(form)) + } else { + doc.set(r, Object::Stream(form)); + run.used.insert(r, true); + r + } + }; + run.done.insert((r, k), to); + (to, fixed) +} + +/// Fix the hairlines on `pages` (0-based). Each page's content, and each Form XObject it draws +/// (for the scale and line width it is drawn under), is rewritten where a stroke is thinner +/// than `h.narrower_than` points on the page. A form drawn under more than one, or drawn by a +/// page not in `pages` or by an annotation's appearance, is rewritten as a copy for the uses +/// that need one, and the page's (or drawing form's) resources point at the copy; a resource +/// dictionary other pages share is not changed. Type 3 glyphs and tiling patterns are rewritten +/// in place, once, at a nominal scale (see `fix_in_resources`), and those other uses of them are +/// counted in [`Fixed::elsewhere`]. +pub fn fix_hairlines(doc: &mut Document, pages: &[usize], h: &Hairlines) -> Result { + let all = page_list(doc); + check(pages, all.len())?; + if !h.in_range() { + return Err(EditError::Invalid(format!("the hairline widths must be numbers of points, above 0 and at most {}", Hairlines::MAX_POINTS))); + } + let mut result = Fixed::default(); + let mut run = + Run { done: HashMap::new(), shared: drawn_elsewhere(doc, pages), used: HashMap::new(), original: HashMap::new(), budget: MAX_FORMS }; + let mut done_glyphs: HashSet = HashSet::new(); + for &i in pages { + let Some(page) = page_list(doc).get(i).cloned() else { continue }; + let resources = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()); + let mut page_fixed = 0usize; + // The page's streams, each rewritten on its own (state carries across them), and the + // forms they draw. A page with a stream that cannot be read is left alone. + if let Some(streams) = PageStreams::read(doc, &page.dict) { + let start = State::new([1.0, 0.0, 0.0, 1.0, 0.0, 0.0], 1.0); + let (new, fixed, xo) = fix_context(doc, &mut run, h, &streams.data, &start, resources.as_ref(), 0); + page_fixed += fixed; + streams.write(doc, page.obj, new)?; + if let Some(xo) = xo { + // This page's own resources point at the copies; a resource dictionary other + // pages share is not changed. + let mut res = resources.clone().unwrap_or_default(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?; + } + } + if h.type3 || h.patterns { + for res in resources_below(doc, resources.as_ref()) { + page_fixed += fix_in_resources(doc, Some(&res), h, &mut done_glyphs, &run.shared, &mut result.elsewhere)?; + } + } + if page_fixed > 0 { + result.lines += page_fixed; + result.pages += 1; + } + } + Ok(result) +} + +/// `resources` and the resources of every form they reach (a form that names none uses its +/// parent's), each once. +fn resources_below(doc: &Document, resources: Option<&Dict>) -> Vec { + let mut all: Vec = resources.into_iter().cloned().collect(); + let mut seen: HashSet = HashSet::new(); + let mut at = 0; + while let Some(res) = all.get(at).cloned() { + at += 1; + let Some(xo) = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue }; + for (_, x) in xo.iter() { + let Some(r) = x.as_ref() else { continue }; + if !seen.insert(r) { + continue; + } + let Some(s) = stream_of(doc, r) else { continue }; + if s.dict.name(b"Subtype") == Some(b"Form") + && let Some(inner) = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) + { + all.push(inner); + } + } + } + all +} + +/// Strokes inside Type 3 glyph procedures and tiling patterns of `resources`, on request. Their +/// drawing scale is the font matrix or the pattern matrix (the text size and placement are +/// not known here, so the glyph's own scale stands in). They are rewritten in place, once per +/// run: a pattern's space is set by its matrix whatever CTM it is painted under, and a glyph +/// is measured at a nominal size, so there is no per-use scale to make a copy for. The forms +/// they draw are not followed. Each one changed that is in `shared` is added to `elsewhere`. +fn fix_in_resources( + doc: &mut Document, + resources: Option<&Dict>, + h: &Hairlines, + done: &mut HashSet, + shared: &HashSet, + elsewhere: &mut usize, +) -> Result { + let Some(res) = resources else { return Ok(0) }; + let mut fixed = 0usize; + let mut targets: Vec<(ObjRef, [f64; 6])> = Vec::new(); + if h.type3 + && let Some(fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) + { + for (_, f) in fonts.iter() { + let fd = doc.resolve(f); + let Some(fd) = fd.as_dict() else { continue }; + if fd.name(b"Subtype") != Some(b"Type3") { + continue; + } + let fm = fd.get(b"FontMatrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + // Glyph space → text space at a nominal 10 pt. + let base = fm.map(|m| mul(m, [10.0, 0.0, 0.0, 10.0, 0.0, 0.0])).unwrap_or([0.01, 0.0, 0.0, 0.01, 0.0, 0.0]); + if let Some(procs) = fd.get(b"CharProcs").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) { + for (_, g) in procs.iter() { + if let Some(r) = g.as_ref() { + targets.push((r, base)); + } + } + } + } + } + if h.patterns + && let Some(pats) = res.get(b"Pattern").map(|p| doc.resolve(p)).and_then(|p| p.as_dict().cloned()) + { + for (_, p) in pats.iter() { + let Some(r) = p.as_ref() else { continue }; + let Some(s) = stream_of(doc, r) else { continue }; + if s.dict.int(b"PatternType") != Some(1) { + continue; + } + let pm = s.dict.get(b"Matrix").map(|m| doc.resolve(m)).and_then(|m| m.as_array().cloned()).and_then(|a| { + let v: Vec = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); + <[f64; 6]>::try_from(v).ok() + }); + targets.push((r, pm.unwrap_or([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]))); + } + } + let none = Renames::new(); + for (r, base) in targets { + if !done.insert(r) { + continue; + } + let Some(s) = stream_of(doc, r) else { continue }; + let Ok(data) = s.decoded() else { continue }; + let res = s.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()); + let mut inner = Vec::new(); + let (new, n_fixed) = rewrite(&data, &mut State::new(base, 1.0), h, res.as_ref(), doc, &mut inner, &none); + if let Some(bytes) = new { + let mut dict = s.dict.clone(); + dict.remove(b"Length"); + dict.remove(b"Filter"); + dict.remove(b"DecodeParms"); + doc.set(r, Object::Stream(Stream::flate(dict, &bytes))); + fixed += n_fixed; + *elsewhere += usize::from(n_fixed > 0 && shared.contains(&r)); + } + } + let _ = n; + Ok(fixed) +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index dda38c7c08..2d508f6878 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -64,6 +64,9 @@ pub use text::{ BlockKey, BlockStyle, LineEdit, MAX_FORM_DEPTH, MAX_FORM_VISITS, RangeStyle, ReadingBlocks, TextBlock, TextLine, TextSession, block_overlapping, clear_block, glyph_text_lines, reading_blocks, reading_blocks_checked, replace_block, replace_line, rewrite_block, text_blocks, text_lines, }; +pub mod hairlines; +mod page_streams; +pub use hairlines::{Fixed as HairlinesFixed, Hairlines, fix_hairlines}; pub mod added; pub use added::{ Added, AddedImage, AddedText, Align, Content, Family, Ink, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, diff --git a/crates/edit/src/page_streams.rs b/crates/edit/src/page_streams.rs new file mode 100644 index 0000000000..eae00399da --- /dev/null +++ b/crates/edit/src/page_streams.rs @@ -0,0 +1,69 @@ +//! A page's content streams rewritten one at a time: each stream keeps its own dictionary +//! (and so its marks, such as `/PCMark`), and only the streams that changed are replaced. + +use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream}; + +use crate::EditError; + +/// The decoded content streams of one page, in drawing order. +pub(crate) struct PageStreams { + /// Each stream's object and dictionary. + entries: Vec<(ObjRef, Dict)>, + /// Each stream's decoded data, in the same order. + pub(crate) data: Vec>, +} + +impl PageStreams { + /// The streams of `page_dict`'s `/Contents`. `None` when one of them is not a stream or + /// cannot be decoded: rewriting the others would draw the page differently, so the page is + /// left alone. A page without content has no streams. + pub(crate) fn read(doc: &Document, page_dict: &Dict) -> Option { + let refs: Vec = match page_dict.get(b"Contents") { + None => Vec::new(), + Some(c) => match &*doc.resolve(c) { + Object::Array(a) => a.clone(), + _ => vec![c.clone()], + }, + }; + let mut entries = Vec::with_capacity(refs.len()); + let mut data = Vec::with_capacity(refs.len()); + for e in &refs { + let r = e.as_ref()?; + let (dict, bytes) = match &*doc.get(r) { + Object::Stream(s) => (s.dict.clone(), s.decoded().ok()?), + _ => return None, + }; + data.push(bytes); + entries.push((r, dict)); + } + Some(Self { entries, data }) + } + + /// Put `new` in place of the streams that changed (`None` keeps a stream as it is). Each + /// replacement is a new object with the old dictionary (minus its encoding), so a stream + /// another page shares is not touched. Returns whether anything changed. + pub(crate) fn write(self, doc: &mut Document, page: ObjRef, new: Vec>>) -> Result { + if new.iter().all(Option::is_none) { + return Ok(false); + } + let mut refs: Vec = Vec::with_capacity(self.entries.len()); + for (i, (r, dict)) in self.entries.into_iter().enumerate() { + match new.get(i).and_then(Option::as_ref) { + Some(bytes) => { + let mut dict = dict; + for k in [&b"Length"[..], b"Filter", b"DecodeParms", b"DL"] { + dict.remove(k); + } + refs.push(Object::Ref(doc.add(Object::Stream(Stream::flate(dict, bytes))))); + } + None => refs.push(Object::Ref(r)), + } + } + let contents = match <[Object; 1]>::try_from(refs) { + Ok([one]) => one, + Err(many) => Object::Array(many), + }; + doc.update_dict(page, |d| d.set(b"Contents".to_vec(), contents))?; + Ok(true) + } +} diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6bf35331db..242659f531 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -549,7 +549,7 @@ fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() { /// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden /// cloudy square and a cloudy square that already has an appearance keep their previous results. #[test] -fn flattening_keeps_a_cloudy_square_it_cannot_draw() { +fn flattening_draws_cloudy_squares_and_keeps_one_it_cannot_draw() { let page = stream("", " "); let form = "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream"; let case = |extra: &str, drawn: usize, left: bool| { @@ -571,10 +571,13 @@ fn flattening_keeps_a_cloudy_square_it_cannot_draw() { assert_eq!(streams(&doc, 0), before, "nothing was drawn in place of the comment"); } }; - case("/BE << /S /C /I 2 >>", 0, true); + // A cloudy square is drawn (ISO 32000-2 Table 169); one whose /RD leaves no square inside + // its /Rect (Table 180: the sums "shall be less than" the width and height) cannot be, and stays. + case("/BE << /S /C /I 2 >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30]", 0, true); case("/BE << /S /S >>", 1, false); - case("/BE << /S /C /I 2 >> /F 2", 0, true); - case("/BE << /S /C /I 2 >> /AP << /N 6 0 R >>", 1, false); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /F 2", 0, true); + case("/BE << /S /C /I 2 >> /RD [60 30 60 30] /AP << /N 6 0 R >>", 1, false); } /// #3213: flattening wraps transferable optional-content memberships (`/OC` indirect OCGs and @@ -802,10 +805,12 @@ fn flattening_keeps_a_widget_whose_value_is_past_the_parent_limit() { assert_eq!(annots, Some(1), "the widget stays"); } -/// #1794: a reply this builder cannot draw stays on the page even when the comment it replies to -/// is flattened; the comment's pop-up still goes with it. +/// #1794, #5631: a reply this builder cannot draw goes with the comment it replies to, as its +/// pop-up does. A reply of type R is not shown on the page (ISO 32000-2 Table 172), so it has no +/// ink of its own to keep; before #5631 it stayed, the orphan of a comment that was gone. (A +/// `/RT /Group` member that cannot be drawn still stays: it is drawn on the page.) #[test] -fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { +fn flattening_takes_an_undrawable_reply_with_its_comment() { let page = stream("", " "); let mut doc = build(&[ "<< /Type /Catalog /Pages 2 0 R >>", @@ -813,15 +818,212 @@ fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", &page, "<< /Type /Annot /Subtype /Square /Rect [100 600 200 650] /C [1 0 0] /Contents (Synthetic comment) /AP << /N 8 0 R >> /Popup 7 0 R >>", - "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /IRT 5 0 R >>", + "<< /Type /Annot /Subtype /Square /Rect [100 500 200 550] /C [1 0 0] /Contents (Synthetic reply) /BE << /S /C /I 2 >> /RD [60 30 60 30] /IRT 5 0 R >>", "<< /Type /Annot /Subtype /Popup /Rect [300 600 400 700] /Parent 5 0 R >>", "<< /Type /XObject /Subtype /Form /BBox [100 600 200 650] /Length 28 >>\nstream\n1 0 0 RG 100 600 100 50 re S\nendstream", ]); - assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the cloudy reply cannot be"); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "the comment is drawn; the reply is not"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the comment, its reply and its pop-up are gone"); +} + +/// #5631: flattening draws no reply of type R into the page, whatever its appearance, and builds +/// none for one without (a note reply would get an icon); every reply goes with its comment, a +/// reply to a reply too, even listed before the reply it answers. A `/RT /Group` member is +/// drawn as any annotation. +#[test] +fn flattening_draws_no_reply_and_takes_the_thread_with_its_comment() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + // The reply to the reply (8) is listed before the reply it answers (6). + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 8 0 R 6 0 R 7 0 R 9 0 R] >>", + &page, + // The comment (blue), its reply (red), its pop-up, a reply to the reply with no + // appearance, and a member of its group (green). + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 10 0 R >> /Popup 7 0 R >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /AP << /N 11 0 R >> >>", + "<< /Type /Annot /Subtype /Popup /Rect [300 500 400 600] /Parent 5 0 R >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply to the reply) /IRT 6 0 R /RT /R >>", + "<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 12 0 R >> >>", + &blue, + &red, + &green, + ]); + let n = flatten(&mut doc, &[0], true, false).unwrap(); + assert_eq!(n, 2, "the comment and the group member are drawn; the replies are not"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the whole thread, its pop-up and the group member are gone"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [10, 12], "the comment's and the member's appearances; not the reply's, and none built for the other"); +} + +/// #5632 review: a reply's text never blocks flattening (a reply is not drawn into the page, so +/// it can't bake in a `?`), and a `/RT /Group` member that flattening skipped (Hidden here) +/// stays when its primary is flattened: only replies go with what they answer. +#[test] +fn flattening_skips_replies_in_the_question_mark_check_and_keeps_skipped_group_members() { + let page = stream("", " "); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Square /Rect [10 10 60 40] /F 4 /AP << /N 8 0 R >> >>", + // A text-box reply whose text Helvetica can't draw, with no appearance. + "<< /Type /Annot /Subtype /FreeText /Rect [10 50 150 80] /DA (0 g /Helv 12 Tf) /Contents /F 4 /IRT 5 0 R >>", + // A hidden member of the square's group. + "<< /Type /Annot /Subtype /Circle /Rect [70 10 100 40] /F 6 /IRT 5 0 R /RT /Group /AP << /N 8 0 R >> >>", + &stream("/Type /XObject /Subtype /Form /BBox [0 0 50 30]", "0 0 1 rg 0 0 50 30 re f"), + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "flattened, the square drawn"); let doc = reopen(&doc); let p = &pdfcraft_model::pages(&doc)[0]; let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); - assert_eq!(left, [6], "the undrawn reply stays; the comment and its pop-up are gone"); + assert_eq!(left, [7], "the reply went with the square; the hidden group member stays"); +} + +/// #5632 review: a thread three replies deep, listed in `/Annots` in reverse (the deepest reply +/// first, the comment last), goes whole with its comment, none of the replies drawn; a reply to a +/// `/RT /Group` member goes with that member, which is drawn. +#[test] +fn flattening_takes_a_thread_three_deep_in_reverse_order_and_a_group_members_reply() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [8 0 R 7 0 R 6 0 R 5 0 R 9 0 R 10 0 R] >>", + &page, + // The comment (blue), and three replies, each to the one before, all with appearances (red). + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 11 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply 1) /IRT 5 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply 2) /IRT 6 0 R /RT /R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [115 485 135 503] /Contents (Reply 3) /IRT 7 0 R /AP << /N 12 0 R >> >>", + // A member of the comment's group (green), and a reply to the member. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [200 500 220 518] /IRT 5 0 R /RT /Group /AP << /N 13 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /Contents (Reply to the member) /IRT 9 0 R /AP << /N 12 0 R >> >>", + &blue, + &red, + &green, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 2, "the comment and the group member are drawn; no reply is"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "the three replies and the member's reply go with what they answer"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [11, 13], "the comment's and the member's appearances, not the replies'"); +} + +/// #5632 review: `/Annots` should hold references (ISO 32000-2 Table 31), but a reply or pop-up +/// written inline in it still goes with the comment it points at, as a referenced one does. One +/// pointing at a comment that stays, stays. +#[test] +fn flattening_takes_inline_replies_and_popups_with_their_comment() { + let page = stream("", " "); + let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R \ + << /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Inline reply) /IRT 5 0 R >> \ + << /Type /Annot /Subtype /Popup /Rect [200 400 300 500] /Parent 5 0 R >> \ + << /Type /Annot /Subtype /Text /F 28 /Rect [105 395 125 413] /Contents (Reply to a hidden note) /IRT 6 0 R >> 6 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 7 0 R >> >>", + // Hidden: not flattened, so it stays, and so does its inline reply. + "<< /Type /Annot /Subtype /Text /F 30 /Rect [100 400 120 418] /Contents (Hidden note) /AP << /N 7 0 R >> >>", + &blue, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let annots = doc.resolve(p.dict.get(b"Annots").unwrap()); + let left: Vec = annots + .as_array() + .unwrap() + .iter() + .map(|a| doc.resolve(a).as_dict().unwrap().get(b"Contents").and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default()) + .collect(); + assert_eq!(left, ["Reply to a hidden note", "Hidden note"], "the inline reply and pop-up went with the comment"); +} + +/// #5632 review: replies whose `/IRT` runs in a loop (two replying to each other, one replying to +/// itself, and a square and a note each answering the other) end the flattening. None of them is +/// drawn: each is a reply. Those that answer nothing flattened stay; the comment beside them is +/// flattened and its reply goes with it. +#[test] +fn flattening_ends_on_replies_whose_irt_loops() { + let page = stream("", " "); + let blue = stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", "0 0 1 rg 0 0 20 18 re f"); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R] >>", + &page, + // A comment and its reply. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [100 500 120 518] /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /IRT 5 0 R /AP << /N 12 0 R >> >>", + // Two replies to each other. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [200 500 220 518] /IRT 8 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [205 495 225 513] /IRT 7 0 R /AP << /N 12 0 R >> >>", + // A reply to itself. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [300 500 320 518] /IRT 9 0 R /AP << /N 12 0 R >> >>", + // A square and a note each answering the other: both are replies. + "<< /Type /Annot /Subtype /Square /F 4 /Rect [400 500 420 518] /IRT 11 0 R /AP << /N 12 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [405 495 425 513] /IRT 10 0 R /AP << /N 12 0 R >> >>", + &blue, + ]); + assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1, "only the comment is drawn"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + let left: Vec = doc.resolve(p.dict.get(b"Annots").unwrap()).as_array().unwrap().iter().filter_map(|f| f.as_ref()).map(|r| r.num).collect(); + assert_eq!(left, [7, 8, 9, 10, 11], "the comment's reply went with it; the looping replies stay"); +} + +/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list and the +/// page read it, so replies with an unknown name or a string there are neither drawn nor kept +/// once their comment is flattened. A form field with a stray `/IRT` is no reply: it is flattened +/// as a field. +#[test] +fn flattening_reads_an_unknown_or_malformed_rt_as_a_reply_and_a_widget_with_irt_as_a_field() { + let page = stream("", " "); + let form = |rgb: &str| stream("/Type /XObject /Subtype /Form /BBox [0 0 20 18]", &format!("{rgb} rg 0 0 20 18 re f")); + let (blue, red, green) = (form("0 0 1"), form("1 0 0"), form("0 1 0")); + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Resources << >> /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>", + &page, + "<< /Type /Annot /Subtype /Text /F 28 /Rect [100 500 120 518] /Contents (Comment) /AP << /N 9 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [105 495 125 513] /Contents (Reply) /IRT 5 0 R /RT /Answer /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Text /F 28 /Rect [110 490 130 508] /Contents (Reply) /IRT 5 0 R /RT (Group) /AP << /N 10 0 R >> >>", + "<< /Type /Annot /Subtype /Widget /F 4 /Rect [200 500 220 518] /IRT 5 0 R /AP << /N 11 0 R >> >>", + &blue, + &red, + &green, + ]); + assert_eq!(flatten(&mut doc, &[0], true, true).unwrap(), 2, "the comment and the field are drawn; neither reply is"); + let doc = reopen(&doc); + let p = &pdfcraft_model::pages(&doc)[0]; + assert!(!p.dict.contains(b"Annots"), "both replies went with the comment, and the field was flattened"); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + let mut drawn: Vec = xo.as_dict().unwrap().iter().filter_map(|(_, v)| v.as_ref()).map(|r| r.num).collect(); + drawn.sort_unstable(); + assert_eq!(drawn, [9, 11], "the comment's and the field's appearances, not the replies'"); } /// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an @@ -1704,6 +1906,30 @@ fn tracked_text_preserves_larger_word_gaps() { } } +/// LibreOffice stretches a justified line on both sides of each space glyph, so a gap that reads +/// as a space comes right before the space glyph it widens: one space, not two. A gap after the +/// space glyph, a space glyph with no gap before it, and two space glyphs after a gap read as +/// before. +#[test] +fn a_gap_before_a_space_glyph_reads_as_one_space() { + for show in ["[(Word) -250 ( next) -250 ( last)] TJ", "[(Word ) -250 (next ) -250 (last)] TJ", "[(Word) -250 (next) 0 ( last)] TJ"] { + let doc = text_page(&format!("BT /F1 10 Tf 72 700 Td {show} ET")); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last", "{show}"); + } + // Only the first space glyph after the gap is the gap's: two real spaces stay two. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250 ( next)] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // The same line split over operators: the gap ends one run, its space glyph starts the next. + let doc = text_page("BT /F1 10 Tf 72 700 Td [(Word) -250] TJ [( next) -250] TJ ( last) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next last"); + // Space glyphs on both sides of a break between runs, with no gap, are two spaces. + let doc = text_page("BT /F1 10 Tf 72 700 Td (Word ) Tj ( next) Tj ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "Word next"); + // Down a vertical column the same. + let doc = vertical_page("BT /V1 20 Tf 500 720 Td [<0041> 300 <00200042> 300 <00200043>] TJ ET"); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "A B C"); +} + #[test] fn tracked_text_positions_do_not_count_character_spacing_twice() { let expected = "TRACKED SAMPLE TEXT"; @@ -3014,6 +3240,175 @@ fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); } +/// #2378: a word the line's subset can't show goes to Helvetica, and only that word. The words +/// it can show keep the source font (here `F2`, a subset of `a` and `b` without a space glyph), +/// one font per word, and the line reads back whole and in place: one line, in its own font, so +/// editing it again replaces all of it (review on #2955), also when it starts with a substitute +/// word. +#[test] +fn only_the_words_a_subset_cannot_show_are_substituted() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_line(&mut doc, 0, 0, "ab ba cab ba").unwrap(); + assert_eq!(r.substituted.as_deref(), Some("Helvetica")); + let mut doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/F2") && content.contains("/PCEdHelvetica"), "both fonts are used: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [("ab ba cab ba", "F2")]); + // Each word where its font puts it, the substituted `cab` included: no gap or overlap opens + // where the font changes (review on #2955). + let starts: Vec = lines[0].ink.iter().map(|b| b[0]).collect(); + assert_eq!(starts.len(), 4, "{:?}", lines[0].ink); + for (got, want) in starts.iter().zip([72.0, 85.5, 98.48, 117.9]) { + assert!((got - want).abs() < 0.01, "word starts {starts:?}, want {want} among them"); + } + assert!(lines[0].ink.windows(2).all(|w| w[0][2] < w[1][0]), "no word box overlaps the next: {:?}", lines[0].ink); + for (text, first) in [("cab ab", "PCEdHelvetica"), ("ba cab", "F2")] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect(); + assert_eq!(read, [(text, "F2")], "after replacing with {text:?} (starting with {first})"); + } +} + +/// Review on #2955: replacing a line written in two fonts again replaces its `/PCEdLine` +/// sequence too. Kept, each edit nested one more, and its `EMC`s outnumbered what the reader +/// counts once 256 deep, closing the sequence around the line (here a `/Span`). The content +/// doesn't grow from one edit to the next. +#[test] +fn replacing_a_mixed_font_line_again_replaces_its_sequence() { + let mut doc = text_page("/Span BMC BT /F2 10 Tf 72 600 Td (ab) Tj ET EMC BT /F2 10 Tf 72 500 Td (ba) Tj ET"); + let count = |content: &str, op: &str| content.split_whitespace().filter(|w| *w == op).count(); + let mut sizes = Vec::new(); + for text in ["ab ba cab ba", "cab ab", "ab cab", "ab cab", "ab ba", "ba ab"] { + text::replace_line(&mut doc, 0, 0, text).unwrap(); + doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let mixed = text.contains('c'); + assert_eq!(content.matches("/PCEdLine").count(), usize::from(mixed), "after {text:?}: {content}"); + assert_eq!((count(&content, "BMC"), count(&content, "EMC")), (1 + usize::from(mixed), 1 + usize::from(mixed)), "after {text:?}: {content}"); + assert_eq!(count(&content, "Tf"), 2 + if mixed { 4 } else { 1 }, "after {text:?}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<&str> = lines.iter().map(|l| l.text.trim()).collect(); + assert_eq!(read, [text, "ba"], "after {text:?}"); + assert_eq!(lines[0].baseline(), [72.0, 600.0], "after {text:?}: in place"); + sizes.push(content.split_whitespace().count()); + } + assert_eq!(sizes[2], sizes[3], "the same text again, the same content"); + // A sequence holding marked content of its own goes whole, and what follows it keeps its + // font and size unless the replacement sets the same again. + for (after, font, size) in [("/F1 12 Tf", "F1", 12.0), ("/F2 12 Tf", "F2", 12.0), ("/F1 10 Tf", "F1", 10.0)] { + let mut doc = text_page(&format!( + "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf /Span <<>> BDC (ab) Tj EMC /F1 10 Tf ( cab) Tj EMC {after} 0 -20 Td (ba) Tj ET" + )); + text::replace_line(&mut doc, 0, 0, "ab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!((count(&content, "BMC"), count(&content, "BDC"), count(&content, "EMC")), (0, 0, 0), "{after}: {content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + let read: Vec<(&str, &str, f64)> = lines.iter().map(|l| (l.text.trim(), l.font.as_str(), l.size)).collect(); + assert_eq!(read, [("ab ab", "F2", 10.0), ("ba", font, size)], "{after}: {content}"); + } +} + +/// Review on #2955: a leading space is not a word the line's font shows. " cab" (no word the +/// subset can show) was split into an empty source-font run and a substitute run, so it was +/// written as a two-font `/PCEdLine` with an empty `Tj` in the line's font; read back, the line +/// was the substitute's alone, and the next edit took Helvetica for the line's own font. It is +/// substituted as a whole instead, as a line with no word in the source font always was. +#[test] +fn a_leading_space_does_not_make_a_source_font_run() { + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + // With a word in the source font after it, the line is still written in both. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " ab cab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && !content.contains("() Tj"), "{content}"); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab cab", "F2")]); + // The spaces the subset has no glyph for are moves, each one kept: the leading one before + // the first word, two between words as two. + let flat = |doc: &Document| String::from_utf8_lossy(&page_content_bytes(doc, 0)).split_whitespace().collect::>().join(" "); + assert!(flat(&doc).contains("/F2 10 Tf [-330] TJ (ab) Tj"), "{}", flat(&doc)); + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, "ab ba cab").unwrap(); + let doc = reopen(&doc); + assert!(flat(&doc).contains("(ab) Tj [-330] TJ [-330] TJ (ba) Tj"), "{}", flat(&doc)); + // Before a substitute word, the leading spaces are the substitute's to show, all of them. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("( cab) Tj"), "{content}"); +} + +/// Review on #2955: a paragraph rewrite keeps the `/PCEdLine` delimiters of a line an earlier +/// replacement wrote in two fonts, so a rewritten paragraph's rows can end up inside one. A +/// later replacement of one of those rows must not take the sequence's other rows with it. +#[test] +fn replacing_one_row_of_a_sequence_keeps_the_other_rows() { + let content = "BT /F2 10 Tf 72 600 Td /PCEdLine BMC /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj 0 -20 Td /F2 10 Tf (ba) Tj EMC ET"; + let rows = text::text_lines(&text_page(content), 0).unwrap(); + assert_eq!(rows.iter().map(|l| l.text.trim()).collect::>(), ["ab cab", "ba"]); + for (row, text, other) in [(0, "ab ab", "ba"), (1, "ab", "ab cab")] { + let mut doc = text_page(content); + text::replace_line(&mut doc, 0, row, text).unwrap(); + let doc = reopen(&doc); + let after: Vec = text::text_lines(&doc, 0).unwrap().iter().map(|l| l.text.trim().to_string()).collect(); + assert!(after.iter().any(|t| t == text) && after.iter().any(|t| t == other), "replacing row {row}: {after:?}"); + } +} + +/// Review on #2955: a row inside a shared `/PCEdLine` sequence that starts with a substitute run +/// has the substitute's font in effect at its first operator. Replaced by text the row's own font +/// shows, it must set that font again, or the new text is drawn in the substitute. +#[test] +fn a_row_replaced_inside_a_shared_sequence_starts_in_its_own_font() { + // A real two-font line, starting with the substitute run, then a second row put inside its + // sequence, as a paragraph rewrite that keeps the delimiters can leave it. + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, " cab ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(content.contains("/PCEdLine") && content.contains("( cab) Tj"), "{content}"); + let mut doc = doc; + let shared = content.replacen("EMC", "0 -20 Td (ba) Tj EMC", 1); + let stream = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), shared.into_bytes())))); + set_page_contents(&mut doc, stream, None); + let rows = text::text_lines(&doc, 0).unwrap(); + assert_eq!(rows.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("cab ab", "F2"), ("ba", "F2")]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.iter().map(|l| (l.text.trim(), l.font.as_str())).collect::>(), [("ab", "F2"), ("ba", "F2")]); +} + +/// Review on #2955: a sequence holding one row can also hold that row's positioning and text +/// state, as a paragraph rewrite writes it inside kept delimiters. Replacing the row drops the +/// delimiters, fonts and text, not the `Tm` that places it. +#[test] +fn replacing_the_only_row_of_a_sequence_keeps_its_position() { + let content = "BT /F2 10 Tf /PCEdLine BMC 1 0 0 1 72 600 Tm /F2 10 Tf (ab) Tj /F1 10 Tf ( cab) Tj EMC ET"; + let mut doc = text_page(content); + let before = text::text_lines(&doc, 0).unwrap(); + assert_eq!(before.iter().map(|l| l.text.trim()).collect::>(), ["ab cab"]); + text::replace_line(&mut doc, 0, 0, "ab").unwrap(); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert!(!content.contains("PCEdLine") && !content.contains("cab"), "{content}"); + let after = text::text_lines(&doc, 0).unwrap(); + assert_eq!(after.len(), 1, "{content}"); + let (was, now) = (before[0].rect, after[0].rect); + assert!((was[0] - now[0]).abs() < 0.01 && (was[1] - now[1]).abs() < 0.01, "moved from {was:?} to {now:?}: {content}"); +} + #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); @@ -5561,6 +5956,20 @@ fn replacing_a_line_keeps_a_distinct_nearby_line() { } } +#[test] +fn text_blocks_split_at_headings_and_blank_lines() { + let page = |size: u32, gap: u32, words: &[&str], top: u32| -> String { + words.iter().enumerate().map(|(i, w)| format!("BT /F1 {size} Tf 40 {} Td ({w}) Tj ET\n", top - i as u32 * gap)).collect() + }; + let count = |content: String| text::text_blocks(&text_page(&content), 0).unwrap().len(); + let heading = page(12, 14, &["First line of text", "second line here", "third line ends"], 340) + + &page(12, 14, &["HEADING"], 340 - 28 - 24) + + &page(12, 14, &["Next paragraph one", "next two", "next three"], 340 - 28 - 24 - 24); + assert_eq!(count(heading), 3); + assert_eq!(count(page(12, 14, &["Para one a", "para one b"], 340) + &page(12, 14, &["Para two a", "para two b"], 340 - 42)), 2); + assert_eq!(count(page(24, 29, &["VISIBLE", "HIDDEN"], 340)), 1); +} + #[test] fn editing_text_keeps_an_overlapping_watermark_and_covered_paragraphs() { let listed = |doc: &Document| text::text_lines(doc, 0).unwrap().into_iter().map(|l| l.text).collect::>(); @@ -9019,6 +9428,52 @@ fn removing_marks_strips_wrappers_that_closed_open_state() { assert_eq!(streams(&doc, 0), ["q 2 0 0 2 0 0 cm"]); } +/// Fix hairlines: zero-width strokes and strokes thinner than the threshold on +/// the page (after the CTM) get the chosen width, in page content and the forms it draws; wider +/// lines, and glyphs and patterns unless asked, are left alone. +#[test] +fn hairlines_are_widened_in_page_content_and_forms() { + use crate::hairlines::{Hairlines, fix_hairlines}; + let mut doc = fixture(); + let page = page_list(&doc)[0].clone(); + // A form drawn at half scale with a 0.1 pt line (0.05 pt on the page) and a 4 pt line. + let mut fd = Dict::new(); + fd.set(b"Type".to_vec(), Object::name("XObject")); + fd.set(b"Subtype".to_vec(), Object::name("Form")); + fd.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 100.0, 100.0].iter().map(|v| Object::Real(*v)).collect())); + let form = doc.add(Object::Stream(Stream::flate(fd, b"0.1 w 0 0 m 50 50 l S 4 w 0 50 m 50 0 l S"))); + let content = b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S q 0.5 0 0 0.5 0 0 cm 0.2 w 0 0 m 10 10 l S /Fm1 Do Q 0.5 w 10 30 m 100 30 l S"; + let cs = doc.add(Object::Stream(Stream::flate(Dict::new(), content))); + let mut xo = Dict::new(); + xo.set(b"Fm1".to_vec(), Object::Ref(form)); + let mut res = Dict::new(); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Ref(cs)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + }) + .unwrap(); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + // Page: the 0 w line and the 0.2 w line at half scale (0.1 pt); the 2 pt and 0.5 pt lines stay. + // Form: the 0.1 w line (0.05 pt on the page); the 4 pt line stays. + assert_eq!(fixed, crate::hairlines::Fixed { lines: 3, pages: 1, elsewhere: 0 }); + let flat = |b: &[u8]| String::from_utf8_lossy(b).split_whitespace().collect::>().join(" "); + let text = flat(&page_content_bytes(&doc, 0)); + assert!(text.contains("0.72 w 10 10 m 100 10 l S"), "zero width → 0.72 pt: {text}"); + assert!(text.contains("2 w 10 20 m 100 20 l S") && text.contains("0.5 w 10 30 m 100 30 l S"), "wider lines untouched: {text}"); + assert!(text.contains("1.44 w 0 0 m 10 10 l S"), "under a half-scale CTM the width is doubled: {text}"); + let Object::Stream(fs) = &*doc.get(form) else { panic!() }; + let ft = flat(&fs.decoded().unwrap()); + assert!(ft.contains("1.44 w 0 0 m 50 50 l S") && ft.contains("4 w 0 50 m 50 0 l S"), "{ft}"); + // Nothing left to fix: a second run changes nothing. + assert_eq!(fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap().lines, 0); + // A stricter threshold catches the 0.5 pt line too, with a custom width. + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + assert!(flat(&page_content_bytes(&doc, 0)).contains("1 w 10 30 m 100 30 l S")); + assert!(fix_hairlines(&mut doc, &[0], &Hairlines { width: 0.0, ..Hairlines::default() }).is_err()); +} + /// #3087: flattening comments leaves alone what isn't a comment: links, and the non-markup /// multimedia and print-production annotations (3D, Screen, Movie, RichMedia, PrinterMark, /// TrapNet, Watermark), which it used to bake into the page and delete with their data. @@ -9314,7 +9769,7 @@ fn vertical_page(content: &str) -> Document { ]); let unicode = doc.add(Object::Stream(Stream::flate( Dict::new(), - b"1 begincodespacerange <0000> endcodespacerange 6 beginbfchar <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", + b"1 begincodespacerange <0000> endcodespacerange 7 beginbfchar <0020> <0020> <0041> <0041> <0042> <0042> <0043> <0043> <0044> <0044> <0045> <0045> <0046> <0046> endbfchar", ))); font.set(b"ToUnicode".to_vec(), Object::Ref(unicode)); font.set( @@ -10980,8 +11435,140 @@ fn carriage_returns_break_rows_on_the_styled_path() { fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() { let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET"); assert_eq!(text::replace_block(&mut doc, 0, 0, "ab ba ab").unwrap().substituted, None); - let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned(); + let reopened = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&reopened, 0)).into_owned(); assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); + assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, "ab ba ab"); +} + +/// Spaces drawn as TJ gaps in a subset without a space glyph are wrapped in `/ActualText`, so +/// repeated spaces and leading indents read back through `text_lines`, `editor_text`, and text +/// extraction, and survive a second edit without shifting the line or miscalibrating its scale +/// (#4229). +#[test] +fn spaces_and_indents_in_a_subset_without_a_space_glyph_round_trip_across_edits() { + for paragraph in [true, false] { + for typed in [" ab ba", "ab ba ab", " ab ba ab "] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj /F1 12 Tf ( ) Tj /F2 12 Tf (ba) Tj ET"); + let edit = if paragraph { text::replace_block(&mut doc, 0, 0, typed) } else { text::replace_line(&mut doc, 0, 0, typed) }; + assert_eq!(edit.unwrap().substituted, None, "{typed:?}"); + let mut doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines.len(), 1, "{typed:?}: {lines:?}"); + assert_eq!(lines[0].text, typed, "{typed:?}"); + assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect); + let blocks = text::text_blocks(&doc, 0).unwrap(); + assert_eq!(blocks[0].editor_text, typed, "{typed:?}"); + + // Re-edit the reopened document and verify the origin, gap count, and scale stay exact. + let next = " ba ab"; + let edit2 = if paragraph { text::replace_block(&mut doc, 0, 0, next) } else { text::replace_line(&mut doc, 0, 0, next) }; + assert_eq!(edit2.unwrap().substituted, None, "{typed:?}"); + let doc = reopen(&doc); + let lines = text::text_lines(&doc, 0).unwrap(); + assert_eq!(lines[0].text, next, "{typed:?}"); + assert!((lines[0].rect[0] - 72.0).abs() < 0.01, "{typed:?}: {:?}", lines[0].rect); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}"); + } + } + + // Styled multi-row path: untouched calibration and row breaks keep 100 Tz and every space. + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None); + let mut doc = reopen(&doc); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab"); + assert_eq!(text::replace_block(&mut doc, 0, 0, " ab ba\nba ab").unwrap().substituted, None); + let doc = reopen(&doc); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let scales: Vec<&str> = content.lines().filter(|l| l.ends_with(" Tz")).collect(); + assert!(scales.iter().all(|l| *l == "100 Tz"), "{scales:?}: {content}"); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, [" ab ba", "ba ab"]); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " ab ba\nba ab"); + + // Two subsets of the same face (/F2 holds 'a' only, /F3 holds 'b' only, neither has ' '): + // spaces adopt their neighbour's subset on the row so no space is stranded in a glyph-less run. + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (a) Tj /F1 12 Tf ( ) Tj /F3 12 Tf (b) Tj ET"); + doc.update_dict(pdfcraft_cos::ObjRef::new(6, 0), |f| { + f.set(b"FirstChar".to_vec(), Object::Int(97)); + f.set(b"LastChar".to_vec(), Object::Int(97)); + f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(500)])); + }) + .unwrap(); + doc.update_dict(pdfcraft_cos::ObjRef::new(7, 0), |f| { + f.set(b"FirstChar".to_vec(), Object::Int(98)); + f.set(b"LastChar".to_vec(), Object::Int(98)); + f.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(520)])); + }) + .unwrap(); + assert_eq!(text::replace_block(&mut doc, 0, 0, " b a \n a b ").unwrap().substituted, None); + let doc = reopen(&doc); + let lines: Vec = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect(); + assert_eq!(lines, [" b a ", " a b "]); + assert_eq!(text::text_blocks(&doc, 0).unwrap()[0].editor_text, " b a \n a b "); + + // A RangeStyle covering only spaces in a subset without a space glyph joins an adjacent + // glyph-bearing segment so its space count survives save/reopen. + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let style = text::BlockStyle { + ranges: vec![text::RangeStyle { + range: 2..5, + family: None, + resource: None, + size: None, + color: Some([1.0, 0.0, 0.0]), + underline: None, + char_spacing: None, + scale: None, + }], + ..Default::default() + }; + assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None); + let doc = reopen(&doc); + assert_eq!(text::text_lines(&doc, 0).unwrap()[0].text, "ab ba"); +} + +/// A space that joins a neighbour's subset for `/ActualText` keeps the underline of its own +/// style, not the neighbour's, and still reads back as that space. +#[test] +fn a_reassigned_subset_space_keeps_its_own_underline() { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + let style = text::BlockStyle { + ranges: vec![text::RangeStyle { + range: 2..5, + family: None, + resource: None, + size: None, + color: None, + underline: Some(true), + char_spacing: None, + scale: None, + }], + ..Default::default() + }; + assert_eq!(text::rewrite_block(&mut doc, 0, 0, Some("ab ba"), &style).unwrap().substituted, None); + let doc = reopen(&doc); + let line = text::text_lines(&doc, 0).unwrap().into_iter().next().expect("a line"); + assert_eq!(line.text, "ab ba"); + let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned(); + let mut widths = Vec::new(); + let mut from = None; + for row in content.lines() { + if let Some(rest) = row.strip_suffix(" m") { + from = rest.split_whitespace().next().and_then(|s| s.parse::().ok()); + } else if let Some(rest) = row.strip_suffix(" l") + && let (Some(x0), Some(x1)) = (from.take(), rest.split_whitespace().next().and_then(|s| s.parse::().ok())) + { + widths.push((x1 - x0).abs()); + } + } + let text_width = line.rect[2] - line.rect[0]; + assert!(widths.iter().any(|w| *w > 1.0), "the spaces lost their underline: {widths:?}\n{content}"); + assert!( + widths.iter().all(|w| *w < text_width * 0.6), + "the space underline covered the neighbouring glyphs ({text_width}): {widths:?}\n{content}" + ); } /// #6131: a subset that writes width 0 for its unused space (pdfTeX does) has no space glyph. A word added to diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 2dbf302685..c08edb1e86 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -6,8 +6,9 @@ //! dropped, and every positioning operator stays, so the lines after it keep their places //! (`Td`, `T*` and friends move from the line matrix, which showing text doesn't change). //! -//! The new text uses the line's own font when every character has a code and a glyph in it; -//! otherwise it is set in Helvetica (WinAnsi), and the result says the font was substituted. +//! The new text uses the line's own font when every character has a code and a glyph in it. +//! Otherwise the words the font can show keep it and only the others are set in Helvetica +//! (WinAnsi), run by run, and the result says the font was substituted. //! Reflowing a paragraph to a new width is not done here (see `edit.text-reflow`). use std::collections::HashMap; @@ -132,6 +133,9 @@ pub struct TextLine { /// Chinese/Japanese word split), from `/PCSoft`'s `/N`; 1 when the marker carried none /// (#2529). Meaningless otherwise. soft_gap: usize, + /// The `/PCEdLine` marked-content sequence a replacement mixing fonts wrote the line in + /// (see [`fallback::continues_font`]). + edited: Option, } impl TextLine { diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 789ffc5456..91e5258b31 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,4 +1,4 @@ -use super::interp::Ts; +use super::interp::{Shown, Ts}; use super::lines::page_dict; use super::{ CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, @@ -148,6 +148,23 @@ fn fonts_shown(doc: &Document, page: &Dict, resources: &Dict) -> Option bool { + same_font(line, &s.font, &s.base_font, s.face.as_deref()) || (s.edited.is_some() && line.edited == s.edited) +} + +/// A line that began with a substitute run takes the source font of a later run, so the next +/// replacement starts from the line's own font again. +pub(super) fn adopt_source_font(line: &mut TextLine, s: &Shown) { + if line.edited.is_some() && line.font.starts_with("PCEd") && !s.font.starts_with(b"PCEd") { + line.font = String::from_utf8_lossy(&s.font).into_owned(); + line.base_font = s.base_font.clone(); + line.face = s.face.clone(); + } +} + /// Whether a run shown in `font` (a resource name), with `/BaseFont` `base_font` and the Japanese /// or Hebrew fallback face it subsets, if any, continues `line`: the same font resource, another /// resource for the same font (a writer may split one font into several subsets, #3661), or @@ -720,9 +737,40 @@ pub(crate) fn source_show(metrics: &Metrics, text: &str, size: f64, space: f64, out.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); } } + if out.iter().any(|op| op.is("Tj")) { + let logical = crate::bidi::to_logical(&if metrics.visual_order { crate::indic::to_logical(text) } else { text.to_owned() }); + let mut properties = Dict::new(); + properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&logical))); + out.insert(0, Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)])); + out.push(Op::new("EMC", Vec::new())); + } Ok(out) } +/// `text` split into runs of words `metrics` can show (`true`) and words it can't (`false`), in +/// order. A word is shown in one font, so a word with any character the font lacks goes to the +/// substitute whole rather than switching fonts letter by letter. Spaces join the run they are +/// in, since [`source_show`] draws spaces the font lacks with a displacement. +pub(super) fn source_runs(metrics: &Metrics, text: &str) -> Vec<(bool, String)> { + let mut runs: Vec<(bool, String)> = Vec::new(); + // Spaces before the first word go with it: on their own they would make a run of the line's + // font with nothing to show (review on #2955). + let mut lead = String::new(); + for (i, word) in text.split(' ').enumerate() { + let piece = if i == 0 { word.to_string() } else { format!(" {word}") }; + if runs.is_empty() && word.is_empty() { + lead.push_str(&piece); + continue; + } + let own = word.is_empty() || metrics.encode(word).is_some(); + match runs.last_mut() { + Some((last, run)) if *last == own || word.is_empty() => run.push_str(&piece), + _ => runs.push((own, std::mem::take(&mut lead) + &piece)), + } + } + runs +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 5114a76263..bc0bce0c7b 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -359,6 +359,7 @@ pub(super) struct Shown { /// `/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, + pub(super) edited: Option, } impl Shown { @@ -492,6 +493,9 @@ pub(super) struct Carry { pub(super) layers: Vec>>, /// Bytes of glyph text the rest of the page may still produce (see [`MAX_PAGE_TEXT`]). pub(super) text_left: usize, + /// Whether the last run ended in a space read from a gap, with the next operator a `Tj` or + /// `TJ` that may start with the space glyph that gap widened. + pub(super) gap_space: bool, } /// The most glyph text one page's extraction produces, in bytes. A ToUnicode destination is @@ -522,7 +526,7 @@ impl Carry { dash: (Vec::new(), 0.0), line_cap: 0, }; - Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT } + Carry { ts, stack: Vec::new(), marked: Vec::new(), layers: Vec::new(), text_left: MAX_PAGE_TEXT, gap_space: false } } } @@ -537,6 +541,8 @@ type Invocation = (usize, Vec, Ts); struct MarkedContent { regions: Vec>, layers: Vec>>, + // The `/PCEdLine` sequences around the text (a replacement mixing fonts), innermost last. + edited: Vec>, actual_text: Vec>, } @@ -550,6 +556,7 @@ impl MarkedContent { let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); self.layers .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + self.edited.push(if op.name(0) == Some(b"PCEdLine") { Some(index) } else { self.edited.last().copied().flatten() }); // The property list is inline or named in the resources' /Properties (§14.6.2). let list = logical_text.and_then(|properties| match op.operands.get(1) { Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), @@ -565,34 +572,37 @@ impl MarkedContent { fn end(&mut self, index: usize, shown: &mut Vec) { self.regions.pop(); self.layers.pop(); + self.edited.pop(); if let Some(Some((start, open, logical))) = self.actual_text.pop() { let tail = shown.split_off(start.min(shown.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for item in items { - first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); - first.extra_ops.push(item.op); - first.extra_ops.extend(item.extra_ops); - first.rect = [ - first.rect[0].min(item.rect[0]), - first.rect[1].min(item.rect[1]), - first.rect[2].max(item.rect[2]), - first.rect[3].max(item.rect[3]), - ]; - first.frame = union(first.frame, item.frame); - first.ink.extend(item.ink); + if tail.iter().any(|s| s.glyph_count > 0) { + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); } - first.extra_ops.extend([open, index]); - // Only right-to-left ActualText is already in typing order and must stay out of - // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - shown.push(first); } } } @@ -654,6 +664,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // the next glyph continues the last word. let mut words: Vec<(f64, f64)> = Vec::new(); let mut in_word = false; + // Whether the text ends in a space read from a gap rather than from a glyph. The run just + // before may have ended in one: a justified line can be split over `TJ` and `Tj`. + let mut gap_space = std::mem::take(&mut carry.gap_space); let mut glyph_count = 0_usize; for p in &pieces { match p { @@ -662,13 +675,17 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit glyph_count = glyph_count.saturating_add(1); let shown = m.text_of(code); let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + // A space glyph right after a gap that read as a space is that space: + // LibreOffice stretches a justified line on both sides of each space glyph. + let after_gap = std::mem::take(&mut gap_space); match shown { - Some(t) => { + Some(shown) => { + let t: &str = if after_gap { shown.strip_prefix(' ').unwrap_or(&shown) } else { &shown }; carry.text_left = carry .text_left .checked_sub(t.len()) .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); + text.push_str(t); } None => decodable = false, } @@ -709,6 +726,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit let dy = -n / 1000.0 * ts.size; if -dy > ts.size * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } if dy.abs() > ts.size.abs() * 0.2 { in_word = false; @@ -720,6 +738,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit // A large gap inside TJ reads as a space. if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { text.push(' '); + gap_space = true; } // Tracking and kerning keep a word together; a larger jump // ends it (whatever the sign of the size or `Tz`). @@ -761,8 +780,11 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit .collect(); let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + // Outside an `/ActualText` replacement it must not create a phantom editable + // line or enlarge its neighbours' boxes; inside one (such as a space gap in a + // subset font without a space glyph, #4229) it is part of the span's box and + // operators when the span also paints glyphs. + if !marked.actual_text.iter().any(Option::is_some) && !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { return Ok(()); } out.push(Shown { @@ -809,7 +831,9 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit soft_break: state.soft_break, hard_break: state.hard_break, soft_gap: state.soft_gap, + edited: marked.edited.last().copied().flatten(), }); + carry.gap_space = gap_space; Ok(()) } @@ -843,6 +867,10 @@ pub(super) fn interpret( // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; for (i, op) in ops.iter().enumerate() { + // A gap's space carries only into a `Tj` or `TJ` right after it. + if !matches!(op.op.as_slice(), b"Tj" | b"TJ") { + carry.gap_space = false; + } 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. @@ -1006,6 +1034,7 @@ pub(super) fn interpret( carry.stack = stack; carry.marked = marked.regions; carry.layers = marked.layers; + out.retain(|s| s.glyph_count > 0 || s.actual_text); positioned_tracking(&mut out); Ok(out) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 2755d6bb71..8d0ad80cba 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use super::fallback::{same_font, same_paint}; +use super::fallback::{adopt_source_font, continues_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; @@ -340,7 +340,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in piece == si && lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -374,6 +374,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); // Spaces may use a separate subset font; they do not change the visible style. } else { lines.push(TextLine { @@ -401,6 +402,7 @@ fn text_lines_in(doc: &Document, p: &pdfcraft_model::Page, actual_text: bool, in soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -461,7 +463,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream let bt = pen.bt; lines.last().is_some_and(|l| l.scope == s.scope && l.paint == s.paint && l.logical_order == s.logical_order) && (bt == s.bt || lines.last().is_some_and(|l| compatible_word(l, &s))) - && (s.text.trim().is_empty() || lines.last().is_some_and(|l| same_font(l, &s.font, &s.base_font, s.face.as_deref()))) + && (s.text.trim().is_empty() || lines.last().is_some_and(|l| continues_font(l, &s))) && (s.text.trim().is_empty() || lines.last().is_some_and(|l| { (s.size - l.size).abs() < 0.01 @@ -492,6 +494,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream if l.unreadable_mode.is_none() { l.unreadable_mode = s.state.unreadable.as_deref().map(str::to_owned); } + adopt_source_font(l, &s); } else { lines.push(TextLine { text: s.text.clone(), @@ -518,6 +521,7 @@ fn add_shown(lines: &mut Vec, last: &mut Option, s: Shown, stream soft_break: s.soft_break, hard_break: s.hard_break, soft_gap: s.soft_gap, + edited: s.edited, ops: std::iter::once(s.op).chain(s.extra_ops.iter().copied()).collect(), origin: Origin { tm: s.tm.0, @@ -701,7 +705,14 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec runs.iter().filter(|(own, _)| !*own).map(|(_, run)| run.as_str()).collect(), + None => fallback_shown.clone(), + }; let win = pdfcraft_fonts::win_ansi(&fallback_shown); // WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing. - let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); - if fallback_shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { + let checked = pdfcraft_fonts::win_ansi(&for_substitute); + let back: String = checked.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect(); + if for_substitute.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') { return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font))); } // The size in text space: the current Tf's size. @@ -121,8 +135,36 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi let family = source_family(&target.base_font); let base = family.base_font(target.bold, target.italic); let substitute_name = format!("PCEd{}", base.replace('-', "")); - replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); - replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + match mixed { + Some(runs) => { + let space = font.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + // Marked as one line, so it reads back as one (see `continues_font`). + replacement.push(Op::new("BMC", vec![Object::name("PCEdLine")])); + for (own, run) in runs { + if own { + replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if let Some(m) = font.as_ref() { + replacement.extend(source_show( + m, + &run, + target.origin.state.font.as_ref().map_or(target.size, |(_, size)| *size), + space, + target.origin.state.char_spacing, + target.origin.state.word_spacing, + )?); + } + } else { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(pdfcraft_fonts::win_ansi(&run)))])); + } + } + replacement.push(Op::new("EMC", Vec::new())); + } + None => { + replacement.push(Op::new("Tf", vec![Object::name(&substitute_name), pdfcraft_content::num(size)])); + replacement.push(Op::new("Tj", vec![Object::String(PdfString::literal(win))])); + } + } replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); substituted = Some(base.to_string()); let mut f = Dict::new(); @@ -135,6 +177,43 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi } } // Rebuild: the line's first operator becomes the replacement, its others go. + // A line an earlier replacement wrote in two fonts is in a `/PCEdLine` sequence of its own, + // with its fonts and the moves standing in for spaces the source font lacks: those go + // with the line, or each edit would nest one more (review on #2955). The line then starts in + // its own font, set again where the sequence was. + if let Some((open, close)) = target.edited.and_then(|open| Some((open, matching_emc(&ops, open)?))) { + // Only when the sequence holds nothing but this line: a paragraph rewrite keeps the + // delimiters, so other rows can be inside it (review on #2955). Then the sequence stays + // as it is, since it is also what reads its own two-font line back as one, and only + // this line's operators go. + let others_inside = lines.iter().any(|l| l.ops.iter().any(|o| (open..=close).contains(o) && !drops.contains(o))); + // Either way the replacement starts in the line's own font: inside the sequence the font + // in effect at the line's first operator can be the substitute's, which a replacement + // the line's font shows by itself would otherwise inherit (review on #2955). + let size = font_size_before(&ops, first).unwrap_or(target.size); + replacement.insert(0, Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)])); + if !others_inside { + // What goes is what the replacement stands in for: the marked-content delimiters + // (nested ones too, which come in pairs), the fonts and the text-showing operators + // (the line's runs and the moves between them). A paragraph rewrite can also write a + // row's positioning and text state (`Tm`, `Td`, `Tw`, ...), a `q`/`Q` or a `BT`/`ET` + // inside kept delimiters; those stay, or the replacement loses its place and the + // content its balance (review on #2955). + drops.extend( + (open..=close) + .filter(|&i| ops.get(i).is_some_and(|o| [b"BMC".as_slice(), b"BDC", b"EMC", b"Tf", b"Tj", b"TJ"].contains(&o.op.as_slice()))), + ); + let own_font = |i: usize| ops.get(i).is_some_and(|o| o.is("Tf") && o.name(0) == Some(target.font.as_bytes()) && o.num(1) == Some(size)); + // The same font set just before the sequence (by the last edit) is set again here. + if let Some(i) = open.checked_sub(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + // And set back after it: the replacement ends in that font too. + if let Some(i) = close.checked_add(1).filter(|&i| own_font(i)) { + drops.insert(i); + } + } + } let new_data = splice(&pieces, &ops, |i| if i == first { (std::mem::take(&mut replacement), false) } else { (Vec::new(), !drops.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data)?; let page_ref = p.obj; @@ -150,6 +229,24 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi Ok(LineEdit { substituted }) } +/// The `EMC` that closes the marked-content sequence `BMC`/`BDC` operator `open` begins. +fn matching_emc(ops: &[Op], open: usize) -> Option { + let mut depth = 0usize; + for (i, op) in ops.iter().enumerate().skip(open) { + match op.op.as_slice() { + b"BMC" | b"BDC" => depth = depth.saturating_add(1), + b"EMC" => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(i); + } + } + _ => {} + } + } + None +} + /// The size of the last `Tf` before operator `at`. fn font_size_before(ops: &[Op], at: usize) -> Option { ops[..at].iter().rev().find(|o| o.is("Tf")).and_then(|o| o.num(1)) @@ -649,12 +746,17 @@ impl LineWriter<'_> { } // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). let visual = crate::bidi::to_visual(shown); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - self.show_piece(piece, line, tw, cursor, ops)?; - if stretch > 0.0 && piece.ends_with(' ') { - ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + if let Some(metrics) = self.source.filter(|m| m.encode(" ").is_none()) { + let indic_line = if metrics.visual_order { crate::indic::to_visual(&visual) } else { visual }; + ops.extend(source_show(metrics, &indic_line, self.size, self.space, self.char_spacing, tw + stretch)?); + } else { + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } } } let tw = tw + stretch; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 1dcb9a9373..72a516292d 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -331,7 +331,19 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec { 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); + // A lone row with none of those: the step to the row after it is the leading of the + // paragraph it opens, and the gap above it is a heading's or a blank line's when it + // is much wider (#3577). + let following = rows.get(row_at.saturating_add(1)).and_then(|next| { + let nfirst = next.first().and_then(|i| lines.get(*i))?; + let ngap = first.frame_origin().1 - nfirst.frame_origin().1; + (same_plane(first, nfirst) && ngap > tol * 0.8 && ngap < tol * 2.05).then_some(ngap) + }); + let max_gap = own_leading + .filter(|lead| *lead > tol * 0.4) + .or(stated) + .or(repeated) + .map_or_else(|| following.map_or(tol * 2.05, |lead| (lead * 1.35).min(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)) diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index d1e0738a49..487bb0e20e 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -354,6 +354,17 @@ struct StyledLines<'a> { } type StyledChar = (char, usize, String); + +/// End of a contiguous run sharing both its style and font resource. +fn styled_run_end(items: &[StyledChar], start: usize) -> usize { + let Some(first) = items.get(start) else { return start }; + let mut end = start + 1; + while items.get(end).is_some_and(|next| next.1 == first.1 && next.2 == first.2) { + end += 1; + } + end +} + type StyledRows = Vec>; type RowRanges = Vec>; enum ShapedFont { @@ -370,6 +381,7 @@ type ShapedRunCache = HashMap<(usize, usize), ShapedText>; struct ShapedRows { runs: ShapedRunCache, advances: HashMap<(usize, usize), f64>, + char_advances: HashMap<(usize, usize), Vec>, } fn prepare_shaped_rows( @@ -382,15 +394,15 @@ fn prepare_shaped_rows( ) -> Result { let mut runs = HashMap::new(); let mut advances = HashMap::new(); + let mut char_advances = HashMap::new(); for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { let mut at = 0; while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { - end += 1; - } + let end = styled_run_end(row, at); let segment = row.get(at..end).unwrap_or_default(); - advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let widths: Vec = segment.iter().map(&advance).collect(); + advances.insert((row_index, at), widths.iter().copied().sum()); + char_advances.insert((row_index, at), widths); let text: String = segment.iter().map(|character| character.0).collect(); if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? { runs.insert((row_index, at), shaped); @@ -398,7 +410,7 @@ fn prepare_shaped_rows( at = end; } } - Ok(ShapedRows { runs, advances }) + Ok(ShapedRows { runs, advances, char_advances }) } type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec)>>; @@ -1297,6 +1309,70 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +fn segment_underline_runs( + widths: &[f64], + segment_width: f64, + x: f64, + default_style: usize, + row_styles: Option<&[Option]>, + pos: usize, +) -> Vec<(usize, f64, f64)> { + let width_sum = widths.iter().copied().sum::(); + let width_scale = if width_sum > 1e-6 { segment_width / width_sum } else { 0.0 }; + let mut cursor = x; + let mut runs: Vec<(usize, f64, f64)> = Vec::new(); + for (offset, width) in widths.iter().enumerate() { + let style_index = row_styles.and_then(|styles| styles.get(pos.saturating_add(offset))).and_then(|style| *style).unwrap_or(default_style); + let next = cursor + *width * width_scale; + match runs.last_mut() { + Some((prev, _, to)) if *prev == style_index => *to = next, + _ => runs.push((style_index, cursor, next)), + } + cursor = next; + } + runs +} + +/// Keep spaces in a subset without a space glyph attached to an adjacent glyph-bearing segment on +/// the same row so `source_show` wraps them in one `/ActualText` span (#4229). +/// +/// The returned slot is the space's style index from before that reassignment, where the +/// grouping key changed. Underlines stay with that style; the row itself keeps the neighbour's +/// key so the space is not a group of its own. +fn assign_subset_space_fonts(row: &mut [(char, usize, String)], metrics: &std::collections::BTreeMap) -> Vec> { + let mut right_any = vec![None; row.len()]; + let mut next_any: Option<(usize, String)> = None; + for (i, item) in row.iter().enumerate().rev() { + if item.0 != ' ' { + next_any = Some((item.1, item.2.clone())); + } else if let Some(slot) = right_any.get_mut(i) { + *slot = next_any.clone(); + } + } + let mut prev_any: Option<(usize, String)> = None; + let mut underline_style = vec![None; row.len()]; + for (i, item) in row.iter_mut().enumerate() { + if item.0 != ' ' { + prev_any = Some((item.1, item.2.clone())); + continue; + } + if metrics.get(&item.2).is_none_or(|m| m.encode(" ").is_some()) { + continue; + } + if let Some((style, font)) = prev_any.as_ref().or_else(|| right_any.get(i).and_then(Option::as_ref)) { + let original = item.1; + item.1 = *style; + item.2 = font.clone(); + if let Some(slot) = underline_style.get_mut(i) + && item.1 != original + { + *slot = Some(original); + } + } + } + underline_style +} + /// 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)> { @@ -1584,10 +1660,7 @@ pub(crate) fn rewrite_styled_block( let mut width = 0.0; let mut at = 0; while let Some(item) = items.get(at) { - let mut end = at + 1; - while items.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } + let end = styled_run_end(items, at); let segment = items.get(at..end).unwrap_or_default(); let string: String = segment.iter().map(|v| v.0).collect(); if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? { @@ -1612,7 +1685,8 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let (rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; + let (mut rows, row_ranges, row_lines, row_soft, row_gap) = wrap_styled_rows(&chars, width, measure)?; + let space_styles: Vec<_> = rows.iter_mut().map(|row| assign_subset_space_fonts(row, &metrics)).collect(); let streams = content_streams(doc, &p.dict)?; let pieces = crate::text::joined(&streams); let ops = pieces.parse().ops; @@ -1643,7 +1717,7 @@ pub(crate) fn rewrite_styled_block( validate_contextual_rows(&rows, &kept)?; let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?; - let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let ShapedRows { runs: shaped_runs, advances: segment_advances, char_advances } = shaped; let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; @@ -1720,10 +1794,7 @@ pub(crate) fn rewrite_styled_block( while pos < row.len() { let Some(item) = row.get(pos) else { break }; let source = lines.get(item.1).unwrap_or(first); - let mut end = pos + 1; - while row.get(end).is_some_and(|other| other.1 == item.1 && other.2 == item.2) { - end += 1; - } + let end = styled_run_end(row, pos); let segment = row.get(pos..end).unwrap_or_default(); let string: String = segment.iter().map(|c| c.0).collect(); let m = metrics.get(&item.2).ok_or_else(|| EditError::Invalid("the paragraph font is missing".into()))?; @@ -1779,11 +1850,20 @@ pub(crate) fn rewrite_styled_block( )?); block_ops.push(Op::new("ET", vec![])); } - if underlines.get(&item.1).copied().or(style.underline) == Some(true) { - let [r, g, b] = overrides.get(&item.1).and_then(|r| r.color).or(style.color).unwrap_or(source.color); - let underline_size = size_of(source, item.1) * source.origin.k; - let (x0, y0) = back.apply(x - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); - let (x1, y1) = back.apply(x + segment_width - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + // A space reassigned onto a neighbour keeps that neighbour's grouping key, and its + // own style for the underline. Stroke each run of one underline style separately. + let widths = char_advances.get(&(row_index, pos)).map(Vec::as_slice).unwrap_or(&[]); + for (style_index, from, to) in + segment_underline_runs(widths, segment_width, x, item.1, space_styles.get(row_index).map(Vec::as_slice), pos) + { + let source = lines.get(style_index).unwrap_or(first); + if underlines.get(&style_index).copied().or(style.underline) != Some(true) || to <= from { + continue; + } + let [r, g, b] = overrides.get(&style_index).and_then(|r| r.color).or(style.color).unwrap_or(source.color); + let underline_size = size_of(source, style_index) * source.origin.k; + let (x0, y0) = back.apply(from - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); + let (x1, y1) = back.apply(to - o.ctm[4], baseline - o.ctm[5] - underline_size * 0.12); block_ops.push(Op::new("RG", vec![n(r), n(g), n(b)])); block_ops.push(Op::new("w", vec![n(underline_size * 0.06 / o.ctm[2].hypot(o.ctm[3]).max(1e-6))])); block_ops.push(Op::new("m", vec![n(x0), n(y0)])); @@ -1819,3 +1899,19 @@ pub(crate) fn rewrite_styled_block( crate::draw_on_top(doc, &p, o.ctm, &on_top)?; Ok(LineEdit { substituted }) } + +#[cfg(test)] +mod tests { + use super::styled_run_end; + + #[test] + fn styled_runs_stop_at_style_or_font_changes() { + let items = vec![('a', 0, "F1".into()), (' ', 0, "F1".into()), ('b', 1, "F1".into()), ('c', 1, "F2".into())]; + assert_eq!(styled_run_end(&items, 0), 2); + assert_eq!(styled_run_end(&items, 1), 2); + assert_eq!(styled_run_end(&items, 2), 3); + assert_eq!(styled_run_end(&items, 3), 4); + assert_eq!(styled_run_end(&items, 4), 4); + assert_eq!(styled_run_end(&[], 0), 0); + } +} diff --git a/crates/edit/tests/hairlines_extgstate_lw.rs b/crates/edit/tests/hairlines_extgstate_lw.rs new file mode 100644 index 0000000000..a7e3cb4333 --- /dev/null +++ b/crates/edit/tests/hairlines_extgstate_lw.rs @@ -0,0 +1,88 @@ +//! Fix hairlines follows the line width an ExtGState's `/LW` sets through `gs`: a stroke it +//! leaves thin is found, and the width put back after a widened stroke is that one. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +/// Page `i`'s content (a single stream), flattened. +fn page_text(doc: &Document, i: usize) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(i); + flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap()) +} + +/// A one-page document drawing `content`, with `/GS0` setting `/LW lw` and `/GS1` no width. +fn with_gstate(lw: &str, content: &[u8]) -> Document { + let page = format!( + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /ExtGState << /GS0 << /Type /ExtGState /LW {lw} >> /GS1 << /Type /ExtGState /CA 1 >> >> >> >>" + ); + pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + (page.as_str(), None), + ("", Some(content)), + ]) +} + +#[test] +fn the_width_put_back_is_the_one_gs_set() { + // 2 pt under a tenth scale is 0.2 pt on the page: both strokes are widened, and each gets + // the 2 pt back after it. + let mut doc = with_gstate("2", b"/GS0 gs 0.1 0 0 0.1 0 0 cm 0 0 m 1 0 l S 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 2); + let t = page_text(&doc, 0); + assert_eq!(t.matches("l S 2 w").count(), 2, "{t}"); + assert!(!t.contains(" 1 w"), "no stroke is put back to the default width: {t}"); +} + +#[test] +fn a_zero_width_set_by_gs_is_a_hairline() { + let mut doc = with_gstate("0", b"/GS0 gs 0 0 m 1 0 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(page_text(&doc, 0), "/GS0 gs 0.72 w 0 0 m 1 0 l S 0 w"); +} + +#[test] +fn a_gs_without_a_width_keeps_the_one_in_effect() { + let mut doc = with_gstate("2", b"0.1 w /GS1 gs 0 0 m 1 0 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(page_text(&doc, 0), "0.1 w /GS1 gs 0.72 w 0 0 m 1 0 l S 0.1 w"); +} diff --git a/crates/edit/tests/hairlines_form_inherits_width.rs b/crates/edit/tests/hairlines_form_inherits_width.rs new file mode 100644 index 0000000000..a8ab4e456f --- /dev/null +++ b/crates/edit/tests/hairlines_form_inherits_width.rs @@ -0,0 +1,75 @@ +//! Fix hairlines measures a form's strokes with the line width in effect where the form is +//! drawn (a form inherits the graphics state of its `Do`), and puts that width back after a +//! stroke it widens, in forms drawn by forms too. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +const CATALOG: (&str, Option<&[u8]>) = ("<< /Type /Catalog /Pages 2 0 R >>", None); +const PAGES: (&str, Option<&[u8]>) = ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None); +const PAGE: (&str, Option<&[u8]>) = ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None); +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; + +#[test] +fn a_form_without_its_own_width_is_measured_with_the_width_at_its_do() { + // 0.1 pt is in effect where the form is drawn; the form sets no width of its own. + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w"); +} + +#[test] +fn a_widened_stroke_in_a_form_gets_the_inherited_width_back() { + // 0.4 pt at half scale is 0.2 pt on the page; after the stroke the form's later strokes + // must draw with the 0.4 they inherited, not the 1 a fresh graphics state has. + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.5 0 0 0.5 0 0 cm 0.4 w /Fm0 Do")), (FORM, Some(b"0 0 m 1 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 5, generation: 0 }), "1.44 w 0 0 m 1 0 l S 0.4 w"); +} + +#[test] +fn a_form_drawn_by_a_form_inherits_the_width_too() { + let fm0 = "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /XObject << /Fm1 6 0 R >> >>"; + let mut doc = pdf(&[CATALOG, PAGES, PAGE, ("", Some(b"0.1 w /Fm0 Do")), (fm0, Some(b"/Fm1 Do")), (FORM, Some(b"0 0 m 10 0 l S"))]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, ObjRef { num: 6, generation: 0 }), "0.72 w 0 0 m 10 0 l S 0.1 w"); +} diff --git a/crates/edit/tests/hairlines_paths.rs b/crates/edit/tests/hairlines_paths.rs new file mode 100644 index 0000000000..a9f6720ce0 --- /dev/null +++ b/crates/edit/tests/hairlines_paths.rs @@ -0,0 +1,83 @@ +//! Fix hairlines gives a thin stroke a width of its own: set before its path begins (a `w` +//! inside a path is not allowed) and put back after it is painted, so the `w` other strokes +//! share, and strokes under another transformation, keep their width. + +use pdfcraft_cos::{Document, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A one-page document drawing `content`. +fn one_page(content: &[u8]) -> Document { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_vec(), + ]; + let mut s = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + s.extend_from_slice(content); + s.extend_from_slice(b"\nendstream"); + objs.push(s); + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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() +} + +/// The page's content, its tokens joined by single spaces. +fn content_text(doc: &Document) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(0); + let list: Vec = match &*doc.resolve(page.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.clone(), + _ => vec![page.dict.get(b"Contents").unwrap().clone()], + }; + let mut text = String::new(); + for e in list { + if let Object::Stream(s) = &*doc.resolve(&e) { + text.push_str(&String::from_utf8_lossy(&s.decoded().unwrap())); + text.push(' '); + } + } + text.split_whitespace().collect::>().join(" ") +} + +#[test] +fn a_thin_stroke_does_not_change_the_width_other_strokes_share() { + // The first line is 0.1 pt on the page; the second, under a tenfold scale, is 1 pt. + let mut doc = one_page(b"0.1 w 0 0 m 10 0 l S 10 0 0 10 0 0 cm 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let t = content_text(&doc); + assert!(t.starts_with("0.1 w 0.72 w 0 0 m 10 0 l S 0.1 w 10 0 0 10 0 0 cm"), "the shared w is kept: {t}"); + assert!(!t.contains("7.2"), "the scaled stroke keeps its 1 pt: {t}"); +} + +#[test] +fn a_stroke_inside_q_does_not_change_the_outer_width() { + let mut doc = one_page(b"0.1 w q 0 0 m 1 0 l S Q 0 1 m 1 1 l S"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 2); + let t = content_text(&doc); + assert!(t.starts_with("0.1 w q 0.72 w 0 0 m 1 0 l S 0.1 w Q 0.72 w 0 1 m 1 1 l S 0.1 w"), "{t}"); +} + +#[test] +fn the_new_width_is_set_before_the_path_not_inside_it() { + // No `w` before the stroke: the default 1 pt at half scale is 0.5 pt on the page. + let mut doc = one_page(b"0.5 0 0 0.5 0 0 cm 0 0 m 10 0 l S 5 5 10 10 re f"); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { narrower_than: 0.6, width: 1.0, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + let t = content_text(&doc); + assert!(t.contains("cm 2 w 0 0 m 10 0 l S 1 w 5 5 10 10 re f"), "{t}"); + assert!(!t.contains("l 2 w S"), "no w inside the path: {t}"); +} diff --git a/crates/edit/tests/hairlines_shared_form.rs b/crates/edit/tests/hairlines_shared_form.rs new file mode 100644 index 0000000000..99f05499b6 --- /dev/null +++ b/crates/edit/tests/hairlines_shared_form.rs @@ -0,0 +1,165 @@ +//! Fix hairlines rewrites a form for the scale it is drawn under: a form a page not being +//! fixed also draws, or one drawn under two scales, is rewritten as a copy for the use that +//! needs it, so the other uses keep drawing it as it was. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A document of `objects` (object `i + 1` is the `i`th: a dictionary, and stream data when +/// it is a stream); object 1 is the catalog. +fn pdf(objects: &[(&str, Option<&[u8]>)]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, (dict, data)) in objects.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + match data { + Some(data) => { + out.extend_from_slice(format!("<< {dict} /Length {} >>\nstream\n", data.len()).as_bytes()); + out.extend_from_slice(data); + out.extend_from_slice(b"\nendstream"); + } + None => out.extend_from_slice(dict.as_bytes()), + } + out.extend_from_slice(b"\nendobj\n"); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.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", objects.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +/// A stream's data, its tokens joined by single spaces. +fn flat(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +/// Page `i`'s content (a single stream), flattened. +fn page_text(doc: &Document, i: usize) -> String { + let page = pdfcraft_model::pages(doc).swap_remove(i); + flat(doc, page.dict.get(b"Contents").and_then(Object::as_ref).unwrap()) +} + +/// The object page `i`'s resources draw as `name`. +fn xobject(doc: &Document, i: usize, name: &[u8]) -> ObjRef { + let page = pdfcraft_model::pages(doc).swap_remove(i); + let res = doc.resolve(page.dict.get(b"Resources").unwrap()); + let xo = doc.resolve(res.as_dict().unwrap().get(b"XObject").unwrap()); + xo.as_dict().unwrap().get(name).and_then(Object::as_ref).unwrap() +} + +const FORM: &str = "/Type /XObject /Subtype /Form /BBox [0 0 100 100]"; +const ORIGINAL: &str = "0.4 w 0 0 m 1 0 l S"; + +#[test] +fn a_form_another_page_draws_is_fixed_as_a_copy() { + // Both pages share one resource dictionary; the first draws the form at half scale (its + // 0.4 pt line is 0.2 pt there), the second at full size. + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 7 0 R >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources 7 0 R >>", None), + ("", Some(b"0.5 0 0 0.5 0 0 cm /Fm0 Do")), + ("", Some(b"/Fm0 Do")), + ("<< /XObject << /Fm0 8 0 R >> >>", None), + (FORM, Some(ORIGINAL.as_bytes())), + ]); + let form = ObjRef { num: 8, generation: 0 }; + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + assert_eq!(flat(&doc, form), ORIGINAL, "the form the second page draws is unchanged"); + assert_eq!(xobject(&doc, 1, b"Fm0"), form, "the second page still draws it"); + let copy = xobject(&doc, 0, b"Fm0"); + assert_ne!(copy, form, "the first page draws a copy"); + assert_eq!(flat(&doc, copy), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w"); +} + +#[test] +fn a_form_drawn_under_two_scales_gets_one_object_for_each() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None), + ("", Some(b"q 0.5 0 0 0.5 0 0 cm /Fm0 Do Q /Fm0 Do")), + (FORM, Some(ORIGINAL.as_bytes())), + ]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let text = page_text(&doc, 0); + let words: Vec<&str> = text.split(' ').collect(); + let drawn: Vec<&str> = words.windows(2).filter(|w| w[1] == "Do").map(|w| w[0]).collect(); + assert_eq!(drawn.len(), 2, "{text}"); + assert_eq!(drawn[0], "/Fm0", "{text}"); + assert_ne!(drawn[1], "/Fm0", "the full-size use draws another name: {text}"); + let half = xobject(&doc, 0, b"Fm0"); + let full = xobject(&doc, 0, drawn[1].trim_start_matches('/').as_bytes()); + assert_ne!(half, full); + assert_eq!(flat(&doc, half), "0.4 w 1.44 w 0 0 m 1 0 l S 0.4 w"); + assert_eq!(flat(&doc, full), ORIGINAL, "at full size the 0.4 pt line is left as it was"); +} + +const SHADED: &str = "/Pattern cs /P0 scn 0 0 10 10 re f"; +const PATTERN: &str = "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 9 9] /XStep 9 /YStep 9 /Resources << >>"; +const THIN: &str = "0 w 0 0 m 9 9 l S"; + +#[test] +fn a_pattern_another_page_uses_is_reported() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources 6 0 R >>", None), + ("", Some(SHADED.as_bytes())), + ("<< /Pattern << /P0 7 0 R >> >>", None), + (PATTERN, Some(THIN.as_bytes())), + ]); + let h = Hairlines { patterns: true, ..Hairlines::default() }; + let mut both = doc.clone(); + assert_eq!(fix_hairlines(&mut both, &[0, 1], &h).unwrap().elsewhere, 0, "every use is in the run"); + let fixed = fix_hairlines(&mut doc, &[0], &h).unwrap(); + assert_eq!((fixed.lines, fixed.elsewhere), (1, 1)); +} + +#[test] +fn a_pattern_a_form_uses_is_fixed_too() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>", None), + ("", Some(b"/Fm0 Do")), + // The form lists itself: walking the resources ends all the same. + ( + "/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Resources << /Pattern << /P0 6 0 R >> /XObject << /Fm0 5 0 R >> >>", + Some(SHADED.as_bytes()), + ), + (PATTERN, Some(THIN.as_bytes())), + ]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines { patterns: true, ..Hairlines::default() }).unwrap(); + assert_eq!(fixed.lines, 1); + assert_ne!(flat(&doc, ObjRef { num: 6, generation: 0 }), THIN); +} + +#[test] +fn widths_out_of_range_are_refused() { + let mut doc = pdf(&[ + ("<< /Type /Catalog /Pages 2 0 R >>", None), + ("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", None), + ("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>", None), + ("", Some(THIN.as_bytes())), + ]); + for (narrower_than, width) in [(0.2, -1.0), (0.2, f64::NAN), (0.2, f64::INFINITY), (0.2, 10_000.0), (-1.0, 0.7), (f64::NAN, 0.7), (10_000.0, 0.7)] + { + let h = Hairlines { narrower_than, width, ..Hairlines::default() }; + assert!(fix_hairlines(&mut doc, &[0], &h).is_err(), "{narrower_than} {width}"); + } + let most = Hairlines { narrower_than: Hairlines::MAX_POINTS, width: Hairlines::MAX_POINTS, ..Hairlines::default() }; + assert_eq!(fix_hairlines(&mut doc, &[0], &most).unwrap().lines, 1); +} diff --git a/crates/edit/tests/hairlines_streams.rs b/crates/edit/tests/hairlines_streams.rs new file mode 100644 index 0000000000..f6508822f5 --- /dev/null +++ b/crates/edit/tests/hairlines_streams.rs @@ -0,0 +1,92 @@ +//! Fix hairlines rewrites each of a page's content streams on its own: a stream keeps its +//! dictionary (its `/PCMark` among it), streams that did not change stay as they are, the +//! graphics state carries from one stream to the next, and a page with a stream that cannot +//! be read is left alone instead of losing that stream. + +use pdfcraft_cos::{Document, ObjRef, Object}; +use pdfcraft_edit::{Hairlines, fix_hairlines}; +use std::sync::Arc; + +/// A one-page document whose `/Contents` lists `streams` (dictionary entries, data). +fn one_page(streams: &[(&str, &[u8])]) -> Document { + let refs: Vec = (0..streams.len()).map(|i| format!("{} 0 R", i + 4)).collect(); + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".to_vec(), + format!("<< /Type /Page /Parent 2 0 R /Contents [{}] >>", refs.join(" ")).into_bytes(), + ]; + for (dict, data) in streams { + let mut s = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes(); + s.extend_from_slice(data); + s.extend_from_slice(b"\nendstream"); + objs.push(s); + } + 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", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + 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() +} + +/// The page's `/Contents` references. +fn contents(doc: &Document) -> Vec { + let page = pdfcraft_model::pages(doc).swap_remove(0); + match &*doc.resolve(page.dict.get(b"Contents").unwrap()) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => page.dict.get(b"Contents").and_then(Object::as_ref).into_iter().collect(), + } +} + +fn text_of(doc: &Document, r: ObjRef) -> String { + match &*doc.get(r) { + Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap()).split_whitespace().collect::>().join(" "), + _ => panic!("not a stream"), + } +} + +#[test] +fn each_stream_keeps_its_dictionary_and_only_changed_ones_are_replaced() { + let mut doc = one_page(&[("", b"2 w 0 0 m 1 1 l S"), ("/PCMark /Redaction", b"0 w 0 0 m 1 1 l S")]); + let before = contents(&doc); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let after = contents(&doc); + assert_eq!(after.len(), 2, "still two streams"); + assert_eq!(after[0], before[0], "the unchanged stream is the same object"); + assert_ne!(after[1], before[1]); + let mark = match &*doc.get(after[1]) { + Object::Stream(s) => s.dict.name(b"PCMark").map(<[u8]>::to_vec), + _ => None, + }; + assert_eq!(mark.as_deref(), Some(&b"Redaction"[..]), "the mark is kept"); + assert!(text_of(&doc, after[1]).contains("0.72 w 0 0 m 1 1 l S")); +} + +#[test] +fn the_graphics_state_carries_from_one_stream_to_the_next() { + // 0.3 pt under the first stream's half scale is 0.15 pt on the page. + let mut doc = one_page(&[("", b"0.5 0 0 0.5 0 0 cm"), ("", b"0.3 w 0 0 m 1 1 l S")]); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 1); + let after = contents(&doc); + assert!(text_of(&doc, after[1]).contains("1.44 w 0 0 m 1 1 l S")); +} + +#[test] +fn a_page_with_an_unreadable_stream_is_left_alone() { + let mut doc = one_page(&[("", b"0 w 0 0 m 1 1 l S"), ("/Filter /DCTDecode", b"not a picture")]); + let before = contents(&doc); + let fixed = fix_hairlines(&mut doc, &[0], &Hairlines::default()).unwrap(); + assert_eq!(fixed.lines, 0); + assert_eq!(contents(&doc), before, "no stream is dropped"); +} diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index d6be8fd4eb..e78786bf8b 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -412,7 +412,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[ item("Convert colors", "palette", "prepress.convert_colors", Planned("M11")), item("Set page boxes", "square-dashed-mouse-pointer", "page.boxes", Ready), item("Add printer marks", "ruler", "prepress.marks", Planned("M11")), - item("Fix hairlines", "minus", "prepress.hairlines", Planned("M11")), + item("Fix hairlines", "minus", "prepress.hairlines", Ready), ], }], }, diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 7cee14657e..01004182cf 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -228,6 +228,9 @@ pub const COMMANDS: &[CommandSpec] = &[ c("edit.snapshot", "Take a snapshot", EDIT, None, Copying, "camera"), c("view.full_screen", "Full screen mode", VIEW, Some(Shortcut::cmd("L")), Document, "maximize"), c("view.read_mode", "Read mode", VIEW, Some(Shortcut { command: true, shift: false, mac_ctrl: true, key: "H" }), Document, "book-open"), + // Split view (Acrobat's Window ▸ Split): the current document in two panes, or back to one; + // a tab's right-click menu also splits right or down with any tab. + c("view.split", "Split", VIEW, Some(Shortcut::cmd("\\")), Document, "columns-2"), // Browsers' tab keys (#1542): Ctrl+Tab on every platform, ⌃Tab rather than ⌘Tab on macOS, // where the system takes ⌘Tab. They follow the tabs' order on screen and wrap around, and // also run from Combine files, which has no document open. @@ -403,6 +406,7 @@ pub const COMMANDS: &[CommandSpec] = &[ c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"), c("page.split", "Split document…", PAGES, None, Assembly, "scissors"), c("page.number", "Number pages…", PAGES, None, Assembly, "hash"), + c("prepress.hairlines", "Fix hairlines…", None, None, Modification, "minus"), c("page.properties", "Page properties…", PAGES, None, Modification, "info"), c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"), c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"), diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs index 9514d9f443..f8f3706446 100644 --- a/crates/engine/src/document.rs +++ b/crates/engine/src/document.rs @@ -442,16 +442,22 @@ impl Document { self.editor.as_ref().and_then(|e| e.cos.permissions()) } + /// The renderer opened an encrypted file that the editing parser did not, so its permission + /// bits are unknown. Unknown is not unrestricted: printing and copying are refused. + fn permissions_unknown(&self) -> bool { + self.editor.is_none() && self.info.encrypted + } + /// Printing is allowed (Table 22, bit 3). pub fn allows_printing(&self) -> bool { - self.permissions().is_none_or(|p| p.print()) + !self.permissions_unknown() && self.permissions().is_none_or(|p| p.print()) } /// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying /// selected text, snapshots, Save Image As, exporting. Assistive technology may read the /// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask. pub fn allows_copying(&self) -> bool { - self.permissions().is_none_or(|p| p.copy()) + !self.permissions_unknown() && self.permissions().is_none_or(|p| p.copy()) } /// [`Self::allows_copying`], as the error to report when it isn't. @@ -606,6 +612,12 @@ impl Document { self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default() } + /// [`Self::hidden_info`] for the categories in `which` alone, without the reading the others + /// need (see [`pdfcraft_redact::sanitize::scan_only`]). + pub fn hidden_info_of(&self, which: &[Hidden]) -> Vec<(Hidden, usize)> { + self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan_only(&e.cos, which)).unwrap_or_default() + } + /// Verification: faux redactions (opaque covers over extractable text) and redaction /// marks that were never applied. Fails closed: any read error is returned, never an /// empty list. diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 6ead3ca09c..b4b8e56ffd 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -3,11 +3,11 @@ use std::sync::Arc; use crate::{ - AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, HeaderFooter, Hidden, ImageEdit, - InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, PageBox, - Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, embed_image, - field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, unrestricted, - xfa, xfa_after_data_change, + AddedContent, AddedText, Background, BorderShape, BoxSpec, EditError, FieldProps, FieldValue, FillMark, Hairlines, HeaderFooter, Hidden, + ImageEdit, InitialView, Label, LineEnding, LinkAction, LinkStyle, MarkFile, MarkKind, Markup, NewAnnotation, NewField, NoteIcon, ObjectTarget, + PageBox, Protection, ReviewState, Rgb, Shape, Standard, Style, TabOrder, TransitionSpec, Watermark, a11y, copy_image, decode_data_file, + embed_image, field_table, fit_picture, image_content_type, is_xfa_data_file, js, mark_source, measure, model_poster, open_source, strip_xfa, + unrestricted, xfa, xfa_after_data_change, }; mod labels; @@ -349,6 +349,12 @@ pub enum Edit { }, /// Document Properties ▸ Initial View (and the language and binding). SetInitialView(Box), + /// Print production ▸ Fix hairlines: strokes narrower than `settings.narrower_than` points on + /// the page (after every transformation; zero-width lines always) get `settings.width`. + FixHairlines { + pages: Vec, + settings: Hairlines, + }, /// Edit ▸ Attach a file: a document-level attachment (`/Names /EmbeddedFiles`) under /// `name`, made unique; `mime` is the embedded file's `/Subtype` when known. AddAttachment { @@ -391,6 +397,11 @@ pub enum Edit { MarkTagArtifact { object: pdfcraft_cos::ObjRef, }, + /// A layout table as plain blocks: the table, its row groups and rows become Div tags and its + /// cells P tags (Div when they hold tags), without their Table attributes. + LayoutTableToBlocks { + object: pdfcraft_cos::ObjRef, + }, /// Document JavaScripts: add, replace (`script`) or remove (`None`) the document-level /// script `name`. SetDocumentScript { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 762e8ced2f..1979c86348 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -250,6 +250,7 @@ impl Edit { Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"), Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"), Edit::SetInitialView(_) => Label::of("Change initial view"), + Edit::FixHairlines { .. } => Label::of("Fix hairlines"), Edit::AddAttachment { name, .. } => Label::with("Attach {name}", name.as_str()), Edit::DeleteAttachment { key } => Label::with("Delete attachment {key}", key.as_str()), Edit::AutoTagText => Label::of("Automatically tag text"), @@ -259,6 +260,7 @@ impl Edit { Edit::MoveTag { .. } => Label::of("Move object"), Edit::MarkDecorative { .. } => Label::of("Mark figure as decorative"), Edit::MarkTagArtifact { .. } => Label::of("Mark content as artifact"), + Edit::LayoutTableToBlocks { .. } => Label::of("Change tag properties"), Edit::AddOcrText { .. } => Label::of("Recognize text"), Edit::EnhanceScan { .. } => Label::of("Enhance scanned file"), Edit::ApplyScriptChanges { .. } => Label::of("Run JavaScript"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c5444c35e0..e584ac2413 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -134,6 +134,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MoveInTabOrder { .. } | Edit::SetCalculationOrder { .. } | Edit::SetInitialView(_) + | Edit::FixHairlines { .. } | Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } | Edit::AutoTagText @@ -143,6 +144,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re | Edit::MoveTag { .. } | Edit::MarkDecorative { .. } | Edit::MarkTagArtifact { .. } + | Edit::LayoutTableToBlocks { .. } | Edit::AddOcrText { .. } | Edit::EnhanceScan { .. } | Edit::SetDocumentScript { .. } @@ -598,6 +600,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::EraseInk { page, index, path, radius } => { pdfcraft_annot::erase_ink(doc, *page, *index, path, *radius, &cx.meta())?; } + Edit::FixHairlines { pages, settings } => { + let fixed = pdfcraft_edit::fix_hairlines(doc, pages, settings)?; + if fixed.lines == 0 { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no hairlines found on those pages".into()))); + } + } Edit::AddAttachment { name, bytes, description, mime } => { pdfcraft_organize::add_attachment(doc, name, bytes, description.as_deref(), mime.as_deref(), cx.date.as_deref())?; } @@ -649,17 +657,12 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Some(js) => pdfcraft_forms::set_value_with(doc, name, value, js), None => pdfcraft_forms::set_value(doc, name, value), }; - match set(doc, value) { - Ok(()) => {} - // A date the field's format doesn't read may be typed by another of an XFA date - // field's patterns: it is kept in the first. Only a format refusal is tried - // again: it comes before any of the field's scripts run, so they run once. - Err(refused @ pdfcraft_forms::FormError::Format(_)) => match xfa::typed_date(doc, cx.xfa.as_deref(), name, value) { - Some(typed) => set(doc, &typed)?, - None => return Err(refused.into()), - }, - Err(refused) => return Err(refused.into()), - } + // A date typed by another of an XFA date field's patterns is kept in the first, before + // the field's format reads it more loosely (`18OCT16` in a `YYYYMMDD|DDMMMYY` field is + // the 18th of October 2016, not a day of 2018). + let laid_out = pdfcraft_xfa::xfa_shape(doc).is_some_and(|s| s.laid_out); + let typed = if laid_out { xfa::typed_date(doc, cx.xfa.as_deref(), name, value) } else { None }; + set(doc, typed.as_ref().unwrap_or(value))?; } Edit::ApplyScriptChanges { changes } => match cx.js.as_mut() { Some(js) => pdfcraft_forms::apply_script_changes(doc, changes, js)?, @@ -797,6 +800,9 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::MarkTagArtifact { object } => { a11y::mark_tag_artifact(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; } + Edit::LayoutTableToBlocks { object } => { + a11y::layout_table_to_blocks(doc, *object).map_err(|e| EditError::Accessibility(e.to_string()))?; + } Edit::AddHeaderFooter { pages, settings, replace } => { let date = cx.today; pdfcraft_edit::add_header_footer(doc, pages, settings, *replace, &pdfcraft_edit::Context { date })?; diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 04d848eb8d..441fb7d1b5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -96,6 +96,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::FixHairlines { .. } // Recognized text is an invisible text layer stamped on its page, with its font in the // page's resources (#6310): a full re-inspection made applying one word cost seconds on // a large file. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index dce233279c..0dd14a42b4 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -119,6 +119,49 @@ impl JsOutput { self.errors.push(e); } } + + /// Takes out the alerts, console lines, requests and errors. The record of the document + /// scripts' errors stays, so they are still reported once. + fn set_aside(&mut self) -> JsOutput { + JsOutput { + alerts: std::mem::take(&mut self.alerts), + console: std::mem::take(&mut self.console), + requests: std::mem::take(&mut self.requests), + errors: std::mem::take(&mut self.errors), + doc_errors: Vec::new(), + } + } + + /// Puts back what [`JsOutput::set_aside`] took out and returns what was added since. + fn put_back(&mut self, waiting: JsOutput) -> JsOutput { + let own = self.set_aside(); + self.alerts = waiting.alerts; + self.console = waiting.console; + self.requests = waiting.requests; + self.errors = waiting.errors; + own + } +} + +impl Session { + /// Runs `f` and returns its value with the script output `f` added to document `id`. The + /// output limits count that output alone, and the output that was waiting stays for + /// [`Session::take_js_output`]. When `f` fails, its output joins the waiting output. + pub fn with_own_js_output(&mut self, id: DocId, f: impl FnOnce(&mut Session) -> Result) -> Result<(T, JsOutput), E> { + let waiting = self.docs.iter_mut().find(|d| d.id == id).map(|d| d.js_output.set_aside()); + let result = f(self); + let mut doc = self.docs.iter_mut().find(|d| d.id == id); + let own = doc.as_mut().zip(waiting).map(|(d, w)| d.js_output.put_back(w)).unwrap_or_default(); + match result { + Ok(value) => Ok((value, own)), + Err(e) => { + if let Some(d) = doc { + d.js_output.append(own); + } + Err(e) + } + } + } } fn kind(k: FieldKind) -> pdfcraft_js::FieldType { @@ -342,8 +385,8 @@ impl Session { && let Some(field) = doc.form.iter().find(|f| f.name == t) && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { - self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?; - let out = self.take_js_output(id); + // The click returns only the output of its own edit; output from opening or from earlier edits stays. + let ((), out) = self.with_own_js_output(id, |s| s.apply(id, Edit::XfaEvent { som, activity: "click".into() }))?; return Ok(Outcome { rc: true, alerts: out.alerts, @@ -570,6 +613,56 @@ mod review_tests { assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); } + /// A click returns its own alert when the output waiting in the document is full, and the + /// waiting output stays. + #[test] + fn a_click_returns_its_alert_when_the_waiting_output_is_full() { + let mut s = Session::new(); + let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()); + let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap(); + s.take_js_output(id); + s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output.alert(vec![Message::plain("waiting"); MAX_ALERTS]); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::>(), ["Hello 2"]); + let out = s.take_js_output(id); + assert_eq!(out.alerts, vec![Message::plain("waiting"); MAX_ALERTS]); + } + + /// With the console full and closed, an operation still gets its own lines. Output of an + /// operation that fails joins the waiting output. + #[test] + fn output_limits_count_only_the_operations_own_output() { + let mut s = Session::new(); + let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()); + let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap(); + s.take_js_output(id); + fn output(s: &mut Session, id: DocId) -> &mut JsOutput { + &mut s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output + } + output(&mut s, id).say(vec!["waiting".to_string(); MAX_CONSOLE_LINES + 1]); + let waiting = output(&mut s, id).console.clone(); + assert!(waiting.last().unwrap().starts_with("more console lines were left out")); + let ((), own) = s + .with_own_js_output::<_, ()>(id, |s| { + output(s, id).say(["mine".to_string()]); + output(s, id).errors.push("own error".into()); + Ok(()) + }) + .unwrap(); + assert_eq!(own.console, ["mine"]); + assert_eq!(own.errors, ["own error"]); + assert_eq!(output(&mut s, id).console, waiting); + assert!(output(&mut s, id).errors.is_empty()); + let failed = s.with_own_js_output::<(), _>(id, |s| { + output(s, id).errors.push("failed edit".into()); + Err("no") + }); + assert_eq!(failed.unwrap_err(), "no"); + let out = s.take_js_output(id); + assert_eq!(out.console, waiting); + assert_eq!(out.errors, ["failed edit"]); + } + /// A form of many calculated fields, each printing its own cap of console lines, keeps a /// bounded console: past the budget, lines are left out and one closing line says so. #[test] @@ -604,7 +697,8 @@ mod review_tests { }; let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); glyphs.push(glyph(n, "x")); - let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + // Built by hand, not read from a page the renderer cut short, so the text is complete. + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false }; UNDERSCORE_CHARS_READ.with(|c| c.set(0)); let words = words_with(&text, |x, y| [x, y]); assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9be9666590..0dc7c231cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ mod stamp_preview; #[cfg(test)] mod test_hooks; pub mod text_fonts; +pub mod text_links; mod text_preview; pub mod text_script; pub mod text_style_bookmarks; @@ -95,8 +96,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk, - MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, + HeaderFooter, Ink as TextInk, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/links.rs b/crates/engine/src/links.rs index e1d54d56ce..da3f0fc904 100644 --- a/crates/engine/src/links.rs +++ b/crates/engine/src/links.rs @@ -227,7 +227,25 @@ pub fn read_remote_document(path: &Path) -> Result, RemoteDocumentPathEr if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { return Err(RemoteDocumentPathError::TooLarge); } - let file = std::fs::File::open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + // The path can change between that check and the open (a file replaced by a FIFO): opened + // without blocking, and checked again on the handle, which is what is read. + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + // O_NONBLOCK: Linux 0o4000, macOS and the BSDs 0x4. + options.custom_flags(if cfg!(target_os = "linux") { 0o4000 } else { 0x4 }); + } + let file = options.open(path).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + let meta = file.metadata().map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; + if !meta.is_file() { + return Err(RemoteDocumentPathError::NotAFile); + } + // the opened handle's own length: a file replaced after the path check must not size the buffer + if meta.len() > MAX_REMOTE_DOCUMENT_BYTES { + return Err(RemoteDocumentPathError::TooLarge); + } let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); file.take(MAX_REMOTE_DOCUMENT_BYTES.saturating_add(1)).read_to_end(&mut bytes).map_err(|e| RemoteDocumentPathError::Read(e.to_string()))?; if u64::try_from(bytes.len()).map_or(true, |n| n > MAX_REMOTE_DOCUMENT_BYTES) { diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index 2e579adb19..a551ad2e9c 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -382,7 +382,7 @@ mod tests { let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap()); let mut s = Session::new(); let id = s.open("locked.pdf", None, bytes, None).unwrap(); - let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() }; + let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new(), truncated: false }; let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err(); assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}"); } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fe2d62aeb3..4f7951b236 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -10,12 +10,13 @@ use crate::check_refresh_panic; use crate::edit::{EditCtx, check_permission, run_edit}; use crate::history::{Keys, MAX_UNDO, Scope, keys_after, scope_of, uses_scripts}; use crate::{ - BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hidden, ImageResolution, - LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, StatusCaptions, SummarySort, - TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, comment_summary, decode_text, - display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, open_source_with, optimize, outline_rows, - parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, set_default_layer_state, shown_under_redactions, signatures_of, - source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, xfa_sync_datasets, + BookmarkPage, BookmarkRow, CombineSource, Combined, DataFormat, DocId, Document, Edit, EditError, Hairlines, HairlinesFixed, Hidden, + ImageResolution, LOW_RESOLUTION_PRINT_DPI, Label, ObjectKind, OptimizeReport, RecoverySnapshot, SignOptions, SourceKind, SplitPart, + StatusCaptions, SummarySort, TrustStore, XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, apply_layer_state, carry_verdict_marks, comment_list, + comment_summary, decode_text, display_bytes, display_config, guard, info_fields, js, local_utc_offset, note_warnings, open_source, + open_source_with, optimize, outline_rows, parse_ymd, print, print_layer_states, print_raster, render_threads, scan_colours, + set_default_layer_state, shown_under_redactions, signatures_of, source_kind, sync_xfa_state, undrawn_notices, use_layer_choices, writable, xfa, + xfa_sync_datasets, }; mod editing; diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 8d6020553e..40b4a4e050 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,7 +1,7 @@ use super::{ - Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, - Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_layer_states, print_raster, - set_default_layer_state, source_kind, writable, write_full, + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hairlines, HairlinesFixed, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, + OptimizeReport, SaveOptions, Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, + print_layer_states, print_raster, set_default_layer_state, source_kind, writable, write_full, }; impl Session { @@ -25,15 +25,17 @@ impl Session { self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?) } - /// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is + /// A new document from plain text (US Letter, 11 pt Helvetica, or the bundled Inter face for + /// text with Latin, Greek or Cyrillic letters Helvetica can't draw). Text neither can draw is /// refused, naming the line and the character (never set as `?`). pub fn create_from_text(&self, title: &str, text: &str) -> Result>, EditError> { self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?) } /// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries, - /// compare reports), which can quote any language: characters Helvetica can't draw are written - /// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused. + /// compare reports), which can quote any language: when the bundled Inter face can't draw it + /// all, characters Helvetica can't draw are written as `[U+XXXX]`, and the report's first line + /// says so, rather than the report being refused. pub(crate) fn create_report(&self, title: &str, text: &str) -> Result>, EditError> { let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?; self.write_new(&doc) @@ -114,6 +116,15 @@ impl Session { .map_err(EditError::Write) } + /// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many + /// pages, without changing the document (the edit itself refuses when there is nothing to fix). + pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed { + let Some(doc) = self.get(id) else { return HairlinesFixed::default() }; + let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() }; + let mut cos = editor.cos.clone(); + pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default() + } + /// XFDF with these comments (0-based page, position in the page's `/Annots`) and their /// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in. pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result { diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 711779903a..f6e75c2ba9 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -100,11 +100,11 @@ impl Session { } /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. + /// per line in user space, URI). Text that already has a link over it is skipped, and so is + /// text on a layer the viewer hides: a link there would sit over nothing the reader can see. pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); let mut out = Vec::new(); for page in 0..doc.info.pages.len() { let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); @@ -133,6 +133,40 @@ impl Session { out } + /// The addresses written as plain text that the viewer makes clickable + /// ([`crate::text_links::find_text_links`]), leaving out any a link annotation lies over, from + /// the 0-based page `from` on. One call reads at most + /// [`MAX_TEXT_LINK_PAGES_PER_SCAN`](crate::text_links::MAX_TEXT_LINK_PAGES_PER_SCAN) pages + /// and stops after the page that brings it to + /// [`MAX_TEXT_LINKS_PER_SCAN`](crate::text_links::MAX_TEXT_LINKS_PER_SCAN) addresses; then + /// `next_page` says where to go on. + pub fn text_links(&self, id: DocId, from: usize) -> crate::text_links::TextLinkScan { + use crate::text_links::{MAX_TEXT_LINK_PAGES_PER_SCAN, MAX_TEXT_LINKS_PER_SCAN, TextLinkScan}; + let Some(doc) = self.get(id) else { return TextLinkScan::default() }; + // The viewer's settings, so text on a layer it hides is not a link. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let mut scan = TextLinkScan::default(); + let pages = doc.info.pages.len(); + for (page, info) in doc.info.pages.iter().enumerate().skip(from).take(MAX_TEXT_LINK_PAGES_PER_SCAN) { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + if let Some(text) = res.text { + let annotations: Vec<_> = doc.info.links.iter().filter(|l| l.page == page).collect(); + for link in crate::text_links::find_text_links(&text) { + if !annotations.iter().any(|l| link.covered_by(info, l.rect)) { + scan.links.push((page, link)); + } + } + } + let next = page.saturating_add(1); + let read = next.saturating_sub(from); + if next < pages && (read >= MAX_TEXT_LINK_PAGES_PER_SCAN || scan.links.len() >= MAX_TEXT_LINKS_PER_SCAN) { + scan.next_page = Some(next); + break; + } + } + scan + } + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. /// The document's own structure is read for it, so every bookmark can be reached by paging. pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { @@ -277,6 +311,8 @@ impl Session { /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; + // The summary copies the comments' text out into a new document. + doc.check_copying()?; let text = comment_summary(&doc.name, &doc.info.annotations, sort); self.create_report(&format!("Summary of Comments on {}", doc.name), &text) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5b0768e301..88f8dcfa5c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1420,6 +1420,35 @@ fn utf16_and_code_page_text_files_convert_to_their_text() { assert_eq!(page_texts(&s, id), ["Crème brûlée"]); } +/// Create ▸ PDF from a text file in Cyrillic was refused, and a comment summary quoting Cyrillic +/// wrote each letter as `[U+XXXX]`. Both are set in the bundled Inter face now and read back as +/// typed. +#[test] +fn cyrillic_text_files_and_comment_summaries_read_back_as_typed() { + let mut s = Session::new(); + let text = "Договор № 12\nЖълтата дюля беше щастлива, че пухът, който цъфна, замръзна като гьон."; + let (_, pdf) = s.convert_to_pdf("бележки.txt", &Arc::new(text.as_bytes().to_vec())).unwrap(); + let id = s.open_new("бележки.pdf", pdf).unwrap(); + assert_eq!(page_texts(&s, id), [text]); + // A sticky note in Russian, summarized. + let note = "Проверьте сумму в разделе 3"; + let hex: String = note.encode_utf16().map(|u| format!("{u:04X}")).collect(); + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [4 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Reviewer) /Contents >> endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let id = s.open("note.pdf", None, Arc::new(pdf.into_bytes()), None).expect("opens"); + let summary = s.summarize_comments(id, SummarySort::Page).unwrap(); + let id = s.open_new("summary.pdf", summary).unwrap(); + let shown = page_texts(&s, id).join("\n"); + assert!(shown.contains(note) && !shown.contains("[U+"), "{shown}"); +} + #[test] fn a_file_split_around_another_combines_in_order_with_one_bookmark() { let mut s = Session::new(); @@ -1647,11 +1676,28 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() { assert_eq!(s.compare_report(oid, id).map(|_| ()), refused()); assert!(s.compare(oid, oid).is_ok()); assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused()); + // The comment summary copies comment text out like the exports do. + assert_eq!(s.summarize_comments(id, SummarySort::Page).map(|_| ()), refused()); // Unencrypted documents allow everything. let (s, plain) = session_with(1); assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c))); } +/// The renderer can open an encrypted file the editing parser rejects. Its permission bits are +/// then unknown, which is not the same as none: copying and printing stay refused. +#[test] +fn an_encrypted_document_without_an_editor_allows_neither_copying_nor_printing() { + let mut s = Session::new(); + let id = s.open("r.pdf", None, protected("", "owner", -1 ^ 0b1_0000), None).unwrap(); + s.doc_mut(id).unwrap().editor = None; + let d = s.get(id).unwrap(); + assert!(d.info.encrypted && d.permissions().is_none()); + assert!(!d.allows_copying() && !d.allows_printing()); + let (mut s, plain) = session_with(1); + s.doc_mut(plain).unwrap().editor = None; + assert!(s.get(plain).unwrap().allows_copying(), "an unencrypted document is not restricted"); +} + /// Table 22, bit 3 (#2669): a document that forbids printing disables Print up front instead /// of refusing once the dialog is confirmed. Low-resolution printing (bit 3 on, bit 12 off) is /// still printing, and the owner password lifts the restriction. @@ -7933,6 +7979,29 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() { assert!(!d2.dirty); } +/// An XFA button's click returns only what the click produced. The alerts and errors of the +/// scripts that ran on open stay in the document's output. +#[test] +fn an_xfa_click_returns_only_its_own_script_output() { + let template = pdfcraft_xfa::fixtures::scripted_template() + .replace("if (qty.rawValue === null) qty.rawValue = 2;", "qty.rawValue = 2; xfa.host.messageBox('opened'); throw new Error('open failed');"); + let mut s = Session::new(); + let id = s.open("xfa.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&template)), None).unwrap(); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::>(), ["Hello 2"]); + assert!(o.error.is_none(), "{o:?}"); + let out = s.take_js_output(id); + assert_eq!(out.alerts.iter().map(|a| a.text.as_str()).collect::>(), ["opened"]); + assert!(out.errors.iter().any(|e| e.contains("open failed")), "{out:?}"); + // A click whose script fails returns that error, and the next click does not repeat it. + let broken = + pdfcraft_xfa::fixtures::scripted_template().replace(r#"xfa.host.messageBox("Hello " + qty.rawValue);"#, "throw new Error('click failed');"); + let id = s.open("broken.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&broken)), None).unwrap(); + s.take_js_output(id); + assert!(s.run_javascript(id, "", Some("hello")).unwrap().error.is_some_and(|e| e.contains("click failed"))); + assert!(s.run_javascript(id, "", Some("legacy")).unwrap().error.is_none()); +} + /// A repeating table inside an area of a positioned page (as in real Designer forms): every /// row is laid out, the values Adobe wrote (under the area's parent: areas are /// not data scopes) are read, a FormCalc total over all rows computes, and what is filled here @@ -10464,6 +10533,8 @@ fn exporting_to_word_keeps_the_page_background() { assert!(docx.windows(b"word/settings.xml".len()).any(|w| w == b"word/settings.xml"), "Word is told to show the page colour"); } +/// A table of contents' entries with dotted leaders export to an office document as paragraphs, in +/// reading order, not as a table. #[test] fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { let mut content = String::new(); @@ -10486,7 +10557,8 @@ fn exporting_office_keeps_dotted_leader_entries_as_paragraphs() { } /// A date typed in another of an XFA date field's edit patterns is kept in its first: yymmdd and -/// yyyymd are rewritten as yyyymmdd, a date none of them reads is refused, and the data holds the date. +/// yyyymd are rewritten as yyyymmdd, and the data holds the date. Text none of them reads is kept +/// as typed, in the field and the data, as another viewer keeps it. #[test] fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() { let mut s = Session::new(); @@ -10498,18 +10570,21 @@ fn a_date_typed_by_another_of_an_xfa_fields_edit_patterns_is_kept_in_its_first() assert_eq!(value(&s), ["20261018"]); s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2026118".into()) }).expect("so is yyyymd"); assert_eq!(value(&s), ["20261108"]); - let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }); - assert!(refused.is_err_and(|e| e.to_string().contains("yyyymmdd")), "none of its patterns reads it"); - assert_eq!(value(&s), ["20261108"]); // The data holds the date. - let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); - let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); - assert_eq!(data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")), Some("2026-11-08")); + let data_of = |s: &Session| { + let cos = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap(); + let data = pdfcraft_xfa::parse_datasets(&pdfcraft_xfa::read_packets(&cos).unwrap().unwrap().xdp).expect("a datasets packet"); + data.text_at(&pdfcraft_xfa::som_to_path("form[0].leave[0]")).map(str::to_string) + }; + assert_eq!(data_of(&s).as_deref(), Some("2026-11-08")); + s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("2618".into()) }).expect("kept as typed"); + assert_eq!(value(&s), ["2618"]); + assert_eq!(data_of(&s).as_deref(), Some("2618")); } -/// A date the field's own scripts refuse is not tried again in another of an XFA date field's -/// patterns: only a date its format refuses is, which happens before any script runs, so the -/// scripts run once. This validate script counts its runs in `name` and accepts on its second. +/// A date typed in another of an XFA date field's patterns is rewritten before any of the field's +/// scripts run, so they run once, and a date they refuse stays refused. This validate script counts +/// its runs in `name` and accepts on its second. #[test] fn a_date_its_validate_script_refuses_is_not_tried_again() { let mut s = Session::new(); @@ -10519,7 +10594,7 @@ fn a_date_its_validate_script_refuses_is_not_tried_again() { let script = Some("var n = Number(getField('name').value || 0) + 1; getField('name').value = String(n); event.rc = n >= 2;".to_string()); s.apply(id, Edit::SetFieldScript { name: "leave".into(), event: "validate".into(), script }).unwrap(); let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).unwrap().value.clone(); - // Its format reads 18OCT26, and so does its DDMMMYY pattern. + // Its DDMMMYY pattern reads 18OCT26, which its format would read more loosely too. let refused = s.apply(id, Edit::SetFieldValue { name: "leave".into(), value: FieldValue::Text("18OCT26".into()) }); assert!(refused.is_err(), "the validate script refused it, once"); assert!(value(&s, "name").is_empty(), "nothing the refused run changed is kept: {:?}", value(&s, "name")); diff --git a/crates/engine/src/text_links.rs b/crates/engine/src/text_links.rs new file mode 100644 index 0000000000..0ed8098ad3 --- /dev/null +++ b/crates/engine/src/text_links.rs @@ -0,0 +1,341 @@ +//! Web and email addresses written as plain page text, with no link annotation over them (#4207). +//! +//! The viewer makes them clickable the way link annotations are: the address opens only through +//! [`crate::links::document_url`] and the confirmation, like any link from a document. No clause +//! of ISO 32000-2 covers this (it defines link annotations, not reading addresses out of page +//! text); the detector is the one Create links from URLs uses ([`pdfcraft_annot::links::find_urls`]). + +use pdfcraft_render::{PageInfo, PageText}; + +/// Pages with more glyphs than this aren't searched for addresses: the work is linear, but it is +/// done for each page as it comes into view. +pub const MAX_TEXT_LINK_GLYPHS: usize = 200_000; + +/// At most this many addresses are found on one page. +pub const MAX_TEXT_LINKS_PER_PAGE: usize = 512; + +/// A scan of a document's pages for addresses ([`crate::Session::text_links`]) reads at most this +/// many pages, and says where the next one starts: a 10,000-page document is read a share at a +/// time, never all at once. The viewer reads only the pages in view. +pub const MAX_TEXT_LINK_PAGES_PER_SCAN: usize = 100; + +/// A scan stops after the page on which it has found this many addresses. +pub const MAX_TEXT_LINKS_PER_SCAN: usize = 2_000; + +/// The addresses one scan of a document found ([`crate::Session::text_links`]). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextLinkScan { + /// (0-based page, address), in page order. + pub links: Vec<(usize, TextLink)>, + /// The 0-based page the next scan starts on, when the scan stopped before the last page. + pub next_page: Option, +} + +/// An address written in a page's text. +#[derive(Clone, Debug, PartialEq)] +pub struct TextLink { + /// Where it is written: one rectangle per line, in view space (points from the displayed + /// page's top-left, y down, the space of [`PageText`]). + pub rects: Vec<[f32; 4]>, + /// The address it opens: as written for `http://` and `https://`, `https://` added for + /// `www.`, and `mailto:` for an email address. Not yet checked: the opener must + /// ([`crate::links::document_url`]). + pub uri: String, +} + +impl TextLink { + /// Whether the view-space point `(x, y)` is on the address. + pub fn contains(&self, x: f32, y: f32) -> bool { + self.rects.iter().any(|r| x >= r[0] && x <= r[2] && y >= r[1] && y <= r[3]) + } + + /// Whether a link annotation with the user-space rectangle `rect` (normalized) lies over part + /// of the address on `page`: the annotation is what the document asks for there. + pub fn covered_by(&self, page: &PageInfo, rect: [f32; 4]) -> bool { + let (a, b) = (page.user_to_view(rect[0], rect[1]), page.user_to_view(rect[2], rect[3])); + let v = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + self.rects.iter().any(|r| r[0] < v[2] && v[0] < r[2] && r[1] < v[3] && v[1] < r[3]) + } +} + +/// The web addresses (`http://`, `https://`, `www.`) and email addresses written in `text`, in +/// reading order. Punctuation that ends a sentence (`.`, `,`, `;`, `:`, `!`, `?`) and brackets +/// around an address are not part of it. Nothing is found on a page of more than +/// [`MAX_TEXT_LINK_GLYPHS`] glyphs, and at most [`MAX_TEXT_LINKS_PER_PAGE`] addresses are kept. An +/// address that starts or ends inside a glyph carrying several characters is left out. +pub fn find_text_links(text: &PageText) -> Vec { + if text.glyphs.len() > MAX_TEXT_LINK_GLYPHS { + return Vec::new(); + } + // Each address with the text it was found in. + let found: std::cell::RefCell, String)>> = Default::default(); + let hits = text.find_with(|chars| { + let mut urls = pdfcraft_annot::links::find_urls(chars); + urls.truncate(MAX_TEXT_LINKS_PER_PAGE); + let ranges = urls.iter().map(|u| u.0.clone()).collect(); + *found.borrow_mut() = + urls.into_iter().map(|(range, uri)| (chars.get(range.clone()).map(|c| c.iter().collect()), web_uri(chars, range, uri))).collect(); + ranges + }); + let found = found.into_inner(); + // `find_with` drops a range it can't place on glyphs; then the two lists no longer pair up, + // and nothing is offered rather than an address on the wrong text. + if hits.len() != found.len() { + return Vec::new(); + } + hits.into_iter() + .zip(found) + // A glyph that carries more than one character (a ligature, or a font that maps a glyph to + // `m,`) is taken whole: an address that starts or ends inside one would make the text next + // to it clickable, so it is left out. + .filter(|(glyphs, (written, _))| written.as_deref() == Some(text.text_of(glyphs.clone()).as_str())) + .map(|(glyphs, (_, uri))| TextLink { rects: text.line_rects(glyphs), uri }) + .filter(|l| !l.rects.is_empty()) + .collect() +} + +/// A `www.` address opens over `https://`: `find_urls`, which writes links into the document, +/// keeps `http://` for them, but a reader following an address it was never given should use the +/// secure scheme. +fn web_uri(chars: &[char], range: std::ops::Range, uri: String) -> String { + let www = "www.".chars().enumerate().all(|(k, w)| chars.get(range.start.saturating_add(k)).is_some_and(|c| c.eq_ignore_ascii_case(&w))); + match uri.strip_prefix("http://") { + Some(rest) if www => format!("https://{rest}"), + _ => uri, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_render::TextGlyph; + + /// One line of text, a glyph per character, 6 points apart; `\n` starts the next line. + fn page_text(s: &str) -> PageText { + let mut glyphs = Vec::new(); + let (mut line, mut x) = (0u32, 0.0f32); + let mut line_of = Vec::new(); + let mut space_before = Vec::new(); + let mut gap = false; + for c in s.chars() { + if c == '\n' { + (line, x, gap) = (line + 1, 0.0, false); + continue; + } + if c == ' ' { + (x, gap) = (x + 6.0, true); + continue; + } + let y = 10.0 + line as f32 * 20.0; + let rect = [x, y, x + 6.0, y + 12.0]; + glyphs.push(TextGlyph { + text: c.to_string(), + rect, + quad: [[x, y + 12.0], [x + 6.0, y + 12.0], [x + 6.0, y], [x, y]], + direction: [1.0, 0.0], + }); + line_of.push(line); + space_before.push(gap); + (x, gap) = (x + 6.0, false); + } + PageText { glyphs, line_of, space_before, truncated: false } + } + + fn uris(s: &str) -> Vec { + find_text_links(&page_text(s)).into_iter().map(|l| l.uri).collect() + } + + #[test] + fn web_and_email_addresses_in_text_are_found() { + assert_eq!(uris("Visit https://example.com/plain-text-url today"), ["https://example.com/plain-text-url"]); + assert_eq!(uris("or http://example.org/a?b=c#d"), ["http://example.org/a?b=c#d"]); + assert_eq!(uris("Go to www.example.com/no-scheme now"), ["https://www.example.com/no-scheme"]); + assert_eq!(uris("Go to WWW.Example.com"), ["https://WWW.Example.com"]); + assert_eq!(uris("Write to someone@example.com please"), ["mailto:someone@example.com"]); + assert_eq!( + uris("a https://a.example\nb www.b.example\nc c@example.org"), + ["https://a.example", "https://www.b.example", "mailto:c@example.org"] + ); + } + + #[test] + fn sentence_punctuation_and_brackets_are_not_part_of_the_address() { + assert_eq!(uris("See https://example.com."), ["https://example.com"]); + assert_eq!(uris("See https://example.com/x, then"), ["https://example.com/x"]); + assert_eq!(uris("(see https://example.com/x)"), ["https://example.com/x"]); + assert_eq!(uris("See www.example.com!"), ["https://www.example.com"]); + assert_eq!(uris("Mail someone@example.com."), ["mailto:someone@example.com"]); + assert_eq!(uris("Ask (someone@example.com)."), ["mailto:someone@example.com"]); + } + + #[test] + fn text_that_only_looks_like_an_address_is_not_found() { + for s in ["example.com", "http://", "https://x", "www.", "user@localhost", "a@b.c", "notwww.example.com", "Version 1.2.3", "@example.com"] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + } + + /// Only `http`, `https` and `mailto` addresses are offered, whatever letter case another + /// scheme is written in: `javascript:`, `file:`, `data:` and any other scheme never become a + /// link. + #[test] + fn only_web_and_email_addresses_are_offered() { + for s in [ + "JaVaScRiPt:alert(1)", + "javascript:void(0)", + "file:///etc/passwd", + "FILE://server/share/report.pdf", + "ftp://ftp.example.com/pub/file.txt", + "data:text/html,example.com", + "data:text/html;base64,PGI+ZXhhbXBsZTwvYj4=", + "myapp://open?doc=example.com", + "vbscript:msgbox(1)", + "tel:+15551234567", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // An address inside one written with another scheme is not taken out of it and opened + // with a scheme the text doesn't say. + for s in [ + "ftp://www.example.com/pub", + "file://www.example.com/share", + "JavaScript:www.example.com", + "ftp://user@example.com/pub", + "smb://admin@example.org/share", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // Every address offered opens with one of the three schemes, and passes the check every + // document link gets. + let offered = uris("a https://a.example b HTTP://B.EXAMPLE c www.c.example d d@example.org"); + assert_eq!(offered.len(), 4, "{offered:?}"); + for uri in offered { + let scheme = uri.split_once(':').map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default(); + assert!(["http", "https", "mailto"].contains(&scheme.as_str()), "{uri}"); + assert!(crate::links::document_url(&uri).is_ok(), "{uri}"); + } + } + + /// The text the hand appears over is the address that opens: the rectangle covers exactly + /// the characters of the address, the link check passes the address on unchanged, and the + /// only addition is the scheme a `www.` or email address is written without. + #[test] + fn the_address_opened_is_the_address_written() { + for (s, written, opened) in [ + ("Visit https://example.com/a?b=c&d=%20e#f now", "https://example.com/a?b=c&d=%20e#f", "https://example.com/a?b=c&d=%20e#f"), + ("Visit HTTPS://EXAMPLE.COM/Path now", "HTTPS://EXAMPLE.COM/Path", "HTTPS://EXAMPLE.COM/Path"), + ("Go to www.Example.com/X now", "www.Example.com/X", "https://www.Example.com/X"), + ("Mail Some.One+x@Example.org now", "Some.One+x@Example.org", "mailto:Some.One+x@Example.org"), + // Internationalized and punycode host names are kept as written; the confirmation + // shows the host and warns about mixed scripts (`links::display_host`). + ("See https://bücher.example/straße now", "https://bücher.example/straße", "https://bücher.example/straße"), + ("See https://xn--bcher-kva.example/ now", "https://xn--bcher-kva.example/", "https://xn--bcher-kva.example/"), + ] { + let links = find_text_links(&page_text(s)); + assert_eq!(links.len(), 1, "{s}"); + assert_eq!(links[0].uri, opened, "{s}"); + assert!(opened.ends_with(written), "{s}: only a scheme is added"); + assert_eq!(crate::links::document_url(&links[0].uri).ok().as_deref(), Some(opened), "{s}: opened unchanged"); + // `page_text` gives every character, spaces included, 6 points. + let at = s.find(written).and_then(|b| s.get(..b)).map(|p| p.chars().count()).expect("written in s"); + let len = written.chars().count(); + assert_eq!(links[0].rects, vec![[at as f32 * 6.0, 10.0, (at + len) as f32 * 6.0, 22.0]], "{s}"); + } + // An address hiding characters is offered as written, and the link check refuses it. + let hidden = uris("https://exa\u{200B}mple.com/x"); + assert_eq!(hidden, ["https://exa\u{200B}mple.com/x"]); + assert!(crate::links::document_url(&hidden[0]).is_err()); + } + + /// A full stop, comma or closing bracket after an address belongs to the sentence; brackets + /// inside an address (a Wikipedia title) belong to it when they pair up. + #[test] + fn punctuation_after_an_address_is_left_out_and_paired_brackets_kept() { + for (s, uri) in [ + ("See https://example.com/x.", "https://example.com/x"), + ("See https://example.com/x...", "https://example.com/x"), + ("Is it https://example.com/x?", "https://example.com/x"), + ("(see https://example.com/x).", "https://example.com/x"), + ("[https://example.com/x]", "https://example.com/x"), + ("\"https://example.com/x\",", "https://example.com/x"), + ("", "https://example.com/x"), + ("See https://en.wikipedia.org/wiki/Mercury_(planet).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("(see https://en.wikipedia.org/wiki/Mercury_(planet)).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("See www.example.com/a_(b)_c, then", "https://www.example.com/a_(b)_c"), + ("See https://example.com/a(b and more", "https://example.com/a"), + ("See https://example.com/a)b", "https://example.com/a"), + ("(someone@example.com).", "mailto:someone@example.com"), + ] { + assert_eq!(uris(s), [uri], "{s}"); + } + } + + /// An address ends where its line ends: the next line is never joined to it, because the page + /// text can't tell an address wrapped onto it from the start of the next sentence. + #[test] + fn an_address_ends_at_the_end_of_its_line() { + let links = find_text_links(&page_text("Read https://example.com/first\npart and more")); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://example.com/first"]); + assert_eq!(links[0].rects.len(), 1, "one line"); + assert!(!links[0].contains(3.0, 35.0), "nothing on the next line is part of it"); + } + + #[test] + fn an_address_is_found_where_it_is_written() { + let links = find_text_links(&page_text("ab https://example.com cd")); + // "ab " is 3 characters of 6 points; the address is 19 characters on the first line. + assert_eq!(links.len(), 1); + assert_eq!(links[0].rects, vec![[18.0, 10.0, 132.0, 22.0]]); + assert!(links[0].contains(20.0, 15.0) && links[0].contains(131.0, 21.0)); + assert!(!links[0].contains(10.0, 15.0), "the text before it"); + assert!(!links[0].contains(140.0, 15.0), "the text after it"); + assert!(!links[0].contains(20.0, 30.0), "the line below"); + } + + #[test] + fn a_link_annotation_over_the_address_covers_it() { + let page = PageInfo { width: 300.0, height: 300.0, label: String::new(), crop: [0.0, 0.0, 300.0, 300.0], rotation: 0 }; + let link = &find_text_links(&page_text("ab https://example.com cd"))[0]; + // View y 10–22 is user y 278–290. + assert!(link.covered_by(&page, [100.0, 280.0, 120.0, 285.0])); + assert!(!link.covered_by(&page, [100.0, 200.0, 120.0, 250.0]), "a link elsewhere on the page"); + assert!(!link.covered_by(&page, [140.0, 280.0, 160.0, 285.0]), "a link after it on the line"); + } + + /// `text` with glyph `i` and the one after it drawn as one glyph carrying both characters. + fn merged(mut text: PageText, i: usize) -> PageText { + let next = text.glyphs.remove(i + 1); + text.line_of.remove(i + 1); + text.space_before.remove(i + 1); + let g = &mut text.glyphs[i]; + g.text.push_str(&next.text); + g.rect[2] = next.rect[2]; + text + } + + #[test] + fn an_address_ending_inside_a_glyph_is_not_offered() { + // "m," is one glyph: the address ends on its first character, and its rectangle would + // take the comma too. + let s = "See https://example.com, then"; + // Glyph indexes skip the one space before the address. + let m = s.find(',').expect("a comma") - 2; + assert!(find_text_links(&merged(page_text(s), m)).is_empty()); + // A glyph of several characters inside the address is part of it. + let links = find_text_links(&merged(page_text("See https://fi.example.com"), 11)); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://fi.example.com"]); + } + + #[test] + fn work_per_page_is_capped() { + let many = "a@example.org ".repeat(MAX_TEXT_LINKS_PER_PAGE + 5); + assert_eq!(find_text_links(&page_text(&many)).len(), MAX_TEXT_LINKS_PER_PAGE); + let mut big = page_text("https://example.com"); + let filler = big.glyphs[0].clone(); + big.glyphs.resize(MAX_TEXT_LINK_GLYPHS + 1, filler); + big.line_of.resize(MAX_TEXT_LINK_GLYPHS + 1, 0); + big.space_before.resize(MAX_TEXT_LINK_GLYPHS + 1, false); + assert!(find_text_links(&big).is_empty()); + assert!(find_text_links(&PageText::default()).is_empty()); + } +} diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 5cb008a4a0..20aa4174e7 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ use super::Zip; use super::as_html::{esc, language_tag}; -use super::layout::{Image, Item, Page, Table, body_size, items}; +use super::layout::{Image, Item, Page, Table, body_size, items, reading_order}; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { @@ -156,7 +156,10 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha // page-sized watermark cannot push the invoice footer onto another page. let mut s = String::from(""); let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { + // Rotated pages are positioned shapes, so paragraph flow never orders them. Emit the + // text boxes in reading order; the file's block order is not the reading order. + let blocks = reading_order(page, page.blocks.iter().map(|b| (b.rect, b.size, b)).collect()); + for b in blocks { *shape_id = shape_id.saturating_add(1); let rotation = layout_number(-b.rotation).rem_euclid(360.0); let (sin, cos) = rotation.to_radians().sin_cos(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index a4efa9fc00..d222c61843 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -173,6 +173,9 @@ fn continued_from(blocks: &[Block]) -> Vec> { .iter() .enumerate() .map(|(bi, b)| { + if b.rotation.abs() > 0.1 { + return None; + } let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); by_end .get(k..) @@ -181,7 +184,10 @@ fn continued_from(blocks: &[Block]) -> Vec> { .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .filter(|(_, a)| { + a.rotation.abs() <= 0.1 + && a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]) + }) .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) @@ -581,6 +587,114 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec f64 { + let mut by: Vec<(i64, f64, usize)> = Vec::new(); + for b in &page.blocks { + if !b.rotation.is_finite() { + continue; + } + let k = b.rotation.round() as i64; + if let Some(e) = by.iter_mut().find(|e| e.0 == k) { + e.2 = e.2.saturating_add(b.text.len()); + } else { + by.push((k, b.rotation, b.text.len())); + } + } + by.into_iter().max_by_key(|e| e.2).filter(|e| e.1.abs() > 0.1).map_or(0.0, |e| e.1) +} + +/// Transform `rect` (`[x0, y0, x1, y1]`, `y` up) into the reading frame of text turned by +/// `rotation` degrees counterclockwise (`[along0, across0, along1, across1]`). For upright text +/// (`0°`), this is `rect` itself; for text turned 90° clockwise (`-90°`), `across` decreases from +/// right to left so lines read in their natural order (#3657). +fn in_reading_frame(rect: [f64; 4], rotation: f64) -> [f64; 4] { + if rotation.abs() <= 0.1 || !rotation.is_finite() { + return rect; + } + let (sin, cos) = rotation.to_radians().sin_cos(); + let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[0], rect[3]), (rect[2], rect[3])]; + let mut out = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for (x, y) in corners { + let along = x * cos + y * sin; + let across = y * cos - x * sin; + out = [out[0].min(along), out[1].min(across), out[2].max(along), out[3].max(across)]; + } + out +} + +/// Put a page's items (`(rect, line_height, item)`) into reading order (#3657): +/// - coordinates are projected into the page's reading frame so turned text reads from first line +/// to last line rather than left-to-right across vertical lines; +/// - a column continues across a paragraph gap (up to 3.5 line heights below the previous item) +/// before jumping to the top of the next column on its right, unless an unread item sits above +/// the continuation in its own horizontal span, sits to its left, or continues the previous +/// item on the same line. +pub(crate) fn reading_order(page: &Page, parts: Vec<([f64; 4], f64, T)>) -> Vec { + let rot = page_rotation(page); + let mut parts: Vec<([f64; 4], f64, T)> = parts.into_iter().map(|(r, h, item)| (in_reading_frame(r, rot), h.clamp(1.0, 200.0), item)).collect(); + if parts.len() <= 1 { + return parts.into_iter().map(|x| x.2).collect(); + } + const MAX_COLUMN_ORDER: usize = 2000; + if parts.len() > MAX_COLUMN_ORDER { + parts.sort_by(|a, b| b.0[3].total_cmp(&a.0[3]).then(a.0[0].total_cmp(&b.0[0]))); + return parts.into_iter().map(|x| x.2).collect(); + } + let beside_left = |cb: &[f64; 4], lb: &[f64; 4]| { + let v = lb[3].min(cb[3]) - lb[1].max(cb[1]); + lb[2] <= cb[0] + COL_TOL && v > 0.5 * (lb[3] - lb[1]).min(cb[3] - cb[1]) + }; + let inline_after = |pb: &[f64; 4], ph: f64, rb: &[f64; 4]| { + let v = rb[3].min(pb[3]) - rb[1].max(pb[1]); + rb[0] >= pb[2] - 1.0 && rb[0] <= pb[2] + INLINE_GAP * ph && v > 0.5 * (rb[3] - rb[1]).min(pb[3] - pb[1]) + }; + let mut remaining: Vec = (0..parts.len()).collect(); + let mut order: Vec = Vec::with_capacity(parts.len()); + while let Some(&top) = remaining.iter().max_by(|&&a, &&b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0]))) { + let next_in_column = order.last().and_then(|&prev| { + let (pb, ph) = (parts[prev].0, parts[prev].1); + if remaining.iter().any(|&r| inline_after(&pb, ph, &parts[r].0)) { + return None; + } + let tol = ph * 0.5; + let c = remaining + .iter() + .copied() + .filter(|&c| { + let cb = parts[c].0; + let overlap = cb[2].min(pb[2]) - cb[0].max(pb[0]); + let minw = (cb[2] - cb[0]).min(pb[2] - pb[0]).max(1.0); + cb[3] <= pb[1] + tol && pb[1] - cb[3] <= ph * 3.5 && overlap > minw * 0.3 + }) + .max_by(|&a, &b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0])))?; + let cb = parts[c].0; + let blocked = remaining.iter().any(|&r| { + if r == c { + return false; + } + let rb = parts[r].0; + (rb[3] > cb[3] + tol && rb[2].min(cb[2]) > rb[0].max(cb[0])) || beside_left(&cb, &rb) + }); + (!blocked).then_some(c) + }); + let pick = next_in_column.unwrap_or_else(|| { + let tb = parts[top].0; + remaining + .iter() + .copied() + .filter(|&c| beside_left(&tb, &parts[c].0)) + .min_by(|&a, &b| parts[a].0[0].total_cmp(&parts[b].0[0]).then(parts[b].0[3].total_cmp(&parts[a].0[3]))) + .unwrap_or(top) + }); + remaining.retain(|&r| r != pick); + order.push(pick); + } + let mut slots: Vec> = parts.into_iter().map(|(_, _, item)| Some(item)).collect(); + order.into_iter().filter_map(|i| slots.get_mut(i).and_then(Option::take)).collect() +} + pub(crate) fn items(pages: &[Page]) -> Vec> { let body = body_size(pages); let mut out = Vec::new(); @@ -589,21 +703,20 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { out.push(Item::PageBreak); } let (tables, consumed) = tables(&p.blocks, &p.rules); - // Blocks and images by their top edge, top to bottom. // A paragraph sits where its first run does: its other runs follow along its line and the // lines it wraps onto. - let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) + let mut parts: Vec<([f64; 4], f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + Some(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, Item::Para(runs, lvl))) }) .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); + parts.extend(tables.into_iter().map(|t| (t.rect, body, Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect, (im.rect[3] - im.rect[1]).max(body), Item::Img(im)))); + out.extend(reading_order(p, parts)); } out } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 885d415fc2..5f50122f90 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -42,7 +42,7 @@ use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, flowing_paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, reading_order, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index c662f9cb0f..ec33137eae 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -137,20 +137,21 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .max_by(|(_, a), (_, b)| a.size.total_cmp(&b.size)) .map(|(i, _)| i); - let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); + let mut parts: Vec<([f64; 4], f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; - items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); + parts.push(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, SlideBodyItem::Para(runs))); } for t in tables { - items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); + parts.push((t.rect, 11.0, SlideBodyItem::Table(t))); } for im in &p.images { - items.push((im.rect[3], im.rect[0], SlideBodyItem::Img(im))); + parts.push((im.rect, (im.rect[3] - im.rect[1]).max(11.0), SlideBodyItem::Img(im))); } - items.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + let items = crate::reading_order(p, parts); let mut current_y = my; let mut shapes = String::new(); @@ -170,12 +171,12 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { } let mut i = 0; - while let Some((_, _, item)) = items.get(i) { + while let Some(item) = items.get(i) { match item { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + while let Some(SlideBodyItem::Para(runs)) = items.get(i) { paras_xml.push_str(""); for r in runs { paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index cb520d002e..eeece99949 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -922,6 +922,100 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } +/// #3657: two-column text finishes the left column before starting the right column across +/// Word, HTML, RTF, Markdown and PowerPoint, and text turned 90° exports in reading order rather +/// than last line first. +#[test] +fn two_column_and_turned_text_export_in_reading_order() { + let para = |t: &str, x: f64, y0: f64, y1: f64, size: f64| Block { + text: t.into(), + rect: [x, y0, x + 130.0, y1], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let columns = Page { + width: 595.0, + height: 842.0, + blocks: vec![ + para("Two columns", 50.0, 790.0, 808.0, 18.0), + para("A1 first paragraph A2 first paragraph A3 first paragraph", 50.0, 720.0, 762.0, 12.0), + para("B1 second paragraph B2 second paragraph", 50.0, 675.0, 702.0, 12.0), + para("B3 second paragraph B4 second paragraph", 320.0, 735.0, 762.0, 12.0), + para("C1 third paragraph C2 third paragraph", 320.0, 695.0, 722.0, 12.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_cols = ["Two columns", "A1 first paragraph", "B1 second paragraph", "B3 second paragraph", "C1 third paragraph"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&columns), "Columns")), + ("docx", part(&docx(std::slice::from_ref(&columns), "Columns"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&columns))), + ("md", markdown(std::slice::from_ref(&columns))), + ("pptx", part(&pptx(std::slice::from_ref(&columns), "Columns"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_cols.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // Text turned 90 degrees clockwise (rotation = -90°): lines run top to bottom (y = 540 + // down to 468) and follow each other right to left (x = 676, 654.4, 632.8, 611.2). + let turned_line = |t: &str, x: f64, size: f64| Block { + text: t.into(), + rect: [x - size, 468.0, x, 540.0], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: -90.0, + space_around: [false; 2], + }; + let rotated = Page { + width: 792.0, + height: 612.0, + blocks: vec![ + turned_line("Line one", 676.0, 20.0), + turned_line("Line two", 654.4, 18.0), + turned_line("Line three", 632.8, 18.0), + turned_line("Line four", 611.2, 18.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_lines = ["Line one", "Line two", "Line three", "Line four"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&rotated), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&rotated), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&rotated))), + ("md", markdown(std::slice::from_ref(&rotated))), + ("pptx", part(&pptx(std::slice::from_ref(&rotated), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // The same lines, stored bottom-to-top. Rotated lines do not join one paragraph, and + // Word's positioned boxes must follow reading order rather than the file's block order. + let mut shuffled = rotated.clone(); + shuffled.blocks.reverse(); + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&shuffled), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&shuffled), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&shuffled))), + ("md", markdown(std::slice::from_ref(&shuffled))), + ("pptx", part(&pptx(std::slice::from_ref(&shuffled), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} shuffled out of order: {positions:?}\n{out}"); + } +} + /// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps /// its borders; the same text without rules (or with rules that only underline it) stays paragraphs. #[test] diff --git a/crates/export/src/zip.rs b/crates/export/src/zip.rs index a89d08cb7b..e15966c119 100644 --- a/crates/export/src/zip.rs +++ b/crates/export/src/zip.rs @@ -1,4 +1,6 @@ -//! A minimal zip writer (deflate or store, no zip64) for Office files. +//! A minimal zip writer (deflate or store) for Office files. Archives that outgrow the classic +//! end-of-central-directory record's 16-bit entry count or 32-bit sizes are written with the +//! ZIP64 records (APPNOTE 4.4), which every reader understands; small ones are unchanged. use std::io::Write; @@ -22,12 +24,102 @@ fn crc32(data: &[u8]) -> u32 { c ^ 0xFFFF_FFFF } -/// Builds a zip archive in memory. +/// The largest entry count the classic end-of-central-directory record holds (APPNOTE 4.3.12). +const MAX_CLASSIC_COUNT: u64 = u16::MAX as u64; +/// The largest size or offset the classic records hold: past it the field is a sentinel and the +/// ZIP64 record carries the real value (APPNOTE 4.4.1). +const MAX_CLASSIC_U32: u64 = u32::MAX as u64; + +/// The ZIP64 extended-information extra field (APPNOTE 4.5.3) holding `fields` — the values that +/// no longer fit their classic fields — in specification order. Empty when everything fits. +fn zip64_extra(fields: &[u64]) -> Vec { + let mut e = Vec::with_capacity(4 + 8 * fields.len()); + if !fields.is_empty() { + e.extend(0x0001u16.to_le_bytes()); + e.extend((8 * fields.len() as u16).to_le_bytes()); + for &f in fields { + e.extend(f.to_le_bytes()); + } + } + e +} + +/// The classic 32-bit field value for `value`: the value itself, or the 0xFFFFFFFF sentinel when it +/// no longer fits — including when it equals the sentinel, which a reader cannot tell from a real +/// value — in which case the ZIP64 extra field carries the real value (APPNOTE 4.4.3). +fn classic_u32(value: u64) -> u32 { + if value >= MAX_CLASSIC_U32 { u32::MAX } else { value as u32 } +} + +/// Builds one member's local file header and central directory entry. The sizes and `offset` are +/// parameters, not read from the body, so tests can inject values past the classic fields without +/// allocating the data. +fn entry_headers(name: &str, crc: u32, method: u16, compressed: u64, uncompressed: u64, offset: u64) -> (Vec, Vec) { + // A value at or above the sentinel cannot be held by a classic field, so it is offloaded. + let size_is_zip64 = uncompressed >= MAX_CLASSIC_U32 || compressed >= MAX_CLASSIC_U32; + // A local header that carries ZIP64 sizes carries both, original then compressed, even when only + // one overflowed (APPNOTE 4.5.3). A central entry carries one value for each field it set to its + // sentinel, and only those, so a reader walking the fields by their classic ones stays in step. + let local_extra = if size_is_zip64 { zip64_extra(&[uncompressed, compressed]) } else { Vec::new() }; + let mut central_fields = Vec::new(); + if uncompressed >= MAX_CLASSIC_U32 { + central_fields.push(uncompressed); + } + if compressed >= MAX_CLASSIC_U32 { + central_fields.push(compressed); + } + if offset >= MAX_CLASSIC_U32 { + central_fields.push(offset); + } + let central_extra = zip64_extra(¢ral_fields); + // An entry that carries ZIP64 fields needs version 4.5 to extract (APPNOTE 4.4.3); the version + // in the local header and the central entry agree. + let version_needed = if size_is_zip64 || offset >= MAX_CLASSIC_U32 { 45u16 } else { 20u16 }; + let header = |sig: u32, central: bool| { + let mut h = Vec::new(); + h.extend(sig.to_le_bytes()); + if central { + h.extend(20u16.to_le_bytes()); // made by + } + h.extend(version_needed.to_le_bytes()); // version needed + h.extend(0x0800u16.to_le_bytes()); // UTF-8 names + h.extend(method.to_le_bytes()); + h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) + h.extend(crc.to_le_bytes()); + // A local ZIP64 extra carries both sizes, so both corresponding classic fields are + // sentinels even when only one size needs offloading (APPNOTE 4.5.3). Central entries + // remain selective and use a sentinel only for fields represented in their extra. + let (compressed_field, uncompressed_field) = + if !central && size_is_zip64 { (u32::MAX, u32::MAX) } else { (classic_u32(compressed), classic_u32(uncompressed)) }; + h.extend(compressed_field.to_le_bytes()); + h.extend(uncompressed_field.to_le_bytes()); + h.extend((name.len() as u16).to_le_bytes()); + let extra = if central { ¢ral_extra } else { &local_extra }; + h.extend((extra.len() as u16).to_le_bytes()); // extra + if central { + h.extend(0u16.to_le_bytes()); // comment + h.extend(0u16.to_le_bytes()); // disk + h.extend(0u16.to_le_bytes()); // internal attributes + h.extend(0u32.to_le_bytes()); // external attributes + h.extend(classic_u32(offset).to_le_bytes()); + } + h.extend(name.as_bytes()); + h.extend(extra); + h + }; + (header(0x0403_4b50, false), header(0x0201_4b50, true)) +} + +/// Builds a zip archive in memory. An archive past the classic record's 16-bit entry count or +/// 32-bit sizes and offsets is written with a ZIP64 end-of-central-directory record: a +/// PowerPoint export writes two entries per page plus ten fixed parts, so a document with +/// 32 763 pages already passes 65 535 entries, and without the ZIP64 record the count wraps +/// and readers see a central directory that does not describe the archive. #[derive(Default)] pub struct Zip { out: Vec, central: Vec, - count: u16, + count: usize, } impl Zip { @@ -41,49 +133,44 @@ impl Zip { } else { (0u16, data.to_vec()) }; - let offset = self.out.len() as u32; - let header = |sig: u32, central: bool| { - let mut h = Vec::new(); - h.extend(sig.to_le_bytes()); - if central { - h.extend(20u16.to_le_bytes()); // made by - } - h.extend(20u16.to_le_bytes()); // version needed - h.extend(0x0800u16.to_le_bytes()); // UTF-8 names - h.extend(method.to_le_bytes()); - h.extend([0, 0, 0x21, 0]); // 00:00, 1980-01-01 (a zero date is not a valid DOS date) - h.extend(crc.to_le_bytes()); - h.extend((body.len() as u32).to_le_bytes()); - h.extend((data.len() as u32).to_le_bytes()); - h.extend((name.len() as u16).to_le_bytes()); - h.extend(0u16.to_le_bytes()); // extra - if central { - h.extend(0u16.to_le_bytes()); // comment - h.extend(0u16.to_le_bytes()); // disk - h.extend(0u16.to_le_bytes()); // internal attributes - h.extend(0u32.to_le_bytes()); // external attributes - h.extend(offset.to_le_bytes()); - } - h.extend(name.as_bytes()); - h - }; - self.out.extend(header(0x0403_4b50, false)); + let offset = self.out.len() as u64; + let (local, central) = entry_headers(name, crc, method, body.len() as u64, data.len() as u64, offset); + self.out.extend(local); self.out.extend(&body); - self.central.extend(header(0x0201_4b50, true)); + self.central.extend(central); self.count += 1; } pub fn finish(mut self) -> Vec { - let start = self.out.len() as u32; - let size = self.central.len() as u32; + let start = self.out.len() as u64; + let size = self.central.len() as u64; + let count = self.count as u64; self.out.append(&mut self.central); + let classic_count = if count > MAX_CLASSIC_COUNT { u16::MAX } else { count as u16 }; + if count > MAX_CLASSIC_COUNT || size >= MAX_CLASSIC_U32 || start >= MAX_CLASSIC_U32 { + let eocd64_at = self.out.len() as u64; + self.out.extend(0x0606_4b50u32.to_le_bytes()); // ZIP64 end of central directory record + self.out.extend(44u64.to_le_bytes()); // size of the rest of this record + self.out.extend(45u16.to_le_bytes()); // version made by (4.5, DOS) + self.out.extend(45u16.to_le_bytes()); // version needed to extract + self.out.extend(0u32.to_le_bytes()); // number of this disk + self.out.extend(0u32.to_le_bytes()); // disk with the start of the central directory + self.out.extend(count.to_le_bytes()); // entries on this disk + self.out.extend(count.to_le_bytes()); // entries in total + self.out.extend(size.to_le_bytes()); // size of the central directory + self.out.extend(start.to_le_bytes()); // offset of the central directory + self.out.extend(0x0706_4b50u32.to_le_bytes()); // ZIP64 end of central directory locator + self.out.extend(0u32.to_le_bytes()); // disk with the ZIP64 record + self.out.extend(eocd64_at.to_le_bytes()); // offset of the ZIP64 record + self.out.extend(1u32.to_le_bytes()); // total number of disks + } self.out.extend(0x0605_4b50u32.to_le_bytes()); - self.out.extend([0u8; 4]); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(self.count.to_le_bytes()); - self.out.extend(size.to_le_bytes()); - self.out.extend(start.to_le_bytes()); - self.out.extend(0u16.to_le_bytes()); + self.out.extend([0u8; 4]); // number of this disk, disk with the central directory + self.out.extend(classic_count.to_le_bytes()); // entries on this disk + self.out.extend(classic_count.to_le_bytes()); // entries in total + self.out.extend(classic_u32(size).to_le_bytes()); + self.out.extend(classic_u32(start).to_le_bytes()); + self.out.extend(0u16.to_le_bytes()); // comment length self.out } } @@ -107,4 +194,155 @@ mod tests { assert_eq!(b.windows(4).filter(|w| *w == b"PK\x01\x02").count(), 2); assert_eq!(&b[b.len() - 22..b.len() - 18], b"PK\x05\x06"); } + + #[test] + fn more_entries_than_the_classic_record_holds_writes_zip64() { + // A PowerPoint export writes two entries per page plus ten fixed parts, so a + // document with 32 763 pages already passes the classic record's 16-bit entry + // count. The count must not wrap: it overflows in a debug build, and in a release + // one it silently writes a central directory readers cannot match to the archive. + let mut z = Zip::default(); + for i in 0..u16::MAX as usize + 1 { + z.add(&format!("ppt/slides/slide{i}.xml"), b"x", true); + z.add(&format!("ppt/slides/_rels/slide{i}.xml.rels"), b"y", true); + } + let b = z.finish(); + let entries = 2 * (u16::MAX as usize + 1); + // The classic end-of-central-directory record holds sentinels… + let eocd = &b[b.len() - 22..]; + assert_eq!(&eocd[..4], &0x0605_4b50u32.to_le_bytes()); + assert_eq!(&eocd[8..10], &u16::MAX.to_le_bytes(), "entries on this disk"); + assert_eq!(&eocd[10..12], &u16::MAX.to_le_bytes(), "entries in total"); + // …and the ZIP64 locator and record carry the truth. + let locator = &b[b.len() - 42..b.len() - 22]; + assert_eq!(&locator[..4], &0x0706_4b50u32.to_le_bytes()); + let at = u64::from_le_bytes(locator[8..16].try_into().unwrap_or([0; 8])) as usize; + assert_eq!(&b[at..at + 4], &0x0606_4b50u32.to_le_bytes()); + assert_eq!(u64::from_le_bytes(b[at + 24..at + 32].try_into().unwrap_or([0; 8])), entries as u64, "entries on this disk"); + assert_eq!(u64::from_le_bytes(b[at + 32..at + 40].try_into().unwrap_or([0; 8])), entries as u64, "entries in total"); + } + + /// The extra field of a local file header (APPNOTE 4.3.7), which follows the file name. + fn local_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(26, 2), le(28, 2)); + &h[30 + name_len..30 + name_len + extra_len] + } + + /// The extra field of a central directory entry (APPNOTE 4.3.12), which follows the file name. + fn central_extra(h: &[u8]) -> &[u8] { + let le = |i: usize, n: usize| h[i..i + n].iter().rev().fold(0usize, |v, b| v << 8 | *b as usize); + let (name_len, extra_len) = (le(28, 2), le(30, 2)); + &h[46 + name_len..46 + name_len + extra_len] + } + + /// The classic compressed and original size fields of a local file header (`at` 18) or a + /// central directory entry (`at` 20). + fn classic_sizes(h: &[u8], at: usize) -> (u32, u32) { + let field = |i: usize| u32::from_le_bytes(h[i..i + 4].try_into().unwrap_or([0; 4])); + (field(at), field(at + 4)) + } + + /// Asserts the local header carries both sizes whenever either needs ZIP64, with both classic + /// size fields set to the sentinel (APPNOTE 4.5.3: a ZIP64 value appears only for a field set to + /// its sentinel, and the local entry must carry both sizes). The central entry stays selective: + /// only the fields that themselves overflow get a value, in specification order. + fn assert_local_pair_and_central_selectivity(compressed: u64, uncompressed: u64) { + let at_limit = |v: u64| v >= u32::MAX as u64; + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(classic_sizes(&local, 18), (u32::MAX, u32::MAX), "both local size fields are sentinels for ({compressed}, {uncompressed})"); + let extra = local_extra(&local); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 16, + "both local sizes are present for ({compressed}, {uncompressed})" + ); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + let expected = + (if at_limit(compressed) { u32::MAX } else { compressed as u32 }, if at_limit(uncompressed) { u32::MAX } else { uncompressed as u32 }); + assert_eq!( + classic_sizes(¢ral, 20), + expected, + "the central entry offloads only the fields that overflow for ({compressed}, {uncompressed})" + ); + let fields = usize::from(at_limit(uncompressed)) + usize::from(at_limit(compressed)); + let extra = central_extra(¢ral); + assert_eq!( + u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), + 8 * fields as u16, + "one central value per sentinel field for ({compressed}, {uncompressed})" + ); + if at_limit(uncompressed) { + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + } + if at_limit(compressed) { + let at = if at_limit(uncompressed) { 12 } else { 4 }; + assert_eq!(u64::from_le_bytes(extra[at..at + 8].try_into().unwrap_or([0; 8])), compressed, "compressed size in specification order"); + } + } + + #[test] + fn both_local_sizes_are_sentinels_when_only_one_needs_zip64() { + // A local ZIP64 extra must carry both sizes, and its values correspond to classic fields set + // to their sentinel — so a small classic size next to an overflowing one is wrong even + // though the extra holds the truth. Both orders of which size overflows. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64 + 1); + assert_local_pair_and_central_selectivity(u32::MAX as u64 + 1, 100); + } + + #[test] + fn both_local_sizes_are_sentinels_when_one_equals_the_sentinel() { + // A size of exactly 0xFFFFFFFF is indistinguishable from the sentinel, so it needs ZIP64 + // just like a larger one; the other local size must then become a sentinel too. Both orders. + assert_local_pair_and_central_selectivity(100, u32::MAX as u64); + assert_local_pair_and_central_selectivity(u32::MAX as u64, 100); + } + + #[test] + fn local_zip64_extra_carries_both_sizes() { + // When only one size overflows the u32 limit, the local header still carries both sizes — + // original then compressed — because a local ZIP64 extra that carries either must carry + // both (APPNOTE 4.5.3). Either size may be the one past the limit. + for (compressed, uncompressed) in [(100u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, 100)] { + let (local, _) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + let extra = local_extra(&local); + assert_eq!(u16::from_le_bytes(extra[..2].try_into().unwrap_or([0; 2])), 0x0001, "ZIP64 extra id"); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "both sizes are present"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } + + #[test] + fn zip64_entries_need_version_4_5() { + // An entry that carries ZIP64 fields must advertise version 4.5 to extract, in both the + // local file header and the central directory entry (APPNOTE 4.4.3). An entry that fits + // every classic field stays at 2.0, so small archives are unchanged. + let (local, central) = entry_headers("a", 0, 0, u32::MAX as u64 + 1, u32::MAX as u64 + 1, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 45, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 45, "central version needed"); + let (local, central) = entry_headers("a", 0, 0, 4, 8, 0); + assert_eq!(u16::from_le_bytes(local[4..6].try_into().unwrap_or([0; 2])), 20, "local version needed"); + assert_eq!(u16::from_le_bytes(central[6..8].try_into().unwrap_or([0; 2])), 20, "central version needed"); + } + + #[test] + fn size_equal_to_the_sentinel_is_offloaded() { + // A real size of exactly u32::MAX is indistinguishable from the 0xFFFFFFFF sentinel, so it + // too must be offloaded: every classic field set to the sentinel needs its 8-byte value in + // the central extra, or a reader such as Python's zipfile reports "Compress size not + // found". Both orders of which size is the MAX+1 one must work. + for (compressed, uncompressed) in [(u32::MAX as u64, u32::MAX as u64 + 1), (u32::MAX as u64 + 1, u32::MAX as u64)] { + let (local, central) = entry_headers("a", 0, 0, compressed, uncompressed, 0); + assert_eq!(u32::from_le_bytes(local[18..22].try_into().unwrap_or([0; 4])), u32::MAX, "local compressed sentinel"); + assert_eq!(u32::from_le_bytes(local[22..26].try_into().unwrap_or([0; 4])), u32::MAX, "local original sentinel"); + assert_eq!(u32::from_le_bytes(central[20..24].try_into().unwrap_or([0; 4])), u32::MAX, "central compressed sentinel"); + assert_eq!(u32::from_le_bytes(central[24..28].try_into().unwrap_or([0; 4])), u32::MAX, "central original sentinel"); + let extra = central_extra(¢ral); + assert_eq!(u16::from_le_bytes(extra[2..4].try_into().unwrap_or([0; 2])), 16, "a value for each sentinel field"); + assert_eq!(u64::from_le_bytes(extra[4..12].try_into().unwrap_or([0; 8])), uncompressed, "original size first"); + assert_eq!(u64::from_le_bytes(extra[12..20].try_into().unwrap_or([0; 8])), compressed, "compressed size second"); + } + } } diff --git a/crates/forms/README.md b/crates/forms/README.md index 6126dc1cbd..67ca3b3bfb 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -75,6 +75,12 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table original entry is preserved. Positive limits still apply. - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. +- Native AF recognition stops at the first genuine matching function call. If its arguments + are unsupported, the complete action remains a custom script for the caller's `Scripts` + interpreter; a later literal call must not replace the original conditional validation. + Async for-of loop headers and bodies do not expose AF-looking text inside regex literals. +- Moving choice fields preserves their own options rather than copying an ancestor's `/Opt`; + inherited check-box and radio-button export mappings remain intact. - `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps a property; `Some(None)` removes a border/fill colour. Unchanged `/DA`, custom font resources, widget-specific `/DA` overrides, indirect `/MK` and `/BS` contents, dash arrays and unknown diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index f5167374d1..2ea52df387 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -75,6 +75,7 @@ impl CalcOp { } } + /// How the operation is named in a list of them (`sum (+)`, `average`). pub fn label(self) -> &'static str { match self { CalcOp::Sum => "sum (+)", @@ -109,6 +110,9 @@ pub struct Actions { pub unsupported: Vec<&'static str>, /// Those scripts' JavaScript, run through [`crate::Scripts`]. pub scripts: Scripts, + /// A date field PdfCraft laid out for an XFA form (`/PCXfaDate true`): text its format doesn't + /// read is kept and shown as typed rather than refused, as an XFA form keeps it. + pub xfa_date: bool, } /// Field scripts that aren't AF calls, by event. @@ -121,9 +125,52 @@ pub struct Scripts { } impl Actions { + /// Whether the field has no format, validation or calculation PdfCraft recognises. pub fn is_empty(&self) -> bool { self.format == Format::None && self.validate == Validate::None && self.calculate == Calculate::None } + + /// The Keystroke event on commit ([`keystroke`]); an XFA date field keeps text its format + /// doesn't read, as typed. + pub fn keystroke(&self, field: &str, value: &str) -> Result { + match keystroke(&self.format, field, value) { + Err(_) if self.xfa_date => Ok(value.to_string()), + checked => checked, + } + } + + /// What the Format event shows for `value` ([`format_value`]). An XFA date field shows a date + /// only for a value that is one, in its format already or as an ISO date (`2026-10-18` or + /// `20261018`); anything else, which its format would read loosely, shows as typed. + pub fn format_value(&self, value: &str) -> String { + self.format_value_in(value, &date_names()) + } + + /// [`Self::format_value`] with month and weekday names from `names`. + pub fn format_value_in(&self, value: &str, names: &DateNames) -> String { + match &self.format { + Format::Date(fmt) if self.xfa_date => { + let t = value.trim(); + let in_format = parse_date(t, fmt).is_some_and(|dt| format_date(dt, fmt).eq_ignore_ascii_case(t)); + match iso_date(t) { + Some(dt) if !in_format => format_date_in(dt, fmt, names), + _ => value.to_string(), + } + } + f => format_value_in(f, value, names), + } + } +} + +/// `text` as an ISO 8601 calendar date, `yyyy-mm-dd` or `yyyymmdd` (digits only, a real day). +fn iso_date(text: &str) -> Option { + let b = text.as_bytes(); + let fmt = match b.len() { + 10 if b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }) => "yyyy-mm-dd", + 8 if b.iter().all(u8::is_ascii_digit) => "yyyymmdd", + _ => return None, + }; + parse_date(text, fmt).filter(|dt| format_date(*dt, fmt) == text) } #[cfg(test)] diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 5c8098dfbb..1c3724ef4a 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -19,13 +19,21 @@ pub(super) enum Js { type Token = (Range, Js); /// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; +const BEFORE_REGEX: [&str; 12] = ["return", "typeof", "case", "do", "else", "in", "new", "delete", "void", "throw", "instanceof", "yield"]; /// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a /// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so /// a `/` after it starts the next statement, and can't divide. const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; +/// Context of an open delimiter. Only the left side of a for header can introduce `of`. +#[derive(Clone, Copy)] +enum Delimiter { + Group, + Condition, + For { left: bool }, +} + /// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. fn line_ends_at(s: &[u8], j: usize) -> bool { matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) @@ -46,10 +54,11 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { // Where a regular expression that never ended stopped being read: every `/` before it // divides, so a line of them is read once, not once for each. let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); + // Nested groups hide the surrounding for header: an `of` in a destructuring default + // or nested expression is an identifier, not the for-of separator. + let mut delimiters: Vec<(char, Delimiter)> = Vec::new(); let mut after_condition = false; + let mut after_for_of = false; 'scan: while let Some(&c) = s.get(i) { let start = i; let token = match c { @@ -90,7 +99,12 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { loop { match s.get(j) { None => break 'scan, - Some(b'\\') => j += 2, + Some(b'\\') => { + let Some(step) = js.get(j + 1..).and_then(|r| r.chars().next()).map(char::len_utf8) else { + break 'scan; + }; + j += 1 + step; + } Some(b'`') => break, Some(_) => j += 1, } @@ -98,7 +112,7 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { i = j + 1; Js::Other } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition, after_for_of) => { // A regular expression ends at a `/` outside a class; a line break first means it // was a division after all. let (mut j, mut class) = (i + 1, false); @@ -106,7 +120,14 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { match s.get(j) { None => break None, Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, + Some(b'\\') => { + if s.get(j + 1).is_none() || line_ends_at(s, j + 1) { + j += 1; + break None; + } + let step = js.get(j + 1..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8); + j += 1 + step; + } Some(b'/') if !class => break Some(j), Some(b) => { class = match b { @@ -133,6 +154,16 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { } } } + b'+' | b'-' + if s.get(i + 1) == Some(&c) + && !regex_may_follow(js, &out, after_condition, after_for_of) + && out.last().is_some_and(|(range, _)| !(range.end..i).any(|j| line_ends_at(s, j))) => + { + // Postfix update ends an expression: a following slash divides. A line + // terminator forbids postfix syntax, including one inside a comment. + i += 2; + Js::Other + } _ if c.is_ascii_digit() => { while word(i) || s.get(i) == Some(&b'.') { i += 1; @@ -150,38 +181,88 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { Js::Punct(char::from(c)) } }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); + let token = (start..i, token); + (after_condition, after_for_of) = advance_context(js, &token, &out, &mut delimiters); + out.push(token); } (out, comments) } +/// Advance only the delimiter/for-header context after a token has been classified. +/// Returns whether it closes a statement's condition or is the for-of separator. +fn advance_context(js: &str, token: &Token, tokens: &[Token], delimiters: &mut Vec<(char, Delimiter)>) -> (bool, bool) { + let mut after_for_of = false; + if let Some((_, Delimiter::For { left })) = delimiters.last_mut() { + if matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("of") && *left && for_target_ends(js, tokens) { + *left = false; + after_for_of = true; + } else if matches!(token.1, Js::Punct(';' | '=')) + || (matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("in") && !matches!(tokens.last(), Some((_, Js::Punct('.'))))) + { + *left = false; + } + } + let mut after_condition = false; + match token.1 { + Js::Punct('(') => delimiters.push((')', opens_condition(js, tokens))), + Js::Punct('[') => delimiters.push((']', Delimiter::Group)), + Js::Punct('{') => delimiters.push(('}', Delimiter::Group)), + Js::Punct(close @ (')' | ']' | '}')) if delimiters.last().is_some_and(|(end, _)| *end == close) => { + after_condition = matches!(delimiters.pop(), Some((_, Delimiter::Condition | Delimiter::For { .. }))); + } + _ => {} + } + (after_condition, after_for_of) +} + /// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening /// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not /// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool, after_for_of: bool) -> bool { match tokens.last() { None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((range, Js::Ident)) => { + !matches!(tokens.get(tokens.len().wrapping_sub(2)), Some((_, Js::Punct('.')))) + && js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w) || (w == "of" && after_for_of)) + } Some((_, Js::Punct(')'))) => after_condition, Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), Some((_, Js::Str(_) | Js::Other)) => false, } } -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` (including +/// `for await`) or `with`, and not /// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { +fn opens_condition(js: &str, tokens: &[Token]) -> Delimiter { let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + let mut keyword = js.get(range.clone()); + if keyword == Some("await") { + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + keyword = js.get(range.clone()); + if keyword != Some("for") { + return Delimiter::Group; + } + } + if matches!(back.next(), Some((_, Js::Punct('.')))) { + return Delimiter::Group; + } + match keyword { + Some("for") => Delimiter::For { left: true }, + Some(w) if BEFORE_CONDITION.contains(&w) => Delimiter::Condition, + _ => Delimiter::Group, + } +} + +/// A binding/assignment target before the for-of separator, not a declaration keyword, +/// expression-opening operator, or the start of the header itself. +fn for_target_ends(js: &str, tokens: &[Token]) -> bool { + match tokens.last() { + Some((range, Js::Ident)) => !matches!(js.get(range.clone()), Some("var" | "let" | "const")), + Some((_, Js::Punct(']' | '}' | ')'))) => true, + _ => false, + } } /// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the @@ -455,10 +536,12 @@ fn splice(src: &str, edits: impl IntoIterator, String)>) -> } /// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +/// Each field name becomes `AFMakeNumber(this.getField("name").value)`; adjacent signs +/// are separated so binary plus/minus followed by a unary sign cannot become an update. pub fn notation_js(expr: &str) -> String { let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + Notation::Op(sign @ ('+' | '-')) if expr.get(range.end..).is_some_and(|rest| rest.starts_with(sign)) => Some((range, format!("{sign} "))), _ => None, }); splice(expr, names) @@ -507,6 +590,10 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option *i += 1; Some(*n) } + T::Op('+') => { + *i += 1; + factor(t, i, d + 1) + } T::Op('-') => { *i += 1; factor(t, i, d + 1).map(|v| -v) @@ -558,3 +645,33 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn regex_scan_does_not_cross_line_break_after_backslash_or_split_utf8() { + let js = "x = /a\\\nthis.getField(\"price\").value / 2;"; + assert_eq!(calculation_reads(js), vec!["price".to_owned()]); + + let js_utf8 = "var r = /\\é/; event.value = this.getField(\"tax\").value;"; + assert_eq!(calculation_reads(js_utf8), vec!["tax".to_owned()]); + + let trailing_backslash = "event.value = this.getField(\"subtotal\").value + /unterminated\\"; + assert_eq!(calculation_reads(trailing_backslash), vec!["subtotal".to_owned()]); + let template_trailing = "event.value = this.getField(\"shipping\").value + `unterminated\\"; + assert_eq!(calculation_reads(template_trailing), vec!["shipping".to_owned()]); + } + + #[test] + fn eval_notation_supports_unary_plus() { + let c = Calculate::Notation("+a + (+b)".to_owned()); + let out = calculate(&c, &|name| match name { + "a" => vec!["3".to_owned()], + "b" => vec!["4".to_owned()], + _ => Vec::new(), + }); + assert_eq!(out.as_deref(), Some("7")); + } +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index f15714c411..8212da5bf2 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -17,7 +17,7 @@ impl Arg { match self { Arg::Num(n) => Some(*n), Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), + Arg::Str(s) => s.trim().parse::().ok().filter(|n| n.is_finite()), Arg::List(_) => None, } } @@ -99,17 +99,36 @@ impl Parser<'_> { let mut encoded = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); } + b'u' if self.s.get(self.i) == Some(&b'{') => { + self.i += 1; + let start = self.i; + let mut code = 0u32; + while let Some(&digit) = self.s.get(self.i).filter(|&&b| b != b'}') { + code = code.checked_mul(16)?.checked_add(char::from(digit).to_digit(16)?)?; + if code > 0x10FFFF { + return None; + } + self.i += 1; + } + if self.i == start || self.s.get(self.i) != Some(&b'}') { + return None; + } + self.i += 1; + // Match the existing handling of unpaired UTF-16 surrogates. + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } b'u' => { let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } + let code = if (0xD800..=0xDBFF).contains(&code) + && let Some(pair) = self.i.checked_add(6).and_then(|end| self.s.get(self.i..end)) + && pair.starts_with(b"\\u") + && let Some(low) = hex4(&pair[2..6]).filter(|low| (0xDC00..=0xDFFF).contains(low)) + { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) } else { u32::from(code) }; @@ -158,7 +177,7 @@ impl Parser<'_> { while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { self.i += 1; } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::().ok().filter(|n| n.is_finite()).map(Arg::Num) } } } @@ -196,15 +215,30 @@ impl Parser<'_> { } } -/// The first call to `name(` in `js` and its arguments. +/// The first genuine call to `name(` in `js` and its arguments. +/// An unsupported first call returns `None`, preserving the custom-script fallback; later calls +/// must not substitute a different native rule. Comments, strings and unrelated identifiers are skipped. pub(super) fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; + let (tokens, _) = js_tokens(js); + for (k, (range, tok)) in tokens.iter().enumerate() { + if *tok != super::calc::Js::Ident { + continue; + } + if k > 0 && matches!(tokens.get(k - 1), Some((_, super::calc::Js::Punct('.')))) { + continue; + } + let Some(target_end) = range.start.checked_add(name.len()) else { continue }; + if js.get(range.start..target_end) != Some(name) { + continue; + } + let Some(end_k) = (k..tokens.len()).take_while(|&m| tokens[m].0.end <= target_end).find(|&m| tokens[m].0.end == target_end) else { + continue; + }; + let Some((open, super::calc::Js::Punct('('))) = tokens.get(end_k + 1) else { continue }; + let mut p = Parser { s: js.as_bytes(), i: open.end, depth: 0 }; + return p.list(b')'); } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') + None } pub fn parse_format(js: &str) -> Option { @@ -246,7 +280,8 @@ pub fn parse_validate(js: &str) -> Option { let args = call(js, "AFRange_Validate")?; let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) + // An enabled but unparseable bound is unsupported, not an absent bound. + Some(Validate::Range { min: if flag(0) { Some(num(1)?) } else { None }, max: if flag(2) { Some(num(3)?) } else { None } }) } pub fn parse_calculate(js: &str) -> Option { @@ -361,3 +396,89 @@ pub fn calculate_js(c: &Calculate) -> Option { Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_first_validation_call_does_not_select_a_later_rule() { + let js = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + assert_eq!(parse_validate(js), None); + assert_eq!( + parse_validate("AFRange_Validate(true, 15, false, 0); AFRange_Validate(false, 0, true, 100);"), + Some(Validate::Range { min: Some(15.0), max: None }) + ); + } + + #[test] + fn validation_call_still_skips_non_calls_before_literal_arguments() { + let js = r#"// AFRange_Validate(true, 10 + 5, false, 0) + "AFRange_Validate(true, 10 + 5, false, 0)"; + my_AFRange_Validate(true, 10 + 5, false, 0); + other.AFRange_Validate(true, 10 + 5, false, 0); + AFRange_Validate(false, 0, true, 100);"#; + assert_eq!(parse_validate(js), Some(Validate::Range { min: None, max: Some(100.0) })); + } + + #[test] + fn unpaired_high_surrogate_at_end_of_string_does_not_abort_parsing() { + let js = "AFDate_FormatEx(\"\\uD800\")"; + assert_eq!(parse_format(js), Some(Format::Date("\u{FFFD}".to_owned()))); + } + + #[test] + fn call_skips_commented_or_prefixed_identifiers_and_rejects_infinite_numbers() { + let js = "// AFPercent_Format(0, 0);\nmy_AFPercent_Format(0, 0); $AFPercent_Format(0, 0); AFPercent_Format (2, 1);"; + assert_eq!(parse_format(js), Some(Format::Percent { decimals: 2, sep: 1 })); + + assert_eq!(parse_validate("AFRange_Validate(true, 1e500, false, 0);"), None); + } + #[test] + fn review_enabled_nonfinite_bound_is_unsupported() { + for script in [r#"AFRange_Validate(true, "1e500", false, 0)"#, r#"AFRange_Validate(false, 0, true, "-1e500")"#] { + assert_eq!(parse_validate(script), None); + } + assert_eq!(parse_validate(r#"AFRange_Validate(false, "1e500", false, "NaN")"#), Some(Validate::Range { min: None, max: None })); + } + + #[test] + fn review_format_after_postfix_division() { + for op in ["++", "--"] { + let js = format!("var y = x{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let commented = format!("var y = x/* comment */{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&commented), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("var of = 8; of / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!( + parse_format("for (var x of /AFPercent_Format(0, 0)/g) {} AFPercent_Format(2, 1);"), + Some(Format::Percent { decimals: 2, sep: 1 }) + ); + // A newline makes ++ a prefix operator; the following regex must remain opaque. + for js in ["x\n++/AFPercent_Format(2, 1)/.lastIndex;", "x/*\n*/++/AFPercent_Format(2, 1)/.lastIndex;"] { + assert_eq!(parse_format(js), None); + } + } + + #[test] + fn review_format_after_brace_unicode() { + assert_eq!(parse_format(r#"var s = "\u{1F600}"; AFPercent_Format(2, 1);"#), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{1F600}")"#), Some(Format::Date("😀".to_owned()))); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{00000041}\u0042\uD83D\uDE00")"#), Some(Format::Date("AB😀".to_owned()))); + for escape in ["\\u{}", "\\u{110000}", "\\u{xyz}", "\\u{41"] { + assert_eq!(parse_format(&format!("AFDate_FormatEx(\"{escape}\")")), None); + } + } + + #[test] + fn review_notation_separates_adjacent_operators() { + let field = |name| format!("AFMakeNumber(this.getField(\"{name}\").value)"); + for (expr, operators) in [("a++b", "+ +"), ("a--b", "- -"), ("a+++b", "+ + +")] { + let js = calculate_js(&Calculate::Notation(expr.to_owned())).unwrap(); + let executable = js.split("event.value = ").nth(1).unwrap(); + assert_eq!(executable, format!("{}{operators}{};", field("a"), field("b"))); + assert_eq!(super::super::calc::calculate(&Calculate::Notation(expr.to_owned()), &|_| vec!["2".to_owned()]), Some("4".to_owned())); + } + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 5e8c4f085f..3c6dab1bef 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -315,6 +315,8 @@ fn a_calculation_reads_each_named_field_once() { assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); } +/// Number formats show separators, decimals, currency and negatives as AFNumber_Format does, +/// and the keystroke check stores the number. #[test] fn numbers_format_like_acrobat() { let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; @@ -335,6 +337,36 @@ fn numbers_format_like_acrobat() { assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); } +/// A date field laid out for an XFA form keeps text its pattern doesn't read, as typed, and +/// shows a date only for a value that is one: in the pattern already, or an ISO date. Another +/// viewer keeps and shows such text the same way on a synthetic form; an ordinary date field +/// still refuses it. +#[test] +fn xfa_date_fields_keep_and_show_what_their_pattern_does_not_read() { + let xfa = |fmt: &str| Actions { format: Format::Date(fmt.into()), xfa_date: true, ..Actions::default() }; + let plain = |fmt: &str| Actions { format: Format::Date(fmt.into()), ..Actions::default() }; + for (fmt, typed) in [("yyyy-mm-dd", "10/18/2016"), ("mm/dd/yyyy", "xyz"), ("yyyymmdd", "10/18/2016")] { + assert_eq!(xfa(fmt).keystroke("Date", typed), Ok(typed.to_string()), "{typed} in {fmt}"); + assert!(plain(fmt).keystroke("Date", typed).is_err(), "an ordinary date field refuses {typed}"); + } + assert_eq!(xfa("mm/dd/yyyy").keystroke("Date", " pending "), Ok(" pending ".to_string()), "spaces and all"); + // Shown: in the pattern, as it is; an ISO date, in the pattern; anything else as typed, + // where an ordinary field's looser reading would show a date the value doesn't hold. + let ymd = xfa("yyyymmdd"); + assert_eq!(ymd.format_value("20161018"), "20161018"); + assert_eq!(ymd.format_value("2016-10-18"), "20161018"); + assert_eq!(ymd.format_value("26 10 18"), "26 10 18"); + assert_ne!(plain("yyyymmdd").format_value("26 10 18"), "26 10 18", "read loosely as a date"); + let mdy = xfa("mm/dd/yyyy"); + assert_eq!(mdy.format_value("2016-10-18"), "10/18/2016"); + assert_eq!(mdy.format_value("20161018"), "10/18/2016"); + assert_eq!(mdy.format_value("10/18/2016"), "10/18/2016"); + assert_eq!(mdy.format_value("xyz"), "xyz"); + assert_eq!(mdy.format_value("2016-02-30"), "2016-02-30", "not a real day: as typed"); + assert_eq!(xfa("ddmmmyy").format_value("18OCT16"), "18OCT16", "in the pattern, its case kept"); +} + +/// Date, time and mask formats show values in their pattern and refuse what doesn't fit. #[test] fn dates_times_and_masks() { let d = Format::Date("mm/dd/yyyy".into()); @@ -412,3 +444,45 @@ fn validation_and_calculation() { assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); } + +/// Async for-of headers have the same regex-literal contexts as ordinary for-of headers. +#[test] +fn for_await_regex_literals_do_not_become_format_calls() { + for header in ["for await", "for /* gap */ await"] { + let js = format!("async function f() {{ {header} (const x of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let body = format!("async function f() {{ {header} (const x of xs) /AFPercent_Format(0, 0)/.test(x); }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&body), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("obj.await(x) / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); +} + +#[test] +fn identifier_of_in_conditions_keeps_division_and_real_format_calls() { + for header in [ + "if (of / 2)", + "while (of / 2)", + "with (of / 2)", + "for (of / 2; ; )", + "for (var x = of / 2; ; )", + "for (var x = 0; of / 2; )", + "for (x of of / 2)", + "for (const x of of / 2)", + "for (x in of / 2)", + "for (var of / 2; ; )", + "for (obj.of / 2; ; )", + ] { + let js = format!("{header} AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header}"); + } +} + +#[test] +fn destructured_for_of_targets_keep_regex_iterables_opaque() { + for header in ["for", "for await"] { + for target in ["const [x]", "const {x}", "(x)", "const {x = of / 2}", "obj.in", "obj.of", "const of"] { + let js = format!("async function f() {{ {header} ({target} of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header} {target}"); + } + } +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 891a533eaa..53c43a8226 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -782,10 +782,10 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] && values.len() == 1 && f.actions.format != crate::af::Format::None { - let shown = crate::af::format_value(&f.actions.format, &values[0]); + let shown = f.actions.format_value(&values[0]); // Month and weekday names in the date language that no font here can draw ("окт." in // Helvetica) are drawn in English rather than as `?` (#5303). - let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES); + let english = || f.actions.format_value_in(&values[0], &crate::af::ENGLISH_DATE_NAMES); let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_)); formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }]; &formatted[..] diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index de597187d4..2c26d265a5 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -123,10 +123,13 @@ pub struct FieldProps { pub rotation: Option<(usize, i64)>, } +/// A [`FormError::Invalid`] with message `m`. fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } +/// Put `value` into field `f` as its kind takes one (a text value through the field's Keystroke +/// and Validate events and its scripts first), and redraw the field. fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index e9fedbd655..b8c2520dd6 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -5,16 +5,17 @@ use std::collections::{HashMap, HashSet}; use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; -use super::tree::{detach, list_of}; +use super::tree::{ancestor_sets, detach, list_of}; use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; /// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` /// keeps only the widget's own triggers. const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; -/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +/// Whether `field` holds or inherits `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). fn has_opt(doc: &Document, field: ObjRef) -> bool { - doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) + doc.get(field).as_dict().and_then(|d| d.get(b"Opt")).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) + || ancestor_sets(doc, field, b"Opt") } /// Refuse what [`join_radio_group`] can't do, before anything is written. @@ -127,3 +128,26 @@ fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { d.set(b"AP".to_vec(), Object::Dict(ap)); true } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_opt_ignores_null_and_detects_inherited_opt() { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"Opt".to_vec(), Object::Array(vec![])); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut f_null = Dict::new(); + f_null.set(b"Opt".to_vec(), Object::Null); + let f_null_ref = doc.add(Object::Dict(f_null)); + assert!(!has_opt(&doc, f_null_ref)); + + let mut f_inherited = Dict::new(); + f_inherited.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + let f_inherited_ref = doc.add(Object::Dict(f_inherited)); + assert!(has_opt(&doc, f_inherited_ref)); + } +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index ce4ee687bb..55b84fa390 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -112,6 +112,11 @@ pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Ok(()) } +/// Whether `d` has a non-null entry `key` (a `null` entry is absent per ISO 32000-2 §7.3.9). +fn dict_has(doc: &Document, d: &Dict, key: &[u8]) -> bool { + d.get(key).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) +} + /// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is /// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep /// as the field walk goes, so a cyclic chain ends. @@ -120,7 +125,7 @@ fn holds_value(doc: &Document, r: ObjRef) -> bool { for _ in 0..=64 { let obj = doc.get(at); let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { + if dict_has(doc, d, b"V") { return true; } match d.reference(b"Parent") { @@ -160,8 +165,8 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo /// The most parts a dotted field name may have ("a.b.c" has three). const MAX_NAME_PARTS: usize = 32; -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1, §12.7.5.2). +const INHERITED_KEYS: [&[u8]; 8] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen", b"Opt"]; /// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a /// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). @@ -234,7 +239,7 @@ pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&s break; }; // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| dict_has(doc, d, k) && !dict_has(doc, &owned, k))); if held { return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); } @@ -259,7 +264,7 @@ pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&st for part in parents { let next = match child_named(doc, af, parent, part) { Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + inherits |= doc.get(r).as_dict().is_some_and(|d| dict_has(doc, d, b"Ff") || dict_has(doc, d, b"Q")); r } None => { @@ -308,12 +313,20 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { } let Some(d) = doc.get(p).as_dict().cloned() else { break }; for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + if let Some(v) = d.get(k).filter(|_| dict_has(doc, &d, k) && !dict_has(doc, &own, k) && !add.contains(k)) { add.set(k.to_vec(), v.clone()); } } parent = d.reference(b"Parent"); } + // /Opt is inheritable for check boxes and radio buttons (Table 230), not choice + // fields (Table 234). Classify by effective /FT and /Ff before materializing it. + let effective = |key: &[u8]| if dict_has(doc, &own, key) { own.get(key) } else { add.get(key) }; + let button = effective(b"FT").is_some_and(|v| doc.resolve(v).as_name() == Some(b"Btn")); + let flags = effective(b"Ff").and_then(|v| doc.resolve(v).as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if !button || flags & crate::flags::PUSH_BUTTON != 0 { + add.remove(b"Opt"); + } add } @@ -327,7 +340,7 @@ pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { return false; } let Some(d) = doc.get(p).as_dict().cloned() else { return false }; - if d.contains(key) { + if dict_has(doc, &d, key) { return true; } parent = d.reference(b"Parent"); @@ -396,3 +409,162 @@ pub(super) fn top_names(doc: &Document) -> Vec { let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// /Opt is inheritable only for check boxes and radio buttons, using effective FT/Ff. + #[test] + fn inherited_options_are_limited_to_checkboxes_and_radios() { + for (ft, flags, expected) in + [("Btn", 0, true), ("Btn", crate::flags::RADIO, true), ("Btn", crate::flags::PUSH_BUTTON, false), ("Ch", 0, false), ("Tx", 0, false)] + { + for own_type in [false, true] { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name(ft)); + parent.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + parent.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("export"))])); + parent.set(b"DA".to_vec(), PdfString::text("/Helv 10 Tf 0 g")); + let p = doc.add(parent); + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(p)); + if own_type { + child.set(b"FT".to_vec(), Object::name(ft)); + child.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + } + let c = doc.add(child); + let inherited = inherited(&doc, c); + assert_eq!(inherited.contains(b"Opt"), expected, "{ft}, flags {flags}, own {own_type}"); + assert!(inherited.contains(b"DA")); + } + } + } + + fn one_page() -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let page = doc.add(page); + 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(); + doc + } + + fn reopen(doc: &Document) -> Document { + let bytes = pdfcraft_cos::write_incremental(doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + Document::open(std::sync::Arc::new(bytes)).unwrap() + } + + /// Moving a choice field must not turn its ancestor's unrelated /Opt into local choices. + #[test] + fn moving_choice_fields_does_not_copy_ancestor_options() { + for combo in [false, true] { + for own_options in [false, true] { + let mut doc = one_page(); + let options = if own_options { vec!["local".into()] } else { Vec::new() }; + let kind = if combo { crate::NewField::Combo { options, editable: false } } else { crate::NewField::List { options, multi: false } }; + crate::add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &kind, Some("old.pick")).unwrap(); + let f = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap(); + let parent = doc.get(f.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + if !own_options { + doc.update_dict(f.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + } + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("borrowed"))]))).unwrap(); + let before = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap().options; + crate::set_props(&mut doc, "old.pick", &crate::FieldProps { name: Some("new.pick".into()), ..Default::default() }).unwrap(); + let after = crate::fields(&doc).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(doc.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + let reopened = reopen(&doc); + let after = crate::fields(&reopened).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(reopened.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + } + } + } + + #[test] + fn moving_buttons_preserves_inherited_export_mapping() { + for kind in [crate::NewField::CheckBox, crate::NewField::Radio { group: None, export: "0".into() }] { + let mut doc = one_page(); + crate::add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &kind, Some("old.button")).unwrap(); + let field = crate::fields(&doc).into_iter().find(|f| f.name == "old.button").unwrap(); + let state = field.widgets.first().unwrap().on_state.clone().unwrap(); + let parent = doc.get(field.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + let opt = Object::Array(vec![Object::String(PdfString::text("exported"))]); + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), opt.clone())).unwrap(); + doc.update_dict(field.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + crate::set_props(&mut doc, "old.button", &crate::FieldProps { name: Some("new.button".into()), ..Default::default() }).unwrap(); + for doc in [&doc, &reopen(&doc)] { + let after = crate::fields(doc).into_iter().find(|f| f.name == "new.button").unwrap(); + assert_eq!(doc.get(after.obj).as_dict().unwrap().get(b"Opt"), Some(&opt)); + assert_eq!(after.export_of(0), Some("exported")); + assert_eq!(after.state_for("exported"), Some(state.as_str())); + } + } + } + + #[test] + fn null_entries_on_parents_and_unsigned_signatures_are_treated_as_absent() { + let mut doc = Document::new_empty(); + let mut sig = Dict::new(); + sig.set(b"FT".to_vec(), Object::name("Sig")); + sig.set(b"T".to_vec(), PdfString::text("sig")); + sig.set(b"V".to_vec(), Object::Null); + let sig_ref = doc.add(Object::Dict(sig)); + assert!(!holds_value(&doc, sig_ref)); + + let mut grand = Dict::new(); + grand.set(b"FT".to_vec(), Object::name("Tx")); + grand.set(b"MaxLen".to_vec(), Object::Int(10)); + let grand_ref = doc.add(Object::Dict(grand)); + + let mut parent = Dict::new(); + parent.set(b"Parent".to_vec(), Object::Ref(grand_ref)); + parent.set(b"T".to_vec(), PdfString::text("group")); + parent.set(b"V".to_vec(), Object::Null); + parent.set(b"MaxLen".to_vec(), Object::Null); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + child.set(b"T".to_vec(), PdfString::text("item")); + let child_ref = doc.add(Object::Dict(child)); + + let inh = inherited(&doc, child_ref); + assert_eq!(inh.get(b"MaxLen"), Some(&Object::Int(10))); + assert!(!inh.contains(b"V")); + assert!(!ancestor_sets(&doc, child_ref, b"V")); + assert!(ancestor_sets(&doc, child_ref, b"MaxLen")); + + // Button /Opt is inherited, text /Opt is not; null kids and /Parent cycles are handled. + let opt_val = Object::Array(vec![Object::String(PdfString::text("Choice1"))]); + doc.update_dict(grand_ref, |d| { + d.set(b"Opt".to_vec(), opt_val.clone()); + d.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Null, Object::Ref(parent_ref)])); + }) + .unwrap(); + assert!(!inherited(&doc, child_ref).contains(b"Opt")); + doc.update_dict(grand_ref, |d| d.set(b"FT".to_vec(), Object::name("Btn"))).unwrap(); + let inh_cycle = inherited(&doc, child_ref); + assert_eq!(inh_cycle.get(b"Opt"), Some(&opt_val)); + assert!(!holds_value(&doc, child_ref)); + assert_eq!(child_named(&doc, grand_ref, Some(grand_ref), "group"), Some(parent_ref)); + } +} diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 94ad7adb37..99f2447c02 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -391,6 +391,8 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { /// enough: dates split into day and month groups of two cells. Cells are square or taller /// than wide; an empty table of wider cells is not a comb. fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + // A repeated box adds nothing to a row, but every repeat would be searched against every cell. + let mut seen = std::collections::HashSet::new(); let cells: Vec<[f64; 4]> = boxes .iter() .copied() @@ -398,6 +400,7 @@ fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { let (w, h) = (r[2] - r[0], r[3] - r[1]); (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 }) + .filter(|r| seen.insert(r.map(f64::to_bits))) .collect(); // `b` comes right after `a` in a row: same size, its left edge on a's right edge. let follows = |a: &[f64; 4], b: &[f64; 4]| { @@ -521,3 +524,17 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S } out } + +#[cfg(test)] +mod comb_tests { + use super::combs; + + #[test] + fn repeated_boxes_do_not_change_or_multiply_a_comb() { + let row = [[10.0, 10.0, 20.0, 22.0], [20.0, 10.0, 30.0, 22.0], [30.0, 10.0, 40.0, 22.0]]; + let mut many = row.to_vec(); + many.extend(std::iter::repeat_n(row[1], 5000)); + assert_eq!(combs(&row), vec![([10.0, 10.0, 40.0, 22.0], 3)]); + assert_eq!(combs(&many), combs(&row)); + } +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index 775306d865..59d040f3df 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -229,6 +229,29 @@ impl Field { } } + /// Whether widget `i` of a check box or radio group is on: its `/AS` is its on state. Widgets + /// sharing an on state tell themselves apart only by `/AS`, which ISO 32000-2 §12.7.5.2.3 and + /// §12.7.5.2.4 make the deciding entry where it and `/V` differ. A widget without `/AS` + /// follows the field's value, except that in a radio group without RadiosInUnison at most one + /// button is on (Table 229): of the widgets sharing that state, none when one of them has it + /// as its `/AS`, else only the first without `/AS`. + pub fn widget_on(&self, i: usize) -> bool { + let Some(w) = self.widgets.get(i) else { return false }; + let Some(on) = w.on_state.as_deref() else { return false }; + if let Some(s) = w.state.as_deref() { + return s == on; + } + if self.value.first().map(String::as_str) != Some(on) { + return false; + } + if self.kind != FieldKind::Radio || self.has(flags::RADIOS_IN_UNISON) { + return true; + } + let sharing = |x: &Widget| x.on_state.as_deref() == Some(on); + !self.widgets.iter().any(|x| sharing(x) && x.state.as_deref() == Some(on)) + && self.widgets.iter().position(|x| sharing(x) && x.state.is_none()) == Some(i) + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { @@ -249,8 +272,13 @@ pub enum FieldValue { Text(String), /// Check box: checked or not. Check(bool), - /// Radio group: the on-state to select (`None`: none selected). + /// Radio group: the on-state to select (`None`: none selected). Of radio buttons sharing that + /// state, the one already on stays on, else the first, unless the group has RadiosInUnison. Radio(Option), + /// Check box or radio group: turn on widget `i` (an index into [`Field::widgets`]); it never + /// turns it off. A radio button sharing its on state with others is the only one turned on + /// unless the group has RadiosInUnison (ISO 32000-2 Table 229). + Widget(usize), /// Combo box or list box: export values (several only for multi-select lists). Choice(Vec), } diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 27dbc2d224..6fcb46f2e1 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -174,7 +174,11 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Decode .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) .and_then(|a| a.as_dict().cloned()) }); - let mut out = af::Actions::default(); + let mut out = af::Actions { + // A date field the XFA layout generated: what its format doesn't read is kept as typed. + xfa_date: matches!(d.get(b"PCXfaDate").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))), + ..af::Actions::default() + }; let Some(aa) = aa else { return out }; let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 5b60e3927b..5c46293303 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -137,23 +137,34 @@ pub fn tab_orders(doc: &Document) -> Vec { /// annotations keep their places. pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + if !all.iter().any(|f| f.name == name) { + return Err(FormError::NoSuchField(name.into())); + } + let (target_fi, page) = all + .iter() + .enumerate() + .filter(|(_, f)| f.name == name) + .find_map(|(fi, f)| Some((fi, f.widgets.iter().filter_map(|w| w.page).min()?))) + .ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + let mut order: Vec<(usize, usize)> = all + .iter() + .enumerate() + .filter_map(|(fi, g)| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, fi))) + .collect(); order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(i) = order.iter().position(|&(_, fi)| fi == target_fi) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < order.len()) }; let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. + order.swap(i, j); + // The widgets of this page in the new order, preserving each field's relative widget tab order. let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + let mut queued = std::collections::HashSet::new(); + for &(_, fi) in &order { + if let Some(g) = all.get(fi) { + let mut own: Vec<(usize, ObjRef)> = g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| (w.tab, w.obj)).collect(); + own.sort_by_key(|&(tab, _)| tab); + widgets.extend(own.into_iter().map(|(_, obj)| obj).filter(|obj| queued.insert(*obj))); } } let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; @@ -162,10 +173,11 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); let mut next = widgets.into_iter(); let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let mut seen_slots = std::collections::HashSet::new(); let new_list: Vec = list .iter() .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + Some(r) if set.contains(&r) && seen_slots.insert(r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), _ => o.clone(), }) .collect(); @@ -178,3 +190,85 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::{Dict, PdfString}; + + #[test] + fn move_in_tab_order_preserves_multi_widget_relative_tab_order() { + let mut doc = Document::new_empty(); + let pages_ref = doc.add(Object::Null); + let page_ref = doc.add(Object::Null); + + let mut w1 = Dict::new(); + w1.set(b"Subtype".to_vec(), Object::name("Widget")); + w1.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(200), Object::Int(700), Object::Int(220), Object::Int(720)])); + let w1_ref = doc.add(Object::Dict(w1)); + + let mut w2 = Dict::new(); + w2.set(b"Subtype".to_vec(), Object::name("Widget")); + w2.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(700), Object::Int(70), Object::Int(720)])); + let w2_ref = doc.add(Object::Dict(w2)); + + let mut radio = Dict::new(); + radio.set(b"FT".to_vec(), Object::name("Btn")); + radio.set(b"Ff".to_vec(), Object::Int(1 << 15)); + radio.set(b"T".to_vec(), PdfString::text("choice")); + // /Kids order is [w1 (right), w2 (left)], opposite to /R (Row) left-to-right order. + radio.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref)])); + let radio_ref = doc.add(Object::Dict(radio)); + doc.update_dict(w1_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + doc.update_dict(w2_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + + let mut text = Dict::new(); + text.set(b"FT".to_vec(), Object::name("Tx")); + text.set(b"Subtype".to_vec(), Object::name("Widget")); + text.set(b"T".to_vec(), PdfString::text("note")); + text.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(600), Object::Int(150), Object::Int(620)])); + let text_ref = doc.add(Object::Dict(text)); + + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page.set(b"Tabs".to_vec(), Object::name("R")); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref), Object::Ref(text_ref)])); + doc.set(page_ref, Object::Dict(page)); + + let mut pages = Dict::new(); + pages.set(b"Type".to_vec(), Object::name("Pages")); + pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page_ref)])); + pages.set(b"Count".to_vec(), Object::Int(1)); + doc.set(pages_ref, Object::Dict(pages)); + + let mut af = Dict::new(); + af.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref)])); + let af_ref = doc.add(Object::Dict(af)); + + let mut catalog = Dict::new(); + catalog.set(b"Type".to_vec(), Object::name("Catalog")); + catalog.set(b"Pages".to_vec(), Object::Ref(pages_ref)); + catalog.set(b"AcroForm".to_vec(), Object::Ref(af_ref)); + let root_ref = doc.root().unwrap(); + doc.set(root_ref, Object::Dict(catalog)); + + // Move "note" earlier (before "choice"): "choice"'s own buttons should stay in their + // /R tab order (w2 then w1), not revert to /Kids order (w1 then w2). + move_in_tab_order(&mut doc, "note", true).unwrap(); + let annots = doc.get(page_ref).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(annots, vec![Object::Ref(text_ref), Object::Ref(w2_ref), Object::Ref(w1_ref)]); + + // Missing page and unplaced field return errors cleanly. + assert!(set_tab_order(&mut doc, &[99], TabOrder::Row).is_err()); + let mut unplaced = Dict::new(); + unplaced.set(b"FT".to_vec(), Object::name("Tx")); + unplaced.set(b"T".to_vec(), PdfString::text("orphan")); + let unplaced_ref = doc.add(Object::Dict(unplaced)); + doc.update_dict(af_ref, |d| { + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref), Object::Ref(unplaced_ref)])); + }) + .unwrap(); + assert!(move_in_tab_order(&mut doc, "orphan", true).is_err()); + } +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index e26fcc0fd3..f00d1cad9a 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1701,6 +1701,43 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. +#[test] +fn unsupported_first_validation_call_preserves_custom_script_dispatch() { + const JS: &str = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + struct RejectingInterpreter { + calls: usize, + } + impl Scripts for RejectingInterpreter { + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult { + assert_eq!(event, FieldEvent::Validate); + assert_eq!(script, JS); + assert_eq!(target.name, "amount"); + self.calls += 1; + ScriptResult { rc: false, value: value.into(), changes: Vec::new(), message: Some("custom validation ran".into()) } + } + } + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("amount")).unwrap(); + set_value(&mut doc, "amount", &FieldValue::Text("20".into())).unwrap(); + set_field_script(&mut doc, "amount", "validate", Some(JS)).unwrap(); + let reopened = reopen(&doc); + for mut doc in [doc, reopened] { + let all = fields(&doc); + let actions = &field(&all, "amount").actions; + assert_eq!(actions.validate, af::Validate::None); + assert_eq!(actions.scripts.validate.as_deref(), Some(JS)); + assert!(actions.unsupported.contains(&"validate")); + let mut scripts = RejectingInterpreter { calls: 0 }; + assert_eq!( + set_value_with(&mut doc, "amount", &FieldValue::Text("10".into()), &mut scripts), + Err(FormError::Invalid("custom validation ran".into())) + ); + assert_eq!(scripts.calls, 1); + assert_eq!(field(&fields(&doc), "amount").value, ["20"]); + } +} + /// Renaming a field renames it in the calculations that read it: a sum dropped it, notation /// read it as 0 and a script read a field that no longer existed. #[test] @@ -6393,6 +6430,123 @@ fn locked_button_export_edits_leave_the_document_unchanged() { } } +/// Three radio groups whose buttons have the on states given (`S`, `M` … or a repeated `Yes`): +/// `free` (Radio), `plain` (Radio | NoToggleToOff) and `unison` (… | RadiosInUnison). +fn radio_groups() -> Document { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 10 0 R 11 0 R 13 0 R 14 0 R 15 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /T (free) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 + ]; + let kid = |parent: usize, x: u32, state: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /{state} 16 0 R /Off 17 0 R >> >> >>", + x + 15 + ) + }; + objs.push(kid(5, 100, "S")); // 6 + objs.push(kid(5, 200, "M")); // 7 + objs.push("<< /FT /Btn /T (plain) /Ff 49152 /V /Off /Kids [9 0 R 10 0 R 11 0 R] >>".into()); // 8 + objs.push(kid(8, 100, "Yes")); // 9 + objs.push(kid(8, 200, "Yes")); // 10 + objs.push(kid(8, 300, "No")); // 11 + objs.push("<< /FT /Btn /T (unison) /Ff 33603584 /V /Off /Kids [13 0 R 14 0 R 15 0 R] >>".into()); // 12 + objs.push(kid(12, 100, "Yes")); // 13 + objs.push(kid(12, 200, "Yes")); // 14 + objs.push(kid(12, 300, "No")); // 15 + objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 16 + objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 17 + document(&objs) +} + +/// Which widgets of field `name` are on, and its value. +fn on_widgets(doc: &Document, name: &str) -> (Vec, Vec) { + let all = fields(doc); + let f = field(&all, name); + ((0..f.widgets.len()).map(|i| f.widget_on(i)).collect(), f.value.clone()) +} + +/// ISO 32000-2 Table 229, RadiosInUnison: "If clear, the buttons are mutually exclusive"; and +/// §12.7.5.2.3: without it "at most one radio button in a field shall be set at a time". Two +/// buttons sharing the on state `Yes` both show `/V /Yes`: their `/AS` alone says which is on, +/// and it survives a save. +#[test] +fn radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison() { + let mut doc = radio_groups(); + set_value(&mut doc, "plain", &FieldValue::Widget(0)).unwrap(); + assert_eq!(on_widgets(&doc, "plain"), (vec![true, false, false], vec!["Yes".to_string()]), "only the clicked button"); + set_value(&mut doc, "plain", &FieldValue::Widget(1)).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(on_widgets(&doc, "plain"), (vec![false, true, false], vec!["Yes".to_string()]), "the other Yes, after a save"); + // Setting the value by its name keeps the button already on. + set_value(&mut doc, "plain", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + assert_eq!(on_widgets(&doc, "plain").0, [false, true, false]); + set_value(&mut doc, "plain", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "plain"), (vec![false, false, true], vec!["No".to_string()])); + // From another state, the name turns on the first button that has it. + set_value(&mut doc, "plain", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + assert_eq!(on_widgets(&doc, "plain").0, [true, false, false]); + // NoToggleToOff still keeps one on. + assert!(matches!(set_value(&mut doc, "plain", &FieldValue::Radio(None)), Err(FormError::Invalid(_)))); + // RadiosInUnison: the buttons sharing the state turn on and off together. + set_value(&mut doc, "unison", &FieldValue::Widget(1)).unwrap(); + assert_eq!(on_widgets(&doc, "unison"), (vec![true, true, false], vec!["Yes".to_string()])); + set_value(&mut doc, "unison", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "unison").0, [false, false, true]); + // Without NoToggleToOff the group can be cleared. + set_value(&mut doc, "free", &FieldValue::Widget(0)).unwrap(); + assert_eq!(on_widgets(&doc, "free"), (vec![true, false], vec!["S".to_string()])); + set_value(&mut doc, "free", &FieldValue::Radio(None)).unwrap(); + assert_eq!(on_widgets(&doc, "free"), (vec![false, false], vec![])); + // A widget that isn't there, or a field that isn't a button, is refused. + assert!(matches!(set_value(&mut doc, "free", &FieldValue::Widget(9)), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "free", &FieldValue::Widget(usize::MAX)), Err(FormError::Invalid(_)))); +} + +/// A radio group whose buttons share the on state `Yes`, have no `/AS` and whose `/V` is `Yes`: +/// field flags `ff` on the field, entries `own` on the second button's own dictionary. +fn radio_without_as(ff: u32, own: &str) -> Document { + document(&[ + "<< /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 [6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /FT /Btn /T (r) /Ff {ff} /V /Yes /Kids [6 0 R 7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [100 700 115 715] /P 3 0 R /AP << /N << /Yes 9 0 R /Off 10 0 R >> >> >>".into(), + format!("<< /Type /Annot /Subtype /Widget /Parent 5 0 R {own} /Rect [200 700 215 715] /P 3 0 R /AP << /N << /Yes 9 0 R /Off 10 0 R >> >> >>"), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [300 700 315 715] /P 3 0 R /AP << /N << /No 9 0 R /Off 10 0 R >> >> >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + ]) +} + +/// Table 229: without RadiosInUnison the buttons are mutually exclusive. Buttons sharing the +/// state `/V` names and lacking `/AS` can't all be on: the first is, as a set by name picks it. +#[test] +fn radio_buttons_without_as_sharing_the_value_show_one_on() { + let doc = radio_without_as(49152, ""); + assert_eq!(on_widgets(&doc, "r").0, [true, false, false]); + // RadiosInUnison: they are on together. + let doc = radio_without_as(33603584, ""); + assert_eq!(on_widgets(&doc, "r").0, [true, true, false]); + // One that has `/AS` naming the state is the one on. + let doc = radio_without_as(49152, "/AS /Yes"); + assert_eq!(on_widgets(&doc, "r").0, [false, true, false]); +} + +/// Table 227, ReadOnly: a processor "shall not allow a user to change the value". A button whose own +/// dictionary sets it can't be turned on by number. +#[test] +fn a_read_only_button_is_not_turned_on_by_number() { + let mut doc = radio_without_as(49152, "/Ff 1"); + assert!(matches!(set_value(&mut doc, "r", &FieldValue::Widget(1)), Err(FormError::ReadOnly(_)))); + assert_eq!(on_widgets(&doc, "r").0, [true, false, false]); + set_value(&mut doc, "r", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "r").0, [false, false, true]); +} + /// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C` /// colours are read (PDFBOX-2699), not RGB from the three numbers left; three numbers stay RGB. #[test] diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 356a8f8b75..d37be9711c 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -139,11 +139,11 @@ fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String match twin.kind { FieldKind::CheckBox => { let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str()), None) } FieldKind::Radio => { let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str))?; + set_states(doc, twin, choice.map(String::as_str), None)?; // The same value (§12.7.4.2), even when none of the twin's buttons draws it: they all // stay off, and only the field that draws the choice shows it. match value.first() { @@ -278,7 +278,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr return invalid(format!("{:?} can't take this text: {why}", f.name)); } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; + let t = &f.actions.keystroke(&f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); @@ -292,6 +292,16 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr })?; redraw(doc, f, std::slice::from_ref(t), scripts) } + (FieldKind::CheckBox | FieldKind::Radio, FieldValue::Widget(i)) => { + // A button can be read-only on its own (its own `/Ff`), though the field isn't. + if f.widgets.get(*i).is_some_and(|w| w.read_only) { + return Err(FormError::ReadOnly(f.name.clone())); + } + let Some(state) = f.widgets.get(*i).and_then(|w| w.on_state.clone()) else { + return invalid(format!("{:?} has no button {} with an on state", f.name, i.saturating_add(1))); + }; + set_states(doc, f, Some(&state), Some(*i)) + } (FieldKind::CheckBox, v) => { let state = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); let on = match v { @@ -308,9 +318,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + FieldValue::Choice(_) | FieldValue::Widget(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), }; - set_states(doc, f, on.then_some(state.as_str())) + set_states(doc, f, on.then_some(state.as_str()), None) } (FieldKind::Radio, v) => { let choice = match v { @@ -331,7 +341,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { return invalid(format!("{:?} must keep one option selected", f.name)); } - set_states(doc, f, choice.as_deref()) + set_states(doc, f, choice.as_deref(), None) } (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { let vals: Vec = match value { @@ -425,13 +435,24 @@ pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], co Ok(()) } -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. The widgets whose on +/// state is `on` turn on together in a check box (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2) and in a +/// radio group with RadiosInUnison. Without it, at most one radio button is on (Table 229, +/// §12.7.5.2.3): widget `pick` when it has that state, else the one already on, else the first. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>, pick: Option) -> Result<(), FormError> { + let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON); + let chosen = on.filter(|_| exclusive).and_then(|c| { + let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c)); + let n = f.widgets.len(); + pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has)) + }); + // `/V` names the on state, the same for every widget that has it: which of them is on is + // their `/AS` alone (§12.7.5.2.4). let v = on.unwrap_or("Off"); doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { + for (i, w) in f.widgets.iter().enumerate() { let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, + (Some(c), Some(s)) if c == s && chosen.is_none_or(|k| k == i) => s, _ => "Off", }; // A widget without appearances for its states gets PdfCraft's own. diff --git a/crates/geom/src/perspective.rs b/crates/geom/src/perspective.rs index 96aa663e25..726d58326b 100644 --- a/crates/geom/src/perspective.rs +++ b/crates/geom/src/perspective.rs @@ -81,29 +81,49 @@ fn solve8(mut m: [[f64; 9]; 8]) -> Option<[f64; 8]> { Some(out) } +/// Axis-aligned `[min_x, min_y, max_x, max_y]` bounding box of finite `points`. +fn bounds(points: &[Point]) -> Option<[f64; 4]> { + let &[first, ref rest @ ..] = points else { return None }; + if points.iter().flatten().any(|v| !v.is_finite()) { + return None; + } + Some(rest.iter().fold([first[0], first[1], first[0], first[1]], |[x0, y0, x1, y1], &[x, y]| [x0.min(x), y0.min(y), x1.max(x), y1.max(y)])) +} + /// The four corners in reading order for a y-down space (view or raster pixels): top-left, /// top-right, bottom-right, bottom-left, whatever order they were given in. `None` unless they /// form a convex quadrilateral with a non-negligible area. pub fn order_quad(points: [Point; 4]) -> Option<[Point; 4]> { - if points.iter().flatten().any(|v| !v.is_finite()) { + let [min_x, min_y, max_x, max_y] = bounds(&points)?; + let span = max_x - min_x; + let rise = max_y - min_y; + let size = span.max(rise); + if span <= 0.0 || rise <= 0.0 || !size.is_finite() { return None; } - let cx = points.iter().map(|p| p[0]).sum::() / 4.0; - let cy = points.iter().map(|p| p[1]).sum::() / 4.0; + let norm = |p: Point| [(p[0] - min_x) / size, (p[1] - min_y) / size]; + let cx = points.iter().map(|p| norm(*p)[0]).sum::() / 4.0; + let cy = points.iter().map(|p| norm(*p)[1]).sum::() / 4.0; let mut q = points; // Ascending angle about the centre is clockwise on screen (y down): top, right, bottom, left. - q.sort_by(|a, b| (a[1] - cy).atan2(a[0] - cx).total_cmp(&(b[1] - cy).atan2(b[0] - cx))); - let first = (0..4).min_by(|&a, &b| (q[a][0] + q[a][1]).total_cmp(&(q[b][0] + q[b][1])))?; + q.sort_by(|a, b| { + let (na, nb) = (norm(*a), norm(*b)); + (na[1] - cy).atan2(na[0] - cx).total_cmp(&(nb[1] - cy).atan2(nb[0] - cx)) + }); + // Score each candidate rotation by its normalised left-edge x and top-edge y. + let score = |i: usize| { + let (tl, tr, bl) = (q[i], q[(i + 1) % 4], q[(i + 3) % 4]); + ((tl[0] - min_x) + (bl[0] - min_x)) / span + ((tl[1] - min_y) + (tr[1] - min_y)) / rise + }; + let first = (0..4).min_by(|&a, &b| score(a).total_cmp(&score(b)))?; q.rotate_left(first); // Convex, with every turn the same way, and not a sliver. + let nq = q.map(norm); let cross = |o: Point, a: Point, b: Point| (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]); - let turns: Vec = (0..4).map(|i| cross(q[i], q[(i + 1) % 4], q[(i + 2) % 4])).collect(); + let turns: [f64; 4] = std::array::from_fn(|i| cross(nq[i], nq[(i + 1) % 4], nq[(i + 2) % 4])); let area = turns.iter().sum::() / 2.0; - let span = q.iter().map(|p| p[0]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[0]).fold(f64::MAX, f64::min); - let rise = q.iter().map(|p| p[1]).fold(f64::MIN, f64::max) - q.iter().map(|p| p[1]).fold(f64::MAX, f64::min); - let size = span.max(rise); - let convex = turns.iter().all(|&t| t > 1e-9 * size * size); - (convex && area > 1e-6 * size * size).then_some(q) + let convex = turns.iter().all(|&t| t > 1e-9); + (convex && area > 1e-6).then_some(q) } #[cfg(test)] @@ -129,6 +149,10 @@ mod tests { let r = [[0.0, 0.0], [10.0, 0.0], [10.0, 20.0], [0.0, 20.0]]; let h = Homography::from_points(r, r).unwrap(); assert!(close(h.apply([3.0, 7.0]).unwrap(), [3.0, 7.0])); + + let narrow = [[0.0, 0.0], [1e10, 0.0], [1e10, 1.0], [0.0, 1.0]]; + let h_narrow = Homography::from_points(narrow, narrow).unwrap(); + assert!(close(h_narrow.apply([5e9, 0.5]).unwrap(), [5e9, 0.5])); } #[test] @@ -144,6 +168,8 @@ mod tests { fn collinear_or_non_finite_points_have_no_transform() { let to = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; assert!(Homography::from_points([[0.0, 0.0], [1.0, 1.0], [2.0, 2.0], [3.0, 3.0]], to).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 1.0], [2.75, 2.75], [3.0, 3.0]]).is_none()); + assert!(Homography::from_points(to, [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [0.0, 1.0]]).is_none()); assert!(Homography::from_points([[0.0, 0.0]; 4], to).is_none()); assert!(Homography::from_points([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); assert!(Homography::from_points([[f64::INFINITY, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], to).is_none()); @@ -155,7 +181,7 @@ mod tests { let tr = [200.0, 5.0]; let br = [210.0, 300.0]; let bl = [0.0, 290.0]; - for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tr, bl, tl, br]] { + for q in [[br, tl, bl, tr], [bl, br, tr, tl], [tl, tr, br, bl], [tl, bl, br, tr], [tr, bl, tl, br]] { assert_eq!(order_quad(q), Some([tl, tr, br, bl]), "{q:?}"); } } @@ -164,8 +190,33 @@ mod tests { fn concave_degenerate_or_non_finite_quads_are_refused() { // A dart: one corner inside the triangle of the other three. assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [20.0, 20.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [50.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); + assert_eq!(order_quad([[0.0, 0.0], [100.0, 0.0], [100.0, 0.0], [0.0, 100.0]]), None); assert_eq!(order_quad([[0.0, 0.0], [10.0, 0.0], [20.0, 0.0], [30.0, 0.0]]), None); assert_eq!(order_quad([[5.0, 5.0]; 4]), None); assert_eq!(order_quad([[f64::NAN, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), None); } + + #[test] + fn foreshortened_tilted_and_large_scale_quads_order_correctly() { + // A landscape page photographed from below has a foreshortened top edge where + // bl[0] + bl[1] (500) < tl[0] + tl[1] (550), yet tl is still top-left. + let (tl, tr, br, bl) = ([250.0, 300.0], [550.0, 300.0], [800.0, 500.0], [0.0, 500.0]); + assert_eq!(order_quad([br, tl, bl, tr]), Some([tl, tr, br, bl])); + + // A narrow portrait strip tilted counter-clockwise has tr[0] + tr[1] (90) < tl[0] + tl[1] (120), + // yet tl is still top-left. + let (tl, tr, br, bl) = ([20.0, 100.0], [80.0, 10.0], [140.0, 400.0], [80.0, 490.0]); + assert_eq!(order_quad([tr, bl, tl, br]), Some([tl, tr, br, bl])); + + // A foreshortened portrait quad where bl sits at x = 0 and y = 60 while tl sits at [40, 0] + // still orders [40, 0] as top-left. + let (tl, tr, br, bl) = ([40.0, 0.0], [100.0, 10.0], [100.0, 200.0], [0.0, 60.0]); + assert_eq!(order_quad([bl, br, tr, tl]), Some([tl, tr, br, bl])); + + // Large finite coordinates where `size * size` would overflow f64 still order cleanly. + let s = 1e160; + let (tl, tr, br, bl) = ([0.0, 0.0], [s, 0.0], [s, s], [0.0, s]); + assert_eq!(order_quad([bl, tr, br, tl]), Some([tl, tr, br, bl])); + } } diff --git a/crates/js/src/formcalc/dates.rs b/crates/js/src/formcalc/dates.rs index bfb6383cdd..4d9befeab2 100644 --- a/crates/js/src/formcalc/dates.rs +++ b/crates/js/src/formcalc/dates.rs @@ -49,6 +49,11 @@ pub(super) fn civil_from_days(n: i64) -> (i64, u32, u32) { /// Day numbers (and years) beyond this are refused rather than computed. pub(super) const MAX_DAYS: f64 = 1e8; +/// The century split of a two-digit year (`YY`), XFA picture clauses' default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999 (XFA 3.3, chapter 26, Picture Clause Reference, +/// p. 1171, which makes 30 the default and lets the century split be set otherwise). +const CENTURY_SPLIT: i64 = 30; + const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; /// Parse `text` as a date by `picture` (`YYYY-MM-DD`, `MM/DD/YYYY`, `MMM D, YYYY`, …). @@ -89,7 +94,7 @@ pub(super) fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> { } let v: i64 = digits.parse().ok()?; match c { - 'Y' => y = Some(if run <= 2 { if v < 50 { 2000 + v } else { 1900 + v } } else { v }), + 'Y' => y = Some(if run <= 2 { if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v } } else { v }), 'M' => m = Some(v as u32), _ => d = Some(v as u32), } diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index a12b78dcb6..46316f90ad 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1346,6 +1346,16 @@ mod formcalc { assert_eq!(calc(r#"Date2Num("2024-12-31") <> 0"#), "1"); } + /// A `YY` year in a date picture splits its century at 30, the XFA picture clause default: + /// `00` to `29` are 2000 to 2029 and `30` to `99` are 1930 to 1999. + #[test] + fn a_two_digit_year_splits_its_century_at_30() { + for (text, iso) in [("12/31/29", "2029-12-31"), ("01/01/30", "1930-01-01"), ("12/31/49", "1949-12-31"), ("01/01/50", "1950-01-01")] { + assert_eq!(calc(&format!(r#"Num2Date(Date2Num("{text}", "MM/DD/YY"), "YYYY-MM-DD")"#)), iso, "{text}"); + assert_eq!(calc(&format!(r#"Parse("MM/DD/YY", "{text}")"#)), iso, "{text}"); + } + } + #[test] fn control_flow_variables_and_functions_work() { assert_eq!(calc("var x = 3\nif (x > 2) then 10 elseif (x > 1) then 20 else 30 endif"), "10"); diff --git a/crates/measure/src/lib.rs b/crates/measure/src/lib.rs index 7ff8bfdcf1..bd60a192c1 100644 --- a/crates/measure/src/lib.rs +++ b/crates/measure/src/lib.rs @@ -177,7 +177,7 @@ impl Scale { pub fn read(doc: &Document, object: &Object) -> Result { let o = doc.resolve(object); let d = o.as_dict().ok_or_else(|| invalid("invalid measure dictionary"))?; - if d.name(b"Subtype").is_some_and(|n| n != b"RL") { + if d.get(b"Subtype").map(|o| doc.resolve(o)).is_some_and(|o| !matches!(*o, Object::Null) && o.as_name() != Some(b"RL")) { return Err(invalid("only rectilinear measurement scales are supported")); } let format = |key: &[u8]| -> Result<(String, f64, u8)> { @@ -188,20 +188,25 @@ impl Scale { } let o = doc.resolve(a.first().ok_or_else(|| invalid("empty number format"))?); let f = o.as_dict().ok_or_else(|| invalid("invalid number format dictionary"))?; - let precision = match f.name(b"F") { + let fmt = f.get(b"F").map(|o| doc.resolve(o)); + let precision = match fmt.as_deref().and_then(Object::as_name) { Some(b"R") => 0, None | Some(b"D") => { - let denom = f.int(b"D").unwrap_or(100); + let denom = f.get(b"D").and_then(|o| doc.resolve(o).as_int()).unwrap_or(100); (0..=6).find(|p| 10_i64.pow(*p) == denom).ok_or_else(|| invalid("unsupported measurement precision"))? } _ => return Err(invalid("fractional measurement formats are not supported yet")), }; - Ok((text(f, b"U"), f.get(b"C").and_then(Object::as_f64).ok_or_else(|| invalid("missing conversion factor"))?, precision as u8)) + Ok(( + text(doc, f, b"U"), + f.get(b"C").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("missing conversion factor"))?, + precision as u8, + )) }; let (unit, x, _) = format(b"X")?; let y = if d.contains(b"Y") { let (_, y, _) = format(b"Y")?; - y * d.get(b"CYX").and_then(Object::as_f64).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? + y * d.get(b"CYX").and_then(|o| doc.resolve(o).as_f64()).ok_or_else(|| invalid("Y scale requires CYX for lengths and areas"))? } else { x }; @@ -210,7 +215,7 @@ impl Scale { return Err(invalid("different coordinate and distance units are not supported yet")); } let (area_unit, area_factor, _) = format(b"A")?; - let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(d, b"R") }; + let out = Self { x, y, unit, distance_factor, area_factor, area_unit, precision, ratio: text(doc, d, b"R") }; out.validate()?; Ok(out) } @@ -279,12 +284,19 @@ pub struct Reading { pub fn reading(kind: Kind, points: &[Point], scale: &Scale) -> Result { check_points(points)?; scale.validate()?; + let points = if kind == Kind::Area { open_polygon(points) } else { points }; let mut length = 0.0; for w in points.windows(2) { if let [a, b] = w { length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); } } + if kind == Kind::Area + && points.len() >= 3 + && let (Some(a), Some(b)) = (points.last(), points.first()) + { + length += ((b[0] - a[0]) * scale.x).hypot((b[1] - a[1]) * scale.y); + } let area = if points.len() >= 3 { let origin = points.first().copied().unwrap_or_default(); points @@ -340,7 +352,9 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { Kind::Area if points.len() < 3 => return Err(invalid("area needs at least three points")), _ => {} } - if points.windows(2).any(|p| matches!(p,[a,b] if distance(*a,*b)<1e-6)) { + if points.windows(2).any(|p| matches!(p, [a, b] if distance(*a, *b) < 1e-6)) + || (kind == Kind::Area && matches!((points.first(), points.last()), (Some(a), Some(b)) if distance(*a, *b) < 1e-6)) + { return Err(invalid("adjacent measurement vertices must be distinct")); } if kind == Kind::Area { @@ -357,8 +371,8 @@ fn validate_geometry(kind: Kind, points: &[Point]) -> Result<()> { } Ok(()) } -fn text(d: &Dict, k: &[u8]) -> String { - d.get(k).and_then(Object::as_string).map(PdfString::to_text).unwrap_or_default() +fn text(doc: &Document, d: &Dict, k: &[u8]) -> String { + d.get(k).and_then(|o| doc.resolve(o).as_string().map(PdfString::to_text)).unwrap_or_default() } fn nums(v: impl IntoIterator) -> Object { Object::Array(v.into_iter().map(Object::Real).collect()) @@ -491,7 +505,16 @@ fn read_measurement(doc: &Document, d: &Dict, kind: Kind, page: usize, index: us let points: Vec = v.as_chunks::<2>().0.to_vec(); validate_geometry(kind, &points)?; let reading = reading(kind, &points, &scale)?; - Ok(Measurement { page, index, id: text(d, b"NM"), author: text(d, b"T"), label: text(d, b"PCMeasureLabel"), points, scale, reading }) + Ok(Measurement { + page, + index, + id: text(doc, d, b"NM"), + author: text(doc, d, b"T"), + label: text(doc, d, b"PCMeasureLabel"), + points, + scale, + reading, + }) } /// Every measurement annotation. One that can't be read is reported in `unsupported` /// rather than failing the whole list. @@ -520,7 +543,8 @@ pub fn list(doc: &Document) -> Listing { } let o = doc.resolve(a); let Some(d) = o.as_dict() else { continue }; - let kind = match d.name(b"IT") { + let it = d.get(b"IT").map(|o| doc.resolve(o)); + let kind = match it.as_deref().and_then(Object::as_name) { Some(b"LineDimension") => Kind::Distance, Some(b"PolyLineDimension") => Kind::Perimeter, Some(b"PolygonDimension") => Kind::Area, diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index b039f7c57f..80d30fdefe 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -549,3 +549,86 @@ fn perimeters_are_charged_linear_work() { assert!(!l.truncated, "{} listed", l.measurements.len()); assert_eq!(l.measurements.len() + l.unsupported.len(), lines, "{:?}", l.unsupported.first()); } + +#[test] +fn indirect_scale_fields_and_closed_area_edges_are_handled() { + let mut d = fixture("", "", &[]); + let mut m = new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]]); + m.scale = Scale { y: 3.0, ..Scale::new(2.0, "m", 2).unwrap() }; + add(&mut d, &m, &Meta { date: None, id: "area-1".into() }).unwrap(); + + // Open and closed vertex lists for the same rectangle produce identical readings (including perimeter length). + let open = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0]], &m.scale).unwrap(); + let closed = reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 4.0], [0.0, 0.0]], &m.scale).unwrap(); + close(open.length, 36.0); + close(closed.length, 36.0); + + // A doubled closing vertex leaves a zero-length closing edge after stripping one closing duplicate, + // and an area with fewer than 3 distinct vertices or a NaN coordinate is also rejected. + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [3.0, 4.0], [0.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [0.0, 0.0]]), &Meta::default()).is_err()); + assert!(add(&mut d, &new(Kind::Area, vec![[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]]), &Meta::default()).is_err()); + assert!(reading(Kind::Area, &[[0.0, 0.0], [3.0, 0.0], [f64::NAN, 4.0]], &m.scale).is_err()); + + // Indirect PDF objects inside /Measure, /NumberFormat, and the annotation dictionary still resolve cleanly. + let (r, mut annot_dict) = annot(&d, 0, 0).unwrap(); + let it_ref = d.add(Object::name("PolygonDimension")); + let nm_ref = d.add(PdfString::text("area-indirect")); + let author_ref = d.add(PdfString::text("Architect")); + let label_ref = d.add(PdfString::text("Floor Plan")); + let rl_ref = d.add(Object::name("RL")); + let ratio_ref = d.add(PdfString::text("1 pt = 2 m")); + let cyx_ref = d.add(Object::Real(1.5)); + let unit_ref = d.add(PdfString::text("m")); + let area_unit_ref = d.add(PdfString::text("m^2")); + let factor_x_ref = d.add(Object::Real(2.0)); + let factor_y_ref = d.add(Object::Real(2.0)); + let factor_d_ref = d.add(Object::Real(1.0)); + let factor_a_ref = d.add(Object::Real(1.0)); + let fmt_d_ref = d.add(Object::name("D")); + let denom_ref = d.add(Object::Int(1000)); + + let nf = |u: ObjRef, c: ObjRef| { + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("NumberFormat")); + f.set(b"U".to_vec(), Object::Ref(u)); + f.set(b"C".to_vec(), Object::Ref(c)); + f.set(b"F".to_vec(), Object::Ref(fmt_d_ref)); + f.set(b"D".to_vec(), Object::Ref(denom_ref)); + Object::Array(vec![Object::Dict(f)]) + }; + let mut measure = Dict::new(); + measure.set(b"Type".to_vec(), Object::name("Measure")); + measure.set(b"Subtype".to_vec(), Object::Ref(rl_ref)); + measure.set(b"R".to_vec(), Object::Ref(ratio_ref)); + measure.set(b"CYX".to_vec(), Object::Ref(cyx_ref)); + measure.set(b"X".to_vec(), nf(unit_ref, factor_x_ref)); + measure.set(b"Y".to_vec(), nf(unit_ref, factor_y_ref)); + measure.set(b"D".to_vec(), nf(unit_ref, factor_d_ref)); + measure.set(b"A".to_vec(), nf(area_unit_ref, factor_a_ref)); + + annot_dict.set(b"IT".to_vec(), Object::Ref(it_ref)); + annot_dict.set(b"NM".to_vec(), Object::Ref(nm_ref)); + annot_dict.set(b"T".to_vec(), Object::Ref(author_ref)); + annot_dict.set(b"PCMeasureLabel".to_vec(), Object::Ref(label_ref)); + annot_dict.set(b"Measure".to_vec(), Object::Dict(measure.clone())); + d.update_dict(r, |target| *target = annot_dict).unwrap(); + + // Non-RL indirect subtypes and numeric subtypes are rejected by Scale::read. + let geo_ref = d.add(Object::name("GEO")); + let mut bad_geo = measure.clone(); + bad_geo.set(b"Subtype".to_vec(), Object::Ref(geo_ref)); + assert!(Scale::read(&d, &Object::Dict(bad_geo)).is_err()); + let mut bad_num = measure; + bad_num.set(b"Subtype".to_vec(), Object::Int(42)); + assert!(Scale::read(&d, &Object::Dict(bad_num)).is_err()); + + let listing = list(&d); + assert!(listing.unsupported.is_empty(), "{:?}", listing.unsupported); + assert_eq!(listing.measurements.len(), 1); + let item = &listing.measurements[0]; + assert_eq!(item.id, "area-indirect"); + assert_eq!(item.author, "Architect"); + assert_eq!(item.label, "Floor Plan"); + assert_eq!(item.scale, Scale { y: 3.0, ..Scale::new(2.0, "m", 3).unwrap() }); +} diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 94da24ea1c..23eede9b45 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -24,8 +24,12 @@ const MIN_BOX_SIDE: f64 = 1.0 / 4096.0; fn rect(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> { let o = doc.resolve(o?); - let v: Vec = o.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect(); - (v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])]) + // Four entries, or not a rectangle: a longer array (one shared by many pages, say) is not + // resolved entry by entry only to be discarded. + let [a, b, c, d] = o.as_array()?.as_slice() else { return None }; + let v = [a, b, c, d].map(|x| doc.resolve(x).as_f64()); + let [Some(x0), Some(y0), Some(x1), Some(y1)] = v else { return None }; + [x0, y0, x1, y1].iter().all(|x| x.is_finite()).then(|| [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)]) } impl Page { diff --git a/crates/optimize/README.md b/crates/optimize/README.md index 4c0549b1c8..1a571452ec 100644 --- a/crates/optimize/README.md +++ b/crates/optimize/README.md @@ -127,7 +127,10 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going its priority. The document only changes in order on one thread, and what a picture costs is paid in that order, so the file written, and the pictures the run's decoding allowance refuses, are the same with any number. -- **Discard objects:** thumbnails, alternate images, document tags, print settings. +- **Discard objects:** thumbnails, alternate images, document tags, print settings, and form + submission, import and reset actions (`/SubmitForm`, `/ImportData`, `/ResetForm`, off by + default). The fields, links, bookmarks and pages that ran a removed action keep their other + actions, and the actions chained after it through `/Next` still run in order. - **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 bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed diff --git a/crates/optimize/src/audit.rs b/crates/optimize/src/audit.rs index afa95f555e..f5da17c45d 100644 --- a/crates/optimize/src/audit.rs +++ b/crates/optimize/src/audit.rs @@ -262,7 +262,7 @@ pub fn audit_space(doc: &Document, file_len: u64) -> Vec { a.claim(o, SpaceCategory::Bookmarks); } let names = a.resolve_dict(cat.get(b"Names")); - if let Some(d) = names.get(b"Dests").or_else(|| cat.get(b"Dests")) { + for d in [names.get(b"Dests"), cat.get(b"Dests")].into_iter().flatten() { a.claim(d, SpaceCategory::NamedDestinations); } if let Some(e) = names.get(b"EmbeddedFiles") { diff --git a/crates/optimize/src/form_actions.rs b/crates/optimize/src/form_actions.rs new file mode 100644 index 0000000000..fbcb24a2fe --- /dev/null +++ b/crates/optimize/src/form_actions.rs @@ -0,0 +1,468 @@ +//! Discard Objects: form submission, import and reset actions (`/SubmitForm`, `/ImportData`, +//! `/ResetForm`). The fields, buttons, links, bookmarks and pages that ran them stay; a removed +//! action's `/Next` actions take its place, so the rest of a chain still runs in order. + +use std::collections::{HashMap, HashSet}; + +use pdfcraft_cos::{Dict, Document, ObjRef, Object}; + +use crate::OptimizeError; + +const FORM_ACTIONS: [&[u8]; 3] = [b"SubmitForm", b"ImportData", b"ResetForm"]; + +/// Keys whose value is one action (or, for `/OpenAction`, an action or a destination). +const ACTION_KEYS: [&[u8]; 2] = [b"A", b"OpenAction"]; + +/// How deep direct objects and action chains are followed. +const MAX_DEPTH: usize = 64; + +/// How many action values one object may make the walk look at. +const MAX_STEPS: usize = 100_000; + +/// How many action values the walk may look at in the whole document. +const MAX_TOTAL_STEPS: usize = 1_000_000; + +/// Remove every form submission, import and reset action. Returns how many the document held in +/// action entries, each counted once: an indirect action however many entries refer to it, and a +/// direct one in the object that holds it. Fails with [`OptimizeError::ActionLimit`] when an +/// object holds a form action more than [`MAX_DEPTH`] direct objects deep, chains form actions +/// more than [`MAX_DEPTH`] deep, or makes the walk look at more than [`MAX_STEPS`] action values, +/// or when the walk has looked at more than [`MAX_TOTAL_STEPS`] action values in the document. +pub(crate) fn discard(doc: &mut Document) -> Result { + let numbers = doc.object_numbers(); + let mut count = Count::default(); + for &num in &numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + if let o @ (Object::Dict(_) | Object::Array(_)) = &*doc.get(r) { + count.object(doc, o, 0); + } + } + let mut total = 0usize; + for num in numbers { + let r = ObjRef { num, generation: doc.generation(num) }; + let mut obj = match &*doc.get(r) { + o @ (Object::Dict(_) | Object::Array(_)) => o.clone(), + _ => continue, + }; + let limit = MAX_STEPS.min(MAX_TOTAL_STEPS - total); + let mut walk = + Walk { this: r, changed: false, steps: 0, limit, exhausted: false, chain: HashSet::new(), looped: false, writes: HashMap::new() }; + walk.clean(doc, &mut obj, 0); + total += walk.steps.min(limit); + if walk.exhausted { + return Err(OptimizeError::ActionLimit(r)); + } + if walk.changed { + doc.set(r, obj); + } + for (shared, cleaned) in walk.writes { + doc.set(shared, cleaned); + } + } + Ok(count.direct + count.indirect.len()) +} + +/// Whether `d` is a form action; its `/S` may be stored as an indirect name. +fn is_form_action(doc: &Document, d: &Dict) -> bool { + d.get(b"S").is_some_and(|s| doc.resolve(s).as_name().is_some_and(|s| FORM_ACTIONS.contains(&s))) +} + +/// The form actions in action entries, counted before they are removed. Each object's pass looks +/// only at its own direct objects, so a direct action is counted once, in the object that holds +/// it; references are counted by identity. +#[derive(Default)] +struct Count { + direct: usize, + indirect: HashSet, + /// Indirect `/AA` dictionaries and `/Next` arrays already counted. + shared: HashSet, +} + +impl Count { + fn object(&mut self, doc: &Document, o: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match o { + Object::Dict(d) => { + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + if let Some(v) = d.get(key) { + self.entry(doc, v, depth); + } + } + match d.get(b"AA") { + Some(Object::Ref(r)) if self.shared.insert(*r) => { + if let Object::Dict(t) = &*doc.get(*r) { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + } + Some(Object::Dict(t)) => { + for (_, v) in t.iter() { + self.entry(doc, v, depth); + } + } + _ => {} + } + for (_, v) in d.iter() { + self.object(doc, v, depth + 1); + } + } + Object::Array(items) => { + for item in items { + self.object(doc, item, depth + 1); + } + } + _ => {} + } + } + + /// One action entry's value: an action, or (in `/Next`) a list of them. + fn entry(&mut self, doc: &Document, v: &Object, depth: usize) { + if depth > MAX_DEPTH { + return; + } + match v { + Object::Dict(a) if is_form_action(doc, a) => self.direct += 1, + Object::Array(items) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => { + self.indirect.insert(*r); + } + // An indirect `/Next` array: its direct actions are counted from the first entry + // that refers to it, and their own entries by the array's own pass. + Object::Array(items) if self.shared.insert(*r) => { + for item in items { + self.entry(doc, item, depth + 1); + } + } + _ => {} + }, + _ => {} + } + } +} + +struct Walk { + /// The object being walked. + this: ObjRef, + /// Whether the object changed. + changed: bool, + steps: usize, + /// How many action values this object's walk may look at: [`MAX_STEPS`], or what is left of + /// [`MAX_TOTAL_STEPS`] when that is less. + limit: usize, + /// Whether a limit stopped the walk before it looked at everything. + exhausted: bool, + /// Indirect form actions and indirect arrays on the path of the chain being followed. + chain: HashSet, + /// Whether the entry being cleaned holds an indirect array that lists itself. + looped: bool, + /// Cleaned replacements for indirect actions, `/Next` arrays and `/AA` dictionaries that + /// action entries of this object refer to, written over them after the walk. + writes: HashMap, +} + +impl Walk { + /// Take form actions out of the action entries of `o` and of the direct objects inside it. + fn clean(&mut self, doc: &Document, o: &mut Object, depth: usize) { + if depth > MAX_DEPTH { + if holds_form_action(doc, o) { + self.exhausted = true; + } + return; + } + match o { + Object::Dict(d) => self.clean_dict(doc, d, depth), + Object::Array(items) => { + for item in items { + self.clean(doc, item, depth + 1); + } + } + _ => {} + } + } + + fn clean_dict(&mut self, doc: &Document, d: &mut Dict, depth: usize) { + for key in ACTION_KEYS { + if let Some(v) = d.get(key) + && let shared = v.as_ref() + && let Some(rest) = self.take(doc, v) + { + let a = chain(doc, rest); + self.put(doc, d, key, shared, a, depth); + } + } + if let Some(v) = d.get(b"Next") + && let shared = v.as_ref() + && let Some(mut rest) = self.take(doc, v) + { + let next = match rest.len() { + 0 => None, + 1 => Some(rest.remove(0)), + // An action that other entries may run as their `/A` stays a dictionary. + _ if shared.is_some_and(|r| doc.get(r).as_dict().is_some()) => chain(doc, rest), + _ => Some(Object::Array(rest)), + }; + self.put(doc, d, b"Next", shared, next, depth); + } + // Additional actions: each trigger holds one action. + if let Some(aa) = d.get(b"AA") + && let shared = aa.as_ref() + && let Object::Dict(t) = &*doc.resolve(aa) + { + let mut triggers = t.clone(); + let mut changed = false; + let keys: Vec> = triggers.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = triggers.get(&key) + && let Some(rest) = self.take(doc, v) + { + changed = true; + match chain(doc, rest) { + Some(a) => triggers.set(key, a), + None => { + triggers.remove(&key); + } + } + } + } + if changed { + let aa = (!triggers.is_empty()).then_some(Object::Dict(triggers)); + self.put(doc, d, b"AA", shared, aa, depth); + } + } + let keys: Vec> = d.iter().map(|(k, _)| k.to_vec()).collect(); + for key in keys { + if let Some(v) = d.get_mut(&key) { + self.clean(doc, v, depth + 1); + } + } + } + + /// Set entry `key` of `d` to its cleaned value, or remove it when nothing is left. When the + /// entry referred to indirect object `shared` and the cleaned value is a direct object, the + /// value (with the form actions inside it taken out too) replaces `shared` and the entry keeps + /// its reference, so the objects that share it share one cleaned copy. An entry that refers to + /// an object already replaced, or being cleaned, keeps its reference. + fn put(&mut self, doc: &Document, d: &mut Dict, key: &[u8], shared: Option, value: Option, depth: usize) { + match (shared, value) { + (_, None) => { + d.remove(key); + } + (Some(r), Some(_)) if self.writes.contains_key(&r) => {} + (Some(r), Some(mut v)) if r != self.this && !matches!(v, Object::Ref(_)) => { + self.writes.insert(r, Object::Null); + self.clean(doc, &mut v, depth + 1); + self.writes.insert(r, v); + } + (_, Some(v)) => d.set(key.to_vec(), v), + } + } + + /// [`Walk::without`] for one action entry; marks the object changed when it changes. An entry + /// that changes while it holds an array that lists itself stops the walk when what is left + /// still reaches a form action through that array. + fn take(&mut self, doc: &Document, v: &Object) -> Option> { + self.chain.clear(); + self.looped = false; + let rest = self.without(doc, v, 0)?; + if self.looped && self.reaches_form_action(doc, &rest) { + self.exhausted = true; + return None; + } + self.changed = true; + Some(rest) + } + + /// The actions an action value stands for once the form actions at the head of its chain are + /// taken out: a form action gives way to the actions of its `/Next`. `None` when nothing + /// changes (the value is kept as it is, references included). + fn without(&mut self, doc: &Document, v: &Object, depth: usize) -> Option> { + self.steps += 1; + if depth > MAX_DEPTH || self.steps > self.limit { + self.exhausted = true; + return None; + } + let resolved = doc.resolve(v); + match &*resolved { + Object::Dict(a) if is_form_action(doc, a) => { + let r = v.as_ref(); + // A chain that comes back to an action it is still inside ends there. + if let Some(r) = r + && !self.chain.insert(r) + { + return Some(Vec::new()); + } + let rest = match a.get(b"Next") { + None => Vec::new(), + Some(next) => match self.without(doc, next, depth + 1) { + Some(rest) => rest, + None => match &*doc.resolve(next) { + Object::Array(items) => items.clone(), + _ => vec![next.clone()], + }, + }, + }; + if let Some(r) = r { + self.chain.remove(&r); + } + Some(rest) + } + Object::Array(items) => { + let r = v.as_ref(); + // An indirect array that lists itself, directly or through other arrays, is left + // as it is where it comes back. + if let Some(r) = r + && !self.chain.insert(r) + { + self.looped = true; + return None; + } + let mut out = Vec::new(); + let mut changed = false; + for item in items { + match self.without(doc, item, depth + 1) { + Some(rest) => { + changed = true; + out.extend(rest); + } + None => out.push(item.clone()), + } + } + if let Some(r) = r { + self.chain.remove(&r); + } + changed.then_some(out) + } + _ => None, + } + } + + /// Whether `items`, or an indirect array they list, lists a form action. Each value looked at + /// counts as a step. + fn reaches_form_action(&mut self, doc: &Document, items: &[Object]) -> bool { + let mut seen = HashSet::new(); + let mut pending = Vec::new(); + if self.lists_form_action(doc, items, &mut seen, &mut pending) { + return true; + } + while let Some(r) = pending.pop() { + if let Object::Array(items) = &*doc.get(r) + && self.lists_form_action(doc, items, &mut seen, &mut pending) + { + return true; + } + } + false + } + + /// Whether `items`, or a direct array inside them, lists a form action; indirect arrays not + /// yet seen go to `pending`. + fn lists_form_action(&mut self, doc: &Document, items: &[Object], seen: &mut HashSet, pending: &mut Vec) -> bool { + let mut stack: Vec<&Object> = items.iter().collect(); + while let Some(item) = stack.pop() { + self.steps += 1; + if self.steps > self.limit { + self.exhausted = true; + return false; + } + match item { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(inner) => stack.extend(inner.iter()), + Object::Ref(r) => match &*doc.get(*r) { + Object::Dict(a) if is_form_action(doc, a) => return true, + Object::Array(_) if seen.insert(*r) => pending.push(*r), + _ => {} + }, + _ => {} + } + } + false + } +} + +/// Whether the walk would take a form action out of `o` or the direct objects inside it: a form +/// action, or an action entry (or a `/Next` array or `/AA` dictionary, direct or indirect) that +/// refers to one. Looks through `o` without recursion. +fn holds_form_action(doc: &Document, o: &Object) -> bool { + let mut stack = vec![o]; + while let Some(o) = stack.pop() { + match o { + Object::Dict(d) => { + if is_form_action(doc, d) { + return true; + } + for key in [b"A".as_slice(), b"OpenAction", b"Next"] { + let refers = match d.get(key) { + Some(Object::Array(items)) => items.iter().any(|item| refers_to_form_action(doc, item)), + Some(v) => refers_to_form_action(doc, v), + None => false, + }; + if refers { + return true; + } + } + if let Some(aa) = d.get(b"AA") + && let Object::Dict(t) = &*doc.resolve(aa) + && t.iter().any(|(_, v)| is_or_refers_to_form_action(doc, v)) + { + return true; + } + stack.extend(d.iter().map(|(_, v)| v)); + } + Object::Array(items) => stack.extend(items.iter()), + _ => {} + } + } + false +} + +/// Whether `v` is a direct form action or a reference to one. +fn is_or_refers_to_form_action(doc: &Document, v: &Object) -> bool { + match v { + Object::Dict(a) => is_form_action(doc, a), + Object::Ref(r) => matches!(&*doc.get(*r), Object::Dict(a) if is_form_action(doc, a)), + _ => false, + } +} + +/// Whether `v` refers to a form action, or to an indirect array that lists one. +fn refers_to_form_action(doc: &Document, v: &Object) -> bool { + let Object::Ref(r) = v else { return false }; + match &*doc.get(*r) { + Object::Dict(a) => is_form_action(doc, a), + Object::Array(items) => items.iter().any(|item| is_or_refers_to_form_action(doc, item)), + _ => false, + } +} + +/// One action value for a list of actions run in order: the first action, with the others +/// after its own `/Next` actions. `None` when no action remains. +fn chain(doc: &Document, actions: Vec) -> Option { + let mut actions = actions.into_iter(); + loop { + let first = actions.next()?; + let rest: Vec = actions.as_slice().to_vec(); + if rest.is_empty() { + return Some(first); + } + // A list needs a dictionary at its head to carry the rest; anything else is dropped. + let Some(mut head) = doc.resolve(&first).as_dict().cloned() else { continue }; + let mut next = match head.get(b"Next") { + None => Vec::new(), + Some(n) => match &*doc.resolve(n) { + Object::Array(items) => items.clone(), + _ => vec![n.clone()], + }, + }; + next.extend(rest); + head.set(b"Next".to_vec(), Object::Array(next)); + return Some(Object::Dict(head)); + } +} diff --git a/crates/optimize/src/fragments.rs b/crates/optimize/src/fragments.rs index 34c8f9ca76..aa52c20750 100644 --- a/crates/optimize/src/fragments.rs +++ b/crates/optimize/src/fragments.rs @@ -9,8 +9,9 @@ //! between them, each drawing an image upright (no rotation, skew or flip); //! - the images are 8-bit grey or RGB in the same colour space, without masks or a decode array //! that changes anything, in a codec PdfCraft decodes; -//! - they share their left and right edges and are stacked edge to edge with no gap or overlap -//! (in any drawing order), at least [`MIN_FRAGMENTS`] of them. +//! - they tile one rectangle edge to edge with no gap or overlap — stacked top to bottom, placed +//! side by side, or arranged in a grid of rows and columns (in any drawing order), at least +//! [`MIN_FRAGMENTS`] of them. //! //! The merged image keeps the finest resolution of its fragments both ways, so nothing is lost //! until the image settings downsample it; it is stored losslessly. A page whose content @@ -89,53 +90,152 @@ fn fragment_image(doc: &Document, xobjects: &Dict, name: &[u8]) -> Option<(ObjRe (w > 0 && h > 0).then_some((r, w, h, cs, n)) } -/// Whether `run` tiles one rectangle top to bottom: same left and right edges, edge to edge. -/// Sorts it from the top down. -fn stacked(run: &mut [Fragment]) -> bool { - let Some(first) = run.first() else { return false }; - let [x0, _, x1, _] = first.rect; - let width = x1 - x0; - if !(width.is_finite() && width > 0.0) { - return false; - } - let tol = 1e-3 * width.max(1.0); - if run.iter().any(|f| { - (f.rect[0] - x0).abs() > tol || (f.rect[2] - x1).abs() > tol || f.rect[3].partial_cmp(&f.rect[1]) != Some(std::cmp::Ordering::Greater) - }) { - return false; - } - run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); - run.windows(2).all(|w| match w { - [a, b] => (a.rect[1] - b.rect[3]).abs() <= 0.01 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]), - _ => true, - }) +/// Whether `a` and `b` share their top and bottom edges within row tolerance. +fn same_y(a: &Fragment, b: &Fragment) -> bool { + let tol = 1e-3 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]); + (a.rect[1] - b.rect[1]).abs() <= tol && (a.rect[3] - b.rect[3]).abs() <= tol } -/// The fragment groups from `i` on while each is drawn right under (or right over) the one -/// before it, in the same direction: a picture drawn row by row, up to a gap or a jump. -fn drawn_in_sequence(ops: &[Op], i: usize, xobjects: &Dict, doc: &Document) -> Vec { +/// Whether `run` tiles one rectangle edge to edge (stacked top to bottom, placed side by side, +/// or arranged in a grid of rows and columns). Sorts it from the top row down, and left to right +/// within each row. Returns that exact row partition: tolerance-based membership is not +/// transitive, so assembly must not regroup after the horizontal sort changes each row's anchor. +fn stacked(run: &mut [Fragment]) -> Option>> { + if run.is_empty() + || run.iter().any(|f| { + let [x0, y0, x1, y1] = f.rect; + let (w, h) = (x1 - x0, y1 - y0); + !(x0.is_finite() && y0.is_finite() && x1.is_finite() && y1.is_finite() && w.is_finite() && w > 0.0 && h.is_finite() && h > 0.0) + }) + { + return None; + } + run.sort_by(|a, b| b.rect[3].total_cmp(&a.rect[3])); + let mut row_bounds: Vec<[f64; 4]> = Vec::new(); + let mut rows = Vec::new(); + let mut start = 0; + while start < run.len() { + let mut end = start + 1; + while end < run.len() && same_y(&run[start], &run[end]) { + end += 1; + } + let row = &mut run[start..end]; + row.sort_by(|a, b| a.rect[0].total_cmp(&b.rect[0])); + if !row.windows(2).all(|w| match w { + [a, b] => (a.rect[2] - b.rect[0]).abs() <= 0.01 * (a.rect[2] - a.rect[0]).min(b.rect[2] - b.rect[0]), + _ => true, + }) { + return None; + } + let (Some(first), Some(last)) = (row.first(), row.last()) else { return None }; + row_bounds.push([first.rect[0], first.rect[1], last.rect[2], first.rect[3]]); + rows.push(start..end); + start = end; + } + let (Some(&[x0, _, x1, y1]), Some(&[_, y0, _, _])) = (row_bounds.first(), row_bounds.last()) else { return None }; + let (width, height) = (x1 - x0, y1 - y0); + if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) { + return None; + } + let tol_x = 1e-3 * width.max(1.0); + if row_bounds.iter().any(|r| (r[0] - x0).abs() > tol_x || (r[2] - x1).abs() > tol_x) { + return None; + } + row_bounds + .windows(2) + .all(|w| match w { + [a, b] => (a[1] - b[3]).abs() <= 0.01 * (a[3] - a[1]).min(b[3] - b[1]), + _ => true, + }) + .then_some(rows) +} + +/// The fragment groups from `i` on (at most `limit`) while they form a picture drawn row by row +/// or strip by strip in one direction, up to a gap or a jump. +fn drawn_in_sequence(ops: &[Op], i: usize, limit: usize, xobjects: &Dict, doc: &Document) -> Vec { let mut run: Vec = Vec::new(); + let mut row_x = [0.0f64, 0.0]; + let mut cur_x = [0.0f64, 0.0]; + let mut cur_y = [0.0f64, 0.0]; + let mut first_row = true; + let mut right: Option = None; let mut down: Option = None; + let mut complete = 0usize; let mut j = i; - while let Some((next, m, name)) = group_at(ops, j) { + while run.len() < limit { + let Some((next, m, name)) = group_at(ops, j) else { break }; let Some((image, width, height, ..)) = fragment_image(doc, xobjects, &name) else { break }; let [a, _, _, d, e, f] = m.0; let f2 = Fragment { image, rect: [e, f, e + a, f + d], width, height }; if let Some(prev) = run.last() { - let tol = 0.01 * (prev.rect[3] - prev.rect[1]).min(f2.rect[3] - f2.rect[1]); - let below = (prev.rect[1] - f2.rect[3]).abs() <= tol; - let above = (f2.rect[1] - prev.rect[3]).abs() <= tol; - let same_x = (prev.rect[0] - f2.rect[0]).abs() <= 1e-3 * a.abs().max(1.0) && (prev.rect[2] - f2.rect[2]).abs() <= 1e-3 * a.abs().max(1.0); - match (down, below, above) { - _ if !same_x => break, - (None | Some(true), true, _) => down = Some(true), - (None | Some(false), _, true) => down = Some(false), - _ => break, + let tol_y = 1e-3 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let same_row = (cur_y[0] - f2.rect[1]).abs() <= tol_y && (cur_y[1] - f2.rect[3]).abs() <= tol_y; + if same_row { + let tol_x = 0.01 * (prev.rect[2] - prev.rect[0]).min(f2.rect[2] - f2.rect[0]); + let step_right = (f2.rect[0] - prev.rect[2]).abs() <= tol_x; + let step_left = (prev.rect[0] - f2.rect[2]).abs() <= tol_x; + match (right, step_right, step_left) { + (None | Some(true), true, _) => { + right = Some(true); + cur_x[1] = f2.rect[2]; + } + (None | Some(false), _, true) => { + right = Some(false); + cur_x[0] = f2.rect[0]; + } + _ => break, + } + if first_row { + row_x = cur_x; + complete = run.len() + 1; + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if cur_x[0] < row_x[0] - tol_span || cur_x[1] > row_x[1] + tol_span { + break; + } + if (cur_x[0] - row_x[0]).abs() <= tol_span && (cur_x[1] - row_x[1]).abs() <= tol_span { + complete = run.len() + 1; + } + } + } else { + let tol_span = 1e-3 * (row_x[1] - row_x[0]).max(1.0); + if (cur_x[0] - row_x[0]).abs() > tol_span || (cur_x[1] - row_x[1]).abs() > tol_span { + break; + } + let tol_v = 0.01 * (cur_y[1] - cur_y[0]).min(f2.rect[3] - f2.rect[1]); + let below = (cur_y[0] - f2.rect[3]).abs() <= tol_v; + let above = (f2.rect[1] - cur_y[1]).abs() <= tol_v; + match (down, below, above) { + (None | Some(true), true, _) => down = Some(true), + (None | Some(false), _, true) => down = Some(false), + _ => break, + } + let starts_left = (f2.rect[0] - row_x[0]).abs() <= tol_span && f2.rect[2] <= row_x[1] + tol_span; + let starts_right = (f2.rect[2] - row_x[1]).abs() <= tol_span && f2.rect[0] >= row_x[0] - tol_span; + if starts_left && starts_right { + right = None; + complete = run.len() + 1; + } else if starts_left { + right = Some(true); + } else if starts_right { + right = Some(false); + } else { + break; + } + first_row = false; + cur_x = [f2.rect[0], f2.rect[2]]; + cur_y = [f2.rect[1], f2.rect[3]]; } + } else { + row_x = [f2.rect[0], f2.rect[2]]; + cur_x = row_x; + cur_y = [f2.rect[1], f2.rect[3]]; + complete = 1; } run.push(f2); j = next; } + run.truncate(complete); run } @@ -152,38 +252,61 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize { } /// The run's pixels as one image, top row first: `(pixels, width, height)`. -fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { - let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]); - let width = run.iter().map(|f| f.width).max()?; - // Pixel rows per unit of height: the finest fragment's. - let density = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); - let height = ((top - bottom) * density).round(); +fn merge(doc: &Document, run: &[Fragment], partition: &[std::ops::Range], n: usize, budget: &mut ImageBudget) -> Option<(Vec, u32, u32)> { + let (first, last) = (run.first()?, run.last()?); + let (left, right, top, bottom) = (first.rect[0], last.rect[2], first.rect[3], last.rect[1]); + let rows: Vec<&[Fragment]> = partition.iter().map(|range| run.get(range.clone())).collect::>()?; + // Pixels per unit along each axis: the finest fragment's. + let density_x = run.iter().map(|f| f.width as f64 / (f.rect[2] - f.rect[0])).fold(0.0, f64::max); + let density_y = run.iter().map(|f| f.height as f64 / (f.rect[3] - f.rect[1])).fold(0.0, f64::max); + let width = if rows.len() == run.len() { f64::from(run.iter().map(|f| f.width).max()?) } else { ((right - left) * density_x).round() }; + let height = if rows.len() == 1 { f64::from(run.iter().map(|f| f.height).max()?) } else { ((top - bottom) * density_y).round() }; + if !(width.is_finite() && width >= 1.0 && width <= f64::from(u32::MAX)) { + return None; + } if !(height.is_finite() && height >= 1.0 && height <= f64::from(u32::MAX)) { return None; } - // In range and whole, so the cast is exact. - let height = height as u32; + // In range and whole, so the casts are exact. + let (width, height) = (width as u32, height as u32); if u64::from(width) * u64::from(height) > MAX_PIXELS { return None; } - let row = (width as usize).checked_mul(n)?; + let stride = (width as usize).checked_mul(n)?; // What this builds isn't what its strips cost to read: four small ones can ask for all of it. - let bytes = row.checked_mul(height as usize)?; + let bytes = stride.checked_mul(height as usize)?; if !budget.pay_pixels(u64::try_from(bytes).ok()?) { return None; } let mut out = vec![255u8; bytes]; - for f in run { - let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height); - let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height); + let mut r0 = 0u32; + for (row_idx, row) in rows.iter().enumerate() { + let r1 = if row_idx + 1 == rows.len() { height } else { (((top - row.first()?.rect[1]) * density_y).round().max(0.0) as u32).min(height) }; if r1 <= r0 { - continue; + return None; } - let obj = doc.get(f.image); - let Object::Stream(s) = &*obj else { return None }; - let px = pixels(doc, s, n, budget)?; - let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? }; - out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?); + let th = r1 - r0; + let mut c0 = 0u32; + for (col_idx, f) in row.iter().enumerate() { + let c1 = if col_idx + 1 == row.len() { width } else { (((f.rect[2] - left) * density_x).round().max(0.0) as u32).min(width) }; + if c1 <= c0 { + return None; + } + let tw = c1 - c0; + let obj = doc.get(f.image); + let Object::Stream(s) = &*obj else { return None }; + let px = pixels(doc, s, n, budget)?; + let px = if (f.width, f.height) == (tw, th) { px } else { resample(&px, n, f.width, f.height, tw, th)? }; + let tile_row = (tw as usize).checked_mul(n)?; + let col_off = (c0 as usize).checked_mul(n)?; + for r in 0..th as usize { + let dst = (r0 as usize + r) * stride + col_off; + let src = r * tile_row; + out.get_mut(dst..dst + tile_row)?.copy_from_slice(px.get(src..src + tile_row)?); + } + c0 = c1; + } + r0 = r1; } Some((out, width, height)) } @@ -288,13 +411,15 @@ fn merge_page( j = next; } scanned = scanned.saturating_add(run.len()).saturating_add(1); - // Not one picture as a whole: the rows drawn one under the other from the start may be. - if run.len() >= MIN_FRAGMENTS && !stacked(&mut run) { - run = drawn_in_sequence(&ops, i, &xobjects, doc); + // Not one picture as a whole: a prefix drawn row by row or strip by strip may be. + let mut rows = if run.len() >= MIN_FRAGMENTS { stacked(&mut run) } else { None }; + if run.len() >= MIN_FRAGMENTS && rows.is_none() { + run = drawn_in_sequence(&ops, i, run.len(), &xobjects, doc); j = run_end(&ops, i, run.len()); scanned = scanned.saturating_add(run.len().saturating_mul(2)).saturating_add(2); + rows = stacked(&mut run); } - let Some((cs, n)) = kind.filter(|_| run.len() >= MIN_FRAGMENTS && stacked(&mut run)) else { + let Some(((cs, n), rows)) = kind.zip(rows).filter(|_| run.len() >= MIN_FRAGMENTS) else { // Not a run: keep this op and look again from the next one. if let Some(op) = ops.get(i) { out.push(op.clone()); @@ -305,7 +430,7 @@ fn merge_page( if !progress(Stage::Fragments) { return Err(OptimizeError::Cancelled); } - let Some((px, w, h)) = merge(doc, &run, n, images) else { + let Some((px, w, h)) = merge(doc, &run, &rows, n, images) else { out.extend(ops.get(i..j).unwrap_or_default().iter().cloned()); i = j; continue; @@ -322,7 +447,7 @@ fn merge_page( xobjects.set(name.clone(), Object::Ref(image)); add_xobject(doc, page, &name, image)?; let (Some(first), Some(last)) = (run.first(), run.last()) else { break }; - let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], first.rect[2], first.rect[3]]; + let [x0, y0, x1, y1] = [first.rect[0], last.rect[1], last.rect[2], first.rect[3]]; let num = Object::Real; out.push(Op::new("q", Vec::new())); out.push(Op::new("cm", vec![num(x1 - x0), Object::Int(0), Object::Int(0), num(y1 - y0), num(x0), num(y0)])); @@ -339,6 +464,7 @@ fn merge_page( Ok(merged) } +/// Merge consecutive tiled image fragments across `pages`. pub(crate) fn run( doc: &mut Document, pages: &[ObjRef], @@ -361,17 +487,45 @@ mod tests { use super::*; + /// Merged dimensions exceeding `u32::MAX` are rejected before allocating pixel buffers. #[test] fn a_merged_height_beyond_u32_is_refused_before_allocating() { // Two fragments, one pixel row each, the top one 2^-48 high: the merged image would be // 2^16 × 2^48 pixels, whose product wraps to 0 in u64. let image = ObjRef { num: 1, generation: 0 }; let edge = 1.0 - (-48f64).exp2(); - let run = [ + let vertical = [ Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 }, Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 }, ]; - assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + assert!(merge(&Document::new_empty(), &vertical, &[0..1, 1..2], 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + let horizontal = [ + Fragment { image, rect: [0.0, 0.0, edge, 1.0], width: 1, height: 1 << 16 }, + Fragment { image, rect: [edge, 0.0, 1.0, 1.0], width: 1, height: 1 << 16 }, + ]; + assert!(merge(&Document::new_empty(), &horizontal, std::slice::from_ref(&(0..2)), 3, &mut ImageBudget::with_work(u64::MAX)).is_none()); + } + + /// Even an inconsistent partition must not silently omit a row or tile from its report. + #[test] + fn assembly_refuses_collapsed_output_rows_and_tiles() { + let mut doc = Document::new_empty(); + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(10)); + dict.set(b"Height".to_vec(), Object::Int(10)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + let image = doc.add(Object::Stream(Stream::flate(dict, &[80; 100]))); + let mut run: Vec<_> = (0..4) + .map(|col| Fragment { image, rect: [10.0 * f64::from(col), 0.0, 10.0 * f64::from(col + 1), 10.0], width: 10, height: 10 }) + .collect(); + // The first supposed row consumes the entire height; the second cannot be discarded. + assert!(merge(&doc, &run, &[0..2, 2..4], 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); + // Likewise, a tile whose right edge does not advance cannot simply vanish. + if let Some(tile) = run.get_mut(1) { + tile.rect = [0.0, 0.0, 10.0, 10.0]; + } + assert!(merge(&doc, &run, std::slice::from_ref(&(0..4)), 1, &mut ImageBudget::with_work(u64::MAX)).is_none()); } /// Merging reads the images the pass after it reads, from the same allowance: with nothing @@ -474,4 +628,149 @@ mod tests { let reads = GROUP_READS.with(|c| c.get()); assert!(reads <= 40 * 4 * groups, "{reads} group reads"); } + + /// Side-by-side vertical strips and 2D tile grids merge into one image XObject while invalid + /// or separated runs are handled per run. + #[test] + fn side_by_side_and_grid_fragments_merge_into_one_image() { + let tile = |doc: &mut Document, w: u32, h: u32, px: Vec| { + 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(i64::from(w))); + d.set(b"Height".to_vec(), Object::Int(i64::from(h))); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + doc.add(Object::Stream(Stream::flate(d, &px))) + }; + let build_page = |doc: &mut Document, xobjects: Dict, ops: &str| { + let content = doc.add(Object::Stream(Stream::flate(Dict::new(), ops.as_bytes()))); + let mut resources = Dict::new(); + resources.set(b"XObject".to_vec(), Object::Dict(xobjects)); + let mut page = Dict::new(); + page.set(b"Contents".to_vec(), Object::Ref(content)); + page.set(b"Resources".to_vec(), Object::Dict(resources)); + doc.add(Object::Dict(page)) + }; + + // Four vertical strips placed side by side (drawn out of order), alternating 1×3 and 2×3 + // pixels so the merged image keeps the finest horizontal resolution (8×3). + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for c in 0..4u8 { + let w = if c % 2 == 0 { 1 } else { 2 }; + let im = tile(&mut doc, w, 3, vec![c * 50; (w * 3) as usize]); + xobjects.set(format!("C{c}").into_bytes(), Object::Ref(im)); + } + let ops = [2usize, 0, 3, 1].into_iter().map(|c| format!("q 10 0 0 30 {} 100 cm /C{c} Do Q\n", 15.0 + c as f64 * 10.0)).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let merged_ref = xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap(); + let Object::Stream(s) = &*doc.get(merged_ref) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(8), Some(3))); + assert_eq!(s.decoded().unwrap(), [0, 0, 50, 50, 100, 100, 150, 150].repeat(3)); + let c_ref = doc.get(page).as_dict().and_then(|d| d.reference(b"Contents")).unwrap(); + let Object::Stream(cs) = &*doc.get(c_ref) else { panic!("expected contents") }; + let parsed = parse(&cs.decoded().unwrap()); + let cm = parsed.ops.iter().find(|o| o.is("cm")).and_then(|o| Matrix::from_operands(&o.operands)).unwrap(); + assert_eq!(cm.0, [40.0, 0.0, 0.0, 30.0, 15.0, 100.0]); + + // A 2×3 grid of tiles (2 rows, 3 columns), each 2×2 pixels, drawn in mixed order. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + for r in 0..2u8 { + for c in 0..3u8 { + let v = r * 100 + c * 20; + let im = tile(&mut doc, 2, 2, vec![v; 4]); + xobjects.set(format!("T{r}{c}").into_bytes(), Object::Ref(im)); + } + } + let ops = [(1u8, 2u8), (0, 1), (1, 0), (0, 2), (0, 0), (1, 1)] + .into_iter() + .map(|(r, c)| { + let x = 5.0 + f64::from(c) * 10.0; + let y = 50.0 + f64::from(1 - r) * 10.0; + format!("q 10 0 0 10 {x} {y} cm /T{r}{c} Do Q\n") + }) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (6, 1)); + let xo = doc + .get(page) + .as_dict() + .and_then(|d| d.get(b"Resources")) + .and_then(Object::as_dict) + .and_then(|d| d.get(b"XObject")) + .and_then(Object::as_dict) + .cloned() + .unwrap(); + let Object::Stream(s) = &*doc.get(xo.get(b"PCMerged1").and_then(Object::as_ref).unwrap()) else { panic!("expected stream") }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(6), Some(4))); + let mut expected = Vec::new(); + expected.extend([0, 0, 20, 20, 40, 40].repeat(2)); + expected.extend([100, 100, 120, 120, 140, 140].repeat(2)); + assert_eq!(s.decoded().unwrap(), expected); + + // Two side-by-side runs of 4 strips separated by a horizontal gap merge into 2 images, + // whether drawn left to right or right to left. + for right_to_left in [false, true] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![80; 4]); + xobjects.set(b"S".to_vec(), Object::Ref(im)); + let xs: Vec = if right_to_left { + vec![110.0, 100.0, 90.0, 80.0, 30.0, 20.0, 10.0, 0.0] + } else { + vec![0.0, 10.0, 20.0, 30.0, 80.0, 90.0, 100.0, 110.0] + }; + let ops = xs.into_iter().map(|x| format!("q 10 0 0 20 {x} 0 cm /S Do Q\n")).collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (8, 2), "right_to_left={right_to_left}"); + } + + // A 2×2 grid followed immediately by a lone fifth tile below only the first column merges + // the 4 grid tiles and leaves the fifth tile alone. + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let ops = [(0.0, 20.0), (10.0, 20.0), (0.0, 10.0), (10.0, 10.0), (0.0, 0.0)] + .into_iter() + .map(|(x, y)| format!("q 10 0 0 10 {x} {y} cm /G Do Q\n")) + .collect::(); + let page = build_page(&mut doc, xobjects, &ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (4, 1)); + + // Overlapping side-by-side strips or rows of different widths are left alone. + for bad_ops in [ + "q 10 0 0 20 0 0 cm /G Do Q\nq 10 0 0 20 5 0 cm /G Do Q\nq 10 0 0 20 15 0 cm /G Do Q\nq 10 0 0 20 25 0 cm /G Do Q\n", + "q 10 0 0 10 0 10 cm /G Do Q\nq 10 0 0 10 10 10 cm /G Do Q\nq 10 0 0 10 0 0 cm /G Do Q\nq 15 0 0 10 10 0 cm /G Do Q\n", + ] { + let mut doc = Document::new_empty(); + let mut xobjects = Dict::new(); + let im = tile(&mut doc, 2, 2, vec![90; 4]); + xobjects.set(b"G".to_vec(), Object::Ref(im)); + let page = build_page(&mut doc, xobjects, bad_ops); + let mut report = Report::default(); + let merged = merge_page(&mut doc, page, &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap(); + assert_eq!((merged, report.merged_images), (0, 0)); + } + } } diff --git a/crates/optimize/src/lib.rs b/crates/optimize/src/lib.rs index 275c6f32a6..fc29a45d4e 100644 --- a/crates/optimize/src/lib.rs +++ b/crates/optimize/src/lib.rs @@ -17,7 +17,7 @@ //! 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. +//! print settings, form submission, import and reset actions. //! - **Clean up:** drop images and forms that pages list but never draw (such as a deleted //! image); Flate-compress streams that have no filter; remove a redundant outer ASCII85 or //! ASCIIHex layer from streams that have one; store LZW-compressed streams with Flate instead. @@ -29,6 +29,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod audit; +mod form_actions; pub mod fragments; mod images; mod links; @@ -49,6 +50,8 @@ pub use samples::{NotDecoded, Samples, decode_samples, encode_samples}; pub enum OptimizeError { #[error("the document has no page tree")] NoPages, + #[error("object {0} nests or chains its form actions too deeply, or the document holds too many actions, to discard them")] + ActionLimit(ObjRef), #[error("the document's encryption requires PDF {required} or later (chosen: PDF {chosen})")] IncompatibleEncryption { required: &'static str, chosen: &'static str }, #[error(transparent)] @@ -197,6 +200,8 @@ pub struct Settings { pub discard_alternate_images: bool, pub discard_tags: bool, pub discard_print_settings: bool, + /// Remove form submission, import and reset actions; the fields and links that ran them stay. + pub discard_form_actions: bool, /// Flate-compress streams stored without a filter, and strip a redundant outer ASCII85 or /// ASCIIHex layer from streams that have one. pub flate_unencoded: bool, @@ -233,6 +238,7 @@ impl Default for Settings { discard_alternate_images: true, discard_tags: false, discard_print_settings: false, + discard_form_actions: false, flate_unencoded: true, object_streams: true, lzw_to_flate: true, @@ -297,6 +303,8 @@ pub struct Report { pub alternate_images: usize, pub tags_removed: bool, pub print_settings: usize, + /// Form submission, import and reset actions removed. + pub form_actions: usize, pub streams_compressed: usize, /// Streams whose LZW compression was replaced with Flate (Clean Up). pub lzw_streams: usize, @@ -426,6 +434,9 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress: _ => {} } } + if settings.discard_form_actions { + report.form_actions = form_actions::discard(doc)?; + } if settings.remove_invalid_links { let (l, b) = links::remove_invalid(doc, &pages)?; report.invalid_links = l; diff --git a/crates/optimize/src/links.rs b/crates/optimize/src/links.rs index cbd64eb928..5b74ad7023 100644 --- a/crates/optimize/src/links.rs +++ b/crates/optimize/src/links.rs @@ -5,16 +5,19 @@ use std::collections::{HashSet, VecDeque}; use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString}; -/// Named destinations: the `/Names /Dests` tree (entries) and the old `/Dests` dictionary. -fn named_dests(doc: &Document) -> HashSet> { +/// Named destinations whose target page exists: the `/Names /Dests` tree and the old `/Dests` +/// dictionary. +fn named_dests(doc: &Document, pages: &HashSet, count: usize) -> HashSet> { let mut out = HashSet::new(); let Some(cat) = doc.root().and_then(|r| doc.get(r).as_dict().cloned()) else { return out }; if let Some(d) = cat.get(b"Dests").map(|d| doc.resolve(d)).and_then(|d| d.as_dict().cloned()) { - out.extend(d.iter().map(|(k, _)| k.clone())); + out.extend(d.iter().filter(|(_, v)| valid_explicit(doc, v, pages, count)).map(|(k, _)| k.clone())); } if let Some(tree) = names_tree(doc, &cat) { - for (k, _) in tree_entries(doc, &tree) { - out.insert(k); + for (k, v) in tree_entries(doc, &tree) { + if valid_explicit(doc, &v, pages, count) { + out.insert(k); + } } } out @@ -74,22 +77,33 @@ fn dest_name(o: &Object) -> Option> { } } -fn valid(o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { - match o { - Object::Array(a) => match a.first() { - Some(Object::Ref(r)) => pages.contains(r), - Some(Object::Int(i)) => *i >= 0 && (*i as usize) < count, - _ => false, - }, - other => dest_name(other).is_some_and(|n| names.contains(&n)), +/// The `[page /Fit …]` array of an explicit destination (an array or a `<< /D […] >>` dictionary). +fn explicit_array(doc: &Document, o: &Object) -> Option> { + match doc.resolve(o).as_ref() { + Object::Array(a) => Some(a.clone()), + Object::Dict(d) => d.get(b"D").and_then(|x| doc.resolve(x).as_array().cloned()), + _ => None, } } +/// Whether an explicit destination targets one of the document's pages. +fn valid_explicit(doc: &Document, o: &Object, pages: &HashSet, count: usize) -> bool { + explicit_array(doc, o).is_some_and(|a| match a.first() { + Some(Object::Ref(r)) => pages.contains(r), + Some(Object::Int(i)) => usize::try_from(*i).is_ok_and(|i| i < count), + _ => false, + }) +} + +fn valid(doc: &Document, o: &Object, pages: &HashSet, count: usize, names: &HashSet>) -> bool { + valid_explicit(doc, o, pages, count) || dest_name(o).is_some_and(|n| names.contains(&n)) +} + /// Remove links whose destination is missing, and clear the destination of such bookmarks /// (they stay, so their children do too). Returns (links, bookmarks). pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(usize, usize), pdfcraft_cos::CosError> { let set: HashSet = pages.iter().copied().collect(); - let names = named_dests(doc); + let names = named_dests(doc, &set, pages.len()); let mut links = 0; for p in pages { let Some(pd) = doc.get(*p).as_dict().cloned() else { continue }; @@ -101,7 +115,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us if d.name(b"Subtype") != Some(b"Link") { return true; } - destination(doc, &d).is_none_or(|dest| valid(&dest, &set, pages.len(), &names)) + destination(doc, &d).is_none_or(|dest| valid(doc, &dest, &set, pages.len(), &names)) }) .cloned() .collect(); @@ -130,7 +144,7 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us stack.push(f); } if let Some(dest) = destination(doc, &d) - && !valid(&dest, &set, pages.len(), &names) + && !valid(doc, &dest, &set, pages.len(), &names) { doc.update_dict(r, |d| { d.remove(b"Dest"); @@ -142,39 +156,40 @@ pub(crate) fn remove_invalid(doc: &mut Document, pages: &[ObjRef]) -> Result<(us Ok((links, bookmarks)) } -/// Every named destination some object refers to (links, bookmarks, actions, open action). +/// Every named destination some reachable object refers to (links, bookmarks, actions, open action). fn referenced_names(doc: &Document) -> HashSet> { let mut out = HashSet::new(); - let mut visit = |o: &Object| { - let mut stack = vec![o.clone()]; - while let Some(o) = stack.pop() { - match o { - Object::Dict(d) => { - // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name - // string, and an unresolved ref has no name, so the destination it uses would - // look unreferenced and be removed. A GoToR or GoToE /D names a destination in - // another document, so it does not count as a use of this one's. - if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { - out.insert(n); - } - if d.name(b"S") == Some(b"GoTo") - && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) - { - out.insert(n); - } - stack.extend(d.iter().map(|(_, v)| v.clone())); - } - Object::Array(a) => stack.extend(a), - Object::Stream(s) => stack.push(Object::Dict(s.dict.clone())), - _ => {} - } - } - }; - for num in doc.object_numbers() { - visit(&doc.get(ObjRef::new(num, doc.generation(num)))); - } + let mut seen = HashSet::new(); + let mut stack = vec![Object::Dict(doc.trailer().clone())]; if let Some(r) = doc.root() { - visit(&doc.get(r)); + stack.push(Object::Ref(r)); + } + while let Some(o) = stack.pop() { + match o { + Object::Ref(r) => { + if seen.insert(r) { + stack.push(doc.get(r).as_ref().clone()); + } + } + Object::Dict(d) => { + // Resolve first: /Dest and a GoTo's /D may be an indirect reference to the name + // string, and an unresolved ref has no name, so the destination it uses would + // look unreferenced and be removed. A GoToR or GoToE /D names a destination in + // another document, so it does not count as a use of this one's. + if let Some(n) = d.get(b"Dest").and_then(|x| dest_name(doc.resolve(x).as_ref())) { + out.insert(n); + } + if d.name(b"S") == Some(b"GoTo") + && let Some(n) = d.get(b"D").and_then(|x| dest_name(doc.resolve(x).as_ref())) + { + out.insert(n); + } + stack.extend(d.iter().map(|(_, v)| v.clone())); + } + Object::Array(a) => stack.extend(a), + Object::Stream(s) => stack.push(Object::Dict(s.dict)), + _ => {} + } } out } diff --git a/crates/optimize/tests/optimize.rs b/crates/optimize/tests/optimize.rs index c4fb865198..92203852c7 100644 --- a/crates/optimize/tests/optimize.rs +++ b/crates/optimize/tests/optimize.rs @@ -2450,6 +2450,347 @@ fn discards_and_clean_up() { assert!(vp.contains(b"HideToolbar") && !vp.contains(b"Duplex"), "other preferences stay"); } +fn action(kind: &str) -> Dict { + let mut a = Dict::new(); + a.set(b"S".to_vec(), Object::name(kind)); + a +} + +fn with(mut d: Dict, key: &str, value: impl Into) -> Dict { + d.set(key.as_bytes().to_vec(), value); + d +} + +/// The kinds of the actions `o` runs, in order: each action, then its `/Next` actions. +fn kinds(doc: &Document, o: Option<&Object>) -> Vec { + let mut out = Vec::new(); + let Some(o) = o else { return out }; + match &*doc.resolve(o) { + Object::Array(items) => { + for item in items { + out.extend(kinds(doc, Some(item))); + } + } + Object::Dict(a) => { + out.push(String::from_utf8_lossy(a.name(b"S").unwrap_or_default()).into_owned()); + out.extend(kinds(doc, a.get(b"Next"))); + } + _ => {} + } + out +} + +fn dict(doc: &Document, r: ObjRef) -> Dict { + doc.get(r).as_dict().cloned().unwrap() +} + +#[test] +fn form_submit_import_and_reset_actions_are_discarded_and_the_fields_stay() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let triggers = with(Dict::new(), "U", action("ResetForm")); + let triggers = with(triggers, "E", with(action("JavaScript"), "JS", Object::String(pdfcraft_cos::PdfString::literal("app.alert(1)")))); + let button = with(with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Btn")), "A", action("SubmitForm")); + let button = doc.add(Object::Dict(with(button, "AA", triggers))); + let import = doc.add(Object::Dict(action("ImportData"))); + let field = with(with(Dict::new(), "Subtype", Object::name("Widget")), "FT", Object::name("Tx")); + let field = doc.add(Object::Dict(with(field, "AA", with(Dict::new(), "K", Object::Ref(import))))); + let link = with(with(Dict::new(), "Subtype", Object::name("Link")), "A", with(action("GoTo"), "Next", action("SubmitForm"))); + let link = doc.add(Object::Dict(with(link, "D", Object::Array(vec![Object::Ref(p), Object::name("Fit")])))); + let reset = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(button), Object::Ref(field), Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(reset))).unwrap(); + + let mut kept = doc.clone(); + assert_eq!(optimize(&mut kept, &Settings::default()).unwrap().form_actions, 0, "the option is off by default"); + assert_eq!(kinds(&kept, dict(&kept, button).get(b"A")), ["SubmitForm"]); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 5, "{r:?}"); + let annots = dict(&doc, p).get(b"Annots").and_then(|a| a.as_array().cloned()).unwrap(); + assert_eq!(annots.len(), 3, "the fields and the link stay"); + let b = dict(&doc, button); + assert_eq!(b.name(b"FT"), Some(&b"Btn"[..])); + assert!(!b.contains(b"A"), "{b:?}"); + let aa = b.get(b"AA").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()]), "other triggers stay"); + assert!(!dict(&doc, field).contains(b"AA"), "an emptied /AA goes"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_discarded_form_action_hands_its_place_to_the_actions_after_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + // A reset whose /S is an indirect name. + let name = doc.add(Object::name("ResetForm")); + let reset = doc.add(Object::Dict(with(with(Dict::new(), "S", Object::Ref(name)), "Next", action("URI")))); + let submit = with( + action("SubmitForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Ref(reset), Object::Dict(action("Named"))]), + ); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + // A reset whose chain comes back to itself. + let looped = doc.add(Object::Dict(action("ResetForm"))); + doc.update_dict(looped, |a| a.set(b"Next".to_vec(), Object::Ref(looped))).unwrap(); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(looped))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "URI", "Named"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn a_form_action_listed_twice_in_a_chain_gives_way_both_times() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let reset = doc.add(Object::Dict(with(action("ResetForm"), "Next", action("JavaScript")))); + let submit = with(action("SubmitForm"), "Next", Object::Array(vec![Object::Ref(reset), Object::Ref(reset)])); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + // An indirect submit that holds a direct reset in its /Next. + let root = doc.root().unwrap(); + let opening = doc.add(Object::Dict(with(action("SubmitForm"), "Next", action("ResetForm")))); + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), Object::Ref(opening))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 4, "each action counts once: {r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["JavaScript", "JavaScript"]); + assert!(!dict(&doc, root).contains(b"OpenAction")); +} + +#[test] +fn form_actions_chained_past_the_limit_stop_the_optimization() { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let root = doc.root().unwrap(); + let mut chain = action("ResetForm"); + for _ in 0..80 { + chain = with(action("SubmitForm"), "Next", chain); + } + doc.update_dict(root, |c| c.set(b"OpenAction".to_vec(), chain)).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == root), "{r:?}"); +} + +#[test] +fn a_next_array_that_lists_itself_is_left_as_it_is() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let looped = doc.add(Object::Null); + doc.set(looped, Object::Array(vec![Object::Ref(looped)])); + let (first, second) = (doc.add(Object::Null), doc.add(Object::Null)); + doc.set(first, Object::Array(vec![Object::Ref(second)])); + doc.set(second, Object::Array(vec![Object::Ref(first)])); + let link = |doc: &mut Document, next: ObjRef| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let (own, pair) = (link(&mut doc, looped), link(&mut doc, first)); + let submit = with(action("SubmitForm"), "Next", Object::Ref(looped)); + let button = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", submit))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(own), Object::Ref(pair), Object::Ref(button)]))).unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + + let mut cleaned = doc.clone(); + let r = optimize(&mut cleaned, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for (l, next) in [(own, looped), (pair, first)] { + let a = dict(&cleaned, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + } + assert_eq!(dict(&cleaned, button).get(b"A"), Some(&Object::Ref(looped)), "the submit's /Next takes its place"); + + // An array that lists itself and a submit, directly or in an array inside it: a cleaned copy + // would still reach the submit. + for submit in [Object::Dict(action("SubmitForm")), Object::Array(vec![Object::Dict(action("SubmitForm"))])] { + let mut mixed = doc.clone(); + mixed.set(first, Object::Array(vec![Object::Ref(second), submit])); + let r = optimize(&mut mixed, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); + } +} + +/// `referrers` actions whose `/Next` is one shared array of 2,000 actions, a submit among them. +fn shared_next_array(referrers: usize) -> Document { + let mut doc = Document::new_empty(); + page(&mut doc, &[], ""); + let script = doc.add(Object::Dict(action("JavaScript"))); + let mut items = vec![Object::Ref(script); 1_999]; + items.push(Object::Dict(action("SubmitForm"))); + let shared = doc.add(Object::Array(items)); + for _ in 0..referrers { + doc.add(Object::Dict(with(action("GoTo"), "Next", Object::Ref(shared)))); + } + doc +} + +#[test] +fn a_shared_next_array_cleaned_for_too_many_actions_stops_the_optimization() { + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut doc = shared_next_array(400); + assert_eq!(optimize(&mut doc, &settings).unwrap().form_actions, 1); + // Each referrer is under the limit for one object; together they pass the document's. + let mut doc = shared_next_array(600); + let r = optimize(&mut doc, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(_))), "{r:?}"); +} + +/// 80 direct dictionaries nested in each other under `/Deep`, the innermost one being `inner`. +fn nested(inner: Dict) -> Object { + let mut o = Object::Dict(inner); + for _ in 0..80 { + o = Object::Dict(with(Dict::new(), "Deep", o)); + } + o +} + +#[test] +fn deep_nesting_stops_the_optimization_only_when_a_form_action_is_past_the_limit() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let plain = doc.add(nested(with(action("GoTo"), "Next", action("URI")))); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", action("SubmitForm")))); + doc.update_dict(p, |d| { + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)])); + d.set(b"PieceInfo".to_vec(), Object::Ref(plain)); + }) + .unwrap(); + let settings = Settings { discard_form_actions: true, ..Settings::default() }; + let mut ok = doc.clone(); + let r = optimize(&mut ok, &settings).unwrap(); + assert_eq!(r.form_actions, 1, "deep nesting with no form action in it is left as it is: {r:?}"); + + let reset = doc.add(Object::Dict(action("ResetForm"))); + for (n, inner) in [ + action("SubmitForm"), + with(Dict::new(), "A", Object::Ref(reset)), + with(Dict::new(), "Next", Object::Ref(doc.add(Object::Array(vec![Object::Ref(reset)])))), + with(Dict::new(), "AA", Object::Ref(doc.add(Object::Dict(with(Dict::new(), "U", action("ImportData")))))), + with(Dict::new(), "AA", with(Dict::new(), "U", Object::Ref(reset))), + with(Dict::new(), "Next", Object::Array(vec![Object::Ref(reset)])), + ] + .into_iter() + .enumerate() + { + let mut deep = doc.clone(); + let held = deep.add(nested(inner)); + deep.update_dict(p, |d| d.set(b"PieceInfo".to_vec(), Object::Ref(held))).unwrap(); + let r = optimize(&mut deep, &settings); + assert!(matches!(r, Err(pdfcraft_optimize::OptimizeError::ActionLimit(at)) if at == held), "case {n}: {r:?}"); + } +} + +#[test] +fn a_shared_additional_actions_dictionary_is_cleaned_for_each_field_that_uses_it() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let triggers = with(with(Dict::new(), "U", action("SubmitForm")), "E", action("JavaScript")); + let shared = doc.add(Object::Dict(triggers)); + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "AA", Object::Ref(shared)))); + let (first, second) = (widget(&mut doc), widget(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + for w in [first, second] { + assert_eq!(dict(&doc, w).get(b"AA"), Some(&Object::Ref(shared)), "the widgets still share one dictionary: {w:?}"); + } + let aa = dict(&doc, shared); + assert_eq!((aa.contains(b"U"), kinds(&doc, aa.get(b"E"))), (false, vec!["JavaScript".to_string()])); +} + +#[test] +fn shared_indirect_actions_and_next_arrays_are_cleaned_once_and_stay_shared() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A shared submit whose /Next is a script that resets the form after it runs. + let script = with(action("JavaScript"), "Next", action("ResetForm")); + let submit = doc.add(Object::Dict(with(action("SubmitForm"), "Next", script))); + let next = doc.add(Object::Array(vec![Object::Dict(action("ImportData")), Object::Dict(action("URI")), Object::Dict(action("Named"))])); + let link = |doc: &mut Document| { + let a = with(action("GoTo"), "Next", Object::Ref(next)); + doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))) + }; + let widget = |doc: &mut Document| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + let (w1, w2, l1, l2) = (widget(&mut doc), widget(&mut doc), link(&mut doc), link(&mut doc)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([w1, w2, l1, l2].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 3, "{r:?}"); + for w in [w1, w2] { + assert_eq!(dict(&doc, w).get(b"A"), Some(&Object::Ref(submit)), "{w:?}"); + assert_eq!(kinds(&doc, dict(&doc, w).get(b"A")), ["JavaScript"], "{w:?}"); + } + for l in [l1, l2] { + let a = dict(&doc, l).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(next)), "{l:?}"); + assert_eq!(kinds(&doc, dict(&doc, l).get(b"A")), ["GoTo", "URI", "Named"], "{l:?}"); + } +} + +#[test] +fn a_form_action_in_an_array_inside_an_indirect_next_array_is_counted() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + let next = doc.add(Object::Array(vec![Object::Array(vec![Object::Dict(action("ResetForm"))]), Object::Dict(action("URI"))])); + let a = with(action("GoTo"), "Next", Object::Ref(next)); + let link = doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(link)]))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(kinds(&doc, dict(&doc, link).get(b"A")), ["GoTo", "URI"]); +} + +#[test] +fn a_shared_action_run_as_a_next_action_and_as_an_a_entry_stays_a_dictionary() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // A reset before two actions, run by one link after a GoTo and by another as its /A. + let reset = doc.add(Object::Dict(with( + action("ResetForm"), + "Next", + Object::Array(vec![Object::Dict(action("JavaScript")), Object::Dict(action("URI"))]), + ))); + let link = |doc: &mut Document, a: Object| doc.add(Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Link")), "A", a))); + let after = link(&mut doc, Object::Dict(with(action("GoTo"), "Next", Object::Ref(reset)))); + let runs = link(&mut doc, Object::Ref(reset)); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array([after, runs].map(Object::Ref).to_vec()))).unwrap(); + + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, reset).name(b"S"), Some(&b"JavaScript"[..]), "the reset's place is taken by a dictionary"); + assert_eq!(dict(&doc, runs).get(b"A"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, runs).get(b"A")), ["JavaScript", "URI"]); + let a = dict(&doc, after).get(b"A").and_then(|a| a.as_dict().cloned()).unwrap(); + assert_eq!(a.get(b"Next"), Some(&Object::Ref(reset))); + assert_eq!(kinds(&doc, dict(&doc, after).get(b"A")), ["GoTo", "JavaScript", "URI"]); +} + +#[test] +fn a_shared_submit_whose_script_runs_it_again_gives_way_to_the_script() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], ""); + // The widget comes first, so the walk reaches the submit through it. + let widget = doc.add(Object::Null); + let submit = doc.add(Object::Null); + doc.set(widget, Object::Dict(with(with(Dict::new(), "Subtype", Object::name("Widget")), "A", Object::Ref(submit)))); + doc.set(submit, Object::Dict(with(action("SubmitForm"), "Next", with(action("JavaScript"), "Next", Object::Ref(submit))))); + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(widget)]))).unwrap(); + let r = optimize(&mut doc, &Settings { discard_form_actions: true, ..Settings::default() }).unwrap(); + assert_eq!(r.form_actions, 1, "{r:?}"); + assert_eq!(dict(&doc, widget).get(b"A"), Some(&Object::Ref(submit))); + let script = dict(&doc, submit); + assert_eq!((script.name(b"S"), script.get(b"Next")), (Some(&b"JavaScript"[..]), Some(&Object::Ref(submit))), "the script runs itself again"); +} + #[test] fn space_audit_shares_the_file_out_by_kind() { let mut doc = Document::new_empty(); @@ -3096,6 +3437,52 @@ fn drawn_images(doc: &Document) -> Vec { .collect() } +/// A tolerated row must not be regrouped after its highest tile moves during X sorting. +#[test] +fn fragment_row_tolerance_preserves_every_tiles_pixels() { + for aligned in [true, false] { + for merge_fragments in [false, true] { + let mut doc = Document::new_empty(); + let mut refs = Vec::new(); + for gray in [20u8, 80, 140, 220] { + 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(10)); + d.set(b"Height".to_vec(), Object::Int(10)); + d.set(b"BitsPerComponent".to_vec(), Object::Int(8)); + d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + refs.push(doc.add(Object::Stream(Stream::flate(d, &[gray; 100])))); + } + let content = if aligned { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 10 10 0 cm /T1 Do Q q 10 0 0 10 20 0 cm /T2 Do Q q 10 0 0 10 30 0 cm /T3 Do Q" + } else { + "q 10 0 0 10 0 0 cm /T0 Do Q q 10 0 0 9.992 10 0.009 cm /T1 Do Q q 10 0 0 9.982 20 0.018 cm /T2 Do Q q 10 0 0 9.991 30 0.009 cm /T3 Do Q" + }; + page(&mut doc, &[("T0", refs[0]), ("T1", refs[1]), ("T2", refs[2]), ("T3", refs[3])], content); + let off = ImageSettings { downsample: false, compression: Compression::Retain, ..ImageSettings::REDUCE }; + let report = optimize(&mut doc, &Settings { merge_fragments, color: off, gray: off, ..Settings::default() }).unwrap(); + // Save and reopen, then inspect only images still drawn by the optimized page. + let doc = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap(); + let drawn = drawn_images(&doc); + if report.fragments_merged == 0 { + assert_eq!(report.merged_images, 0); + assert_eq!(drawn.len(), 4); + for (image, gray) in drawn.iter().zip([20u8, 80, 140, 220]) { + assert_eq!(image.decoded().unwrap(), [gray; 100]); + } + } else { + assert!(merge_fragments); + assert_eq!((report.fragments_merged, report.merged_images, drawn.len()), (4, 1, 1)); + let image = &drawn[0]; + assert_eq!((image.dict.int(b"Width"), image.dict.int(b"Height")), (Some(40), Some(10))); + let row: Vec<_> = [20u8, 80, 140, 220].into_iter().flat_map(|gray| [gray; 10]).collect(); + assert_eq!(image.decoded().unwrap(), row.repeat(10), "aligned={aligned}"); + } + } + } +} + #[test] fn a_page_picture_printed_as_one_pixel_strips_is_merged_into_one_image() { // 120 strips, every other one JPEG and half as wide (as one PDF printer writes them). @@ -4196,6 +4583,107 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin } } +#[test] +fn dictionary_destinations_broken_named_destinations_and_both_dest_tables() { + let mut doc = Document::new_empty(); + let p = page(&mut doc, &[], "BT ET"); + let s = |t: &str| Object::String(pdfcraft_cos::PdfString::literal(t.as_bytes().to_vec())); + let dict_dest = |target: ObjRef| { + let mut d = Dict::new(); + d.set(b"D".to_vec(), Object::Array(vec![Object::Ref(target), Object::name("Fit")])); + Object::Dict(d) + }; + let link = |doc: &mut Document, dest: Object| { + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Link")); + d.set(b"Rect".to_vec(), Object::Array(vec![0.into(), 0.into(), 10.into(), 10.into()])); + d.set(b"Dest".to_vec(), dest); + Object::Ref(doc.add(Object::Dict(d))) + }; + let missing = ObjRef::new(999, 0); + // Links: (1) explicit dictionary destination to `p` (valid), (2) explicit dictionary + // destination to `missing` (invalid), (3) named destination "dict_ok" whose value is + // `<< /D [p /Fit] >>` (valid), (4) named destination "broken_tree" whose target page is + // `missing` (invalid). + let annots = vec![ + link(&mut doc, dict_dest(p)), + link(&mut doc, dict_dest(missing)), + link(&mut doc, Object::Array(vec![Object::Int(i64::from(u32::MAX) + 1), Object::name("Fit")])), + link(&mut doc, s("dict_ok")), + link(&mut doc, s("broken_tree")), + ]; + doc.update_dict(p, |d| d.set(b"Annots".to_vec(), Object::Array(annots))).unwrap(); + + // Bookmark with an indirect GoTo action pointing to "broken_old" in catalog /Dests (whose + // page is `missing`), and a second bookmark pointing to "old_ok" in catalog /Dests. + let outlines = doc.add(Object::Null); + let mut goto_broken = Dict::new(); + goto_broken.set(b"S".to_vec(), Object::name("GoTo")); + goto_broken.set(b"D".to_vec(), Object::name("broken_old")); + let goto_broken = doc.add(Object::Dict(goto_broken)); + let mut bm_broken = Dict::new(); + bm_broken.set(b"Title".to_vec(), s("Broken")); + bm_broken.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_broken.set(b"A".to_vec(), Object::Ref(goto_broken)); + let bm_broken = doc.add(Object::Dict(bm_broken)); + let mut bm_ok = Dict::new(); + bm_ok.set(b"Title".to_vec(), s("Ok")); + bm_ok.set(b"Parent".to_vec(), Object::Ref(outlines)); + bm_ok.set(b"Dest".to_vec(), Object::name("old_ok")); + let bm_ok = doc.add(Object::Dict(bm_ok)); + doc.update_dict(bm_broken, |d| d.set(b"Next".to_vec(), Object::Ref(bm_ok))).unwrap(); + let mut o = Dict::new(); + o.set(b"First".to_vec(), Object::Ref(bm_broken)); + o.set(b"Last".to_vec(), Object::Ref(bm_ok)); + doc.set(outlines, Object::Dict(o)); + + // /Names /Dests has "broken_tree" (pointing to `missing`) and "dict_ok" (`<< /D [p /Fit] >>`). + let broken_dest = doc.add(dict_dest(missing)); + let ok_dest = doc.add(dict_dest(p)); + let mut tree = Dict::new(); + tree.set(b"Names".to_vec(), Object::Array(vec![s("broken_tree"), Object::Ref(broken_dest), s("dict_ok"), Object::Ref(ok_dest)])); + let tree = doc.add(Object::Dict(tree)); + let mut names = Dict::new(); + names.set(b"Dests".to_vec(), Object::Ref(tree)); + + // Catalog /Dests has "broken_old" (pointing to `missing`) and "old_ok" (`<< /D [p /Fit] >>`). + let mut old_dests = Dict::new(); + old_dests.set(b"broken_old".to_vec(), Object::Array(vec![Object::Ref(missing), Object::name("Fit")])); + old_dests.set(b"old_ok".to_vec(), dict_dest(p)); + let old_dests = doc.add(Object::Dict(old_dests)); + + let root = doc.root().unwrap(); + doc.update_dict(root, |c| { + c.set(b"Names".to_vec(), Object::Dict(names)); + c.set(b"Dests".to_vec(), Object::Ref(old_dests)); + c.set(b"Outlines".to_vec(), Object::Ref(outlines)); + }) + .unwrap(); + + // Space audit claims both /Names /Dests and catalog /Dests under NamedDestinations. + let only_tree = { + let mut d = doc.clone(); + d.update_dict(root, |c| { + c.remove(b"Dests"); + }) + .unwrap(); + audit_space(&d, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes + }; + let both_tables = audit_space(&doc, 0).into_iter().find(|u| u.category == SpaceCategory::NamedDestinations).unwrap().bytes; + assert!(both_tables > only_tree, "catalog /Dests is counted alongside /Names /Dests: {both_tables} vs {only_tree}"); + + let report = optimize(&mut doc, &Settings::default()).unwrap(); + assert_eq!((report.invalid_links, report.invalid_bookmarks, report.unreferenced_dests), (3, 1, 2), "{report:?}"); + let left_links = doc.get(p).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().len(); + assert_eq!(left_links, 2, "the two valid dictionary/named links remain"); + assert!(doc.get(bm_broken).as_dict().unwrap().get(b"A").is_none(), "broken bookmark action is cleared"); + assert!(doc.get(bm_ok).as_dict().unwrap().get(b"Dest").is_some(), "valid bookmark destination stays"); + assert_eq!(named_dest_keys(&doc), vec![b"dict_ok".to_vec()]); + let left_old = doc.get(old_dests).as_dict().cloned().unwrap(); + assert!(left_old.contains(b"old_ok") && !left_old.contains(b"broken_old"), "{left_old:?}"); +} + /// A one-bit stencil of `w` × `h`, solid but for a hole in its middle. fn stencil(doc: &mut Document, w: u32, h: u32) -> ObjRef { let row = (w as usize).div_ceil(8); diff --git a/crates/oracle/src/pixels.rs b/crates/oracle/src/pixels.rs index 4ff0ebaf13..6681aaa5df 100644 --- a/crates/oracle/src/pixels.rs +++ b/crates/oracle/src/pixels.rs @@ -105,6 +105,10 @@ pub fn compare(a: Gray<'_>, b: Gray<'_>, tolerance: u8) -> Result, b: Gray<'_>, tolerance: u8) -> usize { + // No pixels, nothing to match: a zero-width image of enormous height is not walked row by row. + if a.pixels.is_empty() { + return 0; + } let mut count = 0usize; for y in 0..a.height { for x in 0..a.width { @@ -210,6 +214,14 @@ fn find(haystack: &[u8], needle: &[u8]) -> Option { #[cfg(test)] mod tests { + /// A zero-width image of enormous height has no pixels to walk. + #[test] + fn an_image_without_pixels_compares_at_once() { + let a = Gray { width: 0, height: usize::MAX, pixels: &[] }; + let d = compare(a, a, 0).unwrap(); + assert_eq!((d.differing, d.total), (0, 0)); + } + use super::*; fn gray(w: usize, h: usize, fill: u8) -> Vec { diff --git a/crates/organize/src/comments.rs b/crates/organize/src/comments.rs index dd5d63ebc4..5ab894b48c 100644 --- a/crates/organize/src/comments.rs +++ b/crates/organize/src/comments.rs @@ -54,9 +54,13 @@ pub fn import_comments(dst: &mut Document, src: &Document) -> Result = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect(); let mut incoming: Vec> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect(); - let kept = threads_kept(&incoming); + let kept = threads_kept(src, &incoming); for list in &mut incoming { - list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p)))); + list.retain(|c| match irt_parent(src, &c.dict) { + None => true, + Some(Some(p)) => kept.contains(&p), + Some(None) => false, + }); } let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect()); // Every comment's object is chosen before any is copied, so a comment that another one reaches @@ -155,13 +159,34 @@ fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict (target, dict) } +/// Resolve `dict`'s `/IRT` entry: `None` when `/IRT` is absent or resolves to `null`, +/// `Some(Some(r))` when it references annotation object `r` (directly or through indirect +/// reference objects), or `Some(None)` for any other non-reference value. +fn irt_parent(src: &Document, dict: &Dict) -> Option> { + let v = dict.get(b"IRT")?; + if matches!(v, Object::Null) { + return None; + } + let Some(mut r) = v.as_ref() else { + return Some(None); + }; + for _ in 0..8 { + match *src.get(r) { + Object::Null => return None, + Object::Ref(next) => r = next, + _ => break, + } + } + Some(Some(r)) +} + /// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one /// only with the comment it answers, up to the first comment of the thread. A thread whose replies /// answer each other in a loop goes, as all of its comments are there. Each comment is visited once. -fn threads_kept(incoming: &[Vec]) -> HashSet { +fn threads_kept(src: &Document, incoming: &[Vec]) -> HashSet { // For each comment: no parent, a parent comment, or a parent that is no comment of the source. let parents: HashMap>> = - incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect(); + incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, irt_parent(src, &c.dict)))).collect(); let mut known: HashMap = HashMap::new(); for &start in parents.keys() { let (mut path, mut on_path) = (Vec::new(), HashSet::new()); @@ -202,8 +227,11 @@ fn page_comments(src: &Document, page: ObjRef) -> Vec { list.unwrap_or_default() .into_iter() .filter_map(|entry| { - let dict = src.resolve(&entry).as_dict().cloned()?; + let mut dict = src.resolve(&entry).as_dict().cloned()?; let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?; + if let Some(Some(r)) = irt_parent(src, &dict) { + dict.set(b"IRT".to_vec(), Object::Ref(r)); + } COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict }) }) .collect() @@ -217,7 +245,8 @@ fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> { .filter_map(|entry| { let r = entry.as_ref()?; let d = src.get(r).as_dict().cloned()?; - (d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))? + let is_popup = d.get(b"Subtype").map(|s| src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice()); + is_popup.then(|| d.reference(b"Parent").map(|p| (r, p)))? }) .collect() } @@ -265,7 +294,7 @@ impl PageAnnots { if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) { names.entry(name).or_default().push(r); } - if d.name(b"Subtype") == Some(b"Popup".as_slice()) + if d.get(b"Subtype").map(|s| dst.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"Popup".as_slice()) && let Some(parent) = d.reference(b"Parent") { popups.entry(parent).or_default().push(r); @@ -654,4 +683,42 @@ mod tests { assert_eq!(import_comments(&mut doc, &src).unwrap(), 0); assert_eq!(page_annots(&doc, 0).len(), before); } + + #[test] + fn comment_with_null_irt_and_popup_with_indirect_subtype_are_imported() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 16 0 R] >>", + "<< /Type /Annot /Subtype /Text /NM (root-null-irt) /IRT null /Contents (root comment) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 14 0 R /Contents (reply to root) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype 7 0 R /Parent 4 0 R /Rect [40 40 200 120] /P 3 0 R >>", + "/Popup", + "<< /Type /Page /Parent 2 0 R /Annots [15 0 R] >>", + // Three-reply chain on page 1 whose root comment (15 0 R) is on dropped page 2: + "<< /Type /Annot /Subtype /Text /IRT 15 0 R /Contents (dropped-1) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 9 0 R /Contents (dropped-2) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 10 0 R /Contents (dropped-3) /Rect [10 10 30 30] /P 3 0 R >>", + // Two-reply cycle on page 1: + "<< /Type /Annot /Subtype /Text /IRT 13 0 R /Contents (cycle-a) /Rect [10 10 30 30] /P 3 0 R >>", + "<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (cycle-b) /Rect [10 10 30 30] /P 3 0 R >>", + "4 0 R", + "<< /Type /Annot /Subtype /Text /Contents (dropped root) /Rect [10 10 30 30] /P 8 0 R >>", + // Comment whose /IRT chains through an intermediate reference (17 0 R -> 18 0 R -> null): + "<< /Type /Annot /Subtype /Text /IRT 17 0 R /Contents (indirect null irt) /Rect [10 10 30 30] /P 3 0 R >>", + "18 0 R", + "null", + ]); + let mut dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + assert_eq!(import_comments(&mut dst, &src).unwrap(), 5); + let annots = page_annots(&dst, 0); + assert_eq!(annots.len(), 6, "two root comments, reply via indirect IRT, two cycling replies, and the popup"); + let (root_ref, _) = by_contents(&annots, "root comment"); + assert_eq!(by_contents(&annots, "reply to root").1.reference(b"IRT"), Some(*root_ref)); + assert!(by_contents(&annots, "indirect null irt").1.get(b"IRT").is_some()); + } } diff --git a/crates/organize/src/dedupe.rs b/crates/organize/src/dedupe.rs index fe5cb0749d..f242fea0e1 100644 --- a/crates/organize/src/dedupe.rs +++ b/crates/organize/src/dedupe.rs @@ -45,6 +45,39 @@ fn is_resource(o: &Object) -> bool { /// out on purpose: it names optional content groups, whose identity matters. const RESOURCE_CATEGORIES: &[&[u8]] = &[b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"XObject", b"Font"]; +/// Whether `d` carries annotation, page, field, or structure identity rather than resource data. +/// Annotations often omit `/Type /Annot` (ISO 32000-2 Table 166 requires `/Subtype` and `/Rect`), +/// whereas resource dictionaries and category maps never carry `/Rect`, `/Popup`, `/IRT`, or `/FT`. +fn is_identity_dict(doc: &Document, d: &pdfcraft_cos::Dict) -> bool { + if let Some(t) = d.get(b"Type").map(|v| doc.resolve(v)).as_deref().and_then(Object::as_name) + && !RESOURCE_TYPES.contains(&t) + { + return true; + } + d.contains(b"Rect") || d.contains(b"Popup") || d.contains(b"IRT") || d.contains(b"FT") || d.contains(b"Parent") +} + +/// Whether a category member has the expected resource shape and is not an identity-bearing +/// dictionary such as an annotation or form field. +fn category_entry(doc: &Document, category: &[u8], value: &Object) -> bool { + let value = doc.resolve(value); + match category { + b"Font" | b"ExtGState" => matches!(&*value, Object::Dict(d) if !is_identity_dict(doc, d)), + b"XObject" => matches!(&*value, Object::Stream(s) if !is_identity_dict(doc, &s.dict)), + b"Pattern" | b"Shading" => match &*value { + Object::Dict(d) => !is_identity_dict(doc, d), + Object::Stream(s) => !is_identity_dict(doc, &s.dict), + _ => false, + }, + b"ColorSpace" => match &*value { + Object::Name(name) => matches!(name.as_slice(), b"DeviceGray" | b"DeviceRGB" | b"DeviceCMYK" | b"Pattern"), + Object::Array(items) => items.first().and_then(Object::as_name).is_some_and(|name| COLOR_SPACE_FAMILIES.contains(&name)), + _ => false, + }, + _ => false, + } +} + /// References listed in any `/Resources` dictionary reachable inside `o` (pages, forms, Type 3 /// fonts, annotation appearances), including the `/Resources` dictionary itself when indirect. fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet) { @@ -69,6 +102,15 @@ fn collect_resource_refs(doc: &Document, o: &Object, out: &mut HashSet) for cat in RESOURCE_CATEGORIES { let entries = res.get(cat).map(|c| doc.resolve(c)); if let Some(entries) = entries.as_ref().and_then(|e| e.as_dict()) { + // A malformed category can point directly at an annotation or list + // annotations inside a category map. Only resource-name maps whose + // members are non-identity resources are containers. + if is_identity_dict(doc, entries) || !entries.iter().all(|(_, value)| category_entry(doc, cat, value)) { + continue; + } + if let Some(Object::Ref(cat_ref)) = res.get(cat) { + out.insert(*cat_ref); + } out.extend(entries.iter().filter_map(|(_, v)| if let Object::Ref(r) = v { Some(*r) } else { None })); } } @@ -189,11 +231,15 @@ fn contains_ref(o: &Object, map: &HashMap) -> bool { /// Merge duplicate resources among `candidates` (into each other or into equal objects already /// in `doc`), rewrite references inside `candidates`, and free the merged-away objects. /// When `index_existing` is false, only candidates are compared (a brand-new document). +/// Repeated candidates are processed once, by complete object reference. /// Returns how many objects were removed. pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existing: bool) -> usize { let cand: HashSet = candidates.iter().copied().collect(); let mut sorted: Vec = candidates.to_vec(); - sorted.sort_unstable_by_key(|r| r.num); + sorted.sort_unstable_by_key(|r| (r.num, r.generation)); + // A repeated candidate must not merge into itself: that would cycle `canon` and free + // the canonical object. Keep generations distinct when normalizing references. + sorted.dedup(); // Existing objects first, so candidates merge into them rather than the reverse. let mut all: Vec<(ObjRef, std::sync::Arc)> = Vec::new(); if index_existing { @@ -273,6 +319,37 @@ mod tests { doc.add(Object::Dict(d)) } + #[test] + fn repeated_candidate_keeps_the_canonical_object() { + for index_existing in [false, true] { + let mut doc = Document::new_empty(); + let r = doc.add(stream(b"glyphs")); + assert_eq!(dedupe_resources(&mut doc, &[r, r], index_existing), 0); + assert_eq!(*doc.get(r), stream(b"glyphs")); + } + } + + #[test] + fn repeated_candidate_with_referrer_terminates_and_keeps_generations() { + for index_existing in [false, true] { + let mut doc = Document::new_empty(); + let r = ObjRef::new(20, 7); + doc.set(r, stream(b"glyphs")); + let s = font(&mut doc, r, "A"); + assert_eq!(dedupe_resources(&mut doc, &[r, r, s], index_existing), 0); + assert_eq!(*doc.get(r), stream(b"glyphs")); + assert_eq!(doc.generation(r.num), 7); + assert_eq!(doc.get(s).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r))); + + let duplicate = ObjRef::new(30, 9); + doc.set(duplicate, stream(b"glyphs")); + let t = font(&mut doc, duplicate, "B"); + assert_eq!(dedupe_resources(&mut doc, &[r, r, s, duplicate, duplicate, t], index_existing), 1); + assert_eq!(*doc.get(r), stream(b"glyphs")); + assert_eq!(doc.get(t).as_dict().unwrap().get(b"FontFile"), Some(&Object::Ref(r))); + } + } + #[test] fn merges_bottom_up_and_keeps_identity_objects() { let mut doc = Document::new_empty(); @@ -367,4 +444,53 @@ mod tests { let cs = doc.get(h).as_dict().unwrap().get(b"CS").unwrap().clone(); assert_eq!(cs, Object::Array(vec![Object::name("ICCBased"), Object::Ref(old)])); } + + #[test] + fn indirect_resource_category_and_resources_dictionaries_merge() { + let mut doc = Document::new_empty(); + let mut gs = Dict::new(); + gs.set(b"CA".to_vec(), Object::Real(0.5)); + let g1 = doc.add(Object::Dict(gs.clone())); + let g2 = doc.add(Object::Dict(gs)); + + let page_with_indirect_res = |doc: &mut Document, g: ObjRef| { + let mut ext = Dict::new(); + ext.set(b"G0".to_vec(), Object::Ref(g)); + let ext_ref = doc.add(Object::Dict(ext)); + let mut res = Dict::new(); + res.set(b"ExtGState".to_vec(), Object::Ref(ext_ref)); + let res_ref = doc.add(Object::Dict(res)); + let mut p = Dict::new(); + p.set(b"Type".to_vec(), Object::name("Page")); + p.set(b"Resources".to_vec(), Object::Ref(res_ref)); + let p_ref = doc.add(Object::Dict(p)); + (ext_ref, res_ref, p_ref) + }; + let (ext1, res1, p1) = page_with_indirect_res(&mut doc, g1); + let (ext2, res2, p2) = page_with_indirect_res(&mut doc, g2); + assert_eq!(dedupe_resources(&mut doc, &[g1, g2, ext1, res1, p1, ext2, res2, p2], false), 3); + assert_eq!(doc.get(p1).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1))); + assert_eq!(doc.get(p2).as_dict().unwrap().get(b"Resources"), Some(&Object::Ref(res1))); + assert_eq!(doc.get(res1).as_dict().unwrap().get(b"ExtGState"), Some(&Object::Ref(ext1))); + + // Distinct font maps (`F1` and `F10` with different fonts on one page vs the same on another) + // deduplicate identical fonts while preserving distinct `F1`/`F10` entries. + let ff1 = doc.add(stream(b"font-f1")); + let ff10 = doc.add(stream(b"font-f10")); + let f1_a = font(&mut doc, ff1, "Helvetica"); + let f10_a = font(&mut doc, ff10, "Courier"); + let f1_b = font(&mut doc, ff1, "Helvetica"); + let f10_b = font(&mut doc, ff10, "Times-Roman"); + let mut fmap_a = Dict::new(); + fmap_a.set(b"F1".to_vec(), Object::Ref(f1_a)); + fmap_a.set(b"F10".to_vec(), Object::Ref(f10_a)); + let fmap_a_ref = doc.add(Object::Dict(fmap_a)); + let mut fmap_b = Dict::new(); + fmap_b.set(b"F1".to_vec(), Object::Ref(f1_b)); + fmap_b.set(b"F10".to_vec(), Object::Ref(f10_b)); + let fmap_b_ref = doc.add(Object::Dict(fmap_b)); + assert_eq!(dedupe_resources(&mut doc, &[f1_a, f10_a, f1_b, f10_b, fmap_a_ref, fmap_b_ref], false), 1); + assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F1"), Some(&Object::Ref(f1_a))); + assert_eq!(doc.get(fmap_b_ref).as_dict().unwrap().get(b"F10"), Some(&Object::Ref(f10_b))); + } } diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 00fb9bd0b8..fdfb9ab597 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -28,14 +28,20 @@ pub(crate) fn set_attachments(dst: &mut Document, mut entries: Vec<(Vec, Obj } // Names are compared and numbered as text: a key written as UTF-16 (a name with an accent // or Chinese letters) must not get ASCII bytes appended to it. + let reserved: std::collections::HashSet = entries.iter().map(|(k, _)| pdfcraft_cos::PdfString::literal(k.clone()).to_text()).collect(); let mut seen = std::collections::HashSet::new(); for (k, _) in entries.iter_mut() { let base = pdfcraft_cos::PdfString::literal(k.clone()).to_text(); let mut text = base.clone(); let mut n = 2; - while !seen.insert(text.clone()) { - text = format!("{base} ({n})"); - n += 1; + if !seen.insert(text.clone()) { + loop { + text = format!("{base} ({n})"); + n += 1; + if !reserved.contains(&text) && seen.insert(text.clone()) { + break; + } + } } if text != base { *k = pdfcraft_cos::PdfString::text(&text).bytes; @@ -141,10 +147,11 @@ fn outline_level(m: &Copier<'_>, first: Option, depth: u8, budget: &mut let target = item.get(b"Dest").cloned().or_else(|| { let a = m.src.resolve(item.get(b"A")?); let a = a.as_dict()?; - (a.name(b"S") == Some(b"GoTo")).then(|| a.get(b"D").cloned()).flatten() + let is_goto = a.get(b"S").map(|s| m.src.resolve(s)).as_deref().and_then(Object::as_name) == Some(b"GoTo"); + is_goto.then(|| a.get(b"D").cloned()).flatten() }); let dest = target.and_then(|t| m.map_dest(&t)); - let open = item.int(b"Count").is_some_and(|c| c > 0); + let open = item.get(b"Count").and_then(|c| m.src.resolve(c).as_int()).is_some_and(|c| c > 0); let children = outline_level(m, item.reference(b"First"), depth + 1, budget); made.push(OutlineNode { title, colour, flags, dest, open, children }); } @@ -235,3 +242,58 @@ pub(super) fn add_outline(doc: &mut Document, marks: &[Mark]) -> Result<(), Orga })?; Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::attachment_keys; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + #[test] + fn set_attachments_does_not_clobber_later_numbered_attachment_name() { + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + let entries = + vec![(b"report.pdf".to_vec(), Object::Null), (b"report.pdf".to_vec(), Object::Null), (b"report.pdf (2)".to_vec(), Object::Null)]; + set_attachments(&mut doc, entries).unwrap(); + assert_eq!(attachment_keys(&doc), ["report.pdf", "report.pdf (2)", "report.pdf (3)"]); + } + + #[test] + fn source_outline_resolves_indirect_goto_action_type_and_count() { + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /Outlines 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R >>", + "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 1 >>", + "<< /Title (Parent) /Parent 4 0 R /First 6 0 R /Last 6 0 R /Count 8 0 R /A << /S 7 0 R /D [3 0 R /Fit] >> >>", + "<< /Title (Child) /Parent 5 0 R /Dest [3 0 R /Fit] >>", + "/GoTo", + "1", + ]); + let nodes = source_outline(&src, HashMap::from([(ObjRef::new(3, 0), ObjRef::new(3, 0))])); + assert_eq!(nodes.len(), 1); + assert!(nodes[0].dest.is_some()); + assert!(nodes[0].open); + } +} diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 7782e5de18..c1a1fed520 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -7,9 +7,13 @@ use super::{Dict, Document, FieldConflicts, HashMap, HashSet, ObjRef, Object, Or /// go to the copied pages. pub fn extract_pages(src: &Document, pages: &[usize]) -> Result { let mut out = Document::new_empty(); + let mut labels = Vec::new(); + let mut has_labels = false; + crate::labels::collect_page_labels(src, pages, 0, &mut labels, &mut has_labels)?; // One source: nothing to deduplicate. Its fields keep their names (a source with two // same-named field roots stays one field, as it was), so Extract and Split never rename. let imported = import_pages_mapped(&mut out, src, pages, 0, FieldConflicts::Merge)?; + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; // The bookmarks of these pages come along, mapped to the copies (#4020). let outline = kept_bookmarks(source_outline(src, imported.page_map)); if !outline.is_empty() { @@ -119,6 +123,8 @@ pub struct Combiner { out: Document, marks: Vec, attachments: Vec<(Vec, Object)>, + labels: Vec, + has_labels: bool, /// The first source's print standard, and what its pages brought along. standard: Option, /// Whether every source so far declares the first source's print standard. @@ -156,6 +162,8 @@ impl Combiner { out: Document::new_empty(), marks: Vec::new(), attachments: Vec::new(), + labels: Vec::new(), + has_labels: false, standard: None, agreed: true, field_conflicts, @@ -182,7 +190,12 @@ impl Combiner { None => (0..n).collect(), }; let at = crate::page_count(&self.out)?; + let mut labels = self.labels.clone(); + let mut has_labels = self.has_labels; + crate::labels::collect_page_labels(src, &pages, at, &mut labels, &mut has_labels)?; let imported = import_pages_mapped(&mut self.out, src, &pages, at, self.field_conflicts)?; + self.labels = labels; + self.has_labels = has_labels; self.renamed_fields.extend(imported.renamed_fields); if let Some(first) = imported.pages.first() { let outline = source_outline(src, imported.page_map); @@ -212,13 +225,14 @@ impl Combiner { /// [`Combiner::finish`] reporting what the combine renamed. pub fn finish_with_renames(self) -> Result { - let Combiner { mut out, marks, attachments, standard, agreed, renamed_fields, .. } = self; + let Combiner { mut out, marks, attachments, labels, has_labels, standard, agreed, renamed_fields, .. } = self; if let Some(first) = standard.filter(|_| agreed) { crate::pdfx::carry(&mut out, first.source.as_ref(), &first.declared, first.objects)?; } // Sources often share fonts, images and profiles (or are the same file): store them once. let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) @@ -299,9 +313,18 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu // The pages in the runs' order. A file's first run comes before its others, and its pages // were copied in run order, so its bookmark (its first copied page) is its first page shown. let mut order: Vec<(ObjRef, Dict)> = Vec::new(); + let mut labels = Vec::new(); + let mut has_labels = false; for (file, start, len) in slices { - let run = copies.get(file).and_then(|c| c.get(start..start.saturating_add(len))).unwrap_or_default(); + let end = start.saturating_add(len); + let at = order.len(); + let run = copies.get(file).and_then(|c| c.get(start..end)).unwrap_or_default(); order.extend(run.iter().map(|r| (*r, Dict::new()))); + if let Some((_, f)) = files.get(file) + && let Some(pages) = f.pages.get(start..end) + { + crate::labels::collect_page_labels(f.src, pages, at, &mut labels, &mut has_labels)?; + } } rebuild(&mut out, &order)?; if let Some((declared, src, objects)) = standard.filter(|_| agreed) { @@ -309,6 +332,7 @@ pub fn combine_grouped_with(runs: &[Run<'_>], conflicts: FieldConflicts) -> Resu } let all: Vec = out.object_numbers().into_iter().map(|n| ObjRef::new(n, out.generation(n))).collect(); crate::dedupe::dedupe_resources(&mut out, &all, false); + crate::labels::finish_page_labels(&mut out, &labels, has_labels)?; add_outline(&mut out, &marks)?; set_attachments(&mut out, attachments)?; Ok(CombineOutcome { doc: out, renamed_fields }) diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 44910d0406..daeaecd3f6 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -50,19 +50,26 @@ pub fn combine_field_conflicts(sources: &[&Document]) -> Vec) -> Vec<(ObjRef, String, String, String)> { + let mut reserved = std::collections::HashSet::new(); + let mut seen = std::collections::HashSet::new(); + for &root in roots { + collect_names(doc, root, "", &mut seen, 0, &mut reserved); + } let mut planned = Vec::new(); - let mut walk = RenameWalk::default(); + let mut walk = RenameWalk { reserved, ..RenameWalk::default() }; for &root in roots { plan_renames_under(doc, root, "", taken, &mut walk, 0, &mut planned); } planned } -/// What one [`plan_renames`] call remembers: the nodes it visited, and for each colliding name -/// the suffix to try next. +/// What one [`plan_renames`] call remembers: the nodes it visited, the incoming tree's own +/// names (so an earlier rename does not steal a later sibling's name), and for each colliding +/// name the suffix to try next. #[derive(Default)] struct RenameWalk { seen: HashSet, + reserved: HashSet, next: HashMap, } @@ -88,18 +95,19 @@ fn plan_renames_under( // still taken: resume there instead of at 1. let mut n = walk.next.get(&fqn).copied().unwrap_or(1); let mut variant; + let mut candidate; while { #[cfg(test)] crate::work(1); variant = format!("{name}-{n}"); - taken.contains(&qualified(prefix, &variant)) + candidate = qualified(prefix, &variant); + taken.contains(&candidate) || walk.reserved.contains(&candidate) } { n = n.saturating_add(1); } walk.next.insert(fqn.clone(), n.saturating_add(1)); - let new_fqn = qualified(prefix, &variant); - planned.push((node, variant.clone(), fqn, new_fqn.clone())); - taken.insert(new_fqn); + planned.push((node, variant.clone(), fqn, candidate.clone())); + taken.insert(candidate); name = variant; } else { taken.insert(fqn); @@ -143,15 +151,16 @@ fn field_roots(doc: &Document) -> Result, OrganizeError> { let annots = d.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default(); for a in annots.iter() { let Some(w) = doc.resolve(a).as_dict().cloned() else { continue }; - if w.name(b"Subtype") != Some(b"Widget") { + if w.get(b"Subtype").map(|s| doc.resolve(s)).as_deref().and_then(Object::as_name) != Some(b"Widget") { continue; } // Walk up to the field the widget belongs to, however deep it sits; a widget that // is its own field is its own root. let Some(mut root) = a.as_ref() else { continue }; let mut up = 0; + let mut visited = HashSet::from([root]); while let Some(parent) = doc.get(root).as_dict().and_then(|p| p.reference(b"Parent")) { - if up >= 32 || root == parent { + if up >= 32 || !visited.insert(parent) || doc.get(parent).as_dict().is_none() { break; } root = parent; @@ -295,3 +304,63 @@ pub(super) fn register_fields(dst: &mut Document, fields: &[ObjRef]) -> Result<( } Ok(()) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + fn build(bodies: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, b) in bodies.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes()); + Document::open(Arc::new(out)).expect("opens") + } + + #[test] + fn renaming_colliding_field_does_not_steal_later_sibling_name() { + let dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>", + ]); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item) /Rect [0 0 10 10] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (item-1) /Rect [0 20 10 30] >>", + ]); + // Only "item" collides with dst; "item-1" in src is already unique and must not be stolen. + assert_eq!(field_name_conflicts(&dst, &src), [("item".to_string(), "item-2".to_string())]); + } + + #[test] + fn field_roots_keeps_widget_with_dangling_parent_or_indirect_subtype() { + let dst = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype 5 0 R /FT /Tx /T (orphan) /Parent 99 0 R /Rect [0 0 10 10] >>", + "/Widget", + ]); + let src = build(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 100] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (orphan) /Rect [0 0 10 10] >>", + ]); + assert_eq!(field_name_conflicts(&dst, &src), [("orphan".to_string(), "orphan-1".to_string())]); + } +} diff --git a/crates/organize/src/labels.rs b/crates/organize/src/labels.rs index 8ee04bed53..e296148ebd 100644 --- a/crates/organize/src/labels.rs +++ b/crates/organize/src/labels.rs @@ -360,11 +360,13 @@ pub fn set_page_label_ranges(doc: &mut Document, ranges: &[LabelRange]) -> Resul return Err(invalid_label("the number tree has too many entries")); } // Validate every range and all effective labels before changing any document object. + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { if r.first == 0 || i64::try_from(r.start).is_err() { return Err(invalid_label("range starts and first numbers must be valid positive PDF integers")); } r.label(r.start)?; + prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; } let mut sorted = ranges.to_vec(); sorted.sort_by_key(|r| r.start); @@ -450,15 +452,9 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl } // Drop ranges that only continue the previous one. let mut merged: Vec = Vec::new(); + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES; for r in ranges { - let continues = merged.last().is_some_and(|p| { - p.style == r.style - && p.prefix == r.prefix - && r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first) - }); - if !continues { - merged.push(r); - } + push_merged_range(&mut merged, &mut prefix_left, r)?; } // Plain 1, 2, 3 everywhere is what a document without labels shows: remove them. if merged.len() == 1 && merged[0] == default_range() { @@ -467,6 +463,117 @@ pub fn number_pages(doc: &mut Document, from: usize, to: usize, style: LabelStyl set_page_label_ranges(doc, &merged) } +fn push_merged_range(merged: &mut Vec, prefix_left: &mut usize, r: LabelRange) -> Result<(), OrganizeError> { + let continues = merged.last().is_some_and(|p| { + p.style == r.style + && p.prefix == r.prefix + && match r.style { + LabelStyle::None => p.first == r.first, + _ => r.start.checked_sub(p.start).and_then(|n| u32::try_from(n).ok()).and_then(|n| p.first.checked_add(n)) == Some(r.first), + } + }); + if !continues { + if merged.len() + 1 >= MAX_LABEL_TREE_WORK { + return Err(invalid_label("the number tree has too many entries")); + } + *prefix_left = prefix_left.checked_sub(r.prefix.len()).ok_or_else(|| invalid_label("decoded prefixes exceed 4 MiB in total"))?; + merged.push(r); + } + Ok(()) +} + +/// Decimal numbering that shows the source page's physical number (`src_page` is 0-based). +fn source_physical_range(dst_start: usize, src_page: usize) -> LabelRange { + LabelRange { start: dst_start, style: LabelStyle::Decimal, prefix: String::new(), first: u32::try_from(src_page.saturating_add(1)).unwrap_or(1) } +} + +/// Whether `set_page_label_ranges` can store `r` while it covers `r.start..end` (`end` exclusive). +/// Entry-count and aggregate-prefix limits stay hard errors in [`push_merged_range`]. +fn range_writable(r: &LabelRange, end: usize) -> bool { + r.label(r.start).is_ok() && check_spans(std::slice::from_ref(r), end).is_ok() +} + +/// Append the label ranges that `pages` (0-based indices in `src`) carry when placed at +/// `dst_start` in an output document. Sets `has_labels` when `src` has `/PageLabels`. +pub(crate) fn collect_page_labels( + src: &Document, + pages: &[usize], + dst_start: usize, + out: &mut Vec, + has_labels: &mut bool, +) -> Result<(), OrganizeError> { + if pages.is_empty() { + return Ok(()); + } + let mut src_ranges = checked_ranges(src).unwrap_or_default(); + if !src_ranges.is_empty() { + *has_labels = true; + } + for r in &mut src_ranges { + // An empty prefix-only range shows each page's physical number in the source. + if r.style == LabelStyle::None && r.prefix.is_empty() { + r.style = LabelStyle::Decimal; + r.first = u32::try_from(r.start.saturating_add(1)).unwrap_or(1); + } + } + if src_ranges.first().is_none_or(|r| r.start > 0) { + src_ranges.insert(0, default_range()); + } + let mut prefix_left = MAX_PREFIX_TOTAL_BYTES.saturating_sub(out.iter().map(|r| r.prefix.len()).sum::()); + let mut i = 0; + while i < pages.len() { + let src_start = pages[i]; + let mut len = 1; + while i + len < pages.len() && src_start.checked_add(len) == Some(pages[i + len]) { + len += 1; + } + let run_dst = dst_start + i; + let src_end = src_start.saturating_add(len); + let run_end = run_dst.saturating_add(len); + let idx = src_ranges.partition_point(|r| r.start <= src_start).saturating_sub(1); + let span_end = |following: Option| { + following + .filter(|start| *start > src_start && *start < src_end) + .and_then(|start| start.checked_sub(src_start).and_then(|delta| run_dst.checked_add(delta))) + .unwrap_or(run_end) + }; + if let Some(first_range) = src_ranges.get(idx) { + let mapped = match first_range.number(src_start) { + Ok(first) => LabelRange { start: run_dst, style: first_range.style, prefix: first_range.prefix.clone(), first }, + Err(_) => source_physical_range(run_dst, src_start), + }; + let mapped = if range_writable(&mapped, span_end(src_ranges.get(idx + 1).map(|r| r.start))) { + mapped + } else { + source_physical_range(run_dst, src_start) + }; + push_merged_range(out, &mut prefix_left, mapped)?; + } + let tail = src_ranges.get(idx + 1..).unwrap_or_default(); + for (n, r) in tail.iter().enumerate() { + if r.start >= src_end { + break; + } + let Some(delta) = r.start.checked_sub(src_start) else { continue }; + let Some(start) = run_dst.checked_add(delta) else { continue }; + let inner = LabelRange { start, style: r.style, prefix: r.prefix.clone(), first: r.first }; + let following = tail.get(n + 1).map(|next| next.start); + let inner = if range_writable(&inner, span_end(following)) { inner } else { source_physical_range(start, r.start) }; + push_merged_range(out, &mut prefix_left, inner)?; + } + i += len; + } + Ok(()) +} + +/// Write collected page label ranges to `dst` when at least one source carried `/PageLabels`. +pub(crate) fn finish_page_labels(dst: &mut Document, ranges: &[LabelRange], has_labels: bool) -> Result<(), OrganizeError> { + if has_labels && !ranges.is_empty() { + set_page_label_ranges(dst, ranges)?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/organize/src/lib.rs b/crates/organize/src/lib.rs index d85cd15645..2fd997f549 100644 --- a/crates/organize/src/lib.rs +++ b/crates/organize/src/lib.rs @@ -685,7 +685,8 @@ pub fn has_info(doc: &Document, key: &str) -> bool { /// /// Documents with XMP metadata also carry Title, Author, Subject, Keywords, Creator and Producer /// in the XMP packet, and viewers that read XMP first show its value: the packet's property is -/// changed (or removed) to match (see `xmp`). +/// changed (or removed) to match, and a custom property is kept there as `pdfx:` as Acrobat +/// keeps it (see `xmp`). pub fn set_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { if !STANDARD_INFO_KEYS.contains(&key) { check_custom_key(key)?; @@ -745,8 +746,11 @@ impl Standard { /// Remove what identifies the document as meeting `standard`: the `pdfaid:` XMP properties of /// ISO 19005, or ISO 15930's `GTS_PDFXVersion` and `GTS_PDFXConformance` in the document -/// information and as `pdfxid:` XMP properties. Output intents and other metadata stay. Refused -/// when the document declares nothing of the kind. +/// information and as `pdfxid:` XMP properties, with the `pdfx:` copies that mirror those +/// information entries as custom properties (see `xmp`; a packet that binds `pdfx` to another +/// namespace keeps that namespace's properties). Output intents and other metadata stay. Refused, +/// changing nothing, when the document declares nothing of the kind: a `pdfx:` copy alone is not a +/// declaration. pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), OrganizeError> { let (props, info_keys): (&[&str], &[&str]) = match standard { Standard::PdfA => (&["pdfaid:part", "pdfaid:conformance", "pdfaid:amd", "pdfaid:rev"], &[]), @@ -759,7 +763,12 @@ pub fn remove_standard_id(doc: &mut Document, standard: Standard) -> Result<(), removed = true; } } - if removed { Ok(()) } else { Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))) } + if !removed { + return Err(OrganizeError::Invalid(format!("the document doesn't declare {}", standard.label()))); + } + // The information entries' custom-property copies go with them. + xmp::remove_custom_properties(doc, info_keys)?; + Ok(()) } #[cfg(test)] diff --git a/crates/organize/src/tests.rs b/crates/organize/src/tests.rs index ed95321006..451a6dc397 100644 --- a/crates/organize/src/tests.rs +++ b/crates/organize/src/tests.rs @@ -636,7 +636,7 @@ fn document_info_edits_update_the_xmp_packet() { set_info(&mut doc, "Author", "Demo Consulting").unwrap(); set_info(&mut doc, "Subject", "").unwrap(); set_info(&mut doc, "Keywords", "alpha, beta").unwrap(); - set_info(&mut doc, "Rating", "5").unwrap(); // no XMP counterpart: /Info only + set_info(&mut doc, "Rating", "5").unwrap(); // a custom property: pdfx:Rating let doc = save_and_reopen(&doc); assert_eq!(info(&doc, "Title").as_deref(), Some("Sales Guide v1.0 & ")); let xmp = xmp_text(&doc).unwrap(); @@ -644,6 +644,7 @@ fn document_info_edits_update_the_xmp_packet() { assert_eq!(xmp_values(&xmp, "dc:creator"), ["Demo Consulting"]); assert!(xmp_values(&xmp, "dc:description").is_empty(), "{xmp}"); assert_eq!(xmp_values(&xmp, "pdf:Keywords"), ["alpha, beta"]); + assert_eq!(xmp_values(&xmp, "pdfx:Rating"), ["5"]); assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); assert!(xmp.starts_with(""), "{xmp}"); // Stored unfiltered, still typed as metadata. @@ -690,9 +691,10 @@ fn document_info_falls_back_to_the_xmp_packet() { #[test] fn document_info_edits_leave_files_without_usable_xmp_alone() { - // No packet: none is added. + // No packet: none is added, for a custom property either. let mut doc = open(fixture()); set_info(&mut doc, "Title", "New").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); assert_eq!(xmp_text(&save_and_reopen(&doc)), None); // A packet that isn't UTF-8 is not rewritten. let mut latin1 = LIBREOFFICE_XMP.as_bytes().to_vec(); @@ -704,6 +706,96 @@ fn document_info_edits_leave_files_without_usable_xmp_alone() { assert_eq!(xmp_stream(&doc).unwrap().decoded().unwrap(), latin1); } +/// Acrobat keeps each custom property in the XMP packet too, as `pdfx:` (the +/// http://ns.adobe.com/pdfx/1.3/ schema), so tools that read XMP see the same properties as +/// Document Properties ▸ Custom. +#[test] +fn custom_document_properties_are_kept_in_the_xmp_packet_as_pdfx() { + // An Acrobat-style packet already holding one, in a description of its own. + let acrobat = LIBREOFFICE_XMP.replace( + " ", + " Sales\n ", + ); + let mut doc = doc_with_xmp(acrobat.as_bytes()); + set_info(&mut doc, "Project", "Apollo & ").unwrap(); + set_info(&mut doc, "Größe", "A4").unwrap(); + set_info(&mut doc, "Dept", "R&D").unwrap(); + let mut doc = save_and_reopen(&doc); + assert_eq!((info(&doc, "Project").as_deref(), info(&doc, "Dept").as_deref()), (Some("Apollo & "), Some("R&D"))); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!(xmp_values(&xmp, "pdfx:Project"), ["Apollo & <X>"], "{xmp}"); + assert_eq!(xmp_values(&xmp, "pdfx:Größe"), ["A4"], "{xmp}"); + assert!(xmp.contains(""), "{xmp}"); + // One already there changes where it stands; the rest of the packet is as it was. + assert!(xmp.contains("xmlns:pdfx=\"http://ns.adobe.com/pdfx/1.3/\">R&D"), "{xmp}"); + assert_eq!(xmp_values(&xmp, "dc:title"), ["Untitled 1"]); + assert_eq!(xmp_values(&xmp, "pdf:Producer"), ["Writer 1.0"]); + // Changed again, then removed with the property. + set_info(&mut doc, "Project", "Gemini").unwrap(); + let xmp = xmp_text(&doc).unwrap(); + assert_eq!((xmp.matches("").count(), xmp_values(&xmp, "pdfx:Project")), (1, vec!["Gemini".to_string()]), "{xmp}"); + for key in ["Project", "Größe", "Dept"] { + set_info(&mut doc, key, "").unwrap(); + } + let xmp = xmp_text(&save_and_reopen(&doc)).unwrap(); + assert!(!xmp.contains("", + " \n ", + ); + let mut doc = doc_with_xmp(pdfa.as_bytes()); + set_info(&mut doc, "Project", "Apollo").unwrap(); + let doc = save_and_reopen(&doc); + assert_eq!(info(&doc, "Project").as_deref(), Some("Apollo")); + assert_eq!(xmp_text(&doc).unwrap(), pdfa, "no undescribed schema in a PDF/A packet"); + // The declaration counts however its value is written (here CDATA, which isn't read as text). + let cdata = LIBREOFFICE_XMP.replace( + " ", + " \n ", + ); + let mut doc = doc_with_xmp(cdata.as_bytes()); + set_info(&mut doc, "Project", "Apollo").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), cdata); + // One the packet already has (described, as an Acrobat PDF/A file would) is changed and removed. + let described = pdfa.replace( + " ", + " Sales\n ", + ); + let mut doc = doc_with_xmp(described.as_bytes()); + set_info(&mut doc, "Dept", "R&D").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), described.replace(">Sales<", ">R&D<")); + set_info(&mut doc, "Dept", "").unwrap(); + assert_eq!(xmp_text(&doc).unwrap(), described.replace("Sales", "")); + + // `pdfx` bound to the PDF/X identification namespace: neither changed nor added to. + let other = LIBREOFFICE_XMP.replace( + " ", + " keep\n ", + ); + let mut doc = doc_with_xmp(other.as_bytes()); + set_info(&mut doc, "Dept", "R&D").unwrap(); + set_info(&mut doc, "Project", "Apollo").unwrap(); + assert_eq!((info(&doc, "Dept").as_deref(), info(&doc, "Project").as_deref()), (Some("R&D"), Some("Apollo"))); + assert_eq!(xmp_text(&save_and_reopen(&doc)).unwrap(), other); +} + #[test] fn successive_incremental_saves_stack_revisions() { let mut doc = open(fixture()); @@ -1384,6 +1476,28 @@ fn removing_the_pdfx_identification_keeps_the_output_intent_and_other_claims() { let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", None); remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); assert_eq!((info(&x1a, "GTS_PDFXVersion"), info(&x1a, "GTS_PDFXConformance")), (None, None)); + + // Those information entries are custom properties, so XMP may also hold them as pdfx: (an + // element or an attribute); the copies go with them, and other custom properties stay. + let xmp = "\ + PDF/X-1:2001Print"; + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001) /GTS_PDFXConformance (PDF/X-1a:2001)", Some(xmp)); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + let x = pdfx_of(&full_roundtrip(&x1a)); + assert!(x.xmp.as_deref().is_some_and(|x| !x.contains("GTS_") && x.contains("Print")), "{:?}", x.xmp); + + // A copy alone declares nothing: refused, and the copy stays. + let mut copy_only = doc_print("CGATS TR 001", "icc", "/Title (Flyer)", Some(xmp)); + assert!(matches!(remove_standard_id(&mut copy_only, Standard::PdfX), Err(OrganizeError::Invalid(_)))); + assert!(!copy_only.is_modified(), "a refused removal changes nothing"); + + // `pdfx` bound to another namespace: the declaration in /Info goes, that namespace's property stays. + let foreign = xmp.replace("http://ns.adobe.com/pdfx/1.3/", "http://example.com/other/"); + let mut x1a = doc_print("CGATS TR 001", "icc", "/GTS_PDFXVersion (PDF/X-1:2001)", Some(foreign.as_str())); + remove_standard_id(&mut x1a, Standard::PdfX).unwrap(); + assert_eq!(info(&x1a, "GTS_PDFXVersion"), None); + assert_eq!(pdfx_of(&full_roundtrip(&x1a)).xmp.as_deref(), Some(foreign.as_str())); } #[test] @@ -1633,6 +1747,63 @@ fn bookmarks_report_the_view_their_destination_asks_for() { ); } +#[test] +fn malformed_resource_categories_preserve_annotation_identity_when_combining_and_inserting() { + for typed in [true, false] { + for with_popups in [false, true] { + let mut source = doc_a(); + let page = pages(&source).unwrap()[0].obj; + let mut annot = Dict::new(); + if typed { + annot.set(b"Type".to_vec(), Object::name("Annot")); + } + annot.set(b"Subtype".to_vec(), Object::name("Text")); + annot.set(b"Rect".to_vec(), Object::Array([0, 0, 10, 10].map(Object::Int).to_vec())); + let first = source.add(Object::Dict(annot.clone())); + let second = source.add(Object::Dict(annot.clone())); + if with_popups { + annot.set(b"Subtype".to_vec(), Object::name("Popup")); + let popup1 = source.add(Object::Dict(annot.clone())); + let popup2 = source.add(Object::Dict(annot)); + source.update_dict(first, |d| d.set(b"Popup".to_vec(), Object::Ref(popup1))).unwrap(); + source.update_dict(second, |d| d.set(b"Popup".to_vec(), Object::Ref(popup2))).unwrap(); + } + source + .update_dict(page, |d| { + let mut font_map = Dict::new(); + font_map.set(b"F1".to_vec(), Object::Ref(first)); + font_map.set(b"F2".to_vec(), Object::Ref(second)); + let mut resources = Dict::new(); + resources.set(b"Font".to_vec(), Object::Dict(font_map)); + resources.set(b"ExtGState".to_vec(), Object::Ref(second)); + d.set(b"Resources".to_vec(), Object::Dict(resources)); + d.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(first), Object::Ref(second)])); + }) + .unwrap(); + let combined = crate::combine(&[("source", &source)]).unwrap(); + let mut inserted = doc_a(); + crate::import_pages(&mut inserted, &source, &[0], 0).unwrap(); + for doc in [combined, inserted] { + let doc = full_roundtrip(&doc); + let page = pages(&doc).unwrap()[0].obj; + let refs = doc.get(page).as_dict().unwrap().get(b"Annots").unwrap().as_array().unwrap().clone(); + assert_eq!(refs.len(), 2); + assert_ne!(refs[0], refs[1], "equal annotations retain their separate identities"); + if with_popups { + let popups: Vec<_> = refs.iter().map(|r| doc.resolve(r).as_dict().unwrap().reference(b"Popup").unwrap()).collect(); + assert_ne!(popups[0], popups[1], "malformed category entries must not merge distinct popups"); + for popup in popups { + assert_eq!(doc.get(popup).as_dict().unwrap().name(b"Subtype"), Some(b"Popup".as_slice())); + } + } + for r in refs { + assert_eq!(doc.resolve(&r).as_dict().unwrap().name(b"Subtype"), Some(b"Text".as_slice())); + } + } + } + } +} + #[test] fn combine_and_insert_share_identical_resources() { let a = doc_a(); @@ -2052,6 +2223,176 @@ fn deleting_pages_keeps_the_label_ranges_on_their_pages() { } } +#[test] +fn extracting_splitting_and_combining_preserve_page_labels() { + use crate::{ + LabelRange, + LabelStyle::{Decimal, LowerRoman, UpperAlpha}, + SplitBy, + }; + let ten = || { + let mut d = doc_a(); + for at in 3..10 { + crate::insert_blank_page(&mut d, at, 300.0, 400.0).unwrap(); + } + let ranges = [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 3, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 7, style: Decimal, prefix: "A-".into(), first: 1 }, + ]; + crate::set_page_label_ranges(&mut d, &ranges).unwrap(); + d + }; + let d = ten(); + + // Extracting non-contiguous pages (e.g. first front-matter page and first appendix page) + // keeps their labels and writes /PageLabels (#2670). + let pair = full_roundtrip(&extract_pages(&d, &[0, 7]).unwrap()); + assert_eq!(crate::page_labels(&pair).unwrap(), ["i", "A-1"]); + assert_eq!( + crate::page_label_ranges(&pair), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: Decimal, prefix: "A-".into(), first: 1 }, + ] + ); + + // Contiguous runs and out-of-order picks keep each page's label and merge continuations. + let picked = full_roundtrip(&extract_pages(&d, &[1, 2, 8, 9, 0]).unwrap()); + assert_eq!(crate::page_labels(&picked).unwrap(), ["ii", "iii", "A-2", "A-3", "i"]); + assert_eq!( + crate::page_label_ranges(&picked), + [ + LabelRange { start: 0, style: LowerRoman, prefix: String::new(), first: 2 }, + LabelRange { start: 2, style: Decimal, prefix: "A-".into(), first: 2 }, + LabelRange { start: 4, style: LowerRoman, prefix: String::new(), first: 1 }, + ] + ); + + // An unlabelled source stays unlabelled after extraction. + let unlabelled = full_roundtrip(&extract_pages(&doc_a(), &[2, 0]).unwrap()); + assert!(crate::page_label_ranges(&unlabelled).is_empty()); + + // Splitting preserves each part's labels, including mid-range starting numbers. + let parts = crate::split(&d, &SplitBy::PageCount(4)).unwrap(); + let part_labels: Vec> = parts.iter().map(|p| crate::page_labels(&full_roundtrip(p)).unwrap()).collect(); + assert_eq!(part_labels, vec![vec!["i", "ii", "iii", "1"], vec!["2", "3", "4", "A-1"], vec!["A-2", "A-3"],]); + + // Combining whole files, selected pages, and grouped runs preserves labels from each source. + let mut b = doc_b(); + crate::set_page_label_ranges(&mut b, &[LabelRange { start: 0, style: UpperAlpha, prefix: "B-".into(), first: 1 }]).unwrap(); + let combined = full_roundtrip(&combine_selected(&[("D", &d, Some(&[0, 1, 7])), ("Plain", &doc_a(), Some(&[0, 1])), ("B", &b, None)]).unwrap()); + assert_eq!(crate::page_labels(&combined).unwrap(), ["i", "ii", "A-1", "1", "2", "B-A", "B-B"]); + + let grouped = + full_roundtrip(&crate::combine_grouped(&[(0, "D", &d, Some(&[0, 1])), (1, "B", &b, Some(&[1])), (0, "D", &d, Some(&[2, 7]))]).unwrap()); + assert_eq!(crate::page_labels(&grouped).unwrap(), ["i", "ii", "B-B", "iii", "A-1"]); + + // Ranges starting after page 0 and prefix-only ranges (LabelStyle::None) merge and carry over. + let mut late = doc_a(); + crate::set_page_label_ranges( + &mut late, + &[ + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 4 }, + ], + ) + .unwrap(); + let from_late = full_roundtrip(&extract_pages(&late, &[0, 1, 1, 2]).unwrap()); + assert_eq!(crate::page_labels(&from_late).unwrap(), ["1", "Cover", "Cover", "iv"]); + assert_eq!( + crate::page_label_ranges(&from_late), + [ + LabelRange { start: 0, style: Decimal, prefix: String::new(), first: 1 }, + LabelRange { start: 1, style: crate::LabelStyle::None, prefix: "Cover".into(), first: 1 }, + LabelRange { start: 3, style: LowerRoman, prefix: String::new(), first: 4 }, + ] + ); + + // An empty selection from a labelled source in combine_selected does not add /PageLabels. + let empty_pick = full_roundtrip(&combine_selected(&[("D", &d, Some(&[])), ("Plain", &doc_a(), Some(&[0, 1]))]).unwrap()); + assert!(crate::page_label_ranges(&empty_pick).is_empty()); + + // A damaged (cyclic) /PageLabels tree does not prevent extracting pages. + let mut cyclic = doc_a(); + let tree = cyclic.add(Object::Null); + let mut dict = Dict::new(); + dict.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(tree)])); + cyclic.set(tree, Object::Dict(dict)); + cyclic.update_dict(cyclic.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted_cyclic = full_roundtrip(&extract_pages(&cyclic, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted_cyclic).unwrap(), ["1", "2"]); + + // An empty prefix-only range falls back to physical page numbers in the source; extracting a + // later page covered by it preserves that physical page number across save/reopen. + let mut empty_prefix = doc_a(); + crate::set_page_label_ranges( + &mut empty_prefix, + &[ + LabelRange { start: 0, style: crate::LabelStyle::None, prefix: String::new(), first: 1 }, + LabelRange { start: 2, style: LowerRoman, prefix: String::new(), first: 1 }, + ], + ) + .unwrap(); + assert_eq!(crate::PageLabels::new(&empty_prefix).unwrap().label(1).unwrap(), "2"); + let extracted_empty_prefix = full_roundtrip(&extract_pages(&empty_prefix, &[1]).unwrap()); + assert_eq!(crate::PageLabels::new(&extracted_empty_prefix).unwrap().label(0).unwrap(), "2"); + + // Repeated non-continuing ranges that exceed the 4 MiB total-prefix limit or the range-count + // limit are refused rather than writing an unreadable tree or silently dropping labels. + let mut one = flat_doc(1); + crate::number_pages(&mut one, 0, 0, Decimal, &"x".repeat(1_000), 1).unwrap(); + let err = extract_pages(&one, &vec![0; 4_195]).unwrap_err().to_string(); + assert!(err.contains("decoded prefixes exceed 4 MiB in total"), "{err}"); + + crate::number_pages(&mut one, 0, 0, LowerRoman, "", 2).unwrap(); + let err = extract_pages(&one, &vec![0; pdfcraft_cos::page_labels::MAX_LABEL_TREE_WORK]).unwrap_err().to_string(); + assert!(err.contains("the number tree has too many entries"), "{err}"); +} + +/// A range the reader accepts but the writer cannot store (a Roman numeral past 1,024 bytes, or a +/// decimal span whose labels grow past that) degrades to the source physical numbers. Extract +/// still succeeds, and a neighbouring range that does fit is kept. +#[test] +fn extracting_unrepresentable_labels_uses_physical_numbers() { + let mut roman = doc_a(); + let mut spec = Dict::new(); + spec.set(b"S".to_vec(), Object::name("R")); + spec.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = roman.add(Object::Dict(tree)); + roman.update_dict(roman.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&roman, &[0, 2]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "3"]); + + let mut wide = flat_doc(5); + let mut spec = Dict::new(); + spec.set(b"P".to_vec(), Object::String(PdfString::text(&"p".repeat(1_020)))); + spec.set(b"S".to_vec(), Object::name("D")); + spec.set(b"St".to_vec(), Object::Int(9_996)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(spec)])); + let tree = wide.add(Object::Dict(tree)); + wide.update_dict(wide.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&wide, &[0, 1, 2, 3, 4]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["1", "2", "3", "4", "5"]); + + let mut mixed = doc_a(); + let mut ok = Dict::new(); + ok.set(b"P".to_vec(), Object::String(PdfString::text("ok-"))); + ok.set(b"S".to_vec(), Object::name("D")); + let mut huge = Dict::new(); + huge.set(b"S".to_vec(), Object::name("R")); + huge.set(b"St".to_vec(), Object::Int(4_000_000)); + let mut tree = Dict::new(); + tree.set(b"Nums".to_vec(), Object::Array(vec![Object::Int(0), Object::Dict(ok), Object::Int(1), Object::Dict(huge)])); + let tree = mixed.add(Object::Dict(tree)); + mixed.update_dict(mixed.root().unwrap(), |c| c.set(b"PageLabels".to_vec(), Object::Ref(tree))).unwrap(); + let extracted = full_roundtrip(&extract_pages(&mixed, &[0, 1]).unwrap()); + assert_eq!(crate::page_labels(&extracted).unwrap(), ["ok-1", "2"]); +} + #[test] fn page_label_limits_reject_unsupported_edits_before_mutating() { use crate::LabelStyle::*; @@ -4164,6 +4505,87 @@ fn bookmark_style_and_colour_are_set_as_the_table_says() { assert_eq!(got, [(Some([0.0, 0.0, 1.0]), true, true), (None, false, false)]); } +/// Gives the fixture a PDF 2.0 document part tree (`/DPartRoot`, ISO 32000-2 14.12) that starts +/// at its first page, and a structure tree root listing a structure namespace (14.7.4). +fn with_document_parts_and_namespaces() -> Document { + // PDF 2.0, the version these structures belong to. + let mut bytes = fixture(); + let header = bytes.get_mut(..8).expect("the fixture starts with its header"); + assert!(header.starts_with(b"%PDF-1.")); + header.copy_from_slice(b"%PDF-2.0"); + let mut doc = open(bytes); + let first = pages(&doc).unwrap()[0].obj; + let mut namespace = pdfcraft_cos::Dict::new(); + namespace.set(b"Type".to_vec(), Object::name("Namespace")); + namespace.set(b"NS".to_vec(), Object::String(pdfcraft_cos::PdfString { bytes: b"http://iso.org/pdf2/ssn".to_vec(), hex: false })); + let namespace = doc.add(Object::Dict(namespace)); + let mut tree = pdfcraft_cos::Dict::new(); + tree.set(b"Type".to_vec(), Object::name("StructTreeRoot")); + tree.set(b"Namespaces".to_vec(), Object::Array(vec![Object::Ref(namespace)])); + let tree = doc.add(Object::Dict(tree)); + let mut part = pdfcraft_cos::Dict::new(); + part.set(b"Type".to_vec(), Object::name("DPart")); + part.set(b"Start".to_vec(), Object::Ref(first)); + part.set(b"End".to_vec(), Object::Ref(first)); + let part = doc.add(Object::Dict(part)); + let mut root = pdfcraft_cos::Dict::new(); + root.set(b"Type".to_vec(), Object::name("DPartRoot")); + root.set(b"DPartRootNode".to_vec(), Object::Ref(part)); + let root = doc.add(Object::Dict(root)); + doc.update_dict(doc.root().unwrap(), |c| { + c.set(b"DPartRoot".to_vec(), Object::Ref(root)); + c.set(b"StructTreeRoot".to_vec(), Object::Ref(tree)); + }) + .unwrap(); + doc +} + +fn assert_document_parts_and_namespaces_kept(doc: &Document, what: &str) { + let catalog = doc.get(doc.root().unwrap()); + let catalog = catalog.as_dict().unwrap(); + let root = doc.resolve(catalog.get(b"DPartRoot").unwrap_or_else(|| panic!("{what}: /DPartRoot dropped"))); + let node = doc.resolve(root.as_dict().unwrap().get(b"DPartRootNode").unwrap_or_else(|| panic!("{what}: /DPartRootNode dropped"))); + let part = node.as_dict().unwrap(); + assert_eq!(part.name(b"Type"), Some(&b"DPart"[..]), "{what}"); + assert!(part.get(b"Start").is_some(), "{what}: /Start dropped"); + assert!(part.get(b"End").is_some(), "{what}: /End dropped"); + let tree = doc.resolve(catalog.get(b"StructTreeRoot").unwrap_or_else(|| panic!("{what}: /StructTreeRoot dropped"))); + let namespaces = doc.resolve(tree.as_dict().unwrap().get(b"Namespaces").unwrap_or_else(|| panic!("{what}: /Namespaces dropped"))); + let namespace = doc.resolve(&namespaces.as_array().unwrap()[0]); + assert_eq!(namespace.as_dict().unwrap().name(b"Type"), Some(&b"Namespace"[..]), "{what}"); + assert_eq!(namespace.as_dict().unwrap().get(b"NS").unwrap().as_string().unwrap().bytes, b"http://iso.org/pdf2/ssn", "{what}: /NS changed"); + assert_eq!(doc.version(), "2.0", "{what}: the PDF 2.0 version is kept"); +} + +#[test] +fn page_edits_keep_document_parts_and_structure_namespaces() { + type Edit = fn(&mut Document); + let edits: [(&str, Edit); 6] = [ + ("rotate", |d| rotate_pages(d, &[0], 90).unwrap()), + ("move", |d| move_pages(d, &[0], 2).unwrap()), + ("reverse", |d| reverse_pages(d, &[0, 1, 2]).unwrap()), + ("duplicate", |d| duplicate_pages(d, &[0], None).unwrap()), + ("insert blank", |d| { + let _ = insert_blank_page(d, 0, 100.0, 100.0).unwrap(); + }), + // The part's start page goes: the keys stay, the page reference is the part's to repair. + ("delete", |d| delete_pages(d, &[0]).unwrap()), + ]; + for (what, edit) in edits { + let mut doc = with_document_parts_and_namespaces(); + let base = write_full(&doc, &SaveOptions::default()).unwrap(); + assert_document_parts_and_namespaces_kept(&open(base.clone()), "setup"); + doc = open(base); + edit(&mut doc); + assert_document_parts_and_namespaces_kept(&doc, what); + assert_document_parts_and_namespaces_kept( + &open(write_incremental(&doc, &SaveOptions::default()).unwrap()), + &format!("{what}, incremental save"), + ); + assert_document_parts_and_namespaces_kept(&open(write_full(&doc, &SaveOptions::default()).unwrap()), &format!("{what}, full save")); + } +} + /// #3502: a `/Contents` array's pieces are joined with a space, as the renderer joins them, so a /// `%` comment at the end of a piece without a line break continues through the first line of the /// next piece in both resource pruning (`extract_pages`) and `page_as_form`. diff --git a/crates/organize/src/xmp.rs b/crates/organize/src/xmp.rs index adda9ab862..23eca1e1aa 100644 --- a/crates/organize/src/xmp.rs +++ b/crates/organize/src/xmp.rs @@ -6,7 +6,10 @@ //! information key maps to the XMP property PDF/A pairs it with (ISO 19005-1 §6.7.3, the same //! mapping `pdfcraft-preflight` writes): Title → `dc:title`, Author → `dc:creator`, Subject → //! `dc:description`, Keywords → `pdf:Keywords`, Creator → `xmp:CreatorTool`, Producer → -//! `pdf:Producer`. +//! `pdf:Producer`. A custom property (Document Properties ▸ Custom) is kept as `pdfx:`, plain +//! text in the `http://ns.adobe.com/pdfx/1.3/` schema where Acrobat keeps them, so tools that read +//! XMP see it too; one whose name can't be an XML element name stays in `/Info` only, and a +//! PDF/A packet gets no new one (see [`sync_info`]). //! //! The packet is edited as text, touching only the property that changed: everything else in it //! (other schemas, padding, unknown properties) stays byte for byte. A file without XMP gets @@ -24,6 +27,8 @@ const MAX_XMP: usize = 16 << 20; const DC: &str = "http://purl.org/dc/elements/1.1/"; const PDF: &str = "http://ns.adobe.com/pdf/1.3/"; const XMP: &str = "http://ns.adobe.com/xap/1.0/"; +/// Adobe's PDF extension schema: custom document properties, each under its own name. +const PDFX: &str = "http://ns.adobe.com/pdfx/1.3/"; /// How an XMP property holds its value. #[derive(Clone, Copy, Debug, PartialEq)] @@ -50,26 +55,97 @@ fn property(key: &str) -> Option<(&'static str, &'static str, Shape)> { } /// After the information entry `key` became `value` (empty: removed), make the catalog's XMP -/// packet say the same. Does nothing for keys XMP doesn't mirror, files without XMP, and packets -/// that can't be edited safely. +/// packet say the same: the standard keys' properties, and `pdfx:` for a custom property. +/// Does nothing for standard keys XMP doesn't mirror (the dates, Trapped), custom names that +/// aren't XML names, files without XMP, and packets that can't be edited safely. +/// +/// A custom property is left alone where `pdfx:` would not mean Adobe's schema (a packet binding +/// the prefix to another namespace keeps its own property), and in a packet that declares PDF/A +/// one is changed or removed where it stands but never added: PDF/A-1 to -3 allow a schema only +/// when the packet describes it in an extension schema (ISO 19005-2 §6.6.2.3), so a new `pdfx:` +/// property would end the file's conformance. `/Info` keeps it either way. pub(crate) fn sync_info(doc: &mut Document, key: &str, value: &str) -> Result<(), OrganizeError> { - let Some((name, ns, shape)) = property(key) else { return Ok(()) }; - edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()) + if let Some((name, ns, shape)) = property(key) { + return edit_packet(doc, |xmp| set_property(xmp, name, ns, shape, value)).map(|_| ()); + } + if crate::STANDARD_INFO_KEYS.contains(&key) || !is_xml_name(key) { + return Ok(()); + } + let name = format!("pdfx:{key}"); + edit_packet(doc, |xmp| { + if binds_prefix_elsewhere(xmp, "pdfx", PDFX) { + return None; + } + // A declaration counts whatever its value says (CDATA, markup): it is the claim that matters. + if !has_property(xmp, &name) && has_property(xmp, "pdfaid:part") { + return None; + } + set_property(xmp, &name, PDFX, Shape::Text, value) + }) + .map(|_| ()) +} + +/// Remove the `pdfx:` copies of the custom properties `keys` (see [`sync_info`]), unless the packet +/// binds `pdfx` to another namespace, whose properties are its own; whether any was there. +pub(crate) fn remove_custom_properties(doc: &mut Document, keys: &[&str]) -> Result { + let names: Vec = keys.iter().map(|k| format!("pdfx:{k}")).collect(); + edit_packet(doc, |xmp| if binds_prefix_elsewhere(xmp, "pdfx", PDFX) { None } else { remove_all(xmp, &names) }) +} + +/// Whether `xmp` has the property `name`, as an element or an attribute, whatever its value. +fn has_property(xmp: &str, name: &str) -> bool { + element_span(xmp, name).is_some() || attribute_span(xmp, name).is_some() +} + +/// Whether `xmp` declares `prefix` for a namespace other than `ns` anywhere (`xmlns:pdfx="…"` on +/// any element), so a property written with that prefix may belong to another schema. A +/// declaration whose value never ends counts as another namespace. +fn binds_prefix_elsewhere(xmp: &str, prefix: &str, ns: &str) -> bool { + let attr = format!("xmlns:{prefix}"); + // Each declaration is read only up to its closing quote, and the first other value stops the + // search, so this is linear in the packet. + xmp.match_indices(&attr).any(|(i, _)| { + // `xmlns:pdfx` must end there (not `xmlns:pdfxid`), then `=` and a quoted value. + let Some(rest) = xmp.get(i + attr.len()..).and_then(|r| r.trim_start().strip_prefix('=')).map(str::trim_start) else { + return false; + }; + let Some(quote) = rest.chars().next().filter(|c| matches!(c, '"' | '\'')) else { return false }; + rest.get(1..).and_then(|v| v.get(..v.find(quote)?)).is_none_or(|value| value != ns) + }) +} + +/// Whether `name` can follow `pdfx:` in an element name: an XML name without a colon (XML 1.0 +/// §2.3 `NameStartChar` then `NameChar`s; Namespaces in XML `NCName`). Custom property names may +/// hold characters XML names can't (`1st`, `Q&A`, `a:b`); those are not mirrored rather than +/// written under an escaped name of our own. +fn is_xml_name(name: &str) -> bool { + let start = |c: char| { + matches!(c, + 'A'..='Z' | '_' | 'a'..='z' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}' | '\u{370}'..='\u{37D}' + | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' + | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}') + }; + let rest = |c: char| start(c) || matches!(c, '-' | '.' | '0'..='9' | '\u{B7}' | '\u{300}'..='\u{36F}' | '\u{203F}'..='\u{2040}'); + let mut chars = name.chars(); + chars.next().is_some_and(start) && chars.all(rest) } /// Remove every occurrence of the properties `names` from the catalog's XMP packet; whether any /// was there. pub(crate) fn remove_properties(doc: &mut Document, names: &[&str]) -> Result { - edit_packet(doc, |xmp| { - let mut out: Option = None; - for name in names { - // Each removal shortens the packet, so this ends. - while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name, "", Shape::Text, "") { - out = Some(next); - } + edit_packet(doc, |xmp| remove_all(xmp, names)) +} + +/// `xmp` without any occurrence of the properties `names`; `None` when none was there. +fn remove_all(xmp: &str, names: &[impl AsRef]) -> Option { + let mut out: Option = None; + for name in names { + // Each removal shortens the packet, so this ends. + while let Some(next) = set_property(out.as_deref().unwrap_or(xmp), name.as_ref(), "", Shape::Text, "") { + out = Some(next); } - out - }) + } + out } /// Rewrite the catalog's XMP packet with `edit` (`None`: unchanged); whether it changed. Files @@ -432,6 +508,30 @@ mod tests { assert!(out.contains("New"), "{out}"); } + #[test] + fn only_names_an_element_can_have_are_mirrored() { + for name in ["Project", "_draft", "Größe", "a.b-c_1", "日本語", "x·y", "Ω"] { + assert!(is_xml_name(name), "{name}"); + } + for name in ["", "1st", "-x", ".x", "·x", "a:b", "Q&A", "it's", "a\"b", "×x", "a b", "a=b", "x\u{FFFE}"] { + assert!(!is_xml_name(name), "{name}"); + } + } + + #[test] + fn a_prefix_bound_to_another_namespace_is_found() { + for (xmp, other) in [ + ("", false), + ("", false), + ("", true), + (""; diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index ba8f8ab50a..7763cb8102 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -1,8 +1,10 @@ //! pdfcraft-platform — L7 operating-system services shared by the desktop app (`ui-egui`) and the -//! automation tools (`automation`), which sit on the same layer and can't depend on each other. +//! automation tools (`automation`), which sit on the same layer and can't depend on each other, +//! and by the two executables (`owner_only`, for the `--control` file). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod desktop_theme; +pub mod owner_only; pub mod staging; pub mod zone; diff --git a/crates/platform/src/owner_only.rs b/crates/platform/src/owner_only.rs new file mode 100644 index 0000000000..7c181dab48 --- /dev/null +++ b/crates/platform/src/owner_only.rs @@ -0,0 +1,736 @@ +//! Owner-only files on Windows (#3922): the `--control` token file must be readable by its user +//! alone, as mode 0600 makes it on Unix. +//! +//! A file created in a folder inherits the folder's access list, and folders such as a new folder +//! at a drive root, `C:\Users\Public` or `C:\ProgramData` let every signed-in user read what is +//! created in them. [`restrict_to_owner`] replaces the whole list with the current user and +//! SYSTEM only, makes that user the owner, and checks the result. [`create_private_dir`] does the +//! same for a new folder whose list the files created in it inherit, so a file can be private from +//! the moment it exists. [`read_with_sddl`] reads a file and +//! its security descriptor through one handle. [`foreign_access`] reads the descriptor in SDDL +//! form, which names accounts by SID and so reads the same in every Windows language, and says why +//! another user could have written or could read the file. They run the tools Windows ships rather +//! than calling the security API directly, which would need `unsafe`. + +/// Why the file whose security descriptor is `sddl` (`O:` owner and `D:` access list, in SDDL or +/// as [`read_with_sddl`] writes it) can't be trusted as user `me`'s own: it is owned by someone else, it has no +/// access list (everyone may use it), or it lets an account other than `me` or SYSTEM use it. +/// Accounts are compared by SID; `None` means it is `me`'s alone. +/// +/// Stricter than OpenSSH's rule for private keys, which also lets Administrators in: an entry for +/// the Administrators group would let another administrator read the token without taking the +/// file over. [`restrict_to_owner`] never grants it, so the app's own file always passes; a file +/// that inherited it from a user's folder is refused. +pub fn foreign_access(sddl: &str, me: &str) -> Option { + let me = me.trim(); + let owner = section(sddl, "O:"); + match owner { + Some(o) if same_account(o, me) => {} + Some(o) => return Some(format!("it is owned by {}", another_account(o, ""))), + None => return Some("its owner can't be read".into()), + } + let Some(dacl) = section(sddl, "D:") else { + return Some("it has no access list, so every user may read it".into()); + }; + if dacl.starts_with("NO_ACCESS_CONTROL") { + return Some("it has no access control, so every user may read it".into()); + } + for ace in dacl.split('(').skip(1) { + let ace = ace.split(')').next().unwrap_or_default(); + let parts: Vec<&str> = ace.split(';').collect(); + // `type;flags;rights;object guid;inherited object guid;account SID[;(condition)]`. + let (Some(kind), Some(flags)) = (parts.first(), parts.get(1)) else { continue }; + // Deny entries take access away, and inherit-only entries apply to children, not the file. + let allows = matches!(*kind, "A" | "OA" | "XA" | "ZA"); + if !allows || flags.contains("IO") { + continue; + } + // An entry that grants access but names no account can't be shown to be the user's. + let Some(sid) = parts.get(5).filter(|s| !s.is_empty()) else { + return Some(format!("its access list has an entry that can't be read ({ace})")); + }; + if !(same_account(sid, me) || matches!(*sid, "SY" | "S-1-5-18" | "OW" | "S-1-3-4")) { + return Some(another_account(sid, "has access to it")); + } + } + None +} + +/// `another account () `, with the account's name before its SID when it is one of the +/// well-known accounts a control file can carry, such as `another account, Administrators +/// (S-1-5-32-544), has access to it` (review on #3956). The SID stays, so the message reads the +/// same in every Windows language and can still be looked up. +fn another_account(sid: &str, rest: &str) -> String { + match (well_known_name(sid), rest.is_empty()) { + (Some(name), true) => format!("another account, {name} ({sid})"), + (Some(name), false) => format!("another account, {name} ({sid}), {rest}"), + (None, true) => format!("another account ({sid})"), + (None, false) => format!("another account ({sid}) {rest}"), + } +} + +/// The English name Windows gives a well-known SID that can be on a file's access list or own it: +/// those a folder hands down at a drive root, in `C:\Users\Public` and in a user's profile, and +/// Everyone. The SIDs and names are Microsoft's public list of well-known SIDs +/// (); the names are +/// the ones `icacls` shows, without the `BUILTIN\` or `NT AUTHORITY\` prefix. Any other SID, +/// a user's or a domain group's, has no fixed name and gets `None`. +fn well_known_name(sid: &str) -> Option<&'static str> { + const NAMES: [(&str, &str); 8] = [ + ("S-1-1-0", "Everyone"), + ("S-1-5-3", "BATCH"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-18", "SYSTEM"), + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ]; + NAMES.iter().find(|(known, _)| known.eq_ignore_ascii_case(sid)).map(|&(_, name)| name) +} + +/// `section(sddl, "O:")` is the owner, `"D:"` the access list: the text after the tag up to the +/// next top-level tag. +fn section<'a>(sddl: &'a str, tag: &str) -> Option<&'a str> { + let start = sddl.find(tag)?.checked_add(tag.len())?; + let rest = sddl.get(start..)?; + // The owner and group are bare SIDs; the access lists run until the next `X:` tag outside + // parentheses. + let mut depth = 0usize; + let mut end = rest.len(); + for (i, c) in rest.char_indices() { + match c { + '(' => depth = depth.saturating_add(1), + ')' => depth = depth.saturating_sub(1), + ':' if depth == 0 && i > 0 => { + // The tag is the one letter before this colon. + end = rest.get(..i).and_then(|s| s.char_indices().last()).map_or(i, |(j, _)| j); + break; + } + _ => {} + } + } + rest.get(..end).map(str::trim) +} + +/// Whether account `a` names user SID `me`. `OW` (owner rights) and `CO` are not users. An alias +/// is never taken for the user: [`read_with_sddl`] gives every account as a full SID, because +/// Windows' own SDDL writes some users by alias (the built-in Administrator as `LA`, whose SID +/// depends on the machine). +fn same_account(a: &str, me: &str) -> bool { + a.eq_ignore_ascii_case(me) +} + +/// The tools Windows ships that these checks run, by their path under `%SystemRoot%\System32`. +#[cfg(windows)] +const WHOAMI: &str = "whoami.exe"; +#[cfg(windows)] +const POWERSHELL: &str = r"WindowsPowerShell\v1.0\powershell.exe"; +#[cfg(windows)] +const ICACLS: &str = "icacls.exe"; + +/// A tool Windows ships, started by its full path (review on #3956). Started by bare name, a +/// `whoami.exe`, `powershell.exe` or `icacls.exe` found first (next to the app, or earlier on the +/// search path) would answer instead, and `whoami`'s answer is what every check here trusts. +#[cfg(windows)] +fn system_tool(relative: &str) -> std::process::Command { + let root = std::env::var_os("SystemRoot").map_or_else(|| std::path::PathBuf::from(r"C:\Windows"), std::path::PathBuf::from); + std::process::Command::new(root.join("System32").join(relative)) +} + +#[cfg(windows)] +thread_local! { + /// How many times [`current_user_sid`] ran on this thread, for [`sid_lookups_on_this_thread`]. + static SID_LOOKUPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many times [`current_user_sid`] has started `whoami` on the calling thread. Each lookup +/// starts a process, so a test can check that a caller reads the SID once and passes it on +/// (review on #3956). Per thread, so tests running in parallel don't count each other's. +#[cfg(windows)] +#[doc(hidden)] +pub fn sid_lookups_on_this_thread() -> usize { + SID_LOOKUPS.with(std::cell::Cell::get) +} + +/// The current user's SID (`whoami /user`). It starts a process, so a caller that needs it for +/// several steps reads it once and passes it to each. +#[cfg(windows)] +pub fn current_user_sid() -> std::io::Result { + SID_LOOKUPS.with(|n| n.set(n.get().saturating_add(1))); + let out = system_tool(WHOAMI).args(["/user", "/fo", "csv", "/nh"]).output()?; + if !out.status.success() { + return Err(std::io::Error::other("whoami /user failed")); + } + sid_from_whoami(&String::from_utf8_lossy(&out.stdout)).ok_or_else(|| std::io::Error::other("whoami /user printed no SID")) +} + +#[cfg(any(test, windows))] +/// The SID in `whoami /user /fo csv /nh` output, `"DOMAIN\user","S-1-5-21-…"`: the last quoted +/// field, when it is one. Anything else (an error message, nothing) gives `None`. +fn sid_from_whoami(text: &str) -> Option { + let last = text.trim().rsplit(',').next()?.trim().trim_matches('"'); + let digits = last.strip_prefix("S-1-")?; + // Every part after `S-1-` is a number; an empty remainder is one empty part, so it fails too. + digits.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())).then(|| last.to_string()) +} + +/// Replace `path`'s access list with one that only user `me` (the current user's SID, from +/// [`current_user_sid`], which the caller reads once for every step) and SYSTEM are on, then make +/// that user its owner (`icacls /setowner`), then read the file's descriptor back and check it with +/// [`foreign_access`]. The whole list is replaced, not just what was inherited: on the +/// windows-latest runner a new file in the temp folder carries an Administrators entry that `icacls +/// /inheritance:r` leaves in place. The owner is set because a file an elevated administrator creates +/// is owned by the Administrators group, not by the user. The list comes first: its owner may always +/// replace it, while setting the owner needs the Full control it grants (a folder may give Modify). +#[cfg(windows)] +pub fn restrict_to_owner(path: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(path, me, Kind::File)?; + icacls(path, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(path)?, me) +} + +/// Create the folder `dir`, which must not exist yet, and give it an access list of user `me` (as +/// for [`restrict_to_owner`]) and SYSTEM only, which every file created in it inherits; then make +/// that user its owner and check the result as [`restrict_to_owner`] does. A file created in it is +/// private from the moment it exists. Restricting a file after creating it leaves a window in which another user +/// whom the parent folder lets read it can open a handle, and Windows checks access only when a +/// handle is opened, so a later change of the list doesn't revoke it (review on #3956). A handle +/// on the folder itself, opened during the folder's own window, gives no access to the files +/// created in it later. The folder is removed again when it can't be restricted. +#[cfg(windows)] +pub fn create_private_dir(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + std::fs::create_dir(dir)?; + fn restrict(dir: &std::path::Path, me: &str) -> std::io::Result<()> { + set_access_list(dir, me, Kind::Folder)?; + icacls(dir, &["/setowner".into(), format!("*{me}")])?; + still_foreign(&sddl_of(dir)?, me) + } + let restricted = restrict(dir, me); + if restricted.is_err() { + let _ = std::fs::remove_dir(dir); + } + restricted +} + +/// `Err` naming the problem when the descriptor read back after restricting still isn't `me`'s +/// alone, so a file that only looks restricted is never used as one. +#[cfg(any(test, windows))] +fn still_foreign(sddl: &str, me: &str) -> std::io::Result<()> { + match foreign_access(sddl, me) { + None => Ok(()), + Some(problem) => Err(std::io::Error::other(format!("the file could not be restricted to its owner: {problem}"))), + } +} + +/// What [`set_access_list`] restricts: a file, or a folder whose entries the files and folders +/// created in it inherit. +#[cfg(windows)] +#[derive(Clone, Copy)] +enum Kind { + File, + Folder, +} + +/// Set `path`'s access list to Full control for `me` and SYSTEM and nothing else, protected from +/// inheritance; on a folder, what is created in it inherits the two entries. Only the list is +/// written, not the owner. `me` and the path reach PowerShell through environment variables, never +/// as script text. +#[cfg(windows)] +fn set_access_list(path: &std::path::Path, me: &str, kind: Kind) -> std::io::Result<()> { + // No double quotes: the command line would strip them. + const FILE: &str = "$s = New-Object Security.AccessControl.FileSecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;;FA;;;SY)', 'Access'); \ + [IO.File]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + const FOLDER: &str = "$s = New-Object Security.AccessControl.DirectorySecurity; \ + $s.SetSecurityDescriptorSddlForm('D:P(A;OICI;FA;;;' + $env:PDFCRAFT_ACL_USER + ')(A;OICI;FA;;;SY)', 'Access'); \ + [IO.Directory]::SetAccessControl($env:PDFCRAFT_ACL_PATH, $s)"; + let script = match kind { + Kind::File => FILE, + Kind::Folder => FOLDER, + }; + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_USER", me) + .output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("PowerShell could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +#[cfg(windows)] +fn icacls(path: &std::path::Path, args: &[String]) -> std::io::Result<()> { + let out = system_tool(ICACLS).arg(path).args(args).output()?; + if out.status.success() { + Ok(()) + } else { + Err(std::io::Error::other(format!("icacls could not restrict the file to its owner: {}", String::from_utf8_lossy(&out.stderr).trim()))) + } +} + +/// PowerShell that turns `$raw`, a `RawSecurityDescriptor`, into `$sddl` as [`read_with_sddl`] +/// describes it. No double quotes: the command line would strip them. An entry with no account (a +/// custom entry) is written with none, and `foreign_access` refuses it. +#[cfg(windows)] +macro_rules! describe_raw { + () => { + "$sddl = 'O:' + $raw.Owner.Value; if ($raw.DiscretionaryAcl -ne $null) { $sddl += 'D:'; foreach ($a in $raw.DiscretionaryAcl) { $t = 'A'; if ([string]$a.AceType -like 'AccessDenied*') { $t = 'D' }; $f = ''; if ($a.AceFlags.HasFlag([Security.AccessControl.AceFlags]::InheritOnly)) { $f = 'IO' }; $sid = ''; if ($a.SecurityIdentifier) { $sid = $a.SecurityIdentifier.Value }; $sddl += '(' + $t + ';' + $f + ';' + $a.AccessMask + ';;;' + $sid + ')' } }; " + }; +} + +/// Open `path` shared for reading only, so that while it is open it can't be replaced, renamed or +/// written, and read through that one handle both its security descriptor (owner and access list) +/// and its first `max + 1` bytes. The descriptor checked is the one of the handle the bytes come +/// from, so there is no moment at which the path could name a different file (a time-of-check to +/// time-of-use race). The path reaches PowerShell through an environment variable, never as script +/// text. +/// +/// The descriptor is written in SDDL's shape but with every account as a full SID, read from the +/// binary descriptor (`RawSecurityDescriptor`): `O:D:(;;;;;)…`, +/// where the type is `D` for a deny entry and `A` for any other, and the flags are `IO` for an +/// inherit-only entry. Windows' own SDDL writes some accounts by alias, and the built-in +/// Administrator's alias `LA` stands for a SID that depends on the machine. +#[cfg(windows)] +pub fn read_with_sddl(path: &std::path::Path, max: u64) -> std::io::Result<(String, Vec)> { + const SCRIPT: &str = concat!( + "$fs = [IO.File]::Open($env:PDFCRAFT_ACL_PATH, 'Open', 'Read', 'Read'); try { $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @($fs.GetAccessControl().GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "$max = [int]$env:PDFCRAFT_ACL_MAX + 1; $buf = New-Object byte[] $max; $total = 0; while ($total -lt $max) { $n = $fs.Read($buf, $total, $max - $total); if ($n -le 0) { break }; $total += $n }; [Console]::Out.Write($sddl + [char]10 + [BitConverter]::ToString($buf, 0, $total)) } finally { $fs.Dispose() }" + ); + // Bounded so the script's `[int]` and buffer stay small; a control file is far below this. + let max = max.min(1 << 20); + let out = system_tool(POWERSHELL) + .args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]) + .env("PDFCRAFT_ACL_PATH", path) + .env("PDFCRAFT_ACL_MAX", max.to_string()) + .output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.ok_or_else(|| std::io::Error::other("PowerShell could not read the file with its owner and access list")) +} + +/// The security descriptor of `path` as [`read_with_sddl`] writes it, read by name without opening +/// the file's contents, so it works on a file that is open for writing (the app restricts its +/// control file before writing the token through the handle it created). +#[cfg(windows)] +pub fn sddl_of(path: &std::path::Path) -> std::io::Result { + const SCRIPT: &str = concat!( + // Stop: a failed read (a missing file) must end the script with an error, not print `O:`. + "$ErrorActionPreference = 'Stop'; $raw = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList @([IO.File]::GetAccessControl($env:PDFCRAFT_ACL_PATH).GetSecurityDescriptorBinaryForm(), 0); ", + describe_raw!(), + "[Console]::Out.Write($sddl + [char]10)" + ); + let out = system_tool(POWERSHELL).args(["-NoProfile", "-NonInteractive", "-Command", SCRIPT]).env("PDFCRAFT_ACL_PATH", path).output()?; + let read = if out.status.success() { sddl_and_bytes(&String::from_utf8_lossy(&out.stdout)) } else { None }; + read.map(|(sddl, _)| sddl).ok_or_else(|| std::io::Error::other("PowerShell could not read the file's owner and access list")) +} + +/// [`read_with_sddl`]'s output: the SDDL, a newline, then the bytes as `[BitConverter]::ToString` +/// prints them (`7B-22-…`, empty for none). Anything else gives `None`. +#[cfg(any(test, windows))] +fn sddl_and_bytes(text: &str) -> Option<(String, Vec)> { + let (sddl, hex) = text.split_once('\n')?; + let sddl = sddl.trim(); + if !sddl.contains("O:") { + return None; + } + let hex = hex.trim(); + if hex.is_empty() { + return Some((sddl.to_string(), Vec::new())); + } + // Two hex digits each; `from_str_radix` alone would also take a sign, as in `+7`. + let byte = |pair: &str| (pair.len() == 2 && pair.bytes().all(|b| b.is_ascii_hexdigit())).then(|| u8::from_str_radix(pair, 16).ok()).flatten(); + let bytes = hex.split('-').map(byte).collect::>>()?; + Some((sddl.to_string(), bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ME: &str = "S-1-5-21-1-2-3-1001"; + + /// The SID of the user running the tests, as `write_control_file` reads it once and passes on. + #[cfg(windows)] + fn user() -> String { + current_user_sid().unwrap() + } + + #[test] + fn the_users_own_file_with_system_is_trusted_and_administrators_are_not() { + assert_eq!(foreign_access(&format!("O:{ME}G:{ME}D:P(A;;FA;;;{ME})(A;;FA;;;SY)"), ME), None); + assert_eq!(foreign_access(&format!("O:{ME}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{ME})"), ME), None); + // Another administrator could read it through the Administrators group, by alias or by SID. + for admins in ["BA", "S-1-5-32-544"] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;SY)(A;;FR;;;{admins})(A;;FA;;;{ME})"); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains(admins)), "{sddl}"); + } + // Inherit-only entries and deny entries don't grant access to the file itself. + assert_eq!(foreign_access(&format!("O:{ME}D:(A;OICIIO;GA;;;CO)(D;;FA;;;BU)(A;;FA;;;{ME})"), ME), None); + } + + /// Review on #3956: each tool is started by its full path in System32, never looked up by + /// name. + #[cfg(windows)] + #[test] + fn the_tools_are_started_from_system32() { + for tool in [WHOAMI, POWERSHELL, ICACLS] { + let command = system_tool(tool); + let path = std::path::Path::new(command.get_program()); + assert!(path.is_absolute() && path.is_file(), "{path:?}"); + assert!(path.to_string_lossy().to_ascii_lowercase().contains(r"\system32\"), "{path:?}"); + } + } + + #[test] + fn other_owners_readers_and_missing_lists_are_refused() { + let other = "S-1-5-21-1-2-3-1002"; + assert!(foreign_access(&format!("O:{other}D:(A;;FA;;;{ME})"), ME).unwrap().contains("owned by another account")); + // Users (BU) and Authenticated Users (AU), as a folder at a drive root passes on. + assert!(foreign_access(&format!("O:{ME}D:AI(A;ID;FA;;;{ME})(A;ID;0x1301bf;;;AU)"), ME).unwrap().contains("AU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FR;;;BU)(A;;FA;;;{ME})"), ME).unwrap().contains("BU")); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{other})"), ME).is_some()); + assert!(foreign_access(&format!("O:{ME}G:SY"), ME).unwrap().contains("no access list")); + assert!(foreign_access(&format!("O:{ME}D:NO_ACCESS_CONTROL"), ME).is_some()); + assert!(foreign_access("D:(A;;FA;;;SY)", ME).unwrap().contains("owner")); + } + + /// A conditional entry can hold a `:` in a string; the access list doesn't end there, so the + /// entry after it is still checked. + #[test] + fn a_colon_inside_an_entry_does_not_end_the_access_list() { + let sddl = format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "a:b"))(A;;FR;;;BU)"#); + assert!(foreign_access(&sddl, ME).is_some_and(|p| p.contains("BU")), "{sddl}"); + } + + /// A conditional entry for the user is the user's; one that grants access but whose account + /// can't be read is refused rather than skipped. + #[test] + fn conditional_and_malformed_entries() { + assert_eq!(foreign_access(&format!(r#"O:{ME}D:(XA;;FA;;;{ME};(@User.dept == "x"))"#), ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR)"), ME).is_some_and(|p| p.contains("can't be read"))); + assert!(foreign_access(&format!("O:{ME}D:(A;;FA;;;)"), ME).is_some_and(|p| p.contains("can't be read"))); + // A conditional entry grants access like a plain one: another account on it is refused. + let other = format!(r#"O:{ME}D:(A;;FA;;;{ME})(XA;;FR;;;BU;(@User.dept == "x"))"#); + assert!(foreign_access(&other, ME).is_some_and(|p| p.contains("BU")), "{other}"); + } + + #[test] + fn the_sid_is_read_from_whoamis_output_and_nothing_else() { + assert_eq!(sid_from_whoami("\"desk\\ada\",\"S-1-5-21-1-2-3-1001\"\r\n").as_deref(), Some("S-1-5-21-1-2-3-1001")); + for bad in [ + "", + "ERROR: Access is denied.", + "\"desk\\ada\",\"\"", + "\"desk\\ada\",\"S-1-\"", + "\"S-1-5-x\"", + "\"desk\\ada\"", + "\"desk\\ada\",\"1-5-21-7\"", + ] { + assert_eq!(sid_from_whoami(bad), None, "{bad:?}"); + } + } + + /// The real tools: a file created in a folder that lets Users read what's in it inherits that + /// access; after `restrict_to_owner` only the current user and SYSTEM are left. + #[cfg(windows)] + #[test] + fn a_file_in_a_shared_folder_is_restricted_to_its_owner() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + let me = current_user_sid().unwrap(); + // Users inherit read access, so the file is refused. (Run elevated, the file is also owned + // by Administrators, which is the reason given first.) + let before = sddl_of(&file).unwrap(); + assert!(before.contains(";BU)") || before.contains(";S-1-5-32-545)"), "Users inherit read access: {before}"); + assert!(foreign_access(&before, &me).is_some(), "{before}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Review on #3956: a file created in a private folder inside a folder that lets Users read + /// what's in it is the user's alone from the moment it exists, before anything restricts it. + #[cfg(windows)] + #[test] + fn a_file_created_in_a_private_folder_is_private_from_the_start() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-private-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let ok = std::process::Command::new("icacls").arg(&dir).arg("/grant").arg("*S-1-5-32-545:(OI)(CI)R").output().unwrap(); + assert!(ok.status.success(), "{}", String::from_utf8_lossy(&ok.stderr)); + let private = dir.join("staging"); + create_private_dir(&private, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let folder = sddl_of(&private).unwrap(); + assert_eq!((section(&folder, "O:"), foreign_access(&folder, &me)), (Some(me.as_str()), None), "{folder}"); + let file = private.join("ctl.json"); + std::fs::OpenOptions::new().write(true).create_new(true).open(&file).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert!(!sddl.contains(";BU)") && !sddl.contains(";S-1-5-32-545)"), "Users must not inherit read access: {sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The name is taken: the folder isn't reused. + assert!(create_private_dir(&private, &user()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A folder may give its user Modify but not Full control, so a file created there doesn't let + /// its owner change the owner (no `WRITE_OWNER`). An owner can always replace the access list, + /// though, and the user's Full control on it then allows setting the owner. + #[cfg(windows)] + #[test] + fn a_file_in_a_folder_granting_only_modify_is_still_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-modify-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let modify = std::process::Command::new("icacls") + .arg(&dir) + .args(["/inheritance:r", "/grant:r"]) + .arg(format!("*{me}:(OI)(CI)M")) + .arg("*S-1-5-18:(OI)(CI)F") + .output() + .unwrap(); + assert!(modify.status.success(), "{}", String::from_utf8_lossy(&modify.stderr)); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A null access list (`NO_ACCESS_CONTROL`) lets every account use the file, so it is refused. + #[cfg(windows)] + #[test] + fn a_file_with_no_access_list_is_refused() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-null-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + // A null access list lets everyone in; the user, as owner, may set one. + let null = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:NO_ACCESS_CONTROL', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_NULL_PATH, $s)", + ]) + .env("PDFCRAFT_NULL_PATH", &file) + .output() + .unwrap(); + assert!(null.status.success(), "{}", String::from_utf8_lossy(&null.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + let me = current_user_sid().unwrap(); + assert!(foreign_access(&sddl, &me).is_some_and(|p| p.contains("no access list")), "{sddl}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// An `OWNER RIGHTS` entry replaces what an owner may do implicitly. Granting only *write + /// owner* that way, the access list can't be replaced although the owner can still be set: the + /// file isn't taken as restricted. + #[cfg(windows)] + #[test] + fn a_file_whose_access_list_cant_be_replaced_is_not_restricted() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-ow-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, b"{}").unwrap(); + // The whole list is written, as `restrict_to_owner` does: `icacls /inheritance:r` leaves + // the Administrators entry the windows-latest runner gives a new file, and with it an + // elevated administrator could replace the list after all. + let rights = std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "$s = New-Object Security.AccessControl.FileSecurity; $s.SetSecurityDescriptorSddlForm('D:P(A;;FR;;;' + $env:PDFCRAFT_OW_USER + ')(A;;WO;;;OW)', 'Access'); [IO.File]::SetAccessControl($env:PDFCRAFT_OW_PATH, $s)", + ]) + .env("PDFCRAFT_OW_PATH", &file) + .env("PDFCRAFT_OW_USER", &me) + .output() + .unwrap(); + assert!(rights.status.success(), "{}", String::from_utf8_lossy(&rights.stderr)); + // Nothing but the two entries: no Administrators entry for the elevated runner to use. + let sddl = sddl_of(&file).unwrap(); + assert_eq!(section(&sddl, "D:"), Some(format!("(A;;524288;;;S-1-3-4)(A;;1179785;;;{me})").as_str()), "{sddl}"); + assert!(restrict_to_owner(&file, &user()).is_err(), "the access list could not be replaced"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// When the tools fail, the functions say so: nothing is taken as restricted or as checked. + #[cfg(windows)] + #[test] + fn tool_failures_are_errors() { + let missing = std::env::temp_dir().join(format!("pdfcraft-owner-only-missing-{}", std::process::id())).join("none.json"); + assert!(restrict_to_owner(&missing, &user()).is_err(), "icacls on a missing file"); + assert!(sddl_of(&missing).is_err(), "Get-Acl on a missing file"); + let me = current_user_sid().unwrap(); + assert!(me.starts_with("S-1-5-") && me.split('-').count() >= 4, "{me}"); + assert!(!me.contains('"') && !me.contains('\\'), "the SID alone, not the account name: {me}"); + } + + #[test] + fn the_descriptor_and_bytes_are_read_from_the_scripts_output_and_nothing_else() { + let sddl = format!("O:{ME}D:P(A;;FA;;;{ME})"); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n7B-0A-ff\r\n")), Some((sddl.clone(), vec![0x7B, 0x0A, 0xFF]))); + assert_eq!(sddl_and_bytes(&format!("{sddl}\n")), Some((sddl.clone(), Vec::new()))); + for bad in [ + String::new(), + sddl.clone(), + "D:P(A;;FA;;;SY)\n7B".into(), + format!("{sddl}\n7B-2"), + format!("{sddl}\n7B--22"), + format!("{sddl}\n7G"), + format!("{sddl}\n+7-22"), + ] { + assert_eq!(sddl_and_bytes(&bad), None, "{bad:?}"); + } + } + + /// The bytes come with the descriptor of the same handle, capped at `max + 1` so an oversized + /// file shows, and the restricted file is owned by the user. + #[cfg(windows)] + #[test] + fn the_file_is_read_with_its_descriptor_through_one_handle() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-read-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("ctl.json"); + std::fs::write(&file, br#"{"port":1}"#).unwrap(); + restrict_to_owner(&file, &user()).unwrap(); + let me = current_user_sid().unwrap(); + let (sddl, bytes) = read_with_sddl(&file, 64).unwrap(); + assert_eq!(section(&sddl, "O:"), Some(me.as_str()), "{sddl}"); + assert_eq!((foreign_access(&sddl, &me), bytes.as_slice()), (None, &br#"{"port":1}"#[..])); + assert_eq!(read_with_sddl(&file, 3).unwrap().1, br#"{"po"#); + // A limit past what the script's `[int]` holds is capped, not handed to it. + assert_eq!(read_with_sddl(&file, u64::MAX).unwrap().1, br#"{"port":1}"#); + assert!(read_with_sddl(&dir.join("none.json"), 64).is_err()); + // A deny entry is read as one: denying Guests takes nothing from the user. + let guests = std::process::Command::new("icacls").arg(&file).arg("/deny").arg("*S-1-5-32-546:(R)").output().unwrap(); + assert!(guests.status.success(), "{}", String::from_utf8_lossy(&guests.stderr)); + let (sddl, _) = read_with_sddl(&file, 64).unwrap(); + assert!(sddl.contains(";;;S-1-5-32-546)") && sddl.contains("(D;"), "{sddl}"); + assert_eq!(foreign_access(&sddl, &me), None, "{sddl}"); + // The handle shares the file for reading only: a file someone has open for writing (or + // could rename or delete) isn't read, and so can't change between the check and the read. + let writer = std::fs::OpenOptions::new().write(true).open(&file).unwrap(); + assert!(read_with_sddl(&file, 64).is_err(), "a file open for writing is not read"); + // Restricting reads the descriptor alone, so it works on the file the app holds open to + // write the token through. + restrict_to_owner(&file, &user()).unwrap(); + assert_eq!(foreign_access(&sddl_of(&file).unwrap(), &me), None); + drop(writer); + assert!(read_with_sddl(&file, 64).is_ok()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Entries of the file's own, not inherited, are replaced too: an Administrators or Users + /// entry (as the windows-latest runner's temp folder leaves on a new file, #3922) and a deny + /// that would keep the user from setting the owner. + #[cfg(windows)] + #[test] + fn entries_of_the_files_own_are_replaced() { + let dir = std::env::temp_dir().join(format!("pdfcraft-owner-only-own-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let me = current_user_sid().unwrap(); + let mine = format!("*{me}:(R)"); + for (name, entries) in [ + ("others.json", ["/grant", "*S-1-5-32-544:(F)", "/grant", "*S-1-5-32-545:(R)"]), + ("deny.json", ["/deny", "*S-1-1-0:(WO)", "/grant", "*S-1-5-32-544:(F)"]), + ] { + let file = dir.join(name); + std::fs::write(&file, b"{}").unwrap(); + // The user keeps read access, so the descriptor can be read before restricting. + let own = std::process::Command::new("icacls").arg(&file).arg("/inheritance:r").args(entries).args(["/grant", &mine]).output().unwrap(); + assert!(own.status.success(), "{}", String::from_utf8_lossy(&own.stderr)); + assert!(foreign_access(&sddl_of(&file).unwrap(), &me).is_some(), "{name}"); + restrict_to_owner(&file, &user()).unwrap(); + let sddl = sddl_of(&file).unwrap(); + assert_eq!((section(&sddl, "O:"), foreign_access(&sddl, &me)), (Some(me.as_str()), None), "{name}: {sddl}"); + assert!(!sddl.contains("(D;"), "{name}: the deny is gone: {sddl}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_descriptor_still_open_to_others_after_restricting_is_an_error() { + assert!(still_foreign(&format!("O:{ME}D:P(A;;FA;;;SY)(A;;FA;;;{ME})"), ME).is_ok()); + for bad in [format!("O:{ME}D:P(A;;FA;;;S-1-5-32-544)(A;;FA;;;{ME})"), format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), format!("O:{ME}")] { + assert!(still_foreign(&bad, ME).is_err_and(|e| e.to_string().contains("could not be restricted")), "{bad}"); + } + } + + /// [`read_with_sddl`] gives accounts as full SIDs (rights as a number, no list flags), the way a + /// CI runner's built-in Administrator reads its own restricted file. An alias such as `LA` + /// is never taken for the user, whatever the user's RID: on a domain-joined machine a domain + /// account can share the local Administrator's RID 500. + #[test] + fn descriptors_with_full_sids_and_aliases_that_are_never_the_user() { + let admin = "S-1-5-21-1-2-3-500"; + let full = format!("O:{admin}D:(A;;2032127;;;S-1-5-18)(A;;2032127;;;{admin})"); + assert_eq!(foreign_access(&full, admin), None); + // Owner rights by SID, a deny by SID, an inherit-only entry for Creator Owner. + let more = format!("O:{ME}D:(D;;1179785;;;S-1-5-32-546)(A;IO;268435456;;;S-1-3-0)(A;;2032127;;;S-1-3-4)(A;;2032127;;;{ME})"); + assert_eq!(foreign_access(&more, ME), None); + assert!(foreign_access(&format!("O:{ME}D:(A;;1179785;;;S-1-5-32-545)(A;;2032127;;;{ME})"), ME).is_some_and(|p| p.contains("S-1-5-32-545"))); + for me in [admin, "S-1-5-21-1-2-3-501"] { + assert!(foreign_access("O:LAD:PAI(A;;FA;;;SY)(A;;FA;;;LA)", me).is_some_and(|p| p.contains("owned by another account (LA)")), "{me}"); + assert!(foreign_access("O:LGD:P(A;;FA;;;LG)", me).is_some(), "{me}"); + } + assert!(foreign_access(&format!("O:{admin}D:P(A;;FA;;;{admin})(A;;FR;;;LA)"), admin).is_some_and(|p| p.contains("LA"))); + } + + /// Review on #3956: a refusal names a well-known account as well as giving its SID, so + /// `S-1-5-32-544` reads as Administrators without a lookup; any other SID is given alone. + #[test] + fn refusals_name_well_known_accounts_and_keep_their_sid() { + for (sid, name) in [ + ("S-1-5-32-544", "Administrators"), + ("S-1-5-32-545", "Users"), + ("S-1-5-11", "Authenticated Users"), + ("S-1-5-4", "INTERACTIVE"), + ("S-1-1-0", "Everyone"), + ("S-1-5-6", "SERVICE"), + ("S-1-5-3", "BATCH"), + ] { + let sddl = format!("O:{ME}D:PAI(A;;FA;;;{ME})(A;ID;1179817;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account, {name} ({sid}), has access to it")), "{sddl}"); + } + // SYSTEM may have access, but a file it owns isn't the user's. + assert_eq!(foreign_access(&format!("O:S-1-5-18D:P(A;;FA;;;{ME})"), ME).as_deref(), Some("it is owned by another account, SYSTEM (S-1-5-18)")); + assert_eq!( + foreign_access(&format!("O:S-1-5-32-544D:P(A;;FA;;;{ME})"), ME).as_deref(), + Some("it is owned by another account, Administrators (S-1-5-32-544)") + ); + // Another user, a lookalike and an alias have no fixed name: the account is given as it was. + for sid in ["S-1-5-21-1-2-3-1002", "S-1-5-32-5440", "S-1-5-1", "BA"] { + let sddl = format!("O:{ME}D:(A;;FA;;;{ME})(A;;FR;;;{sid})"); + assert_eq!(foreign_access(&sddl, ME), Some(format!("another account ({sid}) has access to it")), "{sddl}"); + } + assert_eq!( + foreign_access("O:S-1-5-21-1-2-3-1002D:(A;;FA;;;SY)", ME).as_deref(), + Some("it is owned by another account (S-1-5-21-1-2-3-1002)") + ); + } + + #[test] + fn sections_stop_at_the_next_tag() { + assert_eq!(section("O:S-1-5-18G:S-1-5-32-544D:(A;;FA;;;SY)", "O:"), Some("S-1-5-18")); + assert_eq!(section("O:BAG:SYD:P(A;;FA;;;SY)S:(AU;SA;FA;;;WD)", "D:"), Some("P(A;;FA;;;SY)")); + assert_eq!(section("O:BA", "D:"), None); + } +} diff --git a/crates/preflight/src/gstate.rs b/crates/preflight/src/gstate.rs index 64c19de116..9d24a8e054 100644 --- a/crates/preflight/src/gstate.rs +++ b/crates/preflight/src/gstate.rs @@ -28,7 +28,7 @@ const MAX_DEPTH: u8 = 128; /// The rendering intents of ISO 32000-1 Table 70. const INTENTS: [&[u8]; 4] = [b"AbsoluteColorimetric", b"RelativeColorimetric", b"Saturation", b"Perceptual"]; /// The entries of a type 5 halftone that aren't colorant halftones. -const HALFTONE_KEYS: [&[u8]; 3] = [b"Type", b"HalftoneType", b"HalftoneName"]; +const HALFTONE_KEYS: [&[u8]; 4] = [b"Type", b"HalftoneType", b"HalftoneName", b"TransferFunction"]; /// The primary colour components of DeviceGray, DeviceRGB and DeviceCMYK, whose halftones take /// the graphics state's transfer function. const PRIMARY: [&[u8]; 8] = [b"Gray", b"Red", b"Green", b"Blue", b"Cyan", b"Magenta", b"Yellow", b"Black"]; @@ -222,14 +222,76 @@ impl Scan<'_> { _ => self.findings.halftone_transfer |= transfer, } if colorant.is_none() && kind == Some(5) { - for (k, v) in ht.iter() { + for (k, _) in ht.iter() { if HALFTONE_KEYS.contains(&k.as_slice()) { continue; } - if let Some(component) = self.doc.resolve(v).as_dict() { - self.halftone(component, Some(k)); + let Some(entry) = self.present(ht, k) else { continue }; + match entry.as_dict() { + Some(component) => self.halftone(component, Some(k)), + None => { + self.findings.untyped = true; + if k != b"Default" && !PRIMARY.contains(&k.as_slice()) { + self.findings.missing_transfer.insert(String::from_utf8_lossy(k).into_owned()); + } + } } } } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::sync::Arc; + + use pdfcraft_cos::Document; + + use super::{Findings, check}; + use crate::{Level, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn type5_halftone_transfer_function_is_not_treated_as_colorant() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << /G << /HT << /HalftoneType 5 /Default 5 0 R /Cyan 5 0 R /TransferFunction 4 0 R >> >> >> >> >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + ]); + let found = check(&doc); + assert_eq!(found, Findings { halftone_transfer: true, ..Findings::default() }); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.5").map(|i| i.message).collect(); + assert_eq!(messages, ["A halftone has a transfer function, which only a non-primary colorant's halftone may have"]); + } + + #[test] + fn type5_halftone_non_dict_colorant_reports_untyped_and_missing_transfer() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /ExtGState << /G << /HT << /HalftoneType 5 /Default 4 0 R /Spot /Default /NullColorant null >> >> >> >> >>", + "<< /HalftoneType 1 /Frequency 60 /Angle 45 /SpotFunction /Round >>", + ]); + let found = check(&doc); + assert_eq!(found, Findings { untyped: true, missing_transfer: BTreeSet::from(["Spot".to_string()]), ..Findings::default() }); + } +} diff --git a/crates/preflight/src/spot.rs b/crates/preflight/src/spot.rs index 8367d8d28a..b75512fe37 100644 --- a/crates/preflight/src/spot.rs +++ b/crates/preflight/src/spot.rs @@ -64,7 +64,7 @@ impl Scan<'_> { } match o { Object::Array(a) => { - match a.first().and_then(Object::as_name) { + match a.first().map(|v| self.doc.resolve(v)).as_deref().and_then(Object::as_name) { Some(b"Separation") => self.separation(a), Some(b"DeviceN") => self.devicen(a), _ => {} @@ -138,14 +138,23 @@ impl Scan<'_> { } } - /// Whether `o` is `[/Separation name ...]` for the colorant `name`. + /// Whether `o` is `[/Separation name alternateSpace tintTransform]` for the colorant `name`, + /// with a non-null alternate space and tint transform (ISO 32000-1 8.6.6.4 and 7.3.9). fn separation_named(&self, o: &Object, name: &[u8]) -> bool { let o = self.doc.resolve(o); - let Some([kind, n, ..]) = o.as_array().map(Vec::as_slice) else { return false }; - kind.as_name() == Some(b"Separation") && self.doc.resolve(n).as_name() == Some(name) + let Some([kind, n, alt, tint, ..]) = o.as_array().map(Vec::as_slice) else { return false }; + self.doc.resolve(kind).as_name() == Some(b"Separation") + && self.doc.resolve(n).as_name() == Some(name) + && !matches!(&*self.doc.resolve(alt), Object::Null) + && !matches!(&*self.doc.resolve(tint), Object::Null) } } +/// Whether integer `i` and real `r` have the same numeric value (ISO 32000-1 7.3.3). +fn same_number(i: i64, r: f64) -> bool { + r.is_finite() && r.fract() == 0.0 && (i as f64) == r && (r as i64) == i && (i as f64 as i128) == i128::from(i) +} + /// Compares definitions, remembering which reference pairs it found equal, so a structure shared /// by many definitions is compared once, within [`MAX_COMPARE_STEPS`] and [`MAX_COMPARE_BYTES`]. struct Compare<'a> { @@ -158,13 +167,18 @@ struct Compare<'a> { } impl Compare<'_> { + /// Whether `v` is present (not `null` or a reference to no object, ISO 32000-1 7.3.7 and 7.3.9). + fn is_present(&self, v: &Object) -> bool { + !matches!(&*self.doc.resolve(v), Object::Null) + } + /// Whether `a` and `b` are the same object, wherever their parts are stored: references are /// followed (to [`MAX_COMPARE_DEPTH`]), so two identical streams under different object /// numbers are the same, and streams compare by their decoded data. Once the steps or the /// decode allowance run out, nothing more is called different. fn same(&mut self, a: &Object, b: &Object, depth: u8) -> bool { let pair = match (a, b) { - (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) => return true, + (Object::Ref(x), Object::Ref(y)) if x == y || self.equal.contains(&(*x, *y)) || self.equal.contains(&(*y, *x)) => return true, (Object::Ref(x), Object::Ref(y)) => Some((*x, *y)), _ => None, }; @@ -179,6 +193,7 @@ impl Compare<'_> { (Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| self.same(p, q, deeper)), (Object::Dict(x), Object::Dict(y)) => self.same_dict(x, y, &[], deeper), (Object::Stream(x), Object::Stream(y)) => self.same_data(x, y, deeper) && self.same_dict(&x.dict, &y.dict, &ENCODING, deeper), + (Object::Int(x), Object::Real(y)) | (Object::Real(y), Object::Int(x)) => same_number(*x, *y), (x, y) => x == y, }; if same && let Some(pair) = pair { @@ -187,16 +202,24 @@ impl Compare<'_> { same } - /// Whether two dictionaries hold the same entries, those keyed in `skip` aside. + /// Whether two dictionaries hold the same non-null entries (ISO 32000-1 7.3.7), those keyed + /// in `skip` aside. fn same_dict(&mut self, a: &Dict, b: &Dict, skip: &[&[u8]], depth: u8) -> bool { - let kept = |d: &Dict| d.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).count(); - kept(a) == kept(b) && a.iter().filter(|(k, _)| !skip.contains(&k.as_slice())).all(|(k, v)| b.get(k).is_some_and(|w| self.same(v, w, depth))) + let kept = |c: &Self, d: &Dict| d.iter().filter(|(k, v)| !skip.contains(&k.as_slice()) && c.is_present(v)).count(); + if kept(self, a) != kept(self, b) { + return false; + } + a.iter() + .filter(|(k, v)| !skip.contains(&k.as_slice()) && self.is_present(v)) + .collect::>() + .into_iter() + .all(|(k, v)| b.get(k).filter(|w| self.is_present(w)).is_some_and(|w| self.same(v, w, depth))) } /// Whether two streams hold the same data, however each is encoded. Data that can't be /// decoded within the allowance is not called different. fn same_data(&mut self, a: &Stream, b: &Stream, depth: u8) -> bool { - let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k), b.dict.get(k)) { + let stored_alike = |c: &mut Self, k: &[u8]| match (a.dict.get(k).filter(|v| c.is_present(v)), b.dict.get(k).filter(|v| c.is_present(v))) { (None, None) => true, (Some(x), Some(y)) => c.same(x, y, depth), _ => false, @@ -210,3 +233,129 @@ impl Compare<'_> { } } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + use std::sync::Arc; + + use pdfcraft_cos::{Dict, Document, Object}; + + use super::{Findings, check, same_number}; + use crate::{Level, objects, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + fn spot_findings(spaces: &str, extra: &[&str]) -> Findings { + let page = format!("<< /Type /Page /Parent 2 0 R /Resources << /ColorSpace << {spaces} >> >> >>"); + let mut objs = vec![ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + &page, + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0 0] /N 1 >>", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [0 1 0] /N 1 >>", + "/Separation", + "/DeviceN", + ]; + objs.extend_from_slice(extra); + let doc = build(&objs); + let verified: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.4.4").map(|i| i.message).collect(); + let found = check(&doc, &objects(&doc)); + assert_eq!(verified.len(), found.differing.len() + found.undefined.len()); + found + } + + #[test] + fn resolves_indirect_separation_and_devicen_family_names() { + // Object 6 is `/Separation` and object 7 is `/DeviceN`. + let found = spot_findings("/A [6 0 R /Gold /DeviceRGB 4 0 R] /B [/Separation /Gold /DeviceRGB 5 0 R]", &[]); + assert_eq!(found.differing, BTreeSet::from(["Gold".to_string()])); + + let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << >> >>]", &[]); + assert_eq!(found.undefined, BTreeSet::from(["Foil".to_string()])); + + // A `/Colorants` entry whose `/Separation` family name is indirect defines the colorant. + let found = spot_findings("/A [7 0 R [/Foil] /DeviceRGB 4 0 R << /Colorants << /Foil [6 0 R /Foil /DeviceRGB 4 0 R] >> >>]", &[]); + assert_eq!(found, Findings::default()); + } + + #[test] + fn rejects_incomplete_or_null_operand_colorant_separations() { + for bad in [ + "[/Separation /Spot]", + "[/Separation /Spot /DeviceRGB]", + "[/Separation /Spot null 4 0 R]", + "[/Separation /Spot /DeviceRGB null]", + "[/Separation /Spot 99 0 R 4 0 R]", + "[/Separation /Spot /DeviceRGB 99 0 R]", + ] { + let spaces = format!("/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot {bad} >> >>]"); + let found = spot_findings(&spaces, &[]); + assert_eq!(found.undefined, BTreeSet::from(["Spot".to_string()]), "{bad}"); + } + let valid = "/A [/DeviceN [/Spot] /DeviceRGB 4 0 R << /Colorants << /Spot [/Separation /Spot /DeviceRGB 4 0 R] >> >>]"; + assert_eq!(spot_findings(valid, &[]), Findings::default()); + } + + #[test] + fn ignores_null_dictionary_entries_and_matches_int_real_numbers() { + let extra = [ + "<< /FunctionType 2 /Domain [0.0 1.0] /C0 [1.0 1.0 1.0] /C1 [1.0 0.0 0.0] /N 1.0 /Range null /Encode 99 0 R >>", + "<< /FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Length 15 >>\nstream\n{ dup dup pop }\nendstream", + "<< /FunctionType 4 /Domain [0.0 1.0] /Range [0.0 1.0 0.0 1.0 0.0 1.0] /Filter null /DecodeParms null /Length 15 >>\nstream\n{ dup dup pop }\nendstream", + "<< /FunctionType 2 /Domain [0 1] /C0 [1 1 1] /C1 [1 0.5 0] /N 1 >>", + ]; + // Object 8 has float equivalents of object 4's integers plus null/dangling optional keys. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 4 0 R] /B [/Separation /Red /DeviceRGB 8 0 R]", &extra); + assert_eq!(found, Findings::default()); + + // Object 9 and object 10 are identical Type 4 streams where object 10 has `/Filter null /DecodeParms null` and float domain/range. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 9 0 R] /B [/Separation /Red /DeviceRGB 10 0 R]", &extra); + assert_eq!(found, Findings::default()); + + // Genuinely different numeric values still report differing. + let found = spot_findings("/A [/Separation /Red /DeviceRGB 8 0 R] /B [/Separation /Red /DeviceRGB 11 0 R]", &extra); + assert_eq!(found.differing, BTreeSet::from(["Red".to_string()])); + + // Precision-losing large integers do not falsely match rounded floats. + let mut doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R >>", + ]); + let mut fn_int = Dict::new(); + fn_int.set(b"N".to_vec(), Object::Int(i64::MAX)); + let mut fn_real = Dict::new(); + fn_real.set(b"N".to_vec(), Object::Real(i64::MAX as f64)); + let r_int = doc.add(Object::Dict(fn_int)); + let r_real = doc.add(Object::Dict(fn_real)); + let sep_a = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_int)]); + let sep_b = Object::Array(vec![Object::name("Separation"), Object::name("Big"), Object::name("DeviceGray"), Object::Ref(r_real)]); + doc.add(sep_a); + doc.add(sep_b); + let found = check(&doc, &objects(&doc)); + assert_eq!(found.differing, BTreeSet::from(["Big".to_string()])); + + assert!(same_number(0, -0.0)); + assert!(same_number(i64::MIN, i64::MIN as f64)); + assert!(!same_number(i64::MIN + 1, (i64::MIN + 1) as f64)); + assert!(!same_number(0, f64::NAN)); + assert!(!same_number(0, f64::INFINITY)); + assert!(!same_number(0, f64::NEG_INFINITY)); + } +} diff --git a/crates/preflight/src/transparency.rs b/crates/preflight/src/transparency.rs index 605bf4dfae..ad2fe97c0b 100644 --- a/crates/preflight/src/transparency.rs +++ b/crates/preflight/src/transparency.rs @@ -131,6 +131,16 @@ fn walk_page<'a>(doc: &'a Document, page: &Dict, shared: &'a mut Shared) -> Walk walk } +/// The default colour space resource name and component count for a device colour space name. +fn device_default_space(name: &[u8]) -> Option<(&'static [u8], i64)> { + match name { + b"DeviceGray" => Some((b"DefaultGray", 1)), + b"DeviceRGB" => Some((b"DefaultRGB", 3)), + b"DeviceCMYK" => Some((b"DefaultCMYK", 4)), + _ => None, + } +} + /// Whether `page` names a blending colour space PDF/A accepts without an output intent: its /// `/Group` has a `/CS` that is a blending colour space (ISO 32000-1 and 32000-2 11.3.4: /// `CalGray`, `CalRGB`, or an `ICCBased` space of 1, 3 or 4 components whose profile describes @@ -142,10 +152,19 @@ pub(crate) fn page_names_blending_space(doc: &Document, page: &Dict) -> bool { let group = page.get(b"Group").map(|g| doc.resolve(g)); let Some(cs) = group.as_deref().and_then(Object::as_dict).and_then(|g| g.get(b"CS")).map(|c| doc.resolve(c)) else { return false }; let (default, components): (&[u8], i64) = match &*cs { - Object::Name(n) if n == b"DeviceGray" => (b"DefaultGray", 1), - Object::Name(n) if n == b"DeviceRGB" => (b"DefaultRGB", 3), - Object::Name(n) if n == b"DeviceCMYK" => (b"DefaultCMYK", 4), - Object::Array(a) => return independent_blending_space(doc, a).is_some(), + Object::Name(n) => { + let Some(space) = device_default_space(n) else { return false }; + space + } + Object::Array(a) => match a.as_slice() { + [only] => { + let Some(space) = doc.resolve(only).as_name().and_then(device_default_space) else { + return independent_blending_space(doc, a).is_some(); + }; + space + } + _ => return independent_blending_space(doc, a).is_some(), + }, _ => return false, }; let resources = page.get(b"Resources").map(|r| doc.resolve(r)); @@ -268,7 +287,7 @@ impl Walk<'_> { /// A graphics state (ISO 32000-1 Table 58): whether it paints transparently, and the blend /// modes it names that ISO 32000-1 doesn't define. - fn ext_gstate(&mut self, g: &Object, _depth: usize) { + fn ext_gstate(&mut self, g: &Object, depth: usize) { let doc = self.doc; let g = held(doc, g); let Some(g) = g.as_dict() else { return }; @@ -276,11 +295,17 @@ impl Walk<'_> { let bm = g.get(b"BM").map(|b| held(doc, b)); let blend = bm.as_deref().is_some_and(|b| blends(doc, b)); // A soft mask is a dictionary; `/None`, null or anything else is none (ISO 32000-1 Table 58). - let mask = g.get(b"SMask").map(|m| held(doc, m)).is_some_and(|m| matches!(&*m, Object::Dict(_))); + let smask = g.get(b"SMask").map(|m| held(doc, m)); + let mask = smask.as_deref().is_some_and(|m| matches!(m, Object::Dict(_))); self.found.transparency |= below_one(b"CA") || below_one(b"ca") || blend || mask; if let Some(bm) = bm.as_deref() { unknown_blend_modes(doc, bm, &mut self.found); } + if let Some(Object::Dict(m)) = smask.as_deref() + && let Some(group) = m.get(b"G") + { + self.visit(group, depth + 1, Self::xobject); + } } /// An image with a soft mask, or a form XObject that is a transparency group or holds @@ -290,13 +315,16 @@ impl Walk<'_> { let x = held(doc, x); let Object::Stream(s) = &*x else { return }; let d = &s.dict; - if d.name(b"Subtype") == Some(b"Image") { + if d.get(b"Subtype").is_some_and(|s| held(doc, s).as_name() == Some(b"Image")) { let soft_mask = d.get(b"SMask").is_some_and(|m| matches!(&*held(doc, m), Object::Stream(_))); let in_data = d.get(b"SMaskInData").and_then(|v| held(doc, v).as_int()).is_some_and(|v| v > 0); self.found.transparency |= soft_mask || in_data; return; } - let group = d.get(b"Group").map(|g| held(doc, g)).is_some_and(|g| g.as_dict().is_some_and(|g| g.name(b"S") == Some(b"Transparency"))); + let group = d + .get(b"Group") + .map(|g| held(doc, g)) + .is_some_and(|g| g.as_dict().is_some_and(|g| g.get(b"S").is_some_and(|s| held(doc, s).as_name() == Some(b"Transparency")))); self.found.transparency |= group; if let Some(r) = d.get(b"Resources") { self.visit(r, depth + 1, Self::resources); @@ -323,9 +351,10 @@ impl Walk<'_> { /// A Type 3 font's resources, which its glyph procedures paint with. fn type3(&mut self, f: &Object, depth: usize) { - let f = held(self.doc, f); + let doc = self.doc; + let f = held(doc, f); let Some(d) = f.as_dict() else { return }; - if d.name(b"Subtype") != Some(b"Type3") { + if !d.get(b"Subtype").is_some_and(|s| held(doc, s).as_name() == Some(b"Type3")) { return; } if let Some(r) = d.get(b"Resources") { @@ -389,3 +418,109 @@ fn blends(doc: &Document, b: &Object) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use pdfcraft_cos::Document; + + use super::{Shared, page_names_blending_space, page_painting}; + use crate::{Level, verify}; + + fn build(objs: &[&str]) -> Document { + let mut out = b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + let id = "/ID [<00112233445566778899AABBCCDDEEFF> <00112233445566778899AABBCCDDEEFF>]"; + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R {id} >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() + } + + #[test] + fn walks_soft_mask_transparency_group_for_unknown_blend_modes() { + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/CalRGB << /WhitePoint [0.9505 1 1.089] >>] >> /Resources << /ExtGState << /G0 << /SMask << /Type /Mask /S /Luminosity /G 4 0 R >> >> >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Resources << /ExtGState << /G1 << /BM /MaskCustom >> >> >> /Length 0 >>\nstream\n\nendstream", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + let painting = page_painting(&doc, &page.dict, &mut Shared::default()); + assert!(painting.transparency); + assert_eq!(painting.unknown_blend_modes, ["MaskCustom"]); + let messages: Vec = verify(&doc, Level::A2b).into_iter().filter(|i| i.clause == "6.2.10").map(|i| i.message).collect(); + assert_eq!(messages, ["A graphics state uses the blend mode MaskCustom, which ISO 32000-1 doesn't define"]); + } + + #[test] + fn resolves_indirect_subtypes_and_transparency_group_kinds() { + // Indirect `/Image` subtype on an image with `/SMaskInData 1`. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Im 4 0 R >> >> >>", + "<< /Type /XObject /Subtype 5 0 R /Width 1 /Height 1 /SMaskInData 1 /Length 0 >>\nstream\n\nendstream", + "/Image", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + assert!(page_painting(&doc, &page.dict, &mut Shared::default()).transparency); + + // Indirect `/Transparency` group kind `/S` on a form XObject. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /XObject << /Fm 4 0 R >> >> >>", + "<< /Type /XObject /Subtype /Form /BBox [0 0 10 10] /Group << /S 5 0 R >> /Length 0 >>\nstream\n\nendstream", + "/Transparency", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + assert!(page_painting(&doc, &page.dict, &mut Shared::default()).transparency); + + // Indirect `/Type3` subtype on a font with translucent resources and an unknown blend mode. + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F0 4 0 R >> >> >>", + "<< /Type /Font /Subtype 5 0 R /Resources << /ExtGState << /G << /ca 0.5 /BM /T3Mode >> >> >> >>", + "/Type3", + ]); + let page = &pdfcraft_model::pages(&doc)[0]; + let painting = page_painting(&doc, &page.dict, &mut Shared::default()); + assert!(painting.transparency); + assert_eq!(painting.unknown_blend_modes, ["T3Mode"]); + } + + #[test] + fn accepts_single_element_device_colour_array_with_default_space() { + let cal_rgb = "[/CalRGB << /WhitePoint [0.9505 1 1.089] >>]"; + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R] /Count 4 /MediaBox [0 0 200 200] >>", + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [7 0 R] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB] >> /Resources << >> >>", + &format!( + "<< /Type /Page /Parent 2 0 R /Group << /S /Transparency /CS [/DeviceRGB /Extra] >> /Resources << /ColorSpace << /DefaultRGB {cal_rgb} >> >> >>" + ), + "/DeviceRGB", + ]); + let pages = pdfcraft_model::pages(&doc); + assert!(page_names_blending_space(&doc, &pages[0].dict)); + assert!(page_names_blending_space(&doc, &pages[1].dict)); + assert!(!page_names_blending_space(&doc, &pages[2].dict)); + assert!(!page_names_blending_space(&doc, &pages[3].dict)); + } +} diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 348ae151ba..19a75af32c 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -219,14 +219,22 @@ pub(crate) fn page_form( body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes()); xobjects.set(name.into_bytes(), Object::Ref(ap)); } - if !xobjects.is_empty() { - res.set(b"XObject".to_vec(), Object::Dict(xobjects)); - } + // With no appearance added the form keeps the page's own Resources, shared by reference, + // rather than a deep copy per printed page. + let resources = match page.dict.get(b"Resources") { + Some(r) if k == 0 && doc.resolve(r).as_dict().is_some() => r.clone(), + _ => { + if !xobjects.is_empty() { + res.set(b"XObject".to_vec(), Object::Dict(xobjects)); + } + Object::Dict(res) + } + }; let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); d.set(b"BBox".to_vec(), Object::Array(crop.iter().map(|v| Object::Real(*v)).collect())); - d.set(b"Resources".to_vec(), Object::Dict(res)); + d.set(b"Resources".to_vec(), resources); if let Some(g) = page.dict.get(b"Group") { d.set(b"Group".to_vec(), g.clone()); } diff --git a/crates/print/src/lib.rs b/crates/print/src/lib.rs index 8fffbdf649..3564b86687 100644 --- a/crates/print/src/lib.rs +++ b/crates/print/src/lib.rs @@ -25,7 +25,7 @@ pub mod spool; #[cfg(test)] mod tests; -pub use range::{Subset, select_listed, select_pages}; +pub use range::{Subset, position, select_listed, select_pages}; mod error; mod imposition; diff --git a/crates/print/src/range.rs b/crates/print/src/range.rs index 027158df01..e68d1d31d9 100644 --- a/crates/print/src/range.rs +++ b/crates/print/src/range.rs @@ -1,4 +1,5 @@ -//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed), odd or even pages, reverse. +//! Pages to Print: all, a range ("1-3, 6, 9-", page labels allowed, "(n)" for the page at +//! position n), odd or even pages, reverse. use std::collections::HashMap; @@ -44,24 +45,39 @@ pub enum Subset { Even, } -/// Resolve one range token: a page label, or a number (1-based). +/// Resolve one range token: a page label, a number in parentheses for the page at that position, +/// or a number (1-based). fn page_of(tok: &str, count: usize, labels: &Labels) -> Result { let t = tok.trim(); // Page labels win over numbers (Acrobat's "use logical page numbers"). if let Some(i) = labels.get(t) { return Ok(i); } + let number = parenthesized(t).unwrap_or(t); // `usize::from_str` accepts a leading '+', which is not part of a page number. - if !t.bytes().all(|b| b.is_ascii_digit()) { + if !number.bytes().all(|b| b.is_ascii_digit()) { return Err(PrintError::NotAPage(t.to_string())); } - match t.parse::() { + match number.parse::() { Ok(n) if n >= 1 && n <= count => Ok(n - 1), Ok(n) => Err(PrintError::OutOfRange(n, count)), Err(_) => Err(PrintError::NotAPage(t.to_string())), } } +/// What a token holds between one pair of parentheses, trimmed: "( 3 )" holds "3". +fn parenthesized(t: &str) -> Option<&str> { + t.trim().strip_prefix('(')?.strip_suffix(')').map(str::trim) +} + +/// The page position (1-based) a token names in parentheses. A page's label and its position +/// need not coincide (ISO 32000-2 §12.4.2): "(n)" always names the nth page, so a page can be +/// named by position where a label such as "1" belongs to another page. `None` for anything +/// else. Callers look labels up first, so a label spelled "(1)" keeps naming its own page. +pub fn position(token: &str) -> Option { + parenthesized(token).filter(|n| n.bytes().all(|b| b.is_ascii_digit()))?.parse().ok() +} + /// Split a range whose endpoint is a logical page label containing a hyphen, such /// as "A-1-A-3" or "A-2-". The plain range parser must not mistake the first /// hyphen inside the label for the delimiter between endpoints. @@ -83,8 +99,8 @@ fn hyphenated_label_range(part: &str, count: usize, labels: &Labels) -> Option<( } /// The pages to print (0-based, in print order). `range` is `None` for all pages; it may list -/// numbers and labels with `-` ranges (open-ended allowed: `5-`, `-3`). The subset keeps the odd or -/// even pages of those chosen, by page number (see [`narrow`]). +/// numbers, labels and `(n)` positions with `-` ranges (open-ended allowed: `5-`, `-3`). The subset +/// keeps the odd or even pages of those chosen, by page number (see [`narrow`]). pub fn select_pages(count: usize, range: Option<&str>, labels: &[String], subset: Subset, reverse: bool) -> Result, PrintError> { let mut pages = Vec::new(); match range.map(str::trim).filter(|r| !r.is_empty()) { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index fa34f5c6f8..b4ee125fd3 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -20,7 +20,8 @@ pub fn text_reads_up(doc: &Document, index: usize) -> bool { pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matrix, depth: usize, score: &mut i64, budget: &mut DecodeBudget) { let (mut ctm, mut tm, mut stack) = (base, Matrix::IDENTITY, Vec::new()); - for op in pdfcraft_content::parse(data).ops { + // One operator at a time: only the running score is kept, never the stream's operators. + for op in pdfcraft_content::ops(data) { match op.op.as_slice() { // Bounded like the renderer's graphics-state stack: deeper saves are ignored. b"q" if stack.len() < 256 => stack.push(ctm), @@ -39,13 +40,16 @@ pub(crate) fn text_direction(doc: &Document, data: &[u8], res: &Dict, base: Matr if form.dict.get(b"Subtype").and_then(|s| doc.resolve(s).as_name().map(<[u8]>::to_vec)).as_deref() != Some(b"Form".as_slice()) { continue; } - let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); - let inner = form.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_else(|| res.clone()); + // The visit is paid for before anything else is read, and the form's resources + // (or the inherited ones) are borrowed, not copied for each invocation. if !budget.enter_form() { continue; } + let m = form.dict.get(b"Matrix").and_then(|m| doc.resolve(m).as_array().and_then(|a| Matrix::from_operands(a))).unwrap_or_default(); + let own = form.dict.get(b"Resources").map(|r| doc.resolve(r)); + let inner = own.as_deref().and_then(Object::as_dict).unwrap_or(res); if let Ok(body) = budget.decode(form) { - text_direction(doc, &body, &inner, m.then(&ctm), depth + 1, score, budget); + text_direction(doc, &body, inner, m.then(&ctm), depth + 1, score, budget); } } _ => {} diff --git a/crates/print/src/tests.rs b/crates/print/src/tests.rs index 63d8a351e7..a1026308ea 100644 --- a/crates/print/src/tests.rs +++ b/crates/print/src/tests.rs @@ -126,6 +126,54 @@ fn page_selection_handles_ranges_between_hyphenated_labels() { assert_eq!(select_pages(3, Some("A-1-B-C"), &ambiguous, Subset::All, false).unwrap(), [1, 2]); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). With front matter +/// labelled i–iii and the body 1–7, "1" is the page labelled 1 (the fourth), while "(1)" is the +/// first page, whatever it is labelled; positions combine with labels and open ends in ranges. +#[test] +fn a_number_in_parentheses_names_the_page_at_that_position() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pages = |range| select_pages(10, Some(range), &labels, Subset::All, false); + assert_eq!(pages("1").unwrap(), [3], "a bare number is the label"); + assert_eq!(pages("(1)").unwrap(), [0], "in parentheses it is the position"); + assert_eq!(pages("( 2 )").unwrap(), [1]); + assert_eq!(pages("(1)-(3)").unwrap(), [0, 1, 2]); + assert_eq!(pages("(9)-").unwrap(), [8, 9]); + assert_eq!(pages("-(2)").unwrap(), [0, 1]); + assert_eq!(pages("(2)-1").unwrap(), [1, 2, 3], "a position and a label"); + assert_eq!(pages("ii-(5)").unwrap(), [1, 2, 3, 4]); + assert_eq!(pages("(4)-(2), 7").unwrap(), [3, 2, 1, 9]); + assert_eq!(select_pages(10, Some("(1)-(4)"), &labels, Subset::Even, false).unwrap(), [1, 3], "odd and even stay page numbers"); + assert_eq!(pages("(0)"), Err(PrintError::OutOfRange(0, 10))); + assert_eq!(pages("(11)"), Err(PrintError::OutOfRange(11, 10))); + for bad in ["(x)", "()", "(1", "1)", "((1))", "(+3)"] { + assert_eq!(pages(bad), Err(PrintError::NotAPage(bad.into())), "{bad}"); + } + // The page box takes positions through `position`, with the same digits-only rule. + assert_eq!(crate::position("( 3 )"), Some(3)); + assert_eq!(crate::position("(+3)"), None, "a sign is not part of a page number"); + assert!(pages("(-1)").is_err(), "a dash inside parentheses is still a range, of nothing"); + // A label that is itself "(1)" still names its own page, as every label does. + let odd: Vec = ["a", "b", "(1)"].iter().map(|s| s.to_string()).collect(); + assert_eq!(select_pages(3, Some("(1)"), &odd, Subset::All, false).unwrap(), [2]); + // Without labels a position is the same page as its number. + assert_eq!(select_pages(5, Some("(2)-4"), &[], Subset::All, false).unwrap(), [1, 2, 3]); +} + +/// Odd and even count page positions, never the numbers inside labels: page indices are fixed +/// and consecutive, while labels can restart, repeat, use letters or carry no number (ISO 32000-2 +/// §12.4.2). With front matter labelled i–iii the page labelled 1 is the fourth, so it is even, and +/// a range typed in labels keeps the same pages as the same range typed as positions. +#[test] +fn odd_and_even_count_positions_on_a_labelled_document() { + let labels: Vec = ["i", "ii", "iii", "1", "2", "3", "4", "5", "6", "7"].iter().map(|s| s.to_string()).collect(); + let pick = |range: Option<&str>, subset| select_pages(10, range, &labels, subset, false).unwrap(); + assert_eq!(pick(Some("1-4"), Subset::Even), [3, 5], "labels 1 and 3, the fourth and sixth pages"); + assert_eq!(pick(Some("1-4"), Subset::Odd), [4, 6], "labels 2 and 4, the fifth and seventh pages"); + assert_eq!(pick(Some("1-4"), Subset::Even), pick(Some("(4)-(7)"), Subset::Even), "labels or positions, the same pages"); + assert_eq!(pick(None, Subset::Odd), [0, 2, 4, 6, 8], "i, iii, 2, 4, 6"); + assert_eq!(pick(None, Subset::Even), [1, 3, 5, 7, 9]); +} + #[test] fn size_modes() { let sizes = [(200.0, 300.0), (1000.0, 1500.0)]; @@ -1285,3 +1333,15 @@ fn contents_pieces_join_with_a_space_across_a_trailing_comment() { let printed_ctrl = impose(&with_newline, &settings(vec![0], Layout::Size(SizeMode::Actual))).unwrap(); assert_eq!(form_strings(printed_ctrl), [b"HIDDEN".to_vec(), b"SHOWN".to_vec()]); } + +#[test] +fn a_page_form_without_annotations_shares_the_pages_indirect_resources() { + let mut doc = fixture(2); + let pages = pdfcraft_model::pages(&doc); + let res = doc.add(Object::Dict([(b"Font".to_vec(), Object::Dict(Dict::new()))].into_iter().collect())); + doc.update_dict(pages[1].obj, |d| d.set(b"Resources".to_vec(), Object::Ref(res))).unwrap(); + let pages = pdfcraft_model::pages(&doc); + let form = imposition::page_form(&mut doc, &pages, 1, Content::default(), &mut Default::default()).unwrap(); + let Object::Stream(s) = &*doc.get(form) else { panic!("form stream") }; + assert_eq!(s.dict.get(b"Resources"), Some(&Object::Ref(res))); +} diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 1ac7cb6714..326adff769 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -591,8 +591,10 @@ impl Walk { } fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { - self.gs.size = op.num(1).unwrap_or(self.gs.size); - let Some(name) = op.name(0) else { return }; + // `/F1 12 Tf` takes its last two operands, as the renderer does: a surplus before them + // is ignored, not read in their place. + self.gs.size = op.nums::<1>().map_or(self.gs.size, |[size]| size); + let Some(name) = op.operands.len().checked_sub(2).and_then(|i| op.name(i)) else { return }; let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); self.gs.type3_inherits = font_dict.as_ref().is_some_and(|d| type3_without_resources(doc, d)); @@ -609,17 +611,20 @@ impl Walk { /// Text state and text-positioning operators. fn text_state(&mut self, op: &Op) { let gs = &mut self.gs; + // The operand immediately before the operator, as the renderer reads it: surplus operands + // before it must not move where glyphs are placed (`1 50 Ts` raises text by 50, not 1). + let last = op.nums::<1>().map(|[v]| v); match op.op.as_slice() { b"BT" => { self.tm = Matrix::IDENTITY; self.tlm = Matrix::IDENTITY; } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Tc" => gs.char_spacing = last.unwrap_or(0.0), + b"Tw" => gs.word_spacing = last.unwrap_or(0.0), + b"Tz" => gs.scale = last.unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = last.unwrap_or(0.0), + b"Ts" => gs.rise = last.unwrap_or(0.0), + b"Tr" => gs.render_mode = last.unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if op.is("TD") { @@ -656,8 +661,10 @@ impl Walk { } let o = op.op.as_slice(); if o == b"\"" { - self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); - self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + // `aw ac string "`: the two numbers before the string, whatever surplus precedes them. + let at = |back: usize| op.operands.len().checked_sub(back).and_then(|i| op.num(i)); + self.gs.word_spacing = at(3).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = at(2).unwrap_or(self.gs.char_spacing); } if matches!(o, b"'" | b"\"") { self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1ef9a1f6de..5a4ea3bfcd 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -79,6 +79,21 @@ impl Hidden { } } +#[cfg(test)] +thread_local! { + /// Passes over every page's content ([`content_pass`]) made on this thread. + static CONTENT_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// Walks of the whole object graph for owned entries ([`owners`]) made by [`scan_only`] on + /// this thread. + static OWNER_WALKS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many content passes and owner walks this thread has made, for tests to count the work. +#[cfg(test)] +pub(crate) fn passes() -> (usize, usize) { + (CONTENT_PASSES.with(std::cell::Cell::get), OWNER_WALKS.with(std::cell::Cell::get)) +} + fn catalog(doc: &Document) -> Option<(ObjRef, Dict)> { let r = doc.root()?; Some((r, doc.get(r).as_dict()?.clone())) @@ -565,14 +580,19 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re if !text && !layers { return Ok((0, std::collections::HashSet::new())); } + #[cfg(test)] + CONTENT_PASSES.with(|n| n.set(n.get() + 1)); let mut total = 0; let mut retained_off_layers = std::collections::HashSet::new(); // The original image and form XObjects the rewrite drops or replaces, on any page. let mut doomed = Vec::new(); let mut retired_properties = Vec::new(); - let n = pdfcraft_model::pages(doc).len(); + // The page tree is read once, and again only after a page's rewrite (pinning its inherited + // resources can change its ancestors), not once per page: a scan of many pages stays linear. + let mut pages = pdfcraft_model::pages(doc); + let n = pages.len(); for pi in 0..n { - let page = pdfcraft_model::pages(doc).swap_remove(pi); + let Some(page) = pages.get(pi).cloned() else { break }; let mut budget = pdfcraft_cos::DecodeBudget::page(); let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else { if write { @@ -655,6 +675,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re pdfcraft_content::Pieces::join(&page_content).data(), ); crate::tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + pages = pdfcraft_model::pages(doc); } for (old, new, changed, empty) in out.tag_changes { crate::tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; @@ -672,19 +693,40 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re /// How many items each category would remove (categories with nothing are included as 0). pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { + scan_only(doc, &HIDDEN) +} + +/// [`scan`] for the categories in `which` alone, in [`HIDDEN`] order, each once. A category's +/// count costs what it reads: hidden text and hidden layers read every page's content, and +/// metadata, attachments, links and private data walk every object the catalog reaches, so +/// counting bookmarks alone does neither. +pub fn scan_only(doc: &Document, which: &[Hidden]) -> Vec<(Hidden, usize)> { let cat = catalog(doc).map(|c| c.1).unwrap_or_default(); - let pages = pdfcraft_model::pages(doc); - let annots: Vec = pages.iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect(); - let count_sub = |s: &[u8]| annots.iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); - let names = sub(doc, &cat, b"Names").unwrap_or_default(); - let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); - // A graph nested too deeply to walk counts as holding everything, so the removal runs and - // refuses it. - let held = owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0); - let owned = |h: Hidden| OWNED.iter().zip(&held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::(); + let annots_read = std::cell::OnceCell::>::new(); + let annots = || { + annots_read.get_or_init(|| { + pdfcraft_model::pages(doc).iter().flat_map(|p| annots_of(doc, &p.dict)).filter_map(|a| doc.resolve(&a).as_dict().cloned()).collect() + }) + }; + let count_sub = |s: &[u8]| annots().iter().filter(|a| a.name(b"Subtype") == Some(s)).count(); + let names_read = std::cell::OnceCell::::new(); + let names = || names_read.get_or_init(|| sub(doc, &cat, b"Names").unwrap_or_default()); + let held = std::cell::OnceCell::>::new(); + let owned = |h: Hidden| { + let held = held.get_or_init(|| { + #[cfg(test)] + OWNER_WALKS.with(|n| n.set(n.get() + 1)); + let owned_keys: Vec<&[u8]> = OWNED.iter().map(|o| o.1).collect(); + // A graph nested too deeply to walk counts as holding everything, so the removal + // runs and refuses it. + owners(doc, &owned_keys).map_or_else(|_| vec![1; OWNED.len()], |o| o.0) + }); + OWNED.iter().zip(held).filter(|(o, _)| o.0 == h).map(|(_, n)| n).sum::() + }; let mut doc2 = doc.clone(); HIDDEN .into_iter() + .filter(|h| which.contains(h)) .map(|h| { let n = match h { Hidden::Metadata => { @@ -692,11 +734,11 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { info + owned(Hidden::Metadata) } Hidden::Attachments => { - sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + sub(doc, names(), b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t)) + count_sub(b"FileAttachment") + owned(Hidden::Attachments) } - Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), + Hidden::Comments => annots().iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(), Hidden::FormFields => { let n = pdfcraft_forms::fields(doc).len(); if n > 0 { @@ -717,8 +759,8 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + owned(Hidden::LinksActionsScripts) - + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + + annots().iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + + sub(doc, names(), b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + outline_action_count(doc, &cat) } Hidden::PrivateData => owned(Hidden::PrivateData), @@ -764,36 +806,15 @@ fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), Redac /// sensitive content still present. Call this on a working clone and replace the original only /// on success. On any error, discard the working clone; do not save or reuse it. pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { - let counts = scan(doc); - let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); - let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; - let mut done = Vec::new(); - // Every annotation the pages list now: whatever is no longer listed at the end (flattened - // widgets included) leaves the structure tree too. - let listed_before = listed_annotations(doc); - let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); - let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); - if fields { - // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; - doc.update_dict(root, |d| { - d.remove(b"AcroForm"); - })?; - } - // Content next (it needs the layers still in place). Flattened appearances are page content - // by now, so hidden text or layers inside them are removed with the rest. - let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); - let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { - content_pass(doc, text, layers, true)?.1 - } else { - std::collections::HashSet::new() - }; + // Only what the chosen categories need is counted: a full scan reads every page's content + // twice and walks the whole object graph, which removing bookmarks alone doesn't need. + let counts = scan_only(doc, which); + remove_counted(doc, which, &counts, false) +} + +/// Take off every page the annotations the chosen categories remove (with `fields`, the widgets +/// flattening kept), and the actions of those that stay when links, actions and scripts go. +fn drop_page_annotations(doc: &mut Document, which: &[Hidden], fields: bool) -> Result<(), RedactError> { // Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden // ones, and ones with nothing to draw), which still hold their field's value and parent. let drop_annot = |a: &Dict| -> bool { @@ -805,13 +826,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + let pages = pdfcraft_model::pages(doc); + let removed: std::collections::HashSet = pages .iter() .flat_map(|p| annots_of(doc, &p.dict)) .filter_map(|a| a.as_ref()) .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) .collect(); - for p in pdfcraft_model::pages(doc) { + for p in pages { let list = annots_of(doc, &p.dict); let mut kept = Vec::new(); for a in list { @@ -842,12 +864,58 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); - crate::tags::drop_object_refs(doc, &gone)?; + Ok(()) +} + +/// [`remove_hidden`] with `counts` from a scan of at least the categories in `which`. With +/// `every_page`, every page's annotation list is rewritten even when no chosen category changes +/// it, as removal did before it skipped that work; tests compare the two. +pub(crate) fn remove_counted( + doc: &mut Document, + which: &[Hidden], + counts: &[(Hidden, usize)], + every_page: bool, +) -> Result, RedactError> { + let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); + let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; + let mut done = Vec::new(); + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); + // Pages' annotation lists change only for these; otherwise every page is left as it is. + let annotations = every_page || fields || which.iter().any(|h| matches!(h, Hidden::Comments | Hidden::Attachments | Hidden::LinksActionsScripts)); + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; + if fields { + // Fields without appearances get one first, so their values stay visible. + for f in pdfcraft_forms::fields(doc) { + if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { + pdfcraft_forms::redraw_field(doc, &f.name)?; + } + } + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; + doc.update_dict(root, |d| { + d.remove(b"AcroForm"); + })?; + } + // Content next (it needs the layers still in place). Flattened appearances are page content + // by now, so hidden text or layers inside them are removed with the rest. + let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); + let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; + if annotations { + drop_page_annotations(doc, which, fields)?; + // A tagged PDF's structure tree still points at the removed links, comments and flattened + // widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs, + // text and field values included. + let still = listed_annotations(doc); + let gone: std::collections::HashSet = listed_before.difference(&still).copied().collect(); + crate::tags::drop_object_refs(doc, &gone)?; + } let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6518817899..d4d16920ef 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4113,6 +4113,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod descriptions; +mod hidden_scope; mod inline_images; mod limits; mod property_retirement; diff --git a/crates/redact/src/tests/hidden_scope.rs b/crates/redact/src/tests/hidden_scope.rs new file mode 100644 index 0000000000..a44546b31f --- /dev/null +++ b/crates/redact/src/tests/hidden_scope.rs @@ -0,0 +1,111 @@ +//! Remove Hidden Information does the work of the categories it is asked for, and no more: a +//! textbook of 1,412 pages took over 15 minutes to lose its bookmarks while every page's content +//! was read for hidden text and layers it was not removing (#5873). + +use super::*; +use crate::sanitize::{HIDDEN, Hidden, passes, remove_hidden, scan, scan_only}; + +/// Content passes and owner walks `work` makes on this thread. +fn passes_of(work: impl FnOnce() -> T) -> (T, (usize, usize)) { + let before = passes(); + let out = work(); + let after = passes(); + (out, (after.0 - before.0, after.1 - before.1)) +} + +/// Every category counted alone gives what the full scan gives it; lists come back in scan +/// order, once each. +#[test] +fn each_category_counts_alone_what_the_full_scan_counts() { + let doc = hidden_fixture(); + let full = scan(&doc); + assert_eq!(full.iter().map(|c| c.0).collect::>(), HIDDEN, "the full scan lists every category in order"); + assert!(full.iter().all(|c| c.1 > 0), "the fixture holds something in every category: {full:?}"); + for &(h, n) in &full { + assert_eq!(scan_only(&doc, &[h]), [(h, n)], "{h:?}"); + } + // Listed out of order and twice, the categories come back in scan order, once each. + let mut every: Vec = HIDDEN.into_iter().rev().collect(); + every.push(Hidden::Bookmarks); + assert_eq!(scan_only(&doc, &every), full); + assert!(scan_only(&doc, &[]).is_empty()); +} + +/// What each category costs: content passes for hidden text and layers, one shared object walk +/// for the owned entries, neither for the rest. +#[test] +fn a_category_reads_page_content_or_walks_every_object_only_when_it_needs_to() { + let doc = hidden_fixture(); + assert_eq!(passes_of(|| scan(&doc)).1, (2, 1), "hidden text and hidden layers read the pages; four categories share one walk"); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Bookmarks, Hidden::Comments, Hidden::FormFields])).1, (0, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::HiddenText])).1, (1, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Metadata, Hidden::PrivateData])).1, (0, 1), "one walk counts both"); +} + +/// Bookmarks alone: no content pass or walk, only the catalog edited, the rest still counted. +#[test] +fn removing_bookmarks_changes_the_catalog_alone() { + let mut doc = hidden_fixture(); + let before = scan(&doc); + // The fixture adds its /Info dictionary as an edit. + let edited = doc.modified_objects(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Bookmarks])); + assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 2)]); + assert_eq!(work, (0, 0), "no page content read, no object walk"); + let root = doc.root().unwrap(); + let mut expected = [edited, vec![root.num]].concat(); + expected.sort_unstable(); + assert_eq!(doc.modified_objects(), expected, "the pages, their annotations and everything else stay as they were"); + let cat = doc.get(root).as_dict().cloned().unwrap(); + assert!(!cat.contains(b"Outlines") && !cat.contains(b"PageMode")); + // Every other category still holds what it held. + let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect(); + assert_eq!(after, before.into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect::>()); +} + +/// Hidden text alone: one pass to count it and one to remove it, no object walk. +#[test] +fn removing_hidden_text_reads_the_pages_for_it_alone() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::HiddenText])); + assert_eq!(done.unwrap(), [(Hidden::HiddenText, 13)]); + assert_eq!(work, (2, 0), "one pass to count the hidden text, one to remove it"); + let c = content(&doc, 0); + assert!(c.contains("(Visible)") && !c.contains("Hidden") && c.contains("(Layer)"), "{c}"); +} + +/// Comments alone still rewrite the pages' annotation lists, without reading their content. +#[test] +fn removing_comments_still_takes_them_off_every_page() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Comments])); + assert_eq!(done.unwrap(), [(Hidden::Comments, 1)]); + assert_eq!(work, (0, 0)); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left: Vec = annots_of(&doc, &p.dict).iter().filter_map(Object::as_ref).collect(); + // The square and its pop-up go; the widget, the link and the attachment stay. + assert_eq!(left, [ObjRef::new(17, 0), ObjRef::new(18, 0), ObjRef::new(19, 0)]); +} + +/// Each category alone, every pair and all of them remove what the full scan and a rewrite of +/// every page's annotation list removed: the same report and the same bytes in a full save. +#[test] +fn removal_writes_what_it_wrote_with_the_full_scan() { + let mut cases: Vec> = HIDDEN.iter().map(|&h| vec![h]).collect(); + for (i, &a) in HIDDEN.iter().enumerate() { + cases.extend(HIDDEN[i + 1..].iter().map(|&b| vec![a, b])); + } + cases.push(HIDDEN.to_vec()); + assert_eq!(cases.len(), 9 + 36 + 1); + for which in cases { + let mut before = hidden_fixture(); + let counts = scan(&before); + let old = crate::sanitize::remove_counted(&mut before, &which, &counts, true); + let mut after = hidden_fixture(); + let new = remove_hidden(&mut after, &which); + assert_eq!(new, old, "{which:?}"); + // Removal gives the file a new identifier, salted at random unless the caller pins it. + let save = SaveOptions { id_entropy: Some([7; 16]), ..SaveOptions::default() }; + assert!(write_full(&after, &save).unwrap() == write_full(&before, &save).unwrap(), "{which:?}: the saved files differ"); + } +} diff --git a/crates/redact/src/tests/inline_images.rs b/crates/redact/src/tests/inline_images.rs index 77fd4dc2d7..f1c61b71a5 100644 --- a/crates/redact/src/tests/inline_images.rs +++ b/crates/redact/src/tests/inline_images.rs @@ -32,3 +32,12 @@ fn a_page_whose_inline_image_the_renderer_cannot_end_is_refused() { mark(&mut doc, 0, &[[5.0, 95.0, 45.0, 110.0]], ""); assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); } + +/// The renderer draws an image whose parameters the content parser can't read (a stray operand +/// before `ID`): it would stay under the mark unseen, so the page is refused. +#[test] +fn a_page_whose_inline_image_only_the_renderer_reads_is_refused() { + let mut doc = one_page(b"q 10 0 0 10 50 50 cm BI /W 1 /H 1 /CS /G /BPC 8 7 ID \x80 EI Q", "", vec![]); + mark(&mut doc, 0, &[[45.0, 45.0, 65.0, 65.0]], ""); + assert_eq!(apply(&mut doc, None), Err(RedactError::Unreadable(1))); +} diff --git a/crates/redact/src/tests/limits.rs b/crates/redact/src/tests/limits.rs index 8519e08608..b241dc4cdc 100644 --- a/crates/redact/src/tests/limits.rs +++ b/crates/redact/src/tests/limits.rs @@ -53,3 +53,23 @@ fn a_change_inside_nested_marked_content_touches_every_enclosing_mcid() { assert_eq!(changed, HashSet::from([0, 1])); assert_eq!(empty, HashSet::from([1])); } + +/// The hidden-information scan read the whole page tree again for every page: 20 000 pages cost +/// 400 million page visits. It reads the tree once per pass now. +#[test] +fn a_scan_of_many_pages_reads_the_page_tree_once() { + use crate::sanitize::{Hidden, scan}; + const N: usize = 20_000; + let kids: String = (0..N).map(|i| format!("{} 0 R ", 4 + i)).collect(); + let mut objs = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + format!("<< /Type /Pages /Kids [{kids}] /Count {N} /MediaBox [0 0 300 300] >>").into_bytes(), + stream("", b"BT 3 Tr (x) Tj ET"), + ]; + objs.extend((0..N).map(|_| b"<< /Type /Page /Parent 2 0 R /Contents 3 0 R >>".to_vec())); + let doc = pdf(objs); + let started = std::time::Instant::now(); + let counts: HashMap = scan(&doc).into_iter().collect(); + assert_eq!(counts[&Hidden::HiddenText], N, "one invisible glyph on every page"); + assert!(started.elapsed() < std::time::Duration::from_secs(60), "{:?}", started.elapsed()); +} diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs index 4e888f262c..2df6b53fad 100644 --- a/crates/redact/src/tests/text_state.rs +++ b/crates/redact/src/tests/text_state.rs @@ -25,3 +25,17 @@ fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); } + +/// `1 50 Ts` raises text by 50, the operand before the operator, as the renderer reads it; the +/// surplus `1` before it is ignored. Reading the first operand put the glyphs 49 pt too low, so a +/// mark over the text the page shows missed it. +#[test] +fn surplus_text_state_operands_do_not_move_the_glyphs() { + let mut doc = one_page(b"BT /F1 10 Tf 1 50 Ts 10 100 Td (SECRET) Tj ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[SECRET_AT_10_100]), 0, "nothing is drawn at y 100"); + let raised = [5.0, 145.0, 45.0, 160.0]; + assert_eq!(under(&mut doc, 0, &[raised]), 6); + mark(&mut doc, 0, &[raised], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 5777d987ca..b5fc0e1bf1 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -34,6 +34,9 @@ unicode-bidi = "0.3" getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] +# The content crate's parser, compared operator by operator against hayro's in +# `tests/content_differential.rs` (L3 may depend on L2). +pdfcraft-content = { workspace = true } pdfcraft-testkit = { workspace = true } # Builds the decompression-bomb streams in the raster tests. flate2 = { workspace = true } diff --git a/crates/render/examples/search_bench.rs b/crates/render/examples/search_bench.rs index f65d959c5e..4e7d77c1ae 100644 --- a/crates/render/examples/search_bench.rs +++ b/crates/render/examples/search_bench.rs @@ -13,7 +13,7 @@ fn main() { let glyphs: Vec<_> = text.chars().map(|c| TextGlyph { text: c.to_string(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(); let n = glyphs.len(); - let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }; + let page = PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; black_box(page.find(&query)); let mut times = Vec::new(); for _ in 0..7 { diff --git a/crates/render/src/inspect.rs b/crates/render/src/inspect.rs index 0cf287b3f1..0079ba6f37 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -37,7 +37,7 @@ pub use core::{check_pages, inspect, pretty_date}; use core::{inspect_structure, spend}; pub use model::{ Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, link_replies, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1cc7a6eae1..bf5e8ebb93 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,5 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; -use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; +use super::model::{ + Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies, state_or_default, +}; use super::navigation::text_string; use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; @@ -215,7 +217,7 @@ impl<'a> Inspector<'a> { rect, color, index, - state: self.text(d, b"State"), + state: state_or_default(self.text(d, b"State"), self.text(d, b"StateModel").as_deref()), quads, locked: d.get(b"F").ok().and_then(|f| self.resolve(f).as_i64().ok()).unwrap_or(0) & 128 != 0, intent: self.name(d, b"IT"), diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c5df792f23..16de483e04 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -169,7 +169,8 @@ impl<'a> Inspector<'a> { if let Some(tree) = doc.catalog().ok().and_then(|c| c.get(b"Names").ok()).and_then(|o| me.dict(o)).and_then(|n| n.get(b"Dests").ok()).and_then(|o| me.dict(o)) { - me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named); + let mut key_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; + me.name_tree_raw(tree, &mut HashSet::new(), 0, &mut named, &mut key_budget); } me.named = named; me diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 981de248ae..dc54296c83 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::HashMap; +use super::{HashMap, HashSet}; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -425,10 +425,12 @@ fn rect_from_upright(shown: [f64; 4], rotation: u16) -> [f64; 4] { } /// Settle the reply links of a document's annotations: a reply keeps its `parent` only when -/// following the links reaches a comment of its own within 64 steps. An `/IRT` to an unlisted -/// object, a pop-up, the annotation itself or a loop leaves it a comment of its own, so it stays -/// in view instead of hanging off nothing. Both comment readers (inspect, and the engine's list -/// after an edit) run this. +/// following the links reaches a comment of its own. An `/IRT` to an unlisted object, a pop-up, +/// the annotation itself or a loop leaves it a comment of its own, so it stays in view instead of +/// hanging off nothing. A chain of any length settles: each reviewer's state changes reply to +/// their previous one (ISO 32000-2 §12.5.6.3), so a long review history is a long chain. Each +/// annotation is walked once, with a seen-set against loops. Both comment readers (inspect, and +/// the engine's list after an edit) run this. /// /// Only comments can be parents: both readers leave links, form widgets, pop-ups and other /// non-markup annotations out of `annotations` (`pdfcraft_annot::is_comment_subtype`), so an @@ -439,38 +441,181 @@ pub fn link_replies(annotations: &mut [Annotation]) { annotations.iter().enumerate().filter(|(_, a)| a.subtype != "Popup").map(|(i, a)| ((a.page, a.index), i)).collect(); let links: Vec> = annotations.iter().enumerate().map(|(i, a)| a.parent.and_then(|p| at.get(&p).copied()).filter(|&j| j != i)).collect(); - for (i, a) in annotations.iter_mut().enumerate() { - let mut up = links.get(i).copied().flatten(); - let mut settled = false; - for _ in 0..64 { - let Some(j) = up else { break }; - up = links.get(j).copied().flatten(); - if up.is_none() { - settled = true; - break; + // Whether each annotation's chain reaches a comment of its own, filled in along each walk. + let mut settled: Vec> = vec![None; annotations.len()]; + for start in 0..annotations.len() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), start); + let ends_well = loop { + match settled.get(at).copied().flatten() { + Some(known) => break known, + None if !seen.insert(at) => break false, + None => {} + } + path.push(at); + match links.get(at).copied().flatten() { + Some(up) => at = up, + None => break true, + } + }; + for i in path { + if let Some(s) = settled.get_mut(i) { + *s = Some(ends_well); } } - if !settled { + } + // A reply keeps its parent when it has a listed one and its chain reaches a comment; one whose + // `/IRT` leads nowhere listed (itself, an unlisted object) is a comment of its own. + for ((a, ok), link) in annotations.iter_mut().zip(settled).zip(links) { + if link.is_none() || ok != Some(true) { a.parent = None; } } } /// The comment each reply's thread starts from, by the reply's page and `/Annots` position: a -/// reply to a reply files under that comment too. Expects links [`link_replies`] settled. +/// reply to a reply files under that comment too. Expects links [`link_replies`] settled; a loop +/// in an unsettled list ends where it comes back round. pub fn thread_tops(annotations: &[Annotation]) -> HashMap<(usize, usize), (usize, usize)> { let parents: HashMap<(usize, usize), (usize, usize)> = annotations.iter().filter_map(|a| Some(((a.page, a.index), a.parent?))).collect(); - parents - .iter() - .map(|(&reply, &first)| { - let mut top = first; - for _ in 0..64 { - let Some(&up) = parents.get(&top) else { break }; - top = up; + let mut tops: HashMap<(usize, usize), (usize, usize)> = HashMap::new(); + for &reply in parents.keys() { + let (mut path, mut seen, mut at) = (Vec::new(), HashSet::new(), reply); + let top = loop { + if let Some(&known) = tops.get(&at) { + break known; } - (reply, top) - }) - .collect() + let Some(&up) = parents.get(&at) else { break at }; + if !seen.insert(at) { + break up; + } + path.push(at); + at = up; + }; + for r in path { + tops.insert(r, top); + } + } + tops +} + +/// The status and checkmark replies of a document's annotations, by the annotation whose state +/// each one sets, found by object reference (the settled `parent` links of [`link_replies`]). +/// +/// ISO 32000-2 §12.5.6.3: a state change is a text annotation whose `/IRT` "shall refer to the +/// original annotation" and whose `/State` and `/StateModel` "update the state of the original +/// annotation for the specified user" (`/T`); "Additional state changes shall be made by adding +/// text annotations in reply to the previous reply for a given user". So a state reply sets the +/// state of the first annotation up its `/IRT` chain that is not itself a state reply: the +/// comment, or the reply the state was set on (a reply is an annotation with a state of its own, +/// and is never folded into its thread's comment). There is one status per reviewer, and a +/// checkmark only for the reviewer who set it. +pub struct ReviewStates<'a> { + by_target: HashMap<(usize, usize), Vec<&'a Annotation>>, +} + +impl<'a> ReviewStates<'a> { + pub fn new(all: &'a [Annotation]) -> Self { + let at: HashMap<(usize, usize), &Annotation> = all.iter().filter(|a| a.subtype != "Popup").map(|a| ((a.page, a.index), a)).collect(); + // The annotation each state reply's chain reaches past other state replies, `None` for a + // loop or a reference to nothing listed; filled in along each walk, so a long history + // costs one walk, not one per reply. + let mut reaches: HashMap<(usize, usize), Option<(usize, usize)>> = HashMap::new(); + let mut by_target: HashMap<(usize, usize), Vec<&Annotation>> = HashMap::new(); + for r in all.iter().filter(|a| a.state.is_some()) { + let (mut path, mut seen, mut up) = (vec![(r.page, r.index)], HashSet::from([(r.page, r.index)]), r.parent); + let target = loop { + let Some(key) = up else { break None }; + if let Some(&known) = reaches.get(&key) { + break known; + } + match at.get(&key) { + None => break None, + Some(p) if p.state.is_none() => break Some(key), + Some(_) if !seen.insert(key) => break None, + Some(p) => { + path.push(key); + up = p.parent; + } + } + }; + for key in path { + reaches.insert(key, target); + } + if let Some(t) = target { + by_target.entry(t).or_default().push(r); + } + } + Self { by_target } + } + + /// Every status and checkmark reply about `target`, in document order. + pub fn history(&self, target: &Annotation) -> &[&'a Annotation] { + self.by_target.get(&(target.page, target.index)).map(Vec::as_slice).unwrap_or(&[]) + } + + /// Each reviewer's review status on `target`: their latest, in the order they first set one. + /// Empty when nobody set one; a status set back to "None" stays listed, since None is a state + /// (ISO 32000-2 Table 174: "The user has indicated nothing about the change"). + pub fn statuses(&self, target: &Annotation) -> Vec<(&'a str, &'a str)> { + self.by_reviewer(target, false).into_iter().filter_map(|(who, mine)| Some((who, latest(&mine)?.state.as_deref()?))).collect() + } + + /// The latest review status set on `target` by anyone, for callers that show one. + pub fn latest_status(&self, target: &Annotation) -> Option<&'a str> { + self.history(target).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()) + } + + /// Whether `author`'s latest checkmark on `target` is "Marked". Checkmarks are each + /// reviewer's own (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + pub fn marked_by(&self, target: &Annotation, author: &str) -> bool { + let mine: Vec<&Annotation> = self.history(target).iter().copied().filter(|r| r.is_mark() && reviewer(r) == author.trim()).collect(); + latest(&mine).is_some_and(|r| r.state.as_deref() == Some("Marked")) + } + + /// The reviewers whose latest checkmark on `target` is "Marked". + pub fn marked(&self, target: &Annotation) -> Vec<&'a str> { + self.by_reviewer(target, true) + .into_iter() + .filter(|(_, mine)| latest(mine).is_some_and(|r| r.state.as_deref() == Some("Marked"))) + .map(|(who, _)| who) + .collect() + } + + /// The checkmark (`marks`) or status replies about `target`, by reviewer, in the order each + /// reviewer first set one. + fn by_reviewer(&self, target: &Annotation, marks: bool) -> Vec<(&'a str, Vec<&'a Annotation>)> { + let mut out: Vec<(&str, Vec<&Annotation>)> = Vec::new(); + for &r in self.history(target).iter().filter(|r| r.is_mark() == marks) { + match out.iter_mut().find(|(who, _)| *who == reviewer(r)) { + Some((_, mine)) => mine.push(r), + None => out.push((reviewer(r), vec![r])), + } + } + out + } +} + +/// Of one reviewer's replies in one state model, the latest: the end of their chain, a reply none +/// of the others replies to (by reference), whatever order the file lists them in. Among several +/// such (changes that don't chain), the last listed. +fn latest<'b>(mine: &[&'b Annotation]) -> Option<&'b Annotation> { + let answered: HashSet<(usize, usize)> = mine.iter().filter_map(|r| r.parent).collect(); + mine.iter().rev().find(|r| !answered.contains(&(r.page, r.index))).copied() +} + +/// A reply's state: `/State`, or for a reply that gives only `/StateModel` the model's default +/// (ISO 32000-2 Table 175: "Unmarked if StateModel is Marked; None if StateModel is Review"). +pub(super) fn state_or_default(state: Option, model: Option<&str>) -> Option { + state.or_else(|| match model? { + "Marked" => Some("Unmarked".into()), + "Review" => Some("None".into()), + _ => None, + }) +} + +/// Who set a state: `/T`, trimmed (an author setting typed with spaces is the same reviewer). +fn reviewer(r: &Annotation) -> &str { + r.author.as_deref().unwrap_or("").trim() } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -542,3 +687,257 @@ pub struct FontInfo { /// the document's own glyphs are being shown. pub substitution: Option, } + +/// The review states and reply links of synthetic annotation lists. +#[cfg(test)] +mod review_state_tests { + use super::{Annotation, ReviewStates, link_replies, thread_tops}; + + /// One annotation of a synthetic page: `key` is its `/NM`, and `irt` the key of the + /// annotation its `/IRT` references, resolved by position as the readers resolve references. + type Spec<'s> = (&'s str, Option<&'s str>, &'s str, Option<&'s str>); + + /// The annotations of `specs` on page 0, in order, with their reply links settled as the + /// readers settle them. An `irt` that names no key references nothing listed. + fn listed(specs: &[Spec<'_>]) -> Vec { + let mut all: Vec = specs + .iter() + .enumerate() + .map(|(index, &(nm, irt, author, state))| Annotation { + page: 0, + subtype: "Text".into(), + author: Some(author.into()), + contents: None, + modified: None, + name: Some(nm.into()), + in_reply_to: irt.map(Into::into), + parent: irt.map(|k| (0, specs.iter().position(|s| s.0 == k).unwrap_or(usize::MAX))), + grouped: false, + rect: [0.0; 4], + color: None, + index, + state: state.map(Into::into), + quads: Vec::new(), + locked: false, + intent: None, + fill_sign: false, + no_rotate: false, + points: Vec::new(), + }) + .collect(); + link_replies(&mut all); + all + } + + fn by_name<'a>(all: &'a [Annotation], nm: &str) -> &'a Annotation { + all.iter().find(|a| a.name.as_deref() == Some(nm)).expect("listed") + } + + /// The states these replies set (ISO 32000-2 §12.5.6.3, with synthetic files): one status per + /// reviewer, their latest, whether or not their changes chain; "None" stays listed; a + /// checkmark only for the reviewer who set it. + #[test] + fn review_states_are_each_reviewers_latest_through_chains() { + let all = listed(&[ + ("c", None, "Alice", None), + ("b1", Some("c"), "Bob", Some("Accepted")), + ("k1", Some("c"), "Carol", Some("Completed")), + ("b2", Some("b1"), "Bob", Some("Rejected")), // chained, per §12.5.6.3 + ("r", Some("c"), "Dan", None), // a reply, not a state + ("rs", Some("r"), "Dan", Some("Cancelled")), // a state on the reply, not on c + ("m1", Some("c"), "Bob", Some("Marked")), + ("m2", Some("m1"), "Bob", Some("Unmarked")), + ("m3", Some("c"), "Carol", Some("Marked")), + ("n", None, "Alice", None), + ("n1", Some("n"), "Bob", Some("Completed")), + ("n2", Some("n"), "Bob", Some("None")), // unchained, as PdfCraft wrote it before + // A loop of state replies belongs to nothing. + ("x", Some("y"), "Eve", Some("Accepted")), + ("y", Some("x"), "Eve", Some("Accepted")), + ]); + let states = ReviewStates::new(&all); + let (c, r, n, x) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "n"), by_name(&all, "x")); + assert_eq!(states.statuses(c), [("Bob", "Rejected"), ("Carol", "Completed")]); + assert_eq!(states.latest_status(c), Some("Rejected")); + assert_eq!(states.history(c).len(), 6, "every change, the checkmarks included"); + assert_eq!(states.statuses(r), [("Dan", "Cancelled")]); + assert!(!states.marked_by(c, "Bob"), "Bob took his off"); + assert!(states.marked_by(c, "Carol")); + assert!(!states.marked_by(c, "Alice"), "nobody else's checkmark shows"); + assert_eq!(states.marked(c), ["Carol"]); + assert_eq!(states.statuses(n), [("Bob", "None")], "a status set back to None stays listed"); + assert!(states.statuses(x).is_empty() && states.history(x).is_empty()); + } + + /// §12.5.6.3: a state reply's `/IRT` "shall refer to the original annotation", and a reply is + /// an annotation of its own. A status set on a reply, and the changes that chain from it, are + /// that reply's and never its thread's comment's, at any depth of the thread. + #[test] + fn a_status_on_a_reply_and_its_chain_stay_the_replys() { + let all = listed(&[ + ("c", None, "Alice", None), + ("r", Some("c"), "Bob", None), + ("rr", Some("r"), "Carol", None), + ("s1", Some("rr"), "Dan", Some("Accepted")), + ("s2", Some("s1"), "Dan", Some("Rejected")), + ("e1", Some("s2"), "Eve", Some("Completed")), // Eve answering Dan's state reply + ("m1", Some("r"), "Dan", Some("Marked")), + ]); + let states = ReviewStates::new(&all); + let (c, r, rr) = (by_name(&all, "c"), by_name(&all, "r"), by_name(&all, "rr")); + assert!(states.history(c).is_empty() && states.statuses(c).is_empty(), "not folded to the comment"); + assert!(states.statuses(r).is_empty() && states.marked_by(r, "Dan")); + assert_eq!(states.statuses(rr), [("Dan", "Rejected"), ("Eve", "Completed")]); + assert!(states.history(by_name(&all, "s1")).is_empty(), "a state reply has no states of its own"); + } + + /// States follow object references, not `/NM`: two comments with the same `/NM` (some writers + /// name the first comment on every page alike) each keep their own statuses. + #[test] + fn states_follow_references_not_repeated_names() { + let mut all = listed(&[ + ("a", None, "Alice", None), + ("b", None, "Alice", None), + ("sa", Some("a"), "Bob", Some("Accepted")), + ("sb", Some("b"), "Bob", Some("Rejected")), + ("sb2", Some("sb"), "Bob", Some("Completed")), + ]); + for a in all.iter_mut().take(2) { + a.name = Some("dup".into()); + } + for s in all.iter_mut().skip(2) { + if s.in_reply_to.as_deref().is_some_and(|k| k == "a" || k == "b") { + s.in_reply_to = Some("dup".into()); + } + } + let states = ReviewStates::new(&all); + assert_eq!(states.statuses(&all[0]), [("Bob", "Accepted")]); + assert_eq!(states.statuses(&all[1]), [("Bob", "Completed")]); + } + + /// A state reply whose `/IRT` references nothing listed, or nothing at all, sets no + /// annotation's state, and a loop that never reaches an annotation without a state belongs to + /// nothing even in a list whose links were not settled. + #[test] + fn a_state_reply_without_a_listed_target_belongs_to_nothing() { + let all = listed(&[ + ("c", None, "Alice", None), + ("gone", Some("deleted"), "Bob", Some("Accepted")), + ("bare", None, "Bob", Some("Rejected")), + ("next", Some("gone"), "Bob", Some("Completed")), + ]); + let states = ReviewStates::new(&all); + assert!(all.iter().all(|a| states.history(a).is_empty())); + // Unsettled: the loop's links are still in place. + let mut raw = listed(&[("c", None, "Alice", None)]); + let mut x = raw[0].clone(); + (x.index, x.name, x.state, x.parent) = (1, Some("x".into()), Some("Accepted".into()), Some((0, 2))); + let mut y = x.clone(); + (y.index, y.name, y.parent) = (2, Some("y".into()), Some((0, 1))); + raw.extend([x, y]); + let states = ReviewStates::new(&raw); + assert!(raw.iter().all(|a| states.history(a).is_empty())); + } + + /// A long reply chain, as a reviewer's long status history makes (§12.5.6.3: each change + /// replies to the previous), settles, and its replies file under the comment; a loop, an `/IRT` + /// to itself and one to nothing listed leave an annotation a comment of its own. + #[test] + fn reply_links_settle_at_any_depth_but_not_in_a_loop() { + let keys: Vec = (0..200).map(|i| format!("r{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + specs.push((k, Some(if i == 0 { "c" } else { keys[i - 1].as_str() }), "Bob", None)); + } + specs.extend([ + ("x", Some("y"), "Eve", None), + ("y", Some("x"), "Eve", None), + ("z", Some("x"), "Eve", None), + ("me", Some("me"), "Eve", None), + ("lost", Some("unlisted"), "Eve", None), + ]); + // In file order and newest first: the first reply read then walks the whole chain at once. + for newest_first in [false, true] { + if newest_first { + specs.reverse(); + } + let all = listed(&specs); + let replies: Vec<&Annotation> = all.iter().filter(|a| a.name.as_deref().is_some_and(|n| n.starts_with('r'))).collect(); + assert!(replies.len() == 200 && replies.iter().all(|a| a.is_reply()), "{newest_first}"); + let nowhere = all.iter().filter(|a| matches!(a.name.as_deref(), Some("x" | "y" | "z" | "me" | "lost"))); + assert!(nowhere.clone().count() == 5 && nowhere.clone().all(|a| !a.is_reply()), "a loop, a chain into one, itself or nothing listed"); + let c = by_name(&all, "c"); + let tops = thread_tops(&all); + assert_eq!(tops.len(), 200, "{newest_first}"); + assert!(tops.values().all(|&t| t == (c.page, c.index)), "{newest_first}"); + } + } + + /// Review on #4126: a history has no length limit, and a reviewer's name is compared without + /// surrounding spaces. + #[test] + fn a_long_chain_still_reaches_its_comment_and_names_are_trimmed() { + let keys: Vec = (0..200).map(|i| format!("s{i}")).collect(); + let mut specs: Vec> = vec![("c", None, "Alice", None)]; + for (i, k) in keys.iter().enumerate() { + let parent = if i == 0 { "c" } else { keys[i - 1].as_str() }; + specs.push((k, Some(parent), "Bob", Some(if i == 199 { "Completed" } else { "Accepted" }))); + } + specs.push(("m", Some("c"), " Carol ", Some("Marked"))); + let all = listed(&specs); + let states = ReviewStates::new(&all); + assert_eq!(states.history(&all[0]).len(), 201); + assert_eq!(states.statuses(&all[0]), [("Bob", "Completed")]); + assert!(states.marked_by(&all[0], "Carol") && states.marked_by(&all[0], "Carol ")); + assert_eq!(states.marked(&all[0]), ["Carol"]); + // Listed newest first, the first reply read walks the whole chain at once. + specs.reverse(); + let all = listed(&specs); + let states = ReviewStates::new(&all); + let c = by_name(&all, "c"); + assert_eq!(states.statuses(c), [("Bob", "Completed")]); + assert_eq!(states.history(c).len(), 201); + } + + /// The inspector reads a `/StateModel`-only reply as the model's default state, so it counts + /// as Bob's status (None) on the note. + #[test] + fn the_inspector_reads_a_state_model_alone_as_its_default() { + let objs: Vec<&[u8]> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Annots [4 0 R 5 0 R] >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (note) /T (Ann) >>", + b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /NM (bare) /T (Bob) /IRT 4 0 R /F 30 /StateModel (Review) >>", + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(o); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let info = crate::inspect(std::sync::Arc::new(out), None).expect("inspects"); + let note = by_name(&info.annotations, "note"); + assert_eq!(ReviewStates::new(&info.annotations).statuses(note), [("Bob", "None")]); + } + + /// ISO 32000-2 Table 175: a missing `/State` is "Unmarked if StateModel is Marked; None if + /// StateModel is Review". + #[test] + fn a_state_model_alone_gives_its_default_state() { + use super::state_or_default; + assert_eq!(state_or_default(None, Some("Review")).as_deref(), Some("None")); + assert_eq!(state_or_default(None, Some("Marked")).as_deref(), Some("Unmarked")); + assert_eq!(state_or_default(None, Some("Other")), None); + assert_eq!(state_or_default(None, None), None); + assert_eq!(state_or_default(Some("Accepted".into()), Some("Review")).as_deref(), Some("Accepted")); + } +} diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index fb19526875..4c6e88400e 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -79,6 +79,7 @@ impl<'a> Inspector<'a> { seen: &mut HashSet<*const Dictionary>, depth: u32, out: &mut HashMap, &'a Object>, + key_budget: &mut usize, ) { if depth > 32 || !seen.insert(node as *const _) { return; @@ -87,18 +88,25 @@ impl<'a> Inspector<'a> { for pair in pairs.chunks(2) { if let [k, v] = pair { let key = match self.resolve(k) { - Object::String(s, _) => s.clone(), - Object::Name(n) => n.clone(), + Object::String(s, _) => s, + Object::Name(n) => n, _ => continue, }; - out.entry(key).or_insert(v); + // Every key is hashed, so its bytes are spent from one allowance for the whole + // tree (a key repeated by reference is charged each time); one past it is + // not indexed. Only a first occurrence is copied. + let Some(left) = key_budget.checked_sub(key.len()) else { continue }; + *key_budget = left; + if !out.contains_key(key.as_slice()) { + out.insert(key.clone(), v); + } } } } if let Ok(Object::Array(kids)) = node.get(b"Kids").map(|o| self.resolve(o)) { for k in kids { if let Some(d) = self.dict(k) { - self.name_tree_raw(d, seen, depth + 1, out); + self.name_tree_raw(d, seen, depth + 1, out, key_budget); } } } @@ -559,4 +567,17 @@ mod open_dest_tests { let shallow = ["<< /Kids [7 0 R] >>".to_string(), "<< /Kids [8 0 R] >>".into(), "<< /Names [(x) [4 0 R /Fit]] >>".into()]; assert_eq!(open_dest("/OpenAction (x) /Names << /Dests 6 0 R >>", &shallow), Some((1, DestView::Fit))); } + + #[test] + fn a_key_repeated_by_reference_is_charged_each_time_against_the_name_tree_allowance() { + let big = format!("<{}>", "41".repeat(1 << 20)); + let tree = |repeats: usize| { + let names = format!("{}(x) [4 0 R /Fit]", "7 0 R [4 0 R /Fit] ".repeat(repeats)); + [format!("<< /Names [{names}] >>"), big.clone()] + }; + let catalog = "/OpenAction (x) /Names << /Dests 6 0 R >>"; + assert_eq!(open_dest(catalog, &tree(2)), Some((1, DestView::Fit))); + // 17 MiB of repeats of one reference spend the 16 MiB allowance before the real key. + assert_eq!(open_dest(catalog, &tree(17)), None); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..245f3a53f3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,7 +19,8 @@ pub mod transition; pub use inspect::{ Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, - PageTransition, RemoteDestination, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, thread_tops, + PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect, link_replies, pretty_date, + thread_tops, }; pub use pixels::Pixels; pub use raster::{ diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 679e42cd5e..e42ed7c1cc 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5778,6 +5778,28 @@ fn content_bombs_stop_at_the_budget_and_say_so() { assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); } +/// The text of a cut page says so too (#623): what was extracted is incomplete, and a page +/// read whole is not marked. +#[test] +fn cut_content_marks_the_pages_text_incomplete() { + let text_of = |pdf: Vec| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + rx.recv_timeout(std::time::Duration::from_secs(60)).expect("text extraction must not stall") + }; + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let page = text_of(one_page_pdf(&bomb, "/Filter /FlateDecode")); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.text.as_ref().is_some_and(|t| t.truncated), "the text says it is incomplete"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "and the page says so"); + let page = text_of(one_page_pdf(b"1 0 0 rg 0 0 4 4 re f", "")); + assert!(page.text.as_ref().is_some_and(|t| !t.truncated), "a page read whole is not marked"); + assert!(page.warnings.is_empty()); +} + /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in /// a `/Contents` array a stream that is not Flate-coded, between two that are. #[test] @@ -10507,6 +10529,101 @@ fn content_after_an_inline_image_holding_ei_is_still_read() { assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); } +/// #5631: a reply of type R (`/IRT`, no `/RT` or `/RT /R`) is not drawn on the page, whatever +/// its appearance: ISO 32000-2 Table 172, "shall not display replies to an annotation +/// individually". The comment it answers draws, and so does a `/RT /Group` member. +#[test] +fn replies_are_not_drawn_but_their_comment_and_group_members_are() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // A reply with no /RT, and one with /RT /R, to it and to that reply: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 5 0 R /RT /R /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A member of the comment's group: green. + "<< /Type /Annot /Subtype /Square /F 4 /IRT 4 0 R /RT /Group /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // The middle of each annotation, top-left based at scale 1. + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, reply, reply_r, member) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(reply.iter().all(|v| *v > 240), "a reply with no /RT is not drawn: {reply:?}"); + assert!(reply_r.iter().all(|v| *v > 240), "a reply with /RT /R is not drawn: {reply_r:?}"); + assert!(member[1] > 200 && member[0] < 60, "a group member draws: {member:?}"); +} + +/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list reads +/// it, so a reply with an unknown name or a string there isn't drawn either. `/IRT` is a markup +/// annotation's entry: a form field carrying one still draws. +#[test] +fn an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // Replies with an unknown /RT name and with a string there: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT /Answer /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT (Group) /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A form field with a stray /IRT: green. + "<< /Type /Annot /Subtype /Widget /F 4 /IRT 4 0 R /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, unknown, string, widget) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(unknown.iter().all(|v| *v > 240), "a reply with an unknown /RT is not drawn: {unknown:?}"); + assert!(string.iter().all(|v| *v > 240), "a reply with a string /RT is not drawn: {string:?}"); + assert!(widget[1] > 200 && widget[0] < 60, "a form field with /IRT draws: {widget:?}"); +} + /// `/Crypt` has nothing to undo, so a chain naming it thousands of times costs no copy of the data /// for each. A chain of filters that does work has its own cost, which this isn't about. #[test] @@ -10872,19 +10989,27 @@ trailer << /Root 1 0 R >> /// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream /// leaves it, is the bytes its `/Length` gives: "how many bytes of the PDF file are used for the /// stream's data" (ISO 32000-2 7.3.8.2). Nothing found its end, so the page's content was lost and -/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36. +/// the page drew blank, while other readers draw it. The objects after it are still read: a second +/// content stream paints the right half. A `/Length` past the end of the file, or none, leaves the +/// stream unreadable and its half blank, without a panic. Vendored hayro-syntax patch 36. #[test] fn a_content_stream_without_endstream_is_drawn_to_its_length() { use super::*; - let content = "1 0 0 rg 0 0 200 200 re f"; - let centre = |end: &str| -> [u8; 4] { + const RED: [u8; 4] = [255, 0, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 255]; + const WHITE: [u8; 4] = [255, 255, 255, 255]; + let left = "1 0 0 rg 0 0 100 200 re f"; + let right = "0 0 1 rg 100 0 100 200 re f"; + // The left and right pixels of a page whose content is objects 4 and 5, with `stream_4` the + // whole of object 4 and `rest` written after the trailer. + let pixels = |stream_4: &str, rest: &str| -> [[u8; 4]; 2] { let page = format!( "%PDF-1.4\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] /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents [4 0 R 5 0 R] >> endobj\n\ + 5 0 obj << /Length {} >> stream\n{right}\nendstream\nendobj\n\ + {stream_4}trailer << /Root 1 0 R >>\n{rest}", + right.len() ); let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { page: 0, @@ -10894,15 +11019,101 @@ fn a_content_stream_without_endstream_is_drawn_to_its_length() { tag: 0, preview: false, }); - let start = (100 * page.width as usize + 100) * 4; - let mut pixel = [0; 4]; - pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); - pixel + [50, 150].map(|x| { + let start = (100 * page.width as usize + x) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("pixel")); + pixel + }) }; - assert_eq!(centre("endstream"), [255, 0, 0, 255]); + let object_4 = |length: &str, end: &str| format!("4 0 obj << {length} >> stream\n{left}\n{end}\nendobj\n"); + let length = format!("/Length {}", left.len()); + assert_eq!(pixels(&object_4(&length, "endstream"), "%%EOF"), [RED, BLUE]); for end in ["endstrea", "", "end"] { - assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}"); + assert_eq!(pixels(&object_4(&length, end), "%%EOF"), [RED, BLUE], "{end:?}"); } + // The last object in the file, which ends with its data. + let last = format!("4 0 obj << {length} >> stream\n{left}"); + assert_eq!(pixels("", &last), [RED, BLUE], "at the end of the file"); + // A /Length past the end of the file, and none: object 4 can't be read and its half stays + // blank (as does the rest of the page: a `/Contents` array with an unreadable member isn't + // drawn, as before this change). + let past = format!("4 0 obj << /Length {} >> stream\n{left}", left.len() + 1); + assert_eq!(pixels("", &past)[0], WHITE, "past the end of the file"); + assert_eq!(pixels(&object_4("", "endstrea"), "%%EOF")[0], WHITE, "no /Length"); +} + +/// The raw bytes hayro-syntax reads for object 4 of a file whose objects are `objects`. +fn hayro_stream_4(objects: &str) -> Vec { + use hayro_syntax::object::{ObjectIdentifier, Stream}; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ); + let parsed = hayro_syntax::Pdf::new(pdf.into_bytes()).unwrap(); + let stream = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).expect("object 4 is a stream"); + stream.raw_data().to_vec() +} + +/// A stream whose `endstream` is misspelt (`endstrea`) or left out before `endobj` is its +/// `/Length` in hayro-syntax too, not the bytes up to a later object's `endstream` (vendored +/// hayro-syntax patch 36). +#[test] +fn hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one() { + for end in ["endstrea\nendobj", "endobj"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}\n5 0 obj << /Length 5 >> stream\nWORLD\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), b"HELLO", "{end:?}"); + } +} + +/// A wrong `/Length` that stops before payload bytes beginning like an end marker (`endobject`, +/// `endstreak`, `endstreamer`) does not end the stream there: a token runs on through regular +/// characters (ISO 32000-2 7.2.3), and the search for `endstream` finds the whole payload, past +/// `endstreamer` too. Neither does a whole `endobj` or `endstrea` that the object does not end +/// after (`endobj more`); one followed by another object's header, by `trailer` or by the end of +/// the data still does. +#[test] +fn hayro_does_not_end_a_stream_inside_a_longer_word() { + for word in ["endobject", "endstreak", "endstreamer", "endobj more", "endstrea more", "endstrea endobj more"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO {word} more\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), format!("HELLO {word} more").as_bytes(), "{word}"); + } + for end in ["endobj\n", "endstrea\n", "endstrea\nendobj\n%c\n"] { + assert_eq!(hayro_stream_4(&format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}")), b"HELLO", "{end:?} then trailer"); + } +} + +/// A stream ending in `endstrea` is its `/Length` even when a later object has an `endstream`: +/// the search for one ran on into that object. Here the content is ASCIIHex-encoded without its +/// `>` end marker, so reading on took the next object's header as hex data, which it isn't, and +/// the page drew blank instead of red. +#[test] +fn a_content_stream_without_endstream_is_not_read_into_a_later_stream() { + use super::*; + let content: String = "1 0 0 rg 0 0 200 200 re f".bytes().map(|b| format!("{b:02X}")).collect(); + let unused = "0 1 0 rg 0 0 200 200 re f"; + let page = format!( + "%PDF-1.4\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] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} /Filter /ASCIIHexDecode >> stream\n{content}\nendstrea\nendobj\n\ + 5 0 obj << /Length {} >> stream\n{unused}\nendstream\nendobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + unused.len() + ); + let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..])); } /// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index bc132466b1..abe506ca07 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -11,7 +11,8 @@ use hayro::hayro_interpret::font::Glyph; use hayro::hayro_interpret::hayro_syntax::Pdf; use hayro::hayro_interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::{HashMap, HashSet}; @@ -100,6 +101,9 @@ pub struct PageText { pub line_of: Vec, /// `true` when a word break precedes the glyph on the same line. pub space_before: Vec, + /// The page's content was cut at the renderer's limits (#623), so this text is incomplete: + /// whatever the cut part drew is missing, and a search over it can miss. + pub truncated: bool, } impl PageText { @@ -315,13 +319,21 @@ impl PageText { /// turned by `/Rotate`) rows mean nothing, and the lines come out one per row in reading order /// instead. pub fn column_text(&self, glyphs: &[usize]) -> String { - // The selected glyphs of each line, with their box: (glyphs, bbox). + // The selected glyphs of each line, with their box: (glyphs, bbox). A line is cut where a gap wider than one run + // of text can hold separates two glyphs (`arrange` joins runs on one baseline into a line, as table cells are), so + // cells keep their tab. let mut lines: Vec<(Vec, [f32; 4])> = Vec::new(); let mut line_ids: Vec = Vec::new(); + let mut previous: Option<&TextGlyph> = None; for &i in glyphs { let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue }; + let apart = previous.is_some_and(|p| { + let h = (p.rect[3] - p.rect[1]).max(g.rect[3] - g.rect[1]); + g.rect[0] - p.rect[2] >= CELL_GAP * h + }); + previous = Some(g); match (lines.last_mut(), line_ids.last()) { - (Some((members, bb)), Some(&l)) if l == line => { + (Some((members, bb)), Some(&l)) if l == line && !apart => { members.push(i); union(bb, &g.rect); } @@ -386,9 +398,9 @@ mod device; mod layout; mod order; -use device::WORD_GAP; +use device::{CELL_GAP, WORD_GAP}; #[cfg(test)] -use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice}; +use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice, ligature_letters}; pub(crate) use device::{extract_page, extract_page_filtered}; pub use layout::layout; #[cfg(test)] diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index a04e380788..1a6d1aadb5 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,7 +1,7 @@ -use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; +use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, union, unrolled}; use super::{ - Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, - Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, + InterpreterWarning, PageText, Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, }; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be @@ -13,6 +13,10 @@ pub(super) const DIRECTION_TOLERANCE: f64 = WORD_GAP as f64; /// A word gap is wider than the line's typical letter gap by this fraction of the line height. pub(super) const WORD_GAP: f32 = 0.15; +/// A gap of this many heights between two glyphs of a line is wider than one run of text holds (`arrange` continues +/// a run across less than three heights), so it separates cells that share a baseline. +pub(super) const CELL_GAP: f32 = 3.0; + /// A glyph with its writing direction to the nearest quarter turn (right, down, left, up) and as /// a unit vector. pub(super) type Drawn = (usize, [f64; 2], TextGlyph); @@ -397,6 +401,35 @@ impl TextDevice { } } +/// `text` with each Latin compatibility ligature (U+FB00–U+FB06: ff, fi, fl, ffi, ffl, long s t, +/// st) written as the letters it stands for, their compatibility decomposition. A font whose +/// encoding names a ligature glyph (`/fi`) reads as the ligature character, and copied or searched +/// text wants the letters, as a ToUnicode map gives them (ISO 32000-2 §9.10.3 maps the ff, fi and +/// ffl glyphs to "LATIN SMALL LETTER F … I"). Nothing else is normalised. The glyph keeps its one +/// box: the PDF gives no position for the letters inside it. +pub(super) fn ligature_letters(text: String) -> String { + let letters = |c: char| match c { + '\u{FB00}' => Some("ff"), + '\u{FB01}' => Some("fi"), + '\u{FB02}' => Some("fl"), + '\u{FB03}' => Some("ffi"), + '\u{FB04}' => Some("ffl"), + '\u{FB05}' | '\u{FB06}' => Some("st"), + _ => None, + }; + if !text.chars().any(|c| letters(c).is_some()) { + return text; + } + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match letters(c) { + Some(s) => out.push_str(s), + None => out.push(c), + } + } + out +} + /// Whether `PDFCRAFT_TEXT_DEBUG` is set. `draw_glyph` runs once per glyph and `getenv` scans the /// whole environment under a lock, so read it once. fn text_debug() -> bool { @@ -450,6 +483,7 @@ impl<'a> Device<'a> for TextDevice { if !replaced && text.chars().all(char::is_control) { return; } + let text = ligature_letters(text); // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em // box spans descender (−200) to ascender (800), the advance gives the width. let t = transform * glyph_transform; @@ -573,7 +607,22 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, // as in Acrobat, and match what other extractors report. - let settings = settings.clone(); + // The interpreter says when the content was cut at its limits (#623): the text is then + // marked incomplete, and the caller's own sink still hears it. + let cut = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let settings = { + let flag = cut.clone(); + let inner = settings.warning_sink.clone(); + InterpreterSettings { + warning_sink: std::sync::Arc::new(move |warning| { + if matches!(warning, InterpreterWarning::ContentTruncated) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + inner(warning); + }), + ..settings.clone() + } + }; let initial = p.initial_transform(true).to_kurbo(); let c = p.intersected_crop_box(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings); @@ -638,7 +687,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret flows.sort_by(|a, b| a.top.total_cmp(&b.top)); flows }; - let flows = upright(quarters); + let mut flows = upright(quarters); + let (main, mut inline, place) = turned_lines_in_place(&glyphs, &mut flows); let mut extra: Vec = curves .into_iter() .map(|ids| (None, ids)) @@ -684,7 +734,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret text.space_before.push(space); } }; - for flow in flows { + let placed = |ids: &[usize]| ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX); + for (fi, flow) in flows.into_iter().enumerate() { let mut k = 0; while k < flow.ids.len() { let line = flow.line_of[k]; @@ -693,12 +744,99 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret while let Some(f) = extra.next_if(|f| first(&f.ids) < before) { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + if Some(fi) == main { + let at = placed(&flow.ids[k..end]); + while let Some(l) = inline.next_if(|l| l.at < at) { + emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter); + } + } emit(&mut text, &flow.ids[k..end], &vec![0; end - k], &flow.space_before[k..end], flow.quarter); k = end; } + if Some(fi) == main { + for l in inline.by_ref() { + emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter); + } + } } for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + text.truncated = cut.load(std::sync::atomic::Ordering::Relaxed); Ok(text) } + +/// A line turned a quarter from the page's main text, read in place among it. +struct InPlace { + /// Where laying it out with the main text puts its first glyph. + at: usize, + ids: Vec, + space_before: Vec, + /// The quarter turn of the flow it came from. + quarter: Option, +} + +/// Takes out of `flows` the lines that are turned a quarter from the page's main text (the unturned flow, when it +/// holds more glyphs than any other) and sit inside that text's area: a label set upwards beside a row, a turned +/// table heading. Each is placed where laying it out among the main text, by its box on the page, puts its first +/// glyph, rather than after all the main text. Lines outside the area (a stamp in the margin) and every other flow +/// keep their place, as does a page whose main text is vertical. +/// +/// Returns the main flow's index, the lines in place order, and each glyph's place in that layout. +fn turned_lines_in_place(glyphs: &[Drawn], flows: &mut [Flow]) -> (Option, std::iter::Peekable>, Vec) { + let nothing = || (None, Vec::new().into_iter().peekable(), Vec::new()); + let Some((main, size)) = flows.iter().enumerate().find(|(_, f)| f.quarter == Some(0)).map(|(i, f)| (i, f.ids.len())) else { return nothing() }; + if flows.iter().enumerate().any(|(i, f)| i != main && f.ids.len() >= size) { + return nothing(); + } + let rect = |i: usize| glyphs.get(i).map_or([0.0; 4], |g| g.2.rect); + let bounds = |ids: &[usize]| { + let mut b = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for &i in ids { + union(&mut b, &rect(i)); + } + b + }; + let Some(area) = flows.get(main).map(|f| bounds(&f.ids)) else { return nothing() }; + let mut lines = Vec::new(); + for (fi, flow) in flows.iter_mut().enumerate() { + // Upside-down text (a half turn) is not turned a quarter, and keeps its own flow. + if fi == main || !matches!(flow.quarter, Some(1 | 3)) { + continue; + } + let (mut ids, mut line_of, mut space_before) = (Vec::new(), Vec::new(), Vec::new()); + let mut k = 0; + while let Some(&line) = flow.line_of.get(k) { + let end = flow.line_of.get(k..).and_then(|rest| rest.iter().position(|l| *l != line)).map_or(flow.line_of.len(), |n| k + n); + let (Some(line_ids), Some(line_spaces)) = (flow.ids.get(k..end), flow.space_before.get(k..end)) else { break }; + let b = bounds(line_ids); + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + if (area[0]..=area[2]).contains(&cx) && (area[1]..=area[3]).contains(&cy) { + lines.push(InPlace { at: usize::MAX, ids: line_ids.to_vec(), space_before: line_spaces.to_vec(), quarter: flow.quarter }); + } else { + ids.extend_from_slice(line_ids); + line_of.extend(std::iter::repeat_n(line, line_ids.len())); + space_before.extend_from_slice(line_spaces); + } + k = end; + } + (flow.ids, flow.line_of, flow.space_before) = (ids, line_of, space_before); + } + if lines.is_empty() { + return nothing(); + } + let ids: Vec = + flows.get(main).map_or(&[][..], |f| &f.ids[..]).iter().copied().chain(lines.iter().flat_map(|l| l.ids.iter().copied())).collect(); + let rects = ids.iter().map(|&i| rect(i)).collect(); + let mut place = vec![usize::MAX; glyphs.len()]; + for (r, i) in arrange_flow(glyphs, ids, rects, Some(0)).ids.into_iter().enumerate() { + if let Some(p) = place.get_mut(i) { + *p = r; + } + } + for l in &mut lines { + l.at = l.ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX); + } + lines.sort_by_key(|l| l.at); + (Some(main), lines.into_iter().peekable(), place) +} diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index e147cda84d..334599318b 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,7 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{ - NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, reverse_rtl, + NONE, REDRAW_CANDIDATES, Run, SAME_LINE_GAP, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, + reverse_rtl, same_line, }; use super::{HashMap, PageText, TextGlyph}; @@ -183,13 +184,17 @@ pub(super) fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// (text on a baseline grid), it counts as a row and the top-most rule applies, so the column /// order of #3658 is lost there. Telling prose from a row in that case needs more evidence. /// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the -/// text comes out in logical order. +/// text comes out in logical order. A block whose first line sits on the baseline of the line read +/// just before it, to its right and less than [`SAME_LINE_GAP`] heights away, continues that line +/// after a space (a label and its value, words drawn out of order or in another size). A block of +/// several lines does so only after a one-line block narrower than [`SAME_LINE_GAP`] heights (a +/// label): beside a wider one, or the last line of a column, it is the next column. pub fn layout(glyphs: Vec) -> PageText { let (order, line_of, space_before) = arrange(&glyphs); let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); // Each glyph is emitted exactly once. let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); - PageText { glyphs, line_of, space_before } + PageText { glyphs, line_of, space_before, truncated: false } } /// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. @@ -456,8 +461,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { // `spaces[w]`: a word gap left of the w-th glyph of the segment, by position on the line, not by drawing order // (#1062). Reused by every segment. let mut spaces: Vec = Vec::new(); + // The segment emitted last, whether it had right-to-left text, and whether its block is that one line. + let mut last: Option<(usize, bool, bool)> = None; for b in block_order { - for &si in &blocks[b].0 { + for (k, &si) in blocks[b].0.iter().enumerate() { place_glyphs(&glyphs, &height, &segs[si], &mut sc); let h = segs[si].h; measure_cluster_gaps(&glyphs, &mut sc); @@ -501,7 +508,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let tight_cluster = (loose_sign(pt) || loose_sign(ct)) && gap < h * 0.6; let run_start = sc.breaks.binary_search(&c).is_ok(); if let Some(space) = spaces.get_mut(w + 1) { - *space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty(); + *space = (gap > threshold || run_start) + && !cjk + && !tight_cluster + && !pt.trim().is_empty() + && !ct.trim().is_empty() + && !pt.ends_with(char::is_whitespace) + && !ct.starts_with(char::is_whitespace); } } // Right-to-left spans are reversed (visual → logical) with their gaps, and only then is a second copy made. @@ -512,13 +525,51 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { reverse_rtl(&glyphs, &sc.anchor, &mut sc.logical, &mut spaces); } let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed }; + let joined = joins_line(&segs, last, si, k == 0, blocks[b].0.len() > 1, has_rtl); + let start = order.len(); order.extend_from_slice(idx); - line_of.resize(order.len(), line); - // `spaces[0]` stays false: the first glyph of a segment never takes a space. + // `spaces[0]` stays false: the first glyph of a segment never takes a space, unless it joins a line. space_before.extend(spaces.iter().take(idx.len())); space_before.resize(order.len(), false); - line += 1; + line = put_on_line(&mut line_of, &mut space_before, start..order.len(), line, &segs[si], joined); + last = Some((si, has_rtl, blocks[b].0.len() == 1)); } } (order.into_iter().map(|i| kept[i]).collect(), line_of, space_before) } + +/// Puts the reading-order glyphs `glyphs` of segment `seg` on their line: `line`, or the line +/// before it when `seg` continues the line of `joined` (see [`joins_line`]), with a space before +/// its first glyph when the two are a word gap apart. Returns the line the next segment starts on. +fn put_on_line(line_of: &mut Vec, space_before: &mut [bool], glyphs: std::ops::Range, line: u32, seg: &Seg, joined: Option<&Seg>) -> u32 { + let Some(p) = joined else { + line_of.resize(glyphs.end, line); + return line.saturating_add(1); + }; + line_of.resize(glyphs.end, line.saturating_sub(1)); + if let Some(space) = space_before.get_mut(glyphs.start) { + *space = seg.bbox[0] - p.bbox[2] > WORD_GAP * p.h.min(seg.h); + } + line +} + +/// The segment read just before (`last`, with whether it had right-to-left text) whose line segment +/// `si` goes on, when `si` is the first line of its block (`first`) and sits on that line's baseline +/// to its right (a label and its value, a word drawn out of order or set in another size). +/// Same-baseline segments are always in different blocks. A column of several lines is read after +/// the column to its left ends, so their lines don't meet. A paragraph of several lines that starts +/// beside a line as wide as [`SAME_LINE_GAP`] heights or more is the next column, not that line's +/// value, so it starts a line of its own; only a narrower line that is a block of its own (a label) +/// takes it: the short last line of a column of several lines is no label (review on #4468). One-line +/// paragraphs of two columns that share a baseline cannot be told from a table row, and are further +/// apart than [`same_line`] allows unless the left one fills its column. Right-to-left lines are +/// left alone: their blocks still read left first. +fn joins_line(segs: &[Seg], last: Option<(usize, bool, bool)>, si: usize, first: bool, paragraph: bool, has_rtl: bool) -> Option<&Seg> { + let (p, p_rtl, p_alone) = last?; + if !first || p_rtl || has_rtl { + return None; + } + let (p, s) = (segs.get(p)?, segs.get(si)?); + let label = p_alone && p.bbox[2] - p.bbox[0] < SAME_LINE_GAP * p.h; + (same_line(p, s) && (!paragraph || label)).then_some(p) +} diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index c0ff03a706..ab88f76d76 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,4 +1,5 @@ use super::TextGlyph; +use super::device::WORD_GAP; use super::layout::is_rtl; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. @@ -765,3 +766,19 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [us } } } + +/// Most space between two runs on one baseline that still reads as one line, in heights of the +/// taller run: a label and its value or table cells, and not two column lines that happen to share +/// a baseline (a single-line paragraph in each column), whose gap spans most of a column. +pub(super) const SAME_LINE_GAP: f32 = 8.0; + +/// Whether segment `s` continues the line of segment `p` to its right: their baselines (a fifth of +/// the em box above its bottom, as `draw_glyph` builds it) lie within a fifth of the smaller +/// height, whatever their sizes, and `s` starts no earlier than `p` ends and less than +/// [`SAME_LINE_GAP`] heights after it. +pub(super) fn same_line(p: &Seg, s: &Seg) -> bool { + let h = p.h.min(s.h); + let baseline = |seg: &Seg| seg.bbox[3] - 0.2 * seg.h; + let gap = s.bbox[0] - p.bbox[2]; + (baseline(p) - baseline(s)).abs() < 0.2 * h && gap > -WORD_GAP * h && gap < SAME_LINE_GAP * p.h.max(s.h) +} diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index 71670cc271..bf64334d6e 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -73,7 +73,11 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } } matched = 0; - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric()); + // A neighbour bounds a word only in a script that puts spaces between words. Han, kana, Thai + // and the like run their words together, so a character of theirs next to the match is no + // reason to reject it: Acrobat finds 台北 inside 台北市 with Whole words only on. Digits still + // bound, in any script: ๑ is not a whole word inside the number ๑๒. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric() && (c.0.is_numeric() || !unspaced_script(c.0))); let mut hits = Vec::new(); for (i, &(c, owner)) in chars.iter().enumerate() { matched = advance(&needle, &prefix, matched, c); @@ -205,3 +209,34 @@ fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> us } if needle.get(matched) == Some(&c) { matched.saturating_add(1) } else { 0 } } + +/// Whether `c` is a letter of a script written without spaces between words (CJK ideographs, +/// kana, bopomofo, Thai, Lao, Myanmar, Khmer, Tibetan), so that a whole-word search cannot take a +/// neighbouring letter as a word boundary. +fn unspaced_script(c: char) -> bool { + matches!( + u32::from(c), + 0x0E00..=0x0EFF // Thai, Lao + | 0x0F00..=0x0FFF // Tibetan + | 0x1000..=0x109F // Myanmar + | 0x1780..=0x17FF // Khmer + | 0x2E80..=0x2FDF // CJK and Kangxi radicals + | 0x3005..=0x3007 // 々 〆 〇 + | 0x3031..=0x3035 // vertical kana repeat marks + | 0x303B..=0x303C // 〻 〼 + | 0x3040..=0x30FF // hiragana, katakana + | 0x3100..=0x312F // bopomofo + | 0x31A0..=0x31BF // bopomofo extended + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3400..=0x4DBF // CJK extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA9E0..=0xA9FF // Myanmar extended-B + | 0xAA60..=0xAA7F // Myanmar extended-A + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF66..=0xFF9F // halfwidth katakana + | 0x1AFF0..=0x1B16F // kana extended-B, kana supplement, kana extended-A, small kana + // The supplementary and tertiary ideographic planes, all CJK ideographs (extensions B + // to J, compatibility supplement). + | 0x20000..=0x3FFFF + ) +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a3c67696fe..15e091a5b9 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1052,6 +1052,48 @@ fn upright_mapping_round_trips() { assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); } +#[test] +fn whole_words_ignore_neighbours_in_scripts_written_without_spaces() { + // "台北市的PDF檔案" in one run: Chinese writes no spaces between words, so "台北" is a + // whole word inside "台北市" and "PDF" inside "的PDF檔", as Acrobat finds them with Whole + // words only on. A Latin neighbour still bounds: "cat" is not a whole word in "category". + let mut v = Vec::new(); + word(&mut v, "台北市的PDF檔案", 10.0, 10.0, 6.0); + word(&mut v, "category", 74.0, 10.0, 6.0); + let t = layout(v); + assert_eq!(t.plain_text(), "台北市的PDF檔案 category"); + assert_eq!(t.find_opts("台北", false, true), vec![0..2], "a word inside a longer run of ideographs"); + assert_eq!(t.find_opts("北市", false, true), vec![1..3]); + assert_eq!(t.find_opts("PDF", false, true), vec![4..7], "Latin between ideographs"); + assert_eq!(t.find_opts("pdf檔", false, true), vec![4..8]); + assert!(t.find_opts("cat", false, true).is_empty(), "Latin neighbours still bound a word"); + assert_eq!(t.find_opts("category", false, true), vec![9..17]); + // Thai and Japanese kana are written without spaces too. + let mut v = Vec::new(); + word(&mut v, "ภาษาไทย", 10.0, 30.0, 6.0); + word(&mut v, "ひらがなとカタカナ", 62.0, 30.0, 6.0); + let t = layout(v); + assert_eq!(t.find_opts("ไทย", false, true), vec![4..7]); + assert_eq!(t.find_opts("カタカナ", false, true), vec![12..16]); +} + +/// #2075 review: digits still bound a whole word in those scripts (๑ is not a word inside +/// the number ๑๒), and letters from the later Myanmar and Han blocks don't. +#[test] +fn whole_words_in_unspaced_scripts_keep_digits_and_later_blocks() { + let one = |text: &str| { + let mut v = Vec::new(); + word(&mut v, text, 10.0, 10.0, 6.0); + layout(v) + }; + assert!(one("๑๒").find_opts("๑", false, true).is_empty(), "a Thai digit beside it bounds"); + assert!(one("台北3").find_opts("台北", false, true).is_empty(), "so does an ASCII digit"); + // Myanmar Extended-A (U+AA60), Myanmar Extended-B (U+A9E0), CJK Extension H (U+31350). + for text in ["\u{AA60}PDF", "\u{A9E0}PDF", "\u{31350}PDF"] { + assert_eq!(one(text).find_opts("PDF", false, true), vec![1..4], "{text}"); + } +} + #[test] fn turkish_dotted_and_dotless_i_match_in_any_case() { // "İSTANBUL IRMAK KIŞ" on one line: each word typed in lower case must find it, and the @@ -1213,7 +1255,7 @@ fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { let mut space_before = vec![false; 10]; space_before[5] = !line_break; let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); - let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let page = PageText { glyphs: glyphs.clone(), line_of, space_before, truncated: false }; let plain = if line_break { "quick\nbrown" } else { "quick brown" }; assert_eq!(page.plain_text(), plain); assert_eq!(page.find("quick "), vec![0..5]); @@ -1238,7 +1280,7 @@ fn phrase_search_ignores_repeated_spaces() { v.push(g(" ", 52.0, 10.0, 58.0)); word(&mut v, "brown", 58.0, 10.0, 6.0); let n = v.len(); - let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] }; + let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; assert_eq!(t.plain_text(), "quick brown"); assert_eq!(t.find("quick brown"), vec![0..12]); assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too"); @@ -1354,6 +1396,197 @@ fn two_columns_read_left_then_right_even_if_drawn_interleaved() { assert_eq!(t.plain_text(), "Left1\nLeft2\nRight1\nRight2"); } +/// Runs on one baseline read as one line, left to right, with a space at the gap between them, however they were +/// drawn and whatever their sizes. Columns of several lines still read one after the other. +#[test] +fn runs_on_one_baseline_read_as_one_line_in_any_drawing_order() { + // Glyphs of `h` pt on the baseline `base`, 0.6 em apart. + let sized = |v: &mut Vec, s: &str, x: f32, base: f32, h: f32| { + for (i, c) in s.chars().enumerate() { + let x0 = x + i as f32 * h * 0.6; + v.push(em_box(&c.to_string(), x0, base - 0.8 * h, x0 + h * 0.6, base + 0.2 * h)); + } + }; + // The second word drawn first, three and a half heights to the right of the first. + let mut v = Vec::new(); + sized(&mut v, "second", 160.0, 100.0, 14.0); + sized(&mut v, "first", 90.0, 100.0, 14.0); + let t = layout(v); + assert_eq!(t.plain_text(), "first second"); + assert_eq!(t.line_rects(0..t.glyphs.len()).len(), 1, "one line, one highlight"); + // A 9 pt label, a 14 pt word and a 9 pt note on one baseline. + let mut v = Vec::new(); + sized(&mut v, "11", 40.0, 100.0, 9.0); + sized(&mut v, "hidden", 90.0, 100.0, 14.0); + sized(&mut v, "note", 170.0, 100.0, 9.0); + assert_eq!(layout(v).plain_text(), "11 hidden note"); + // A label beside two columns of two lines: the label joins the first line of the left column, and the + // columns keep their own lines. + let mut v = Vec::new(); + sized(&mut v, "07", 40.0, 100.0, 9.0); + sized(&mut v, "leftone", 90.0, 100.0, 12.0); + sized(&mut v, "lefttwo", 90.0, 114.0, 12.0); + sized(&mut v, "rightone", 300.0, 100.0, 12.0); + sized(&mut v, "righttwo", 300.0, 114.0, 12.0); + assert_eq!(layout(v).plain_text(), "07 leftone\nlefttwo\nrightone\nrighttwo"); + // Two columns of prose 20 pt apart, where a one-line paragraph that fills the left column shares its baseline + // with the first line of a paragraph in the right one: the paragraph is the next column, not the line's value. + let mut v = Vec::new(); + for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() { + sized(&mut v, &format!("leftparagraphlinenumber{k}xxxxxxxxxxxxxxx"), 72.0, *y, 10.0); + } + sized(&mut v, "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx", 72.0, 160.0, 10.0); + for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() { + sized(&mut v, &format!("rightparagraphline{k}"), 326.0, *y, 10.0); + } + sized(&mut v, "rightsecondpara", 326.0, 160.0, 10.0); + sized(&mut v, "rightsecondparaline2", 326.0, 172.0, 10.0); + let t = layout(v).plain_text(); + assert!(t.lines().any(|l| l == "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx"), "{t}"); + assert!(t.lines().any(|l| l == "rightsecondpara"), "{t}"); + // Staggered columns (review on #4468): the left column's short last line shares its baseline with + // the right column's first line. That last line is no label, so the columns keep their lines. + let mut v = Vec::new(); + sized(&mut v, "leftcol1", 10.0, 100.0, 10.0); + sized(&mut v, "leftcol2", 10.0, 112.0, 10.0); + sized(&mut v, "end", 10.0, 124.0, 10.0); + sized(&mut v, "rightone", 82.0, 124.0, 10.0); + sized(&mut v, "righttwo", 82.0, 136.0, 10.0); + assert_eq!(layout(v).plain_text(), "leftcol1\nleftcol2\nend\nrightone\nrighttwo"); + // Half a height lower is another line. + let mut v = Vec::new(); + sized(&mut v, "first", 90.0, 100.0, 14.0); + sized(&mut v, "lower", 160.0, 107.0, 14.0); + assert_eq!(layout(v).plain_text(), "first\nlower"); + // Right-to-left runs on one baseline keep their lines: their blocks read left first. + let mut v = Vec::new(); + sized(&mut v, "שלום", 90.0, 100.0, 14.0); + sized(&mut v, "עולם", 200.0, 100.0, 14.0); + assert_eq!(layout(v).plain_text().lines().count(), 2); +} + +/// A page of fourteen labelled rows, each a case of text copy: a `/fi` glyph by name, a ToUnicode map, an +/// `/ActualText` span, word gaps by `TJ` and `Td`, a hyphen at a line end, two columns, words drawn out of order, +/// fake bold, invisible text, letter spacing and a line turned upwards. +fn copy_test_page() -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let rows: [(&str, &str); 14] = [ + ("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"), + ("02", "BT /F3 14 Tf 90 {Y} Td (XEY) Tj ET"), + ("03", "/Span << /ActualText (replaced) >> BDC BT /F1 14 Tf 90 {Y} Td (shown) Tj ET EMC"), + ("04", "BT /F1 14 Tf 90 {Y} Td [(Hello) -400 (world)] TJ ET"), + ("05", "BT /F1 14 Tf 90 {Y} Td [(W) 80 (ave)] TJ ET"), + ("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"), + ("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"), + ("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"), + ("09", "BT /F1 14 Tf 90 {Y} Td (caf\\351 na\\357ve) Tj ET"), + ("10", "BT /F1 14 Tf 90 {Y} Td (Bold) Tj ET BT /F1 14 Tf 90.4 {Y} Td (Bold) Tj ET"), + ("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"), + ("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"), + ("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"), + ("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"), + ]; + let mut content = String::from("BT /F1 9 Tf 40 770 Td (Text copy and search test) Tj ET\n"); + let mut y = 740; + for (tag, body) in rows { + content += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", body.replace("{Y}", &y.to_string())); + y -= 38 + + match tag { + "06" | "13" => 10, + "07" => 6, + _ => 0, + }; + } + let mut doc = Document::with_version("1.7"); + let helvetica = || dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => "WinAnsiEncoding" }; + let f1 = doc.add_object(helvetica()); + let differences = dictionary! { "Type" => "Encoding", "BaseEncoding" => "WinAnsiEncoding", "Differences" => vec![Object::Integer(128), Object::Name(b"fi".to_vec())] }; + let f2 = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => differences }); + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 3 beginbfchar <58> <004B> <45> <0045> <59> <0059> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.as_bytes().to_vec())); + let mut f3 = helvetica(); + f3.set("ToUnicode", to_unicode); + let f3 = doc.add_object(f3); + let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => f1, "F2" => f2, "F3" => f3 } }, "Contents" => contents + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() +} + +/// Copy of the whole page: one line per baseline in left-to-right order whatever the drawing order, the `/fi` +/// glyph as its letters, and the line turned upwards in its row, not after the page. `/ActualText` replaces what +/// is drawn (ISO 32000-2 §14.9.4), a hard hyphen at a line end stays (§14.8.2.3: an incidental one is U+00AD), +/// fake bold reads once, and letter spacing that is the same between every letter is tracking, not word gaps. +#[test] +fn a_copied_page_reads_one_line_per_baseline_in_reading_order() { + let text = extract_page(©_test_page(), 0, &InterpreterSettings::default()).unwrap(); + assert_eq!( + text.plain_text(), + "Text copy and search test\n01 office\n02 KEY\n03 replaced\n04 Hello world\n05 Wave\n06 exam-\nple\n07 left one\nleft two\n\ + right one\nright two\n08 first second\n09 café naïve\n10 Bold\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words" + ); + assert_eq!(text.find("first second").len(), 1); + assert_eq!(text.find("office").len(), 1); +} + +/// A turned line inside the page's text reads where it stands; one in the margin, beside every line (a stamp +/// set up the edge of the page), still reads after the text instead of splitting a paragraph. +#[test] +fn a_turned_line_reads_in_place_inside_the_text_and_after_it_in_the_margin() { + use lopdf::{Document, Object, Stream, dictionary}; + let page = |before: &str, after: &str| { + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let mut content = before.to_owned(); + for (k, word) in ["Alpha", "Beta", "Gamma", "Delta"].iter().enumerate() { + content += &format!("BT /F1 12 Tf 72 {} Td ({word}) Tj ET\n", 700 - 30 * k); + } + content += after; + let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => contents + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() + }; + // "up", set upwards from the baseline of "Beta" beside it, and "dn", set downwards in the space under it. + let up = "BT /F1 12 Tf 0 1 -1 0 110 670 Tm (up) Tj ET\n"; + let down = "BT /F1 8 Tf 0 -1 1 0 100 662 Tm (dn) Tj ET\n"; + assert_eq!(page("", up), "Alpha\nBeta\nup\nGamma\nDelta"); + assert_eq!(page(up, ""), "Alpha\nBeta\nup\nGamma\nDelta", "drawn before the text"); + assert_eq!(page("", down), "Alpha\nBeta\ndn\nGamma\nDelta"); + // Upside-down text inside the text's area, under "Beta", is not turned a quarter and keeps its own flow. + let upside_down = "BT /F1 12 Tf -1 0 0 -1 110 660 Tm (ud) Tj ET\n"; + assert_eq!(page("", upside_down), "Alpha\nBeta\nGamma\nDelta\nud"); + // A stamp up the left margin, beside "Beta" and "Gamma": laid out with the text, it would read after "Alpha". + let stamp = "BT /F1 10 Tf 0 1 -1 0 30 640 Tm (arXiv stamp) Tj ET\n"; + assert_eq!(page("", stamp), "Alpha\nBeta\nGamma\nDelta\narXiv stamp"); +} + +#[test] +fn ligature_characters_read_as_their_letters() { + assert_eq!(ligature_letters("o\u{FB01}ce".into()), "ofice"); + assert_eq!(ligature_letters("\u{FB00}\u{FB02}\u{FB03}\u{FB04}\u{FB05}\u{FB06}".into()), "ffflffifflstst"); + // Only those seven: other compatibility characters stay as they are. + assert_eq!(ligature_letters("\u{FB13}\u{FF21}\u{2460}é".into()), "\u{FB13}\u{FF21}\u{2460}é"); + assert_eq!(ligature_letters(String::new()), ""); +} + /// Lays out one-word lines, each `(text, x, y)`, 10 pt high: 14 pt apart is a line in a paragraph, 24 pt a /// paragraph gap. fn lines(at: &[(&str, f32, f32)]) -> String { @@ -1451,7 +1684,32 @@ fn a_full_width_line_ends_the_columns_above_it() { fn table_rows_apart_still_read_across() { // Rows a paragraph gap apart whose cells start level read row by row, as before #3658. let rows = [("Name", 10.0, 10.0), ("Qty", 100.0, 10.0), ("Apple", 10.0, 34.0), ("12", 100.0, 34.0), ("Pear", 10.0, 58.0), ("7", 100.0, 58.0)]; - assert_eq!(lines(&rows), "Name\nQty\nApple\n12\nPear\n7"); + // The cells of a row share its baseline, so they share a line too. + assert_eq!(lines(&rows), "Name Qty\nApple 12\nPear 7"); + // Column select still separates them with a tab. + let mut v = Vec::new(); + for &(s, x, y) in &rows { + word(&mut v, s, x, y, 6.0); + } + let t = layout(v); + assert_eq!(t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0])), "Name\tQty\nApple\t12\nPear\t7"); +} + +/// Two cells two heights apart (closer than [`CELL_GAP`]) copy the same whichever is drawn first: +/// drawn left to right they were always one run, and the right cell drawn first now joins it too. +#[test] +fn close_cells_copy_the_same_in_either_drawing_order() { + let copy = |cells: &[(&str, f32)]| { + let mut v = Vec::new(); + for &(s, x) in cells { + word(&mut v, s, x, 10.0, 6.0); + } + let t = layout(v); + (t.plain_text(), t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0]))) + }; + let in_order = copy(&[("Name", 10.0), ("Qty", 54.0)]); + assert_eq!(copy(&[("Qty", 54.0), ("Name", 10.0)]), in_order); + assert_eq!(in_order.0, "Name Qty"); } #[test] @@ -1470,7 +1728,7 @@ fn form_rows_in_mixed_sizes_still_read_across() { sized(&mut v, label, 10.0, base, 8.0); sized(&mut v, value, 100.0, base, 12.0); } - assert_eq!(layout(v).plain_text(), "Name\nAlice\nStreet\nMain\nCity\nOslo"); + assert_eq!(layout(v).plain_text(), "Name Alice\nStreet Main\nCity Oslo"); // Values drawn 6 pt below their labels, rows a paragraph gap apart. let offset = [("Name", 10.0, 10.0), ("Val1", 100.0, 16.0), ("Addr", 10.0, 40.0), ("Val2", 100.0, 46.0), ("City", 10.0, 70.0), ("Val3", 100.0, 76.0)]; @@ -1479,7 +1737,8 @@ fn form_rows_in_mixed_sizes_still_read_across() { #[test] fn a_column_does_not_jump_to_a_distant_footer_footnote_or_caption() { - // A page number far below the left column reads after the right column. + // A page number far below the left column reads after the right column. L3 and R3, one-line paragraphs + // on one baseline a column apart, keep their own lines. let footer = [ ("L1", 10.0, 10.0), ("L2", 10.0, 24.0), @@ -1941,6 +2200,7 @@ fn words_and_lines_around_a_glyph() { })), line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], space_before: vec![false, false, false, false, false, false, true, false], + truncated: false, }; assert_eq!(t.word_at(1), Some((0, 1))); assert_eq!(t.word_at(3), Some((3, 4))); @@ -2440,6 +2700,7 @@ fn vertical_lines_keep_reading_order() { glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)], line_of: vec![0, 0, 1, 1], space_before: vec![false; 4], + truncated: false, }; let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]); assert_eq!(t.column_text(&all), "日本\n語文"); @@ -3134,6 +3395,7 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], line_of: vec![0, 0], space_before: vec![false, false], + truncated: false, }; assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); diff --git a/crates/render/tests/content_differential.rs b/crates/render/tests/content_differential.rs new file mode 100644 index 0000000000..00c3158472 --- /dev/null +++ b/crates/render/tests/content_differential.rs @@ -0,0 +1,476 @@ +//! The two content-stream parsers PdfCraft runs, operator by operator on the same bytes. +//! +//! The renderer, text extraction and Find read a page through hayro's tokenizer +//! (`hayro_syntax::content::UntypedIter`, typed through `TypedIter`). Edit text, redaction, +//! the redaction audit, Office export, accessibility and the automation tools read it through +//! `pdfcraft_content::parse`. Where the two disagree on the same bytes, a feature acts on a +//! page the renderer never showed: #3107, #3108, #3111 and #3177 are that class. +//! +//! Each fixture here is parsed by both and the operator sequences compared. Where the parsers +//! agree, that is asserted. Where they are known to disagree, the test pins what *each* side +//! does today and names the issue, so a change on either side fails here and the expectation +//! is updated with it. Nothing in this file touches product code, and no oracle runs here: the +//! MuPDF and pdftotext readings quoted in comments were taken by hand on one-page files built +//! from the same bytes. +//! +//! Several tests pin what `vendor/hayro-syntax` does today, so a hayro upgrade or a new patch +//! to it can fail them on purpose. When that happens, update the pinned side to the new +//! behaviour and note the upstream change (the version or patch, and its PR) in that test's +//! comment, as the tests under "divergences closed" do. + +use std::fmt::Write as _; + +use hayro_syntax::content::ops::TypedInstruction; +use hayro_syntax::content::{TypedIter, UntypedIter}; +use hayro_syntax::object::{MaybeRef, Object as HObject}; +use pdfcraft_cos::Object as CObject; + +/// One operator with its operands, normalized so the two parsers can be compared as text. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tok { + op: String, + operands: Vec, +} + +fn tok(op: &str, operands: &[&str]) -> Tok { + Tok { op: op.into(), operands: operands.iter().map(|s| s.to_string()).collect() } +} + +fn num(v: f64) -> String { + let r = (v * 1e6).round() / 1e6; + if r.fract() == 0.0 && r.abs() < 1e15 { format!("{}", r as i64) } else { format!("{r}") } +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut s, b| { + let _ = write!(s, "{b:02x}"); + s + }) +} + +fn norm_cos(o: &CObject) -> String { + match o { + CObject::Null => "null".into(), + CObject::Bool(b) => b.to_string(), + CObject::Int(i) => num(*i as f64), + CObject::Real(r) => num(*r), + CObject::String(s) => format!("({})", hex(&s.bytes)), + CObject::Name(n) => format!("/{}", String::from_utf8_lossy(n)), + CObject::Array(a) => format!("[{}]", a.iter().map(norm_cos).collect::>().join(" ")), + CObject::Dict(d) => { + let mut entries: Vec = d.iter().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k), norm_cos(v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + CObject::Ref(r) => format!("{} {} R", r.num, r.generation), + CObject::Stream(_) => "stream".into(), + } +} + +fn norm_hayro(o: &HObject<'_>) -> String { + match o { + HObject::Null(_) => "null".into(), + HObject::Boolean(b) => b.to_string(), + HObject::Number(n) => num(n.as_f64()), + HObject::String(s) => format!("({})", hex(s.as_bytes())), + HObject::Name(n) => format!("/{}", String::from_utf8_lossy(n.as_ref())), + HObject::Array(a) => format!("[{}]", a.raw_iter().map(|m| norm_maybe(&m)).collect::>().join(" ")), + HObject::Dict(d) => { + let mut entries: Vec = d.entries().map(|(k, v)| format!("/{} {}", String::from_utf8_lossy(k.as_ref()), norm_maybe(&v))).collect(); + entries.sort(); + format!("<<{}>>", entries.join(" ")) + } + // An inline image: its dictionary and data. ISO 32000-2 §8.9.7 (p.268) defines the data as + // the bytes between `ID` and `EI` "excluding the white-space delimiting those operators", + // which is what the content crate keeps. hayro keeps exactly the samples of an unfiltered + // image whose length its parameters give (#3438), but a filtered image, which it ends at + // a candidate `EI`, still keeps the white-space byte before that `EI` as its last byte. + // `hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data` pins both. The + // comparisons strip that byte from filtered images only, so that every other difference + // stands out: stripping it from an unfiltered image would cut a sample that happens to + // be white space. + HObject::Stream(s) => { + let raw = s.raw_data(); + let filtered = s.dict().contains_key(b"F") || s.dict().contains_key(b"Filter"); + let data = match raw.split_last() { + Some((b'\0' | b'\t' | b'\n' | b'\x0C' | b'\r' | b' ', rest)) if filtered => rest, + _ => &raw[..], + }; + format!("{} data:{}", norm_hayro(&HObject::Dict(s.dict().clone())), hex(data)) + } + } +} + +fn norm_maybe(m: &MaybeRef>) -> String { + match m { + MaybeRef::NotRef(o) => norm_hayro(o), + MaybeRef::Ref(r) => format!("{} {} R", r.obj_number, r.gen_number), + } +} + +/// The content crate's operators. +fn content(data: &[u8]) -> Vec { + pdfcraft_content::parse(data) + .ops + .iter() + .map(|op| Tok { + op: String::from_utf8_lossy(&op.op).into_owned(), + operands: match &op.inline { + Some((dict, image)) => vec![format!("{} data:{}", norm_cos(&CObject::Dict(dict.clone())), hex(image))], + None => op.operands.iter().map(norm_cos).collect(), + }, + }) + .collect() +} + +/// hayro's untyped instructions: every operator the tokenizer reads, with its operand stack. +fn hayro(data: &[u8]) -> Vec { + let mut iter = UntypedIter::new(data); + let mut out = Vec::new(); + while let Some(instr) = iter.next() { + out.push(Tok { op: String::from_utf8_lossy(instr.operator).into_owned(), operands: instr.operands().map(norm_hayro).collect() }); + } + out +} + +/// How many instructions hayro's typed iterator, the layer the renderer consumes, yields before +/// it stops. +fn hayro_typed_len(data: &[u8]) -> usize { + let mut iter = TypedIter::new(data); + let mut n = 0; + while iter.next().is_some() { + n += 1; + } + n +} + +fn ops(toks: &[Tok]) -> Vec<&str> { + toks.iter().map(|t| t.op.as_str()).collect() +} + +/// Both parsers on `data`, asserting they agree and that the operators are `expected`. +fn agree(name: &str, data: &[u8], expected: &[&str]) { + let (c, h) = (content(data), hayro(data)); + assert_eq!(c, h, "{name}: content crate (left) and hayro (right) differ on {:?}", String::from_utf8_lossy(data)); + assert_eq!(ops(&c), expected, "{name}"); +} + +/// An unfiltered 4 × 1 grey inline image whose samples are the four bytes of `samples`. +fn inline_gray(samples: &[u8; 4], after: &[u8]) -> Vec { + let mut v = b"q BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + v.extend_from_slice(samples); + v.extend_from_slice(b"\nEI Q "); + v.extend_from_slice(after); + v +} + +const GRAY_4X1: &str = "<>"; +const GRAY_1X1: &str = "<>"; + +// ── where the two parsers agree ───────────────────────────────────────────────────────────── + +#[test] +fn the_two_parsers_agree_on_well_formed_streams_and_common_damage() { + agree("plain", b"q 1 0 0 1 10 20 cm BT /F1 12 Tf (Hi) Tj ET Q", &["q", "cm", "BT", "Tf", "Tj", "ET", "Q"]); + agree("number forms", b"0.5 .5 6. -.5 +3 -0 5 w", &["w"]); + assert_eq!(content(b"0.5 .5 6. -.5 +3 -0 5 w")[0].operands, ["0.5", "0.5", "6", "-0.5", "3", "0", "5"]); + agree("strings", b"BT (a(b)c) Tj (es\\)c\\\\ape) Tj (line\\\ncont) Tj <48 65 6c> Tj ET", &["BT", "Tj", "Tj", "Tj", "Tj", "ET"]); + assert_eq!(content(b"(line\\\ncont) Tj")[0].operands, [format!("({})", hex(b"linecont"))], "a backslash joins the lines"); + agree("unterminated string ends both streams", b"BT (open Tj ET q Q", &["BT"]); + agree("names with escapes", b"/A#20B gs /#41 gs", &["gs", "gs"]); + assert_eq!(content(b"/A#20B gs /#41 gs").iter().map(|t| t.operands[0].as_str()).collect::>(), ["/A B", "/A"]); + agree("array and dictionary operands", b"/Span <> BDC [(a) -250 (b) 1.5] TJ EMC", &["BDC", "TJ", "EMC"]); + agree("ten operands", b"1 2 3 4 5 6 7 8 9 10 d0 q Q", &["d0", "q", "Q"]); + agree("unknown operators", b"q foo 1 bar Q BT ET", &["q", "foo", "bar", "Q", "BT", "ET"]); + agree("comments everywhere", b"q % c1\n1 0 % c2\n0 1 0 0 cm [1 % c3\n 2] TJ /N % c4\ngs Q % end", &["q", "cm", "TJ", "gs", "Q"]); + agree("comment without end of line", b"q Q % trailing", &["q", "Q"]); + agree("unbalanced q/Q and BT/ET", b"Q Q q BT BT ET q", &["Q", "Q", "q", "BT", "BT", "ET", "q"]); + agree("operands without an operator at the end", b"q 1 0 0 1 5", &["q"]); + agree("empty", b"", &[]); + agree("only a comment", b"% nothing", &[]); +} + +/// The content crate exposes two operand conventions: `Op::nums::` takes the last N, as +/// hayro's typed operators do (`0 20 Tc` is `Tc 20` to the renderer), while `Op::num(i)` counts +/// from the front. Both tokenizers keep every operand, so the choice is each consumer's: the +/// edit reader reads `Tc` through `num(0)` and gets 0 where the renderer gets 20, so the editor +/// and the renderer disagree on the character spacing (MuPDF renders the 20). No issue is filed +/// for the convention. +#[test] +fn surplus_operands_are_read_from_the_end_by_hayro_and_either_way_by_the_content_crate() { + let data = b"BT 0 20 Tc (x) Tj ET"; + agree("surplus operand before Tc", data, &["BT", "Tc", "Tj", "ET"]); + let parsed = pdfcraft_content::parse(data); + let tc = &parsed.ops[1]; + assert_eq!(tc.nums::<1>(), Some([20.0]), "last N, as the renderer"); + assert_eq!(tc.num(0), Some(0.0), "the i-th from the front"); + let mut iter = TypedIter::new(data); + let mut typed_tc = None; + while let Some(instr) = iter.next() { + if let TypedInstruction::CharacterSpacing(c) = instr { + typed_tc = Some(c.0.as_f64()); + } + } + assert_eq!(typed_tc, Some(20.0), "hayro's typed Tc takes the last operand"); +} + +// ── divergences closed since this file was written: both parsers now agree ───────────────── + +/// hayro's typed layer used to end the whole stream at a path operator whose operands don't +/// convert, so the renderer and text extraction showed nothing after it. +/// Closed by #3636 (30ed249a2): `TypedIter::next` (`vendor/hayro-syntax/src/content/mod.rs`) +/// now yields such an operator as `Fallback`, which adds nothing to the path, and reads on, as +/// the content crate, MuPDF and pdftotext do (`After` is shown). +#[test] +fn both_parsers_read_on_past_a_path_operator_with_too_few_operands() { + for (data, expected) in + [(&b"0 0 100 re f BT /F1 12 Tf (After) Tj ET"[..], &["re", "f", "BT", "Tf", "Tj", "ET"][..]), (b"5 m 0 0 100 100 re f", &["m", "re", "f"])] + { + agree("untyped: both keep the short operator", data, expected); + assert_eq!(hayro_typed_len(data), expected.len(), "hayro's typed stream reads on past the short operator"); + } + assert_eq!(hayro_typed_len(b"0 0 100 100 re f BT /F1 12 Tf (After) Tj ET"), 6, "control: a complete re is read through"); +} + +/// hayro's operand stack used to hold ten entries, and an eleventh ended its stream. Closed by +/// #4345 (hayro-syntax patch 28, #4260): an instruction keeps up to 256 operands and past that +/// drops the oldest, so both parsers read all eleven and go on, as MuPDF and pdftotext do +/// (`AFTER` is shown). +#[test] +fn both_parsers_keep_more_than_ten_operands() { + let data = b"1 2 3 4 5 6 7 8 9 10 11 d0 q Q"; + agree("eleven operands", data, &["d0", "q", "Q"]); + assert_eq!(content(data)[0].operands.len(), 11); +} + +/// A stray closing delimiter used to end hayro's stream while the content crate skipped it. +/// Closed by #3636 (30ed249a2): hayro steps over the malformed token too. MuPDF reports a +/// syntax error and goes on (`AFTER` is shown). +#[test] +fn both_parsers_skip_a_stray_closing_delimiter() { + agree("stray delimiters", b"q ) > ] } { Q", &["q", "Q"]); +} + +/// A name whose `#` escape is not followed by two hex digits (ISO 32000-2 §7.3.5, p.28) used to +/// end hayro's stream. Since #3636 (30ed249a2) hayro steps over the name and reads on, so both +/// parsers now read the same operators. One difference is still pinned: the content crate keeps +/// the name as written as the `gs` operand, while hayro drops it, so `gs` reaches the renderer +/// with no operand and does nothing, where the editor reads `/C#2`. No issue is filed. An empty +/// name (`/`) is read by both. MuPDF goes on in both cases, reporting the resource as missing. +#[test] +fn both_parsers_read_on_past_a_malformed_name_escape_but_only_the_content_crate_keeps_the_name() { + let data = b"/C#2 gs q Q"; + assert_eq!(content(data), [tok("gs", &["/C#2"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("gs", &[]), tok("q", &[]), tok("Q", &[])], "hayro: the operators, without the name"); + agree("an empty name", b"/ gs q Q", &["gs", "q", "Q"]); +} + +/// #3111 (closed by #3299) made the cos lexer, and so the content crate, read `--280` as -280. +/// hayro used to read it as the two numbers 0 and -280, so `cm` took an extra operand and the +/// renderer drew the page with the matrix `0 0 1 0 -280 10`. Closed by #3630 (766ff9d05): a +/// doubled sign is one number to hayro too, so both read the matrix MuPDF applies (text at +/// x = 120). The content crate skips no byte: without the lexer's tolerance it would skip the +/// first `-` and read the same `-280` from the rest, so the operands alone don't show it. +#[test] +fn both_parsers_read_a_doubled_minus_as_one_number() { + let data = b"1 0 0 1 --280 10 cm"; + agree("doubled minus", data, &["cm"]); + assert_eq!(content(data)[0].operands, ["1", "0", "0", "1", "-280", "10"]); + assert_eq!(pdfcraft_content::parse(data).skipped, 0, "--280 is read as one number, not recovered from -280"); +} + +// ── known divergences, pinned on both sides ───────────────────────────────────────────────── + +/// No issue filed; #2877 (open) changes number reading in cos and render. ISO 32000-2 §7.3.3 +/// (p.24) says a writer "shall not use … exponential format", so neither reading is the +/// spec's. The content crate reads `1e3` as the number 1000; hayro reads it as an unknown +/// operator and drops the operands before it. MuPDF stops the whole stream at it +/// ("unknown keyword: '1e3'"); pdftotext goes on. +#[test] +fn scientific_notation_is_a_number_to_the_content_crate_and_an_unknown_operator_to_hayro() { + let data = b"1e3 w q Q"; + assert_eq!(content(data), [tok("w", &["1000"]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + // A number glued to letters: the content crate skips the digits and reads the letters as an + // operator; hayro reads the whole token as an operator (and clears the operands before it). + let data = b"12abc w q Q"; + assert_eq!(content(data), [tok("abc", &[]), tok("w", &[]), tok("q", &[]), tok("Q", &[])]); + assert_eq!(hayro(data), [tok("w", &[]), tok("q", &[]), tok("Q", &[])]); +} + +// ── inline images ─────────────────────────────────────────────────────────────────────────── + +/// The bytes of the first inline image each parser reads in `data`: the content crate's, then +/// hayro's raw stream data (nothing stripped). +fn inline_bytes(data: &[u8]) -> (Option>, Option>) { + let parsed = pdfcraft_content::parse(data); + let c = parsed.ops.iter().find_map(|op| op.inline.as_ref().map(|(_, d)| d.clone())); + let mut iter = UntypedIter::new(data); + let mut h = None; + while let Some(instr) = iter.next() { + if let Some(HObject::Stream(s)) = instr.operands().next() { + h = Some(s.raw_data().to_vec()); + break; + } + } + (c, h) +} + +/// ISO 32000-2 §8.9.7 (p.268): the data lies between `ID` and `EI` "excluding the white-space +/// delimiting those operators". The content crate keeps exactly that. hayro used to keep the +/// delimiter before `EI` as the last byte of every inline image. Since #3438 (f9d37c089) it +/// takes an unfiltered image's length from `/W /H /BPC /CS` and keeps exactly the samples, so +/// both read `ABCD`. A filtered image, which hayro still ends at a candidate `EI`, keeps the +/// delimiter: that part is still pinned. Decoders stop at the filter's end-of-data marker and +/// ignore the extra byte, so the other tests strip it from filtered images (see `norm_hayro`). +#[test] +fn hayro_keeps_the_delimiter_before_ei_only_in_filtered_image_data() { + let unfiltered = inline_bytes(&inline_gray(b"ABCD", b"")); + assert_eq!(unfiltered, (Some(b"ABCD".to_vec()), Some(b"ABCD".to_vec())), "unfiltered: both keep exactly the samples"); + let filtered = inline_bytes(b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q"); + assert_eq!(filtered, (Some(b"41>".to_vec()), Some(b"41> ".to_vec())), "filtered: hayro keeps the space before EI"); +} + +/// Samples that end in a white-space byte (here ` EI ` as four gray samples, the last one 0x20) +/// are all image data when the length is computed from the parameters (4 × 1 × 8 bits = 4 +/// bytes, ISO 32000-2 §8.9.7). Both parsers keep all four bytes; hayro's raw data, unstripped, +/// is the same four bytes, so its unfiltered-length path (#3438) does not take a white-space +/// sample for the delimiter before `EI`. +#[test] +fn both_parsers_keep_a_last_sample_that_is_white_space() { + for samples in [b" EI ", b"\x10\x20\x30\x20", b"\x10\x20\x30\x0A"] { + let (c, h) = inline_bytes(&inline_gray(samples, b"q Q")); + assert_eq!(c.as_deref(), Some(&samples[..]), "content crate: {samples:02x?}"); + assert_eq!(h.as_deref(), Some(&samples[..]), "hayro: {samples:02x?}"); + } +} + +#[test] +fn inline_images_agree_when_ei_is_delimited() { + agree("raw samples", &inline_gray(b"ABCD", b"BT (t) Tj ET"), &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCIIHex with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("ASCII85 with a space before EI", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~> EI Q BT (t) Tj ET", &["q", "BI", "Q", "BT", "Tj", "ET"]); + agree("two images in a row", b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A\nEI BI /W 1 /H 1 /BPC 8 /CS /G ID B\nEI Q", &["q", "BI", "BI", "Q"]); + agree("a comment inside the parameters", b"q BI /W 1 % w\n/H 1 /BPC 8 /CS /G ID A\nEI Q", &["q", "BI", "Q"]); + // A fake ` EI ` inside the samples with no text operator after the image: hayro's look-ahead + // keeps searching and finds the real EI, so both read the four bytes. + agree("fake EI, nothing but q/Q after", &inline_gray(b" EI ", b"q Q"), &["q", "BI", "Q", "q", "Q"]); + assert_eq!(content(&inline_gray(b" EI ", b"q Q"))[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3107. Unfiltered samples that happen to hold ` EI ` followed by a text operator: hayro's +/// look-ahead used to accept the first `EI` because text follows it, so its image held no +/// samples and the rest of them ran as content (`EI` became an operator). Closed by #3438 +/// (f9d37c089): hayro now takes the length from `/W /H /BPC /CS`, as the content crate does, and +/// both keep all four bytes (ISO 32000-2 §8.9.7). MuPDF and pdftotext show the text after. +#[test] +fn both_parsers_keep_a_fake_ei_inside_unfiltered_samples() { + let data = inline_gray(b" EI ", b"BT (t) Tj ET"); + agree("fake EI, text after", &data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(&data)[1].operands, [format!("{GRAY_4X1} data:20454920")]); +} + +/// #3108. `EI` written straight after the filter's end-of-data marker (`>EI`, `~>EI`): hayro +/// ends the image there, and the content crate used to find no delimited `EI` and swallow the +/// rest of the stream into the image. Closed by #3259 (the content crate accepts `EI` after the +/// marker) and b72078871 (the content crate takes a filtered image's end from hayro's +/// tokenizer): both end the image at the marker and read the text after, as MuPDF and pdftotext +/// do. +#[test] +fn both_parsers_end_the_image_at_ei_glued_to_the_filters_end_marker() { + for (name, data, dict, marker) in [ + ("ASCIIHex", &b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /AHx ID 41>EI Q BT (t) Tj ET"[..], "<>", "34313e"), + ("ASCII85", b"q BI /W 1 /H 1 /BPC 8 /CS /G /F /A85 ID 5l~>EI Q BT (t) Tj ET", "<>", "356c7e3e"), + ] { + agree(name, data, &["q", "BI", "Q", "BT", "Tj", "ET"]); + assert_eq!(content(data)[1].operands, [format!("{dict} data:{marker}")], "{name}"); + } +} + +/// No issue filed. `EI` glued to the next operator (`EIQ`): the content crate swallows the rest +/// of the stream into the image; hayro yields a `BI` with no image at all, skips to the next +/// delimited token and reads on, so the `Q` glued to `EI` is lost too. MuPDF ends the whole +/// stream ("syntax error after inline image"). +#[test] +fn ei_glued_to_the_next_operator_loses_the_image_on_both_sides_differently() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A EIQ BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A EIQ BT (t) Tj ET"))]); + assert_eq!(hayro(data), [tok("q", &[]), tok("BI", &[]), tok("BT", &[]), tok("Tj", &["(74)"]), tok("ET", &[])]); +} + +/// No issue filed. An inline image with no `EI` at all: the content crate makes the rest of the +/// stream the image's data; hayro yields a `BI` with no image and then reads the samples and +/// everything after them as operators. MuPDF ends the whole stream. +#[test] +fn an_inline_image_without_ei_is_data_to_the_content_crate_and_operators_to_hayro() { + let data = b"q BI /W 1 /H 1 /BPC 8 /CS /G ID A Q BT (t) Tj ET"; + let c = content(data); + assert_eq!(ops(&c), ["q", "BI"]); + assert_eq!(c[1].operands, [format!("{GRAY_1X1} data:{}", hex(b"A Q BT (t) Tj ET"))]); + assert_eq!(ops(&hayro(data)), ["q", "BI", "A", "Q", "BT", "Tj", "ET"]); +} + +/// No issue filed. Parameters that can't be read (`/W` with no value): the content crate skips +/// to the `EI` and goes on; hayro ends its stream. MuPDF reports the dictionary and goes on. +#[test] +fn an_inline_image_with_unreadable_parameters_is_skipped_by_the_content_crate_and_ends_hayros_stream() { + let data = b"q BI /W ID A\nEI Q"; + assert_eq!(ops(&content(data)), ["q", "Q"]); + assert_eq!(ops(&hayro(data)), ["q"]); +} + +/// One page, 612 × 792, whose content stream is `content`. +fn one_page_pdf(content: &[u8]) -> Vec { + let mut stream = format!("<< /Length {} >>\nstream\n", content.len()).into_bytes(); + stream.extend_from_slice(content); + stream.extend_from_slice(b"\nendstream"); + let objs: [&[u8]; 4] = [ + b"<< /Type /Catalog /Pages 2 0 R >>", + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>", + &stream, + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, body) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes()); + out.extend_from_slice(body); + out.extend_from_slice(b"\nendobj\n"); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for off in offsets { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// End to end: the renderer draws all four samples of an unfiltered 4 × 1 gray inline image +/// whose last sample is a white-space byte (0x20 or 0x0A), so hayro's computed length (#3438) +/// reaches the pixels and no sample is taken for the delimiter before `EI`. +#[test] +fn the_renderer_draws_a_last_sample_that_is_white_space() { + use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + for samples in [[0x20, 0x45, 0x49, 0x20], [0x10, 0x60, 0xB0, 0x20], [0x10, 0x60, 0xB0, 0x0A]] { + // Each sample is a 100 × 100 pt block from x = 100, y = 300 (PDF space). + let mut content = b"q 400 0 0 100 100 300 cm BI /W 4 /H 1 /BPC 8 /CS /G ID ".to_vec(); + content.extend_from_slice(&samples); + content.extend_from_slice(b"\nEI Q"); + let pdf = std::sync::Arc::new(one_page_pdf(&content)); + let mut renderer = PageRenderer::new(pdf, RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }); + assert!(page.error.is_none(), "{samples:02x?}: {:?}", page.error); + // Block centres: x = 150 + 100 i, raster y = 792 - 350 = 442. + let drawn: Vec = (0..4u32) + .map(|i| { + let idx = ((442 * page.width + 150 + 100 * i) * 4) as usize; + page.rgba.get(idx).copied().unwrap_or(0xFF) + }) + .collect(); + assert_eq!(drawn, samples, "{samples:02x?}: the gray of each block"); + } +} diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 5b2a1e4c46..d78b18087a 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -6,9 +6,88 @@ fn page(parts: &[&str]) -> PageText { glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), line_of: vec![0; parts.len()], space_before: vec![false; parts.len()], + truncated: false, } } +/// The letters of the property test's alphabet that come from scripts written without spaces +/// between words: 界 (CJK Unified Ideographs, U+4E00..U+9FFF) and ก (Thai, U+0E00..U+0E7F). +/// 한 (Hangul Syllables) is written with spaces and bounds a word. The reference knows only +/// these three letters, so it does not share the block table in `text/search.rs`; that table is +/// checked against `UNSPACED_BLOCK_SAMPLES` instead. +fn unspaced(c: char) -> bool { + matches!(c, '界' | 'ก') +} + +/// Letters at the edges of each block of a script written without spaces (`true`), and letters +/// just outside them (`false`), named by their block as Unicode's Blocks.txt gives it. Each one is +/// alphabetic and not a digit, so whether it bounds a whole word depends on the table alone. +const UNSPACED_BLOCK_SAMPLES: &[(char, bool, &str)] = &[ + ('\u{0DC6}', false, "Sinhala (0D80..0DFF), before Thai"), + ('\u{0E01}', true, "Thai (0E00..0E7F), first letter"), + ('\u{0E46}', true, "Thai, MAIYAMOK"), + ('\u{0E81}', true, "Lao (0E80..0EFF), first letter"), + ('\u{0EDF}', true, "Lao, last letter"), + ('\u{0F00}', true, "Tibetan (0F00..0FFF), first letter"), + ('\u{0F8C}', true, "Tibetan, last letter"), + ('\u{1000}', true, "Myanmar (1000..109F), first letter"), + ('\u{108E}', true, "Myanmar, last letter"), + ('\u{10A0}', false, "Georgian (10A0..10FF), after Myanmar"), + ('\u{1770}', false, "Tagbanwa (1760..177F), before Khmer"), + ('\u{1780}', true, "Khmer (1780..17FF), first letter"), + ('\u{17DC}', true, "Khmer, last letter"), + ('\u{1820}', false, "Mongolian (1800..18AF), after Khmer"), + ('\u{3005}', true, "CJK Symbols and Punctuation (3000..303F), ideographic iteration mark"), + ('\u{3006}', true, "CJK Symbols and Punctuation, ideographic closing mark"), + ('\u{3031}', true, "CJK Symbols and Punctuation, vertical kana repeat mark"), + ('\u{3035}', true, "CJK Symbols and Punctuation, vertical kana repeat mark lower half"), + ('\u{303B}', true, "CJK Symbols and Punctuation, vertical ideographic iteration mark"), + ('\u{303C}', true, "CJK Symbols and Punctuation, masu mark"), + ('\u{3041}', true, "Hiragana (3040..309F), first letter"), + ('\u{309F}', true, "Hiragana, last letter"), + ('\u{30A1}', true, "Katakana (30A0..30FF), first letter"), + ('\u{30FF}', true, "Katakana, last letter"), + ('\u{3105}', true, "Bopomofo (3100..312F), first letter"), + ('\u{312F}', true, "Bopomofo, last letter"), + ('\u{3131}', false, "Hangul Compatibility Jamo (3130..318F), after Bopomofo"), + ('\u{318E}', false, "Hangul Compatibility Jamo, last letter, before Bopomofo Extended"), + ('\u{31A0}', true, "Bopomofo Extended (31A0..31BF), first letter"), + ('\u{31BF}', true, "Bopomofo Extended, last letter"), + ('\u{31F0}', true, "Katakana Phonetic Extensions (31F0..31FF), first letter"), + ('\u{31FF}', true, "Katakana Phonetic Extensions, last letter"), + ('\u{3400}', true, "CJK Unified Ideographs Extension A (3400..4DBF), first"), + ('\u{4DBF}', true, "CJK Unified Ideographs Extension A, last"), + ('\u{4E00}', true, "CJK Unified Ideographs (4E00..9FFF), first"), + ('\u{9FFF}', true, "CJK Unified Ideographs, last"), + ('\u{A000}', false, "Yi Syllables (A000..A48F), after CJK Unified Ideographs"), + ('\u{A9CF}', false, "Javanese (A980..A9DF), before Myanmar Extended-B"), + ('\u{A9E0}', true, "Myanmar Extended-B (A9E0..A9FF), first letter"), + ('\u{A9FE}', true, "Myanmar Extended-B, last letter"), + ('\u{AA00}', false, "Cham (AA00..AA5F), after Myanmar Extended-B"), + ('\u{AA4B}', false, "Cham, before Myanmar Extended-A"), + ('\u{AA60}', true, "Myanmar Extended-A (AA60..AA7F), first letter"), + ('\u{AA7F}', true, "Myanmar Extended-A, last letter"), + ('\u{AA80}', false, "Tai Viet (AA80..AADF), after Myanmar Extended-A"), + ('\u{D7FB}', false, "Hangul Jamo Extended-B (D7B0..D7FF), before CJK Compatibility Ideographs"), + ('\u{F900}', true, "CJK Compatibility Ideographs (F900..FAFF), first"), + ('\u{FAD9}', true, "CJK Compatibility Ideographs, last"), + ('\u{FB00}', false, "Alphabetic Presentation Forms (FB00..FB4F), after CJK Compatibility Ideographs"), + ('\u{FF5A}', false, "Halfwidth and Fullwidth Forms (FF00..FFEF), fullwidth z, before halfwidth katakana"), + ('\u{FF66}', true, "Halfwidth and Fullwidth Forms, first halfwidth katakana"), + ('\u{FF9F}', true, "Halfwidth and Fullwidth Forms, last halfwidth katakana"), + ('\u{FFA1}', false, "Halfwidth and Fullwidth Forms, halfwidth Hangul, after halfwidth katakana"), + ('\u{1AFF0}', true, "Kana Extended-B (1AFF0..1AFFF), first letter"), + ('\u{1B000}', true, "Kana Supplement (1B000..1B0FF), first letter"), + ('\u{1B122}', true, "Kana Extended-A (1B100..1B12F), last letter"), + ('\u{1B132}', true, "Small Kana Extension (1B130..1B16F), first letter"), + ('\u{1B167}', true, "Small Kana Extension, last letter"), + ('\u{1B170}', false, "Nushu (1B170..1B2FF), after Small Kana Extension"), + ('\u{20000}', true, "CJK Unified Ideographs Extension B (20000..2A6DF), first"), + ('\u{2A6DF}', true, "CJK Unified Ideographs Extension B, last"), + ('\u{2F800}', true, "CJK Compatibility Ideographs Supplement (2F800..2FA1F), first"), + ('\u{323AF}', true, "CJK Unified Ideographs Extension H (31350..323AF), last"), +]; + // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; @@ -50,7 +129,9 @@ fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: } } } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); + // A neighbour bounds a word only in a script written with spaces between words, or when it + // is a digit. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric() && (c.is_numeric() || !unspaced(c))); // A word ends at a skipped zero width space as it does at a space. let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp); let mut hits = Vec::new(); @@ -92,9 +173,24 @@ fn whole_word_rejection_keeps_overlapping_candidates() { assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); } +/// A letter from a block in `UNSPACED_BLOCK_SAMPLES` next to a whole-word match leaves it standing +/// when the block's script is written without spaces, and bounds it otherwise, on either side. +#[test] +fn whole_words_follow_the_unicode_blocks_of_unspaced_scripts() { + for &(c, unspaced, block) in UNSPACED_BLOCK_SAMPLES { + let (neighbour, cp) = (c.to_string(), u32::from(c)); + assert!(c.is_alphanumeric() && !c.is_numeric(), "U+{cp:04X} ({block}) is a letter, so the sample tests the table"); + // Case-sensitive, so that case folding does not move a neighbour into another block + // (Georgian U+10A0 folds to U+2D00). + let (before, after) = if unspaced { (Some(1..2), Some(0..1)) } else { (None, None) }; + assert_eq!(page(&[&neighbour, "PDF"]).find_opts("PDF", true, true), Vec::from_iter(before), "U+{cp:04X} ({block}) before the match"); + assert_eq!(page(&["PDF", &neighbour]).find_opts("PDF", true, true), Vec::from_iter(after), "U+{cp:04X} ({block}) after the match"); + } +} + #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "ก", "한", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index f9f2f3e6d9..a4740b4707 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -16,6 +16,7 @@ use crate::SignError; pub mod tag { pub const BOOLEAN: u8 = 0x01; pub const INTEGER: u8 = 0x02; + pub const ENUMERATED: u8 = 0x0A; pub const BIT_STRING: u8 = 0x03; pub const OCTET_STRING: u8 = 0x04; /// An OCTET STRING split into segments (BER only, X.690 §8.21); see [`super::Tlv::octets`]. @@ -205,12 +206,26 @@ impl<'a> Tlv<'a> { /// A non-negative INTEGER that fits in a u64. pub fn u64(&self) -> Result { - if self.tag != tag::INTEGER || self.value.is_empty() || self.value.len() > 9 || self.value[0] & 0x80 != 0 { + // (nine bytes only with a 0x00 pad: a nonzero first byte is past 64 bits) + if self.tag != tag::INTEGER + || self.value.is_empty() + || self.value.len() > 9 + || self.value[0] & 0x80 != 0 + || (self.value.len() == 9 && self.value[0] != 0) + { return Err(bad("expected a small non-negative INTEGER")); } Ok(self.value.iter().fold(0u64, |acc, b| (acc << 8) | *b as u64)) } + /// A non-negative ENUMERATED that fits in a u64 (same encoding as an INTEGER). + pub fn enumerated(&self) -> Result { + if self.tag != tag::ENUMERATED { + return Err(bad("expected an ENUMERATED")); + } + Tlv { tag: tag::INTEGER, ..*self }.u64() + } + /// The magnitude of an INTEGER (leading zero dropped). pub fn uint_bytes(&self) -> &'a [u8] { match self.value { @@ -544,6 +559,20 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec { mod tests { use super::*; + #[test] + fn a_nine_byte_integer_needs_a_zero_pad_to_fit_64_bits() { + let tlv = |value: &[u8]| { + let mut b = vec![tag::INTEGER, value.len() as u8]; + b.extend_from_slice(value); + b + }; + // 2^64 would fold to 0: refused, not read as 0 + assert!(Tlv::parse_all(&tlv(&[1, 0, 0, 0, 0, 0, 0, 0, 0])).unwrap().u64().is_err()); + // u64::MAX with its pad is fine + let max = [0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; + assert_eq!(Tlv::parse_all(&tlv(&max)).unwrap().u64().unwrap(), u64::MAX); + } + #[test] fn try_oid_rejects_signs_and_empty_arcs() { for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] { diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 057e0717de..93191b2528 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -181,7 +181,7 @@ impl OcspResponse { return Err(bad("OCSP response is empty or exceeds the 2 MiB limit")); } let resp = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "OCSPResponse")?.children()?; - let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.u64()?; + let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.enumerated()?; if status != 0 { return Err(bad(format!("responder reported an error (status {status})"))); } diff --git a/crates/sign/src/rsa_pad.rs b/crates/sign/src/rsa_pad.rs index 8cc6e08f1e..531f703d52 100644 --- a/crates/sign/src/rsa_pad.rs +++ b/crates/sign/src/rsa_pad.rs @@ -14,6 +14,9 @@ use crate::keys::{DigestAlg, PssParams}; /// signature value was not written with exactly the modulus' length (RFC 8017 §8.2.2 wants /// that); some signers drop or add leading zero bytes. fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result>, SignError> { + // Redundant leading zeros would size the big-integer arithmetic (and the backend's own size + // limit) by the encoding rather than the number, so they are dropped before anything is built. + let (n, e) = (trim_zeros(n), trim_zeros(e)); let k = n.len(); let key = RsaPublicKey::new(BoxedUint::from_be_slice_vartime(n), BoxedUint::from_be_slice_vartime(e)) .map_err(|e| SignError::Malformed(format!("RSA key: {e}")))?; @@ -40,6 +43,10 @@ fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str> Ok(Some(out)) } +fn trim_zeros(b: &[u8]) -> &[u8] { + b.get(b.iter().position(|x| *x != 0).unwrap_or(b.len())..).unwrap_or_default() +} + /// `DigestInfo ::= SEQUENCE { AlgorithmIdentifier, OCTET STRING }`. fn digest_info(alg: DigestAlg, digest: &[u8], null_params: bool) -> Vec { let id = der::algorithm(alg.oid(), null_params.then(der::null).as_deref()); @@ -99,6 +106,7 @@ pub fn pss_verify( notes: &mut Vec<&'static str>, ) -> Result { let Some(raw) = encoded_message(n, e, sig, notes)? else { return Ok(false) }; + let n = trim_zeros(n); let mod_bits = n.first().map_or(0, |b| (n.len() * 8) as u32 - b.leading_zeros()); let Some(em_bits) = mod_bits.checked_sub(1) else { return Ok(false) }; let em_len = em_bits.div_ceil(8) as usize; diff --git a/crates/sign/src/x509.rs b/crates/sign/src/x509.rs index 5dd03d086d..1cf586b3e8 100644 --- a/crates/sign/src/x509.rs +++ b/crates/sign/src/x509.rs @@ -415,10 +415,19 @@ impl Certificate { /// Whether `key` verifies this certificate's signature. pub fn signed_by(&self, key: &PublicKey) -> bool { + self.signed_by_cached(key, &mut None) + } + + /// [`Self::signed_by`] keeping the digest of the signed part in `digest`, so checking one + /// certificate against many candidate keys hashes it once. + fn signed_by_cached(&self, key: &PublicKey, digest: &mut Option>) -> bool { let Ok(alg) = Tlv::parse_all(&self.sig_alg) else { return false }; match keys::signature_algorithm(&alg) { Ok((scheme @ keys::Scheme::Ed25519, _)) => key.verify_message(scheme, &self.tbs, &self.signature).unwrap_or(false), - Ok((scheme, Some(digest))) => key.verify(scheme, digest, &digest.digest(&[&self.tbs]), &self.signature).unwrap_or(false), + Ok((scheme, Some(d))) => { + let hash = digest.get_or_insert_with(|| d.digest(&[&self.tbs])); + key.verify(scheme, d, hash, &self.signature).unwrap_or(false) + } _ => false, } } @@ -533,7 +542,8 @@ pub fn build_chain_noted<'a>(leaf: &'a Certificate, pool: &'a [Certificate], at: // CA certificates between `last` and the leaf: what a candidate's pathLenConstraint limits. let below = chain.len() - 1; let mut found = None; - for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by(&c.public_key)) { + let mut digest = None; + for c in pool.iter().filter(|c| c.subject.raw == last.issuer.raw && !chain.contains(c) && last.signed_by_cached(&c.public_key, &mut digest)) { // Each reason is a whole sentence, not a phrase stitched into one: a language puts the // two names where it needs them. let why = if !c.may_issue() { diff --git a/crates/sign/tests/it/crypto.rs b/crates/sign/tests/it/crypto.rs index f184d7161a..0df66b25e9 100644 --- a/crates/sign/tests/it/crypto.rs +++ b/crates/sign/tests/it/crypto.rs @@ -369,7 +369,7 @@ fn signed_ocsp(key: &pdfcraft_sign::PrivateKey, responder_id: &[u8], certs: Opti None => der::seq(&[&tbs, &alg, &der::bit_string(&sig)]), }; let basic_type = der::oid("1.3.6.1.5.5.7.48.1.1"); - der::seq(&[&der::int(0), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) + der::seq(&[&der::tlv(tag::ENUMERATED, &[0]), &der::explicit(0, &der::seq(&[&basic_type, &der::octets(&basic)]))]) } fn mid_june() -> Time { @@ -470,6 +470,9 @@ fn ocsp_responses_verify_and_match_the_certificate() { let wrong_key = OcspResponse::parse(&ocsp(&other, RevocationStatus::Good, this, next)).unwrap(); assert_eq!(wrong_key.check(issuer, issuer, at), RevocationStatus::Unknown); // An error response is rejected at parse time. + let error = der::seq(&[&der::tlv(tag::ENUMERATED, &[6])]); + assert!(OcspResponse::parse(&error).is_err()); + // responseStatus is an ENUMERATED (RFC 6960); an INTEGER is not one. let error = der::seq(&[&der::int(6)]); assert!(OcspResponse::parse(&error).is_err()); } @@ -524,6 +527,12 @@ fn rsa_signatures_are_read_with_every_digestinfo_variant() { for (name, sig) in [("standard", RSA_STD), ("no NULL", RSA_NO_NULL)] { assert!(key.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(sig)).unwrap(), "{name}"); } + // Redundant leading zeros on the modulus change neither the result nor the arithmetic size. + if let pdfcraft_sign::keys::PublicKey::Rsa { n, e } = key { + let pad = |v: &[u8]| [vec![0u8; 1 << 16], v.to_vec()].concat(); + let padded = pdfcraft_sign::keys::PublicKey::Rsa { n: pad(n), e: pad(e) }; + assert!(padded.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(RSA_STD)).unwrap(), "padded key"); + } // The bare digest, with no DigestInfo, binds no hash algorithm: not a signature we accept. assert!(!key.verify(Scheme::RsaPkcs1, DigestAlg::Sha256, &digest, &unhex(RSA_BARE)).unwrap(), "bare digest"); // PSS with the salt length it declares, or any when none is declared. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c6c934171c..7f9f39894e 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -29,7 +29,8 @@ pdfcraft-model = { workspace = true } pdfcraft-platform = { workspace = true } egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] } egui_extras = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] } +# `glow` is always on: `eframe::App::on_exit` takes a GL context when that feature is enabled. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "persistence", "links", "clipboard"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -93,6 +94,8 @@ web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlE [dev-dependencies] craft-update = { workspace = true } pdfcraft-xfa = { workspace = true } +# A content stream that inflates past the renderer's limits, for the cut-content notice test. +flate2 = { workspace = true } egui_kittest = { workspace = true, features = ["eframe", "wgpu"] } # Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must # be enabled on the test graph itself (a dependency's dev-features do not propagate). diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index a48de69111..fd6d2dcbc6 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -38,16 +38,18 @@ impl PdfCraftApp { // The window shows the active document's name (or title, if it asks for that), with • // while it has unsaved changes, as the other Craft apps write it (#3640). Unsaved means // what closing would ask about, so a draft not yet applied counts too. - let dirty = self.active.is_some_and(|i| self.has_unsaved_work(i)); + // In split view it names the main pane's document. + let front = self.pane_front(panes::Place::Main); + let dirty = front.is_some_and(|i| self.has_unsaved_work(i)); let mark = if dirty { " •" } else { "" }; let titled = |name: Option| { name.or_else(|| self.combine_showing().then(|| tl!("Combine files").to_owned())) .map_or_else(|| "PdfCraft".to_owned(), |name| format!("{name}{mark} — PdfCraft")) }; - let title = titled(self.active.and_then(|i| crate::canvas::tab_label(self, i))); + let title = titled(front.and_then(|i| crate::canvas::tab_label(self, i))); // The web page's title is what an embedding host reads to tell which file opened, so it // names the file even when the document asks to show its own title. - let page_title = titled(self.active.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone())); + let page_title = titled(front.and_then(|i| self.views.get(i)).and_then(|v| self.session.get(v.id)).map(|d| d.name.clone())); if title != self.window_title { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.window_title = title; @@ -65,6 +67,7 @@ impl PdfCraftApp { /// Construct an application with the default session, panels, theme and control state. pub fn new() -> Self { Self { + panes: Default::default(), session: Session::new(), status_captions_lang: None, views: Vec::new(), @@ -136,6 +139,7 @@ impl PdfCraftApp { reload_changed_files: true, update_check_at_start: true, show_secured_notice: true, + text_links: true, always_default_view: false, #[cfg(not(target_arch = "wasm32"))] watch: Default::default(), @@ -275,6 +279,7 @@ impl PdfCraftApp { recovery: None, recoverable: Vec::new(), recovery_keys: Default::default(), + recovered_paths: Default::default(), last_autosave: None, allow_quit: false, deferred_commands: Vec::new(), @@ -328,6 +333,7 @@ impl PdfCraftApp { replace_draft: None, number_draft: NumberDraft { from: 1, to: 1, style: pdfcraft_engine::LabelStyle::Decimal, prefix: String::new(), start: 1 }, word_count_draft: word_count_ui::Draft::default(), + hairlines_draft: Default::default(), } } @@ -468,6 +474,11 @@ impl eframe::App for PdfCraftApp { storage.set_string("pdfcraft", self.persist()); } + /// Starts an update that waits for the exit. On macOS the process ends before the app is dropped. + fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + self.updates.exiting(); + } + /// Each frame's work before drawing: opens, operating-system and control-channel requests, /// the quit guard, shortcuts, and the background jobs' results. /// @@ -603,8 +614,16 @@ impl PdfCraftApp { ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); ctx.send_viewport_cmd(egui::ViewportCommand::Focus); } + // Combine from File Explorer's PDF menu, also handed to the running app: the dialog + // it opens has to be seen, so the window comes to the front as for Open. + #[cfg(not(target_arch = "wasm32"))] + OsEvent::Combine(paths) => { + self.combine_paths(&paths); + ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } #[cfg(target_arch = "wasm32")] - OsEvent::Open(_) => {} + OsEvent::Open(_) | OsEvent::Combine(_) => {} // Like closing the window: `guard_quit` asks about unsaved changes. OsEvent::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), } @@ -721,6 +740,7 @@ impl PdfCraftApp { view.pending_pick = None; } } + panes::frame(self); self.sync_titles(&ctx); // The accessible window takes the same name, so a screen reader (AT-SPI on Linux, UIA on // Windows) can tell it apart (#4614). egui builds the root node afresh every pass. @@ -785,14 +805,7 @@ impl PdfCraftApp { if self.mode == Mode::AllTools && self.all_tools_full { all_tools_ui::show(self, ui); } else { - match self.active { - None if self.combine_showing() => combine_ui::page(self, ui), - None => home::show(self, ui), - Some(index) => match compare_ui::side_by_side(self, index) { - Some((old, new)) => compare_ui::compare_area(self, old, new, ui), - None => canvas::document_area(self, index, ui), - }, - } + panes::page_area(self, ui); } }); self.process_pending_edits(); diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs index 35a214d9aa..aaf5c009f8 100644 --- a/crates/ui-egui/src/app/control.rs +++ b/crates/ui-egui/src/app/control.rs @@ -142,6 +142,12 @@ impl PdfCraftApp { self.execute("page.number"); Some(Dialog::NumberPages) } + "fix-hairlines" => { + // Through the command, so it is refused with no document or none that + // can be modified, as the menu is. + self.execute("prepress.hairlines"); + return Ok(()); + } "page-properties" => { // Same path as the panel, so the selected pages and their order are seeded. self.execute("page.properties"); @@ -351,6 +357,7 @@ impl PdfCraftApp { }; } ("author", _) => self.comment_prefs.author = value.to_string(), + ("folder-collapsed", _) => self.set_folder_collapsed(value)?, ("comment-tool-pinned", _) => { self.comment_prefs.pinned = match value { "true" => true, @@ -365,6 +372,13 @@ impl PdfCraftApp { _ => return Err("hide-empty-highlight-hover must be true or false".into()), }; } + ("text-links", _) => { + self.text_links = match value { + "true" => true, + "false" => false, + _ => return Err("text-links must be true or false".into()), + }; + } ("ui-scale", _) => self.ui_scale = ui_scale::parse(value)?, // `--secured-notice false`: no notice above the pages that a document is secured. ("secured-notice", _) => { diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index ff7e06f377..a86c72ef7d 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -840,20 +840,25 @@ impl PdfCraftApp { return; } self.remember_view(index); - let id = self.views.remove(index).id; - #[cfg(not(target_arch = "wasm32"))] - if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { - if self.closed_tabs.len() == 10 { - self.closed_tabs.remove(0); + let view = self.views.remove(index); + let (id, place) = (view.id, view.place()); + // Another tab showing the same document (split view) keeps it open. + if !self.views.iter().any(|v| v.id == id) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(path) = self.session.get(id).and_then(|d| d.path.clone()) { + if self.closed_tabs.len() == 10 { + self.closed_tabs.remove(0); + } + self.closed_tabs.push(path); } - self.closed_tabs.push(path); + self.forget_recovery(id); + self.session.close(id); } - self.forget_recovery(id); - self.session.close(id); self.active = match self.active { _ if self.views.is_empty() => None, Some(a) if a > index => Some(a - 1), - Some(a) if a >= self.views.len() => Some(self.views.len() - 1), + // The tab after it in its strip comes forward (or the last). + Some(a) if a == index => self.after_close(place, index), other => other, }; } diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs index 3d6f493e10..733eabdd8e 100644 --- a/crates/ui-egui/src/app/frame.rs +++ b/crates/ui-egui/src/app/frame.rs @@ -97,23 +97,39 @@ impl PdfCraftApp { self.finish_document_copy(ctx); // The Combine grid's thumbnails, whichever tab shows (renders under way still finish). self.combine_thumbs_frame(ctx); - // Retire hidden documents before admitting the visible document's textures, so the + // Retire hidden documents before admitting the visible documents' textures, so the // three cache limits apply to the application, regardless of how many tabs are open. - // Side by side: the comparison's old file renders alongside the active one. - let partner = self.active.and_then(|i| compare_ui::side_by_side(self, i)).map(|(old, _)| old); + // On screen: the active tab, the comparison's old file side by side with it, and what + // split view's other pane shows. + let shown = self.views_on_screen(); + let shown_docs: std::collections::HashSet = shown.iter().filter_map(|&i| self.views.get(i)).map(|v| v.id).collect(); for (i, view) in self.views.iter_mut().enumerate() { - if Some(i) != self.active - && Some(i) != partner - && let Some(doc) = self.session.get(view.id) - { + if shown.contains(&i) { + continue; + } + if shown_docs.contains(&view.id) { + // Another tab of this document is on screen, and renders through its pool. + view.release_rasters(); + } else if let Some(doc) = self.session.get(view.id) { view.suspend_rendering(&doc.renderer); } } - for i in self.active.into_iter().chain(partner) { - if let Some(view) = self.views.get_mut(i) - && let Some(doc) = self.session.get(view.id) - { - view.finish_render_frame(ctx, &doc.info, &doc.renderer); + // Each document on screen once: two views of it (split view) share its render pool. + let mut done = std::collections::HashSet::new(); + for &i in &shown { + let Some(id) = self.views.get(i).map(|v| v.id) else { continue }; + if !done.insert(id) { + continue; + } + let Some(doc) = self.session.get(id) else { continue }; + let group: Vec = shown.iter().copied().filter(|&k| self.views.get(k).is_some_and(|v| v.id == id)).collect(); + match group.as_slice() { + [only] => { + if let Some(view) = self.views.get_mut(*only) { + view.finish_render_frame(ctx, &doc.info, &doc.renderer); + } + } + _ => DocView::finish_shared_render_frame(ctx, &doc.info, &doc.renderer, &mut self.views, &group), } } } diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index aa40ea0d24..6a6452d463 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -87,6 +87,7 @@ impl PdfCraftApp { "reload_changed_files": self.reload_changed_files, "system_title_bar": self.system_title_bar, "pinned_folders": self.pinned.folders, + "pinned_folders_collapsed": self.pinned.collapsed, "theme": self.theme_preference, "scroll_speed": self.scroll_speed, "invert_middle_drag": self.invert_middle_drag, @@ -160,6 +161,9 @@ impl PdfCraftApp { v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + // The Print dialog's paper list and each printer's paper (print.page-setup). + v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers); + v["print_paper_by_printer"] = serde_json::json!(self.print_draft.remembered_paper); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and // settings written then would turn this off unasked. @@ -167,9 +171,12 @@ impl PdfCraftApp { v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); + v["text_links"] = serde_json::Value::Bool(self.text_links); v["organize_zoom"] = serde_json::json!(self.organize_zoom); - // Canonical names survive changes to the order of PAPERS and do not depend on UI language. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // Canonical names survive changes to the order of PAPERS and do not depend on UI language; + // a custom paper is stored by the name it was given. + let d = &self.print_draft; + v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name())); v.to_string() } @@ -242,7 +249,11 @@ impl PdfCraftApp { if let Some(on) = v["show_secured_notice"].as_bool() { self.show_secured_notice = on; } + if let Some(on) = v["text_links"].as_bool() { + self.text_links = on; + } self.pinned.restore(&v["pinned_folders"]); + self.pinned.restore_collapsed(&v["pinned_folders_collapsed"]); if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { let mut tools = Vec::new(); for item in items { @@ -286,11 +297,16 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - if let Some(paper) = v - .get("print_paper") - .and_then(serde_json::Value::as_str) - .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) - { + // The Print dialog's paper list and each printer's paper; settings are untrusted, so each + // custom paper is checked as if it were typed. + if v.get("print_custom_papers").is_some() { + self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); + } + if v.get("print_paper_by_printer").is_some() { + self.print_draft.remembered_paper = print_paper::read_remembered(&v["print_paper_by_printer"]); + } + // After the custom papers, so a custom paper chosen last time is found by its name. + if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) { self.print_draft.paper = paper; } if let Some(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 26d1bd4946..5af43213de 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9,6 +9,7 @@ use std::ops::Range; use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; +use pdfcraft_engine::text_links::TextLink; use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; @@ -500,6 +501,9 @@ pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapsho } pub struct DocView { + /// Where the tab shows (split view's panes) and the key that tells two + /// views of one document apart (`crate::panes`). + pub(crate) pane: crate::panes::ViewPane, pub id: DocId, pub zoom: f32, pub fit: Fit, @@ -563,6 +567,10 @@ pub struct DocView { pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, + /// Pages whose content was cut at the renderer's limits (#623): drawn and searched in part. + cut_pages: std::collections::BTreeSet, + /// Cut pages whose notice the reader dismissed: a page cut later shows it again. + cut_notice_dismissed: std::collections::BTreeSet, /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, @@ -592,6 +600,8 @@ pub struct DocView { tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, /// Text layers, extracted in the background on demand (selection, find, copy). pub(crate) texts: HashMap>, + /// Addresses written as plain text on a page (#4207), with the text layer they were found in. + text_links: HashMap, Arc<[TextLink]>)>, pub(crate) text_failed: HashSet, pub find: Option, selection: Option, @@ -618,8 +628,8 @@ pub struct DocView { viewport_w: f32, viewport_h: f32, page_count: usize, - /// Page heights in points (view space), for mapping text positions to scroll offsets. - page_heights: Vec, + /// Page sizes (width, height) in points (view space), for mapping text positions to scroll offsets. + page_sizes: Vec<(f32, f32)>, /// Screen rects of the pages drawn last frame (hit-testing, tests, automation). screen_rects: Vec<(usize, Rect)>, screen_xforms: Vec<(usize, PageXform)>, @@ -989,6 +999,7 @@ impl DocView { /// any layout or zoom the PDF itself asks for. pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { Self { + pane: Default::default(), id, zoom: defaults.zoom, fit: defaults.fit, @@ -1021,6 +1032,8 @@ impl DocView { presentation: None, pages: HashMap::new(), errors: HashMap::new(), + cut_pages: Default::default(), + cut_notice_dismissed: Default::default(), waiting_since: HashMap::new(), thumbs: HashMap::new(), thumb_demand: HashMap::new(), @@ -1035,6 +1048,7 @@ impl DocView { grayscale: false, tiles: HashMap::new(), texts: HashMap::new(), + text_links: HashMap::new(), text_failed: HashSet::new(), find: None, selection: None, @@ -1050,7 +1064,7 @@ impl DocView { viewport_w: 800.0, viewport_h: 600.0, page_count: info.pages.len(), - page_heights: info.pages.iter().map(|p| p.height).collect(), + page_sizes: info.pages.iter().map(|p| (p.width, p.height)).collect(), screen_rects: Vec::new(), screen_xforms: Vec::new(), screen_ppp: 1.0, @@ -1140,7 +1154,7 @@ impl DocView { self.invalidate_content(); self.frame_geometry_changed = true; self.page_count = info.pages.len(); - self.page_heights = info.pages.iter().map(|p| p.height).collect(); + self.page_sizes = info.pages.iter().map(|p| (p.width, p.height)).collect(); let last = self.page_count.saturating_sub(1); self.current = self.current.min(last); self.selected.retain(|p| *p <= last); @@ -1179,10 +1193,11 @@ impl DocView { fresh.organize = self.organize; fresh.grid_zoom = self.grid_zoom; fresh.thumbnail_zoom = self.thumbnail_zoom; - // Same document id, so the scroll offset egui keeps for it is still this document's. + // Same view key (`pane`), so the scroll offset egui keeps for it is still this view's. fresh.shown = self.shown; fresh.highlight_fields = self.highlight_fields; fresh.notice_dismissed = self.notice_dismissed; + fresh.cut_notice_dismissed = std::mem::take(&mut self.cut_notice_dismissed); fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; @@ -1196,6 +1211,7 @@ impl DocView { fresh.viewport_w = self.viewport_w; fresh.viewport_h = self.viewport_h; fresh.viewport_screen = self.viewport_screen; + fresh.pane = self.pane.clone(); *self = fresh; // Marks the kept images out of date and clamps the page to the new page count. self.document_changed(info); @@ -1203,6 +1219,45 @@ impl DocView { self.page_input_reselect = true; } + /// Another view of this document changed it (split view: an edit, Undo, + /// a reload): start afresh as a reload does, keeping the reader's place, so nothing here + /// points into the old version. + pub(crate) fn follow_document(&mut self, info: &DocInfo) { + let continuous = self.continuous; + self.document_replaced(info); + self.continuous = continuous; + } + + /// A second view of this document (split view), showing what this one shows. + pub(crate) fn twin(&self, info: &DocInfo) -> Self { + let mut v = Self::new(self.id, info, ViewDefaults::default()); + v.zoom = self.zoom; + v.fit = self.fit; + v.layout = self.layout; + v.continuous = self.continuous; + v.show_page_gaps = self.show_page_gaps; + v.rotation = self.rotation; + v.cover = self.cover; + v.current = self.current; + v.page_input = self.page_input.clone(); + v.highlight_fields = self.highlight_fields; + v.notice_dismissed = self.notice_dismissed; + v.scripts_off_noted = self.scripts_off_noted; + v.goto = Some((self.current, 0.0)); + v + } + + /// The key that tells this view apart from another view of the same document (split view): + /// unique for the app's life. Ids of what is drawn over the page are keyed by it. + pub fn view_key(&self) -> u64 { + self.pane.key + } + + /// Where this tab shows: the main pane or split view's second pane. + pub fn place(&self) -> crate::panes::Place { + self.pane.place + } + /// Select pages in the organize grid (tests, automation). Empty clears the selection. pub(crate) fn page_count(&self) -> usize { self.page_count @@ -1266,6 +1321,7 @@ struct PageEnv<'a> { precise_scroll: Vec2, prefs: &'a comments::CommentPrefs, preparing: bool, + prepare_tab_order: Option<&'a [(usize, usize)]>, rects: &'a Vec, redact_mark: ([f64; 3], f64), scale: f32, @@ -1277,6 +1333,8 @@ struct PageEnv<'a> { table_preview: &'a Option, tag: u64, text_style: &'a pdfcraft_engine::AddedText, + /// Preferences: addresses written as plain page text open like links (#4207). + text_links: bool, tool: QuickTool, units: crate::units::Unit, unobstructed: bool, @@ -1640,6 +1698,10 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; + // Addresses written as plain text are found once per text layer, before the tools hit-test them. + if frame.env.text_links { + view.refresh_text_links(i); + } let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. @@ -2481,20 +2543,14 @@ fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { } /// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The -/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own -/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). +/// tab is looked up by its view key (indices may have shifted since the click, and split view can +/// show one document in two tabs), and the flag rides its own view, so a stale flag can never +/// close the wrong tab. Whether a tab was closed (or asked about). fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { - let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.view_key()).and_then(|key| app.tab_with_key(key)); let Some(i) = pending else { return false }; app.views[i].close_requested = false; - if app.hold_close_for_ocr_review(i) { - return true; - } - if app.has_unsaved_work(i) { - app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); - } else { - app.close_tab(i); - } + app.request_close_tab(i); true } @@ -2523,8 +2579,9 @@ fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry }, ..Default::default() }); - // Each document keeps its own scroll position when switching tabs (#189). - scroll = scroll.id_salt(("page-view", view.id)); + // Each tab keeps its own scroll position when switching tabs (#189), and so does each view of + // one document in split view. + scroll = scroll.id_salt(("page-view", view.view_key())); // Every scroll here asks for no animation. An animated area would still queue a zero-length // offset target for the next frame, which overwrites the jump a page turn just set. scroll = scroll.animated(false); @@ -3154,6 +3211,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let prepare_tab_order = (preparing && view.prepare.show_tab_numbers).then(|| crate::forms_ui::tab_order(&form, info)); let env = PageEnv { added: &added, allowed, @@ -3195,6 +3253,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { precise_scroll, prefs, preparing, + prepare_tab_order: prepare_tab_order.as_deref(), rects: &rects, redact_mark, scale, @@ -3206,6 +3265,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { table_preview: &table_preview, tag, text_style: &text_style, + text_links: app.text_links, tool, units, unobstructed, @@ -3222,7 +3282,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { can_paste_image, }; let mut page_out = PageOut::default(); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| { + // Keyed by the view, not the document: with one document in both panes of split view, shared + // ids gave the main pane's clicks on the page to the second pane's widgets. + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.view_key())), app.show_page_edge_shadows, |ui, viewport| { draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) }); let PageOut { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 3e12bd6ce3..a8a538e86c 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -59,8 +59,8 @@ pub(super) enum Notice { } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then the signature status bar, a form's scripts having been turned -/// off, security, forms and warnings. +/// snapshot is, then pages cut at the renderer's limits, then the signature status bar, a +/// form's scripts having been turned off, security, forms and warnings. pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, @@ -119,6 +119,13 @@ pub(super) fn notices( return Some(Notice::Close); } } + // Content cut at the renderer's limits (#623): what is missing is not visible, so the page + // is named in a bar of its own, which the signature, form and security bars below can't + // replace and dismissing them doesn't hide. Dismissing it covers the pages cut so far: a + // page found cut later shows it again. + if view.cut_pages.iter().any(|p| !view.cut_notice_dismissed.contains(p)) { + cut_pages_bar(view, ui, t); + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { @@ -242,6 +249,33 @@ pub(super) fn notices( open_security.then_some(Notice::Security).or(toggled) } +/// The bar naming the pages whose content was cut at the renderer's limits (#623). +fn cut_pages_bar(view: &mut DocView, ui: &mut egui::Ui, t: &Tokens) { + let Some(&first) = view.cut_pages.iter().next() else { return }; + let page = first.saturating_add(1).to_string(); + let n = view.cut_pages.len(); + let text = if n == 1 { + crate::i18n::fmt( + tl!("Page {page} holds more content than PdfCraft can read: part of it isn't drawn, and only part of its text can be found or copied."), + &[("page", &page)], + ) + } else { + crate::i18n::fmt( + tl!( + "{n} pages hold more content than PdfCraft can read, page {page} first: parts of them aren't drawn, and only part of their text can be found or copied." + ), + &[("n", &n.to_string()), ("page", &page)], + ) + }; + let mut dismiss = false; + notice_bar(ui, t, "triangle-alert", t.accent_text, text, |ui| { + dismiss = icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked(); + }); + if dismiss { + view.cut_notice_dismissed.extend(view.cut_pages.iter().copied()); + } +} + /// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the /// right. The buttons are laid out first, so the message only gets the width they leave: it /// wraps there (or, when very little is left, is cut short with the full text on hover) instead diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index 9a0abad953..2b2e519f4c 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,7 +1,7 @@ use super::find::Follow; use super::{ - Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, - device_pixels, + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextLink, TextureHandle, + TextureOptions, device_pixels, }; pub(super) const THUMB_TAG: u64 = 1 << 63; @@ -140,6 +140,12 @@ impl DocView { } } + /// Pages whose content was cut at the renderer's limits, so part of each is not drawn and + /// its text is incomplete (#623; for automation; 0-based, in order). + pub fn cut_pages(&self) -> Vec { + self.cut_pages.iter().copied().collect() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -186,6 +192,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.cut_pages.clear(); self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); @@ -229,6 +236,7 @@ impl DocView { self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); + self.cut_pages.remove(&page); self.last_queue.clear(); if self.selection.is_some_and(|s| s.page == page) { self.selection = None; @@ -245,6 +253,12 @@ impl DocView { self.pages.get(&page).map(|p| p.tex.id()) } + /// The size of `page`'s whole-page raster, when one is drawn and up to date (not waiting for + /// its replacement after a change; tests and automation). + pub fn drawn_page_size(&self, page: usize) -> Option<[usize; 2]> { + self.pages.get(&page).filter(|p| p.tag != STALE_TAG).map(|p| p.tex.size()) + } + /// Whether `page`'s whole-page raster (a tiled page's backdrop too) is still the one from /// before its last change, whatever its tiles are. pub(crate) fn page_raster_out_of_date(&self, page: usize) -> bool { @@ -267,6 +281,37 @@ impl DocView { self.texts.get(&page).cloned() } + /// Finds the addresses written as plain text on `page` (#4207): once per text layer, and only + /// once the page's text is known. The addresses of text layers that were dropped or replaced + /// are forgotten. + pub(crate) fn refresh_text_links(&mut self, page: usize) { + let texts = &self.texts; + self.text_links.retain(|p, (t, _)| texts.get(p).is_some_and(|current| Arc::ptr_eq(current, t))); + if self.text_links.contains_key(&page) { + return; + } + if let Some(text) = self.texts.get(&page).cloned() { + let found: Arc<[TextLink]> = pdfcraft_engine::text_links::find_text_links(&text).into(); + self.text_links.insert(page, (text, found)); + } + } + + /// The address written as plain text on `page` under the view-space point `(x, y)` (#4207), + /// with its index among the page's, unless a link annotation lies over it: the annotation is + /// what the document asks for there. Only addresses [`Self::refresh_text_links`] found count. + pub(crate) fn text_link_at(&self, info: &DocInfo, page: usize, x: f32, y: f32) -> Option<(usize, &TextLink)> { + let (_, links) = self.text_links.get(&page)?; + let page_info = info.pages.get(page)?; + let (index, hit) = links.iter().enumerate().find(|(_, l)| l.contains(x, y))?; + let covered = info.links.iter().any(|l| l.page == page && hit.covered_by(page_info, l.rect)); + (!covered).then_some((index, hit)) + } + + /// The address of the `index`-th text link found on `page` (#4207). + pub(crate) fn text_link_uri(&self, page: usize, index: usize) -> Option { + self.text_links.get(&page).and_then(|(_, links)| links.get(index)).map(|l| l.uri.clone()) + } + /// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and /// find matches on a page all wait for it (tests and automation). pub fn has_page_text(&self, page: usize) -> bool { @@ -309,6 +354,22 @@ impl DocView { /// Only the displayed document owns raster textures. Tabs keep their document and view /// state, but do not each retain a separate full application texture allowance. pub(crate) fn suspend_rendering(&mut self, pool: &RenderPool) { + let queued = !self.last_queue.is_empty(); + self.release_rasters(); + if queued { + pool.set_queue(Vec::new()); + } + // Results already finished for this tab would otherwise sit in its pool, rasters and + // all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.) + if !pool.is_inline() { + while pool.try_recv().is_some() {} + } + } + + /// The view's half of [`DocView::suspend_rendering`]: its rasters and its demand go, and its + /// document's pool is left alone. On its own for a view whose document another view on screen + /// renders through the same pool (split view). + pub(crate) fn release_rasters(&mut self) { self.begin_render_frame(); self.pages.clear(); self.tiles.clear(); @@ -322,15 +383,8 @@ impl DocView { self.signature_drag = Default::default(); self.text_background = Default::default(); self.move_background = Default::default(); - if !self.last_queue.is_empty() { - pool.set_queue(Vec::new()); - self.last_queue.clear(); - } - // Results already finished for this tab would otherwise sit in its pool, rasters and - // all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.) - if !pool.is_inline() { - while pool.try_recv().is_some() {} - } + self.last_queue.clear(); + self.pane.shared = false; } pub(super) fn thumbnail_requests(&self, info: &DocInfo, ppp: f32) -> Vec { @@ -527,7 +581,8 @@ impl DocView { return; } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); - if queue != self.last_queue { + // (Shared with another view of the document last frame, the pool's queue was both views'.) + if queue != self.last_queue || std::mem::take(&mut self.pane.shared) { pool.set_queue(queue.clone()); self.last_queue = queue; } @@ -539,25 +594,69 @@ impl DocView { } } + /// [`DocView::finish_render_frame`] for several views of one document on screen at once (split + /// view), `group` in `views`: the document's one render pool works for + /// all of them, and a request they share (the same page at the same scale) is rendered once + /// and handed to each. + pub(crate) fn finish_shared_render_frame(ctx: &egui::Context, info: &DocInfo, pool: &RenderPool, views: &mut [DocView], group: &[usize]) { + // As for one view (#3149): an edit after the canvases collected demand leaves its scales + // and indexes on the old pages, in every view of the document (the pool's queue is theirs). + if group.iter().any(|&i| views.get(i).is_some_and(|v| v.frame_geometry_changed)) { + for &i in group { + if let Some(view) = views.get_mut(i) { + view.begin_render_frame(); + view.last_queue.clear(); + } + } + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } + let ppp = ctx.pixels_per_point(); + let mut changed = false; + let mut union: Vec = Vec::new(); + for &i in group { + let Some(view) = views.get_mut(i) else { continue }; + let queue = view.prepare_render_queue(info, ppp); + changed |= !view.pane.shared || queue != view.last_queue; + for r in &queue { + if !union.contains(r) { + union.push(*r); + } + } + view.last_queue = queue; + view.pane.shared = true; + } + if changed { + pool.set_queue(union); + } + // A result can go to both views, so one view holding its rasters holds them for both. + if !group.iter().any(|&i| views.get(i).is_some_and(|v| v.hold_rasters)) { + receive_batch(pool, |r| { + let wanting: Vec = + group.iter().copied().filter(|&i| views.get(i).is_some_and(|v| v.last_queue.contains(&r.request))).collect(); + let Some((&last, rest)) = wanting.split_last() else { return 0 }; + let mut uploaded = 0usize; + for &i in rest { + if let Some(view) = views.get_mut(i) { + uploaded = uploaded.saturating_add(view.receive_result(ctx, copy_result(&r))); + } + } + if let Some(view) = views.get_mut(last) { + uploaded = uploaded.saturating_add(view.receive_result(ctx, r)); + } + uploaded + }); + } + if group.iter().any(|&i| views.get(i).is_some_and(|v| !v.last_queue.is_empty())) { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + } + /// Pull a bounded batch of current results into textures. Demand is committed first, so /// offscreen results and obsolete scale/configuration tags never allocate a ColorImage. pub fn receive(&mut self, ctx: &egui::Context, pool: &RenderPool) { - let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME }; - let mut uploaded = 0usize; - #[cfg(not(target_arch = "wasm32"))] - let started = std::time::Instant::now(); - for _ in 0..budget { - let Some(r) = pool.try_recv() else { break }; - uploaded = uploaded.saturating_add(self.receive_result(ctx, r)); - // One whole-page upload may exceed the frame allowance; it must still progress. - if uploaded >= UPLOAD_BYTES_PER_FRAME { - break; - } - #[cfg(not(target_arch = "wasm32"))] - if started.elapsed() >= std::time::Duration::from_millis(4) { - break; - } - } + receive_batch(pool, |r| self.receive_result(ctx, r)); } pub(super) fn receive_result(&mut self, ctx: &egui::Context, r: pdfcraft_render::RenderedPage) -> usize { @@ -565,6 +664,10 @@ impl DocView { return 0; } ctx.request_repaint(); + // Whether drawn or read for its text, a page cut at the limits is named (#623). + if r.warnings.contains(&pdfcraft_render::RenderWarning::ContentTruncated) { + self.cut_pages.insert(r.request.page); + } if r.request.kind == RequestKind::Text { match r.text { Some(t) => { @@ -745,6 +848,44 @@ impl DocView { } } +/// Take a bounded batch of finished renders from `pool` and hand each to `receive`, which +/// returns the bytes it uploaded: at most a few results, one frame's upload allowance, and 4 ms +/// a frame. +fn receive_batch(pool: &RenderPool, mut receive: impl FnMut(pdfcraft_render::RenderedPage) -> usize) { + let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME }; + let mut uploaded = 0usize; + #[cfg(not(target_arch = "wasm32"))] + let started = std::time::Instant::now(); + for _ in 0..budget { + let Some(r) = pool.try_recv() else { break }; + uploaded = uploaded.saturating_add(receive(r)); + // One whole-page upload may exceed the frame allowance; it must still progress. + if uploaded >= UPLOAD_BYTES_PER_FRAME { + break; + } + #[cfg(not(target_arch = "wasm32"))] + if started.elapsed() >= std::time::Duration::from_millis(4) { + break; + } + } +} + +/// A copy of a render result, for a second view of the document that asked for the same request +/// (split view). Not `Clone` on purpose: page rasters are large, and this is the one place they +/// are copied. +fn copy_result(r: &pdfcraft_render::RenderedPage) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: r.request, + width: r.width, + height: r.height, + rgba: r.rgba.clone(), + error: r.error.clone(), + text: r.text.clone(), + warnings: r.warnings.clone(), + millis: r.millis, + } +} + /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). pub(super) fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index bcb4b42a20..9d750cfdb2 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -180,16 +180,40 @@ impl DocView { self.flash_match(p); } + /// The centre of the current match as a fraction of its displayed page `[x, y]` (0..1 from + /// the top-left corner, after the view rotation, as `goto_point` takes it). + fn match_centre(&self) -> Option<[f32; 2]> { + let f = self.find.as_ref()?; + let (p, r) = f.matches.get(f.current?)?; + let glyphs = self.texts.get(p)?.glyphs.get(r.clone())?; + let [x0, y0, x1, y1] = glyphs.iter().map(|g| g.rect).reduce(|a, b| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])?; + let &(w, h) = self.page_sizes.get(*p).filter(|(w, h)| *w > 0.0 && *h > 0.0)?; + let (u, v) = ((((x0 + x1) / 2.0) / w).clamp(0.0, 1.0), (((y0 + y1) / 2.0) / h).clamp(0.0, 1.0)); + // Turned clockwise by the view rotation, as `PageInfo::dest_fraction` does. + Some(match self.rotation % 360 { + 90 => [1.0 - v, u], + 180 => [1.0 - u, 1.0 - v], + 270 => [v, 1.0 - u], + _ => [u, v], + }) + } + fn flash_match(&mut self, page: usize) { let frac = self .find .as_ref() .and_then(|f| f.current.and_then(|c| f.matches.get(c))) .and_then(|(p, r)| self.texts.get(p).and_then(|t| t.glyphs.get(r.start)).map(|g| g.rect[1])) - .zip(self.page_heights.get(page).copied()) + .zip(self.page_sizes.get(page).map(|&(_, h)| h)) .map(|(y, h)| ((y - 60.0) / h).clamp(0.0, 1.0)) .unwrap_or(0.0); self.goto = Some((page, frac)); + // Then put the match's centre at the window's centre, as Acrobat does (#3530) and as a + // /FitR destination's rectangle is: a page wider or taller than the window no longer + // leaves it off to one side or near an edge. + if let Some([fx, fy]) = self.match_centre() { + self.goto_point = Some((page, Some(fx), Some(fy), vec2(self.viewport_w, self.viewport_h) / 2.0)); + } self.current = page; self.reset_coarse_state(); } @@ -204,6 +228,19 @@ impl DocView { } } +/// The id of a pane's find field: one per view, so two find bars open at once do not share an id +/// (and so the focus). +fn find_input_id(key: u64) -> egui::Id { + egui::Id::unique(("find-input", key)) +} + +impl DocView { + /// The id of this view's find field, for what needs to give it the focus or take it away. + pub fn find_input_id(&self) -> egui::Id { + find_input_id(self.pane.key) + } +} + /// Acrobat-style floating find bar at the top-right of the document area. pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut egui::Ui, t: &Tokens) { let Some(find) = view.find.as_mut() else { return }; @@ -214,7 +251,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg let mut close = false; let mut step: Option = None; let searched = view.texts.len() + view.text_failed.len(); - egui::Area::new(egui::Id::unique("find-bar")) + egui::Area::new(egui::Id::unique(("find-bar", view.pane.key))) .order(egui::Order::Middle) .pivot(Align2::RIGHT_TOP) .fixed_pos(area.right_top() + vec2(-18.0, 12.0)) @@ -229,7 +266,7 @@ pub(super) fn find_bar(view: &mut DocView, pages: usize, area: Rect, ui: &mut eg ui.horizontal(|ui| { ui.add(icons::image("search", 16.0, t.text_muted)); let edit = egui::TextEdit::singleline(&mut find.query) - .id(egui::Id::unique("find-input")) + .id(find_input_id(view.pane.key)) .hint_text(tl!("Find text")) .desired_width(220.0) .frame(egui::Frame::NONE); diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 49dc3336c6..4e491d514c 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -240,7 +240,8 @@ impl DocView { /// Retain the current page when `typed` exactly matches its displayed label, even if /// another page has that label. Otherwise resolve a trimmed label (case-insensitive), - /// then a physical page number. `false` when it names no page. + /// then a position in parentheses ("(3)", as print ranges take it), then a physical page + /// number. `false` when it names no page. pub fn go_to_typed(&mut self, typed: &str, labels: &[String]) -> bool { // Compare the displayed value before trimming: a label may itself contain spaces. // Repeated labels occur in restarted sections and prefix-only ranges. @@ -251,7 +252,7 @@ impl DocView { let page = labels .iter() .position(|l| !l.is_empty() && l.eq_ignore_ascii_case(t)) - .or_else(|| t.parse::().ok().filter(|n| *n >= 1).map(|n| n - 1)); + .or_else(|| pdfcraft_engine::print::position(t).or_else(|| t.parse::().ok()).filter(|n| *n >= 1).map(|n| n - 1)); match page { Some(p) if p < self.page_count => { self.go_to_page(p); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e253fd6b59..f96ff4e91e 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,7 +2,7 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_render::{DocInfo, PageText}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText}; use super::{ DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor, @@ -67,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut redact_mark, table_preview, text_style, + text_links, tool, .. } = frame.env; @@ -100,6 +101,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut lines: &lines, images: &images, clip: ui.clip_rect(), + text_links, }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it @@ -453,6 +455,8 @@ struct PageHits<'a> { images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). clip: Rect, + /// Addresses written as plain page text open like links (#4207). + text_links: bool, } impl PageHits<'_> { @@ -534,6 +538,7 @@ impl PageHits<'_> { lines, images, clip, + text_links, } = *self; // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { @@ -589,11 +594,27 @@ impl PageHits<'_> { let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() + let text_link = (text_links && link.is_none()).then(|| text_link_hit(view, info, xf, page, p)).flatten(); + let on_link = link.is_some() || text_link.is_some(); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, on_link) }; + [link, text_link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } +/// The web or email address written as plain text under `p` on `page` (#4207), with no link +/// annotation over it: the same hand and tip (its address) as a link annotation's. +fn text_link_hit(view: &DocView, info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (vx, vy) = xf.screen_to_view(p); + let (index, link) = view.text_link_at(info, page, vx, vy)?; + let owner = crate::page_router::Owner::TextLink { index }; + // A drag from the address selects its text, as from any other text: only a click opens it. + Some(crate::page_router::Hit { + tip: Some(link.uri.clone()), + declines_drag: true, + ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) + }) +} + /// Text layer: find matches, selection, I-beam and drag-to-select. pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { let PageEnv { selects_text, tool, .. } = frame.env; @@ -605,9 +626,9 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page && !input.consumed && let Some(p) = pointer.filter(|p| r.contains(*p)) { - // The I-beam drops where a press would go to a link (ruling E: the tools that select a - // link's text show it there). - let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. })); + // The I-beam drops where a press would go to a link, an address written as page text + // included (#4207) (ruling E: the tools that select a link's text show it there). + let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. } | Owner::TextLink { .. })); let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag }; text_pointer_input(ui, view, &text, pointer); } @@ -819,6 +840,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P form, generation, info, + prepare_tab_order, prefs, preparing, redact_mark, @@ -871,7 +893,7 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); } if preparing { - crate::prepare::paint_page(tool_cx, form, view); + crate::prepare::paint_page(tool_cx, form, prepare_tab_order.unwrap_or_default(), view); if let Some(preview) = &table_preview { crate::field_table_ui::paint_preview(painter, &xf, i, preview); } @@ -905,6 +927,19 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P { frame.out.clicked_link = Some(l.target.clone()); } + // A web or email address written as plain text (#4207): the same click as a link annotation, + // and the same check and confirmation on opening. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && frame.out.clicked_link.is_none() + && let PagePress::Mine(Owner::TextLink { index }) = input.press + && input.released_over + && let Some(uri) = view.text_link_uri(i, index) + { + frame.out.clicked_link = Some(LinkTarget::Uri(uri)); + } // Comments say what they are with any tool, beside its cursor, as Acrobat's do. comments::describe(&pcx, view); for (mp, mr, mc) in &view.compare_marks { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 30a3f2126f..5dbe5d4461 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -24,6 +24,8 @@ pub(crate) enum Owner { Comment { index: usize, part: CommentPart }, /// A link, by its index in its page's `/Annots`. Link { index: usize }, + /// A web or email address written as plain page text (#4207), by its index among the page's. + TextLink { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, /// Prepare Form: a field's widget, to select and move. diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index f872f8915e..c9bc8cd3e1 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -206,7 +206,7 @@ pub(super) fn selection_actions( let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; - egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( + egui::Area::new(egui::Id::unique(("text-selection-actions", view.view_key()))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d8a5259251..c05a03bb7b 100644 --- a/crates/ui-egui/src/canvas/tests.rs +++ b/crates/ui-egui/src/canvas/tests.rs @@ -484,6 +484,7 @@ fn history_search_view() -> DocView { }], line_of: vec![0], space_before: vec![false], + truncated: false, } .into(), ); @@ -509,7 +510,8 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { // Chrome refreshes the idle page box; navigation must not overwrite a draft // that may still be focused. DocView does not own the editor's focus state. assert_eq!(v.page_input, "draft in progress"); - assert!(v.goto_point.is_none(), "an earlier link destination cannot override the search offset"); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(40.0 / 300.0), Some(0.675), middle)), "the match centre goes to the middle of the viewport"); v.go_to_match(1); assert_eq!(v.back, vec![0], "same-page matches do not duplicate history"); assert!(v.view_history(false)); @@ -523,6 +525,18 @@ fn explicit_search_jumps_record_history_and_keep_match_destination() { } } +#[test] +fn search_centres_the_match_on_a_rotated_view() { + // The match's centre is 40 pt from the left and 270 pt down a 300 × 400 page. + for (rotation, fx, fy) in [(90, 1.0 - 0.675, 40.0 / 300.0), (180, 1.0 - 40.0 / 300.0, 1.0 - 0.675), (270, 0.675, 1.0 - 40.0 / 300.0)] { + let mut v = history_search_view(); + v.rotation = rotation; + v.find_step(true); + let middle = vec2(v.viewport_w, v.viewport_h) / 2.0; + assert_eq!(v.goto_point, Some((3, Some(fx), Some(fy), middle)), "view rotated {rotation}°"); + } +} + #[test] fn automatic_search_updates_do_not_record_history_or_discard_forward() { let mut v = history_search_view(); @@ -550,6 +564,7 @@ fn find_test_text(s: &str) -> PageText { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, } } @@ -1178,6 +1193,7 @@ fn select_all_of_the_document_only_while_scrolling_through_several_pages() { .collect(), line_of: s.chars().map(|_| 0).collect(), space_before: s.chars().map(|_| false).collect(), + truncated: false, }; let mut v = DocView::new(DocId(1), &info, ViewDefaults::default()); for p in 0..3 { @@ -1208,6 +1224,7 @@ fn text_layers_keep_the_selection_page() { glyphs: vec![pdfcraft_render::TextGlyph { text: "x".into(), rect: [0.0, 0.0, 10.0, 10.0], quad: [[0.0, 0.0]; 4], direction: [1.0, 0.0] }], line_of: vec![0], space_before: vec![false], + truncated: false, }; for p in 0..100 { v.texts.insert(p, Arc::new(text.clone())); @@ -2064,6 +2081,59 @@ fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() { assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } +/// A cut page (#623) is named in a bar of its own: a static XFA form's notice, which comes +/// first among the form notices, still shows beside it instead of replacing it (review on #2113). +#[test] +fn a_cut_page_is_named_beside_an_xfa_form_notice() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = pdfcraft_xfa::fixtures::static_shell("Ada"); + app.open_bytes("form.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("XFA data are kept in step"); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label_contains("XFA data are kept in step"); +} + +/// Dismissing another notice doesn't hide a page found cut later (#623), and dismissing the +/// cut-page notice lasts until another page is found cut (review on #2113). +#[test] +fn a_page_found_cut_after_a_dismissal_is_still_named() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(300.0, 400.0, 2).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + // The reader dismissed the document's notice before the render found the cut page. + h.state_mut().views[0].notice_dismissed = true; + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the cut-page notice can be dismissed"); + // Re-reading the same page (an undo, a layer toggle) doesn't bring it back. + h.state_mut().views[0].invalidate_content(); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the dismissed page stays dismissed"); + h.state_mut().views[0].cut_pages.insert(1); + h.run_steps(2); + h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3531dffca5..dba8419919 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -4,6 +4,8 @@ use craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; +use crate::panes::{Place, TabDrop}; +use crate::tab_context::Action; use crate::theme::{self, ThemePreference, Tokens}; use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets, window_controls}; @@ -41,6 +43,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + app.panes.main_strip = Some(full); let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); @@ -80,7 +83,8 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // Tabs shrink to share the strip (names end in "…") and scroll only once they reach // their narrowest; then arrows either side say so and step through them. let mut natural: Vec = Vec::with_capacity(app.views.len() + 1); - for i in 0..app.views.len() { + // (The main pane's tabs: split view's second pane has a strip of its own.) + for i in app.tabs_in(Place::Main) { if let Some(name) = crate::canvas::tab_label(app, i) { natural.push(tab_natural_width(ui, &t, &name)); } @@ -126,7 +130,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // (with whether it was just released). let mut slots = Vec::with_capacity(app.views.len()); let mut dragged = None; - for i in 0..app.views.len() { + for i in app.tabs_in(Place::Main) { let Some(doc) = app.session.get(app.views[i].id) else { continue }; let Some(name) = crate::canvas::tab_label(app, i) else { continue }; let dirty = doc.dirty; @@ -141,7 +145,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { name: &name, file, dirty, - active: app.active == Some(i), + active: app.pane_front(Place::Main) == Some(i), index: i, cap, height: tab_h, @@ -156,23 +160,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if changed && app.active == Some(i) && !app.combine_showing() { response.scroll_to_me(Some(Align::Center)); } - let id = app.views[i].id; - let reveal = cfg!(not(target_arch = "wasm32")) && doc.path.is_some(); - response.context_menu(|ui| { - if ui.button(tl!("Close Tab")).clicked() { - action = Some(crate::tab_context::Action::Close(id)); - ui.close(); - } - if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() { - action = Some(crate::tab_context::Action::Reopen); - ui.close(); - } - let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; - if ui.add_enabled(reveal, egui::Button::new(label)).clicked() { - action = Some(crate::tab_context::Action::Reveal(id)); - ui.close(); - } - }); + response.context_menu(|ui| tab_menu(app, ui, i, &mut action)); if response.clicked() { app.active = Some(i); } @@ -182,33 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { close = Some(i); } } - // Dragging a tab moves it (#1537): a line marks the gap it will land - // in, and releasing the button drops it there. - if let Some((from, released)) = dragged - && let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x) - { - let to = drop_index(&slots, from, x); - if released { - if close.is_none() && action.is_none() { - app.move_tab(from, to); - } - } else { - drag_x = Some(x); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) { - let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range()); - // In front of the first tab the gap lies outside the scroll area's - // clip rect (#2225): keep the whole line inside it. - let clip = ui.clip_rect(); - let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0)); - ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent)); - } - } - } + drag_x = tab_drag(app, ui, &slots, dragged, Place::Main, close.is_some() || action.is_some()); if let Some(i) = close && let Some(view) = app.views.get(i) { - action.get_or_insert(crate::tab_context::Action::Close(view.id)); + action.get_or_insert(Action::Close(view.view_key())); } if let Some(action) = action { app.tab_action(action); @@ -326,6 +292,143 @@ fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } +/// A document tab's right-click menu, in any strip: the choice is left in `action`. +fn tab_menu(app: &PdfCraftApp, ui: &mut egui::Ui, index: usize, action: &mut Option) { + let Some(view) = app.views.get(index) else { return }; + let (tab, id) = (view.view_key(), view.id); + if ui.button(tl!("Close Tab")).clicked() { + *action = Some(Action::Close(tab)); + ui.close(); + } + if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() { + *action = Some(Action::Reopen); + ui.close(); + } + ui.separator(); + // Split view (view.split-window). + if ui.button(tl!("Split right")).clicked() { + *action = Some(Action::Split { tab, down: false }); + ui.close(); + } + if ui.button(tl!("Split down")).clicked() { + *action = Some(Action::Split { tab, down: true }); + ui.close(); + } + ui.separator(); + let reveal = cfg!(not(target_arch = "wasm32")) && app.session.get(id).is_some_and(|d| d.path.is_some()); + let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; + if ui.add_enabled(reveal, egui::Button::new(label)).clicked() { + *action = Some(Action::Reveal(id)); + ui.close(); + } +} + +/// A tab of `place`'s strip (its tabs `slots`) being dragged (`dragged`: its index, and whether it +/// was let go this frame). It moves the tab along the strip (#1537): a line marks the gap it will +/// land in, and letting go drops it there. Over the right half of a page area that isn't split, or +/// over split view's other pane, a hint shows where it would go instead, and letting go puts it +/// there (`crate::panes`). `busy` is a close or menu choice this frame, which wins. Returns the +/// pointer's x while the tab moves along the strip, for scrolling at the strip's edges. +fn tab_drag(app: &mut PdfCraftApp, ui: &egui::Ui, slots: &[(usize, Rect)], dragged: Option<(usize, bool)>, place: Place, busy: bool) -> Option { + let (from, released) = dragged?; + let pos = ui.ctx().pointer_latest_pos()?; + match app.drop_target(pos, place) { + TabDrop::Strip => { + let x = pos.x; + let to = drop_index(slots, from, x); + if released { + if !busy { + app.move_tab_in_strip(from, slots, to); + } + return None; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if let Some(marker) = drop_marker(slots, from, to, ui.spacing().item_spacing.x) { + let t = Tokens::get(ui.ctx()); + let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range()); + // In front of the first tab the gap lies outside the scroll area's clip rect + // (#2225): keep the whole line inside it. + let clip = ui.clip_rect(); + let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0)); + ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent)); + } + Some(x) + } + target => { + if !released { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + app.panes.drop_hint = app.drop_hint_rect(target); + } else if !busy { + app.drop_tab(from, target); + } + None + } + } +} + +/// The tab strip of split view's second pane, in `strip`: its tabs share the width (no scrolling), +/// and are clicked, closed, moved along and dragged to the other pane as in the main strip. +pub(crate) fn pane_tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui, strip: Rect, place: Place) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(strip, 0.0, t.titlebar); + ui.painter().hline(strip.x_range(), strip.bottom() - 0.5, Stroke::new(1.0, t.divider)); + let tabs = app.tabs_in(place); + let front = app.pane_front(place); + let inner = Rect::from_min_max(strip.min + vec2(6.0, 2.0), strip.max - vec2(6.0, 0.0)); + let mut row = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::left_to_right(Align::Max)).id_salt(("tab-row", place))); + row.spacing_mut().item_spacing.x = 4.0; + let natural: Vec = tabs.iter().filter_map(|&i| crate::canvas::tab_label(app, i)).map(|name| tab_natural_width(&row, &t, &name)).collect(); + let cap = tab_cap(&natural, row.available_width(), 4.0, TAB_MIN_WIDTH); + let mut close = None; + let mut action = None; + let mut slots = Vec::with_capacity(tabs.len()); + let mut dragged = None; + for &i in &tabs { + let Some(view) = app.views.get(i) else { continue }; + let Some(doc) = app.session.get(view.id) else { continue }; + let Some(name) = crate::canvas::tab_label(app, i) else { continue }; + let dirty = doc.dirty; + let file = (name != doc.name).then(|| doc.name.clone()); + let id = row.scope_id().with(("pane-tab", view.view_key())); + let spec = TabSpec { + id, + theme: &t, + icon: "file-text", + name: &name, + file: file.as_deref(), + dirty, + active: front == Some(i), + index: i, + cap, + height: inner.height(), + draggable: true, + }; + let response = tab(&mut row, spec, &mut close); + slots.push((i, response.rect)); + if response.dragged() || response.drag_stopped() { + dragged = Some((i, response.drag_stopped())); + } + response.context_menu(|ui| tab_menu(app, ui, i, &mut action)); + if response.clicked() { + app.active = Some(i); + app.combine_tab.focused = false; + } + // A middle click closes the tab, as in the main strip (#1853). + if response.middle_clicked() { + close = Some(i); + } + } + tab_drag(app, &row, &slots, dragged, place, close.is_some() || action.is_some()); + if let Some(i) = close + && let Some(view) = app.views.get(i) + { + action.get_or_insert(Action::Close(view.view_key())); + } + if let Some(action) = action { + app.tab_action(action); + } +} + /// A tab's chrome around its name: the icon on the left, the close button on the right. const TAB_CHROME: f32 = 64.0; /// The narrowest a tab shrinks to before the strip scrolls: room for most of a name. diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d455d0a74b..3d277fb2e9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -218,6 +218,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); @@ -330,6 +331,7 @@ impl PdfCraftApp { } } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), + "view.split" => self.toggle_split(), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { return Some(v.view_history(id == "view.next_view")); @@ -800,13 +802,15 @@ impl PdfCraftApp { /// end (#1542). The Combine files tab, when open, comes after the documents. From Home the /// next tab is the first and the previous one the last. fn step_tab(&mut self, forward: bool) { - let count = self.views.len() + usize::from(self.combine_tab.open); - let current = if self.combine_showing() { Some(self.views.len()) } else { self.active }; + // The tabs of the strip being worked in: split view's second pane steps through its own. + let place = self.active.and_then(|i| self.place_of(i)).unwrap_or_default(); + let tabs = self.tabs_in(place); + let count = tabs.len() + usize::from(place == crate::panes::Place::Main && self.combine_tab.open); + let current = if self.combine_showing() { Some(tabs.len()) } else { self.active.and_then(|a| tabs.iter().position(|&i| i == a)) }; let Some(next) = tab_after(current, count, forward) else { return }; - if next < self.views.len() { - self.active = Some(next); - } else { - self.open_combine_tab(); + match tabs.get(next) { + Some(&i) => self.active = Some(i), + None => self.open_combine_tab(), } } @@ -837,6 +841,9 @@ impl PdfCraftApp { continue; } let Some(key) = Key::from_name(s.key) else { continue }; + if ctx.input(|i| altgr_pressed(i, key)) { + continue; + } let mut m = Modifiers::NONE; if s.command { m |= Modifiers::COMMAND; @@ -873,6 +880,17 @@ impl PdfCraftApp { } } +/// Whether `key` was pressed with Ctrl+Alt this frame. That is how AltGr can reach us, and egui's +/// shortcut match ignores an extra Alt, so German or Spanish AltGr+ß or AltGr+º, which type a +/// backslash, would run ⌘\ (and AltGr+8 ⌘[) instead. No registered shortcut uses Alt. On macOS +/// the command key is not Ctrl, and Option stays free to type a shortcut's character. +fn altgr_pressed(input: &egui::InputState, key: egui::Key) -> bool { + input + .events + .iter() + .any(|e| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.ctrl && modifiers.alt)) +} + /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From /// no tab, forward is the first and back the last. `None` when there are no tabs. fn tab_after(current: Option, count: usize, forward: bool) -> Option { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 44e5481a11..59fb97d7d1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -45,6 +45,8 @@ //! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar` //! reports its tools, relative position, orientation and strip preference. These options persist across restarts. //! `ui.state.comment_tool_pinned` reports the same preference. +//! `folder-collapsed==on|off` folds a pinned folder on Home down to its heading or +//! opens it again; `ui.state.pinned_folders` lists each pinned folder and whether it is. //! - `ui.open {path}`: open a file. A file whose tab is already open focuses it //! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until //! they complete and are listed in `ui.state.pending_opens`; only a failed open replies `Err`. diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 12c98007cc..cf8e5e7fcc 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -100,6 +100,8 @@ impl Host for crate::PdfCraftApp { "viewport": [v.viewport_rect().min.x, v.viewport_rect().min.y, v.viewport_rect().max.x, v.viewport_rect().max.y], "find_open": v.find.is_some(), "page_errors": v.page_errors().iter().map(|(p, e)| json!({ "page": p + 1, "error": e })).collect::>(), + // Pages cut at the renderer's limits (#623), 1-based. + "cut_pages": v.cut_pages().iter().map(|p| p + 1).collect::>(), // Where pages are on screen (points), to aim ui.click / ui.drag at page content. "pages_on_screen": v.visible_page_rects().iter().map(|(p, r)| json!({ "page": p + 1, "rect": [r.min.x, r.min.y, r.max.x, r.max.y] })).collect::>(), "selected_comment": v.comments.selected.map(|(p, i)| json!({ "page": p + 1, "index": i + 1 })), @@ -144,6 +146,8 @@ impl Host for crate::PdfCraftApp { "default_mode": self.default_mode, "hotbar": self.hotbar, "comment_tool_pinned": self.comment_prefs.pinned, + // Home's pinned folders, in order, and whether each is folded down to its heading. + "pinned_folders": self.pinned.folders.iter().map(|f| json!({ "folder": f, "collapsed": self.pinned.collapsed.contains(f) })).collect::>(), "left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null }, "tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" }, "rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" }, diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34e282ea21..1db3be1218 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; pub(super) mod credits; +pub(super) mod hairlines_ui; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; @@ -43,6 +44,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + fix_hairlines_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -446,6 +448,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::FixHairlines => { + let (go, cancel) = crate::hairlines_ui::body(ui, app, &t); + req.fix_hairlines_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1977,6 +1985,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.fix_hairlines_now { + app.fix_hairlines(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 7b404c2f76..2309e7e600 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -312,10 +312,12 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo .zip(app.comment_props.as_ref()) .map(|(doc, d)| { let me = doc.info.annotations.iter().find(|a| a.page == d.page && a.index == d.index && !a.is_reply()); - doc.info - .annotations + // Every state change set on it, including a reviewer's later ones, which reply to + // their earlier (ISO 32000-2 §12.5.6.3). + let states = pdfcraft_render::ReviewStates::new(&doc.info.annotations); + me.map(|a| states.history(a)) + .unwrap_or_default() .iter() - .filter(|r| r.state.is_some() && me.is_some_and(|a| r.replies_to(a))) .map(|r| (r.state.clone().unwrap_or_default(), r.author.clone().unwrap_or_default(), r.modified.clone().unwrap_or_default())) .collect() }) diff --git a/crates/ui-egui/src/dialogs/hairlines_ui.rs b/crates/ui-egui/src/dialogs/hairlines_ui.rs new file mode 100644 index 0000000000..6b5c093c76 --- /dev/null +++ b/crates/ui-egui/src/dialogs/hairlines_ui.rs @@ -0,0 +1,129 @@ +//! Print production ▸ Fix hairlines: lines narrower than a width on the page become a chosen +//! width; Type 3 glyphs and pattern strokes on request; the pages. Fix says how many strokes +//! changed, and refuses nothing: a document with no hairlines is told so. + +use pdfcraft_engine::{Edit, Hairlines}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::units::Unit; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct HairlinesDraft { + /// Both widths in points (shown in the preferences' unit). + pub settings: Hairlines, + pub range: Range, +} + +impl Default for HairlinesDraft { + fn default() -> Self { + Self { settings: Hairlines::default(), range: Range::All } + } +} + +impl HairlinesDraft { + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// A width in points edited in `unit`, named by the label `label`. +fn width_field(ui: &mut egui::Ui, pts: &mut f64, unit: Unit, label: egui::Id) { + let k = unit.per_point(); + let mut v = *pts * k; + let step = 0.001 * k; + if ui + .add(egui::DragValue::new(&mut v).range(0.0..=Hairlines::MAX_POINTS * k).speed(step.max(0.0005)).fixed_decimals(unit.decimals().max(3))) + .labelled_by(label) + .changed() + { + *pts = v / k; + } + ui.label(unit.label()); +} + +/// The dialog: (fix, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let unit = app.units; + let d = &mut app.hairlines_draft; + ui.label(egui::RichText::new(tl!("Fix hairlines")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("hairlines").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let label = ui.label(tl!("Narrower than")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.narrower_than, unit, label)); + ui.end_row(); + let label = ui.label(tl!("New width")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.width, unit, label)); + ui.end_row(); + }); + ui.label(egui::RichText::new(tl!("Zero-width lines always count as narrower.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Also")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.type3, tl!("Type 3 font glyphs"))); + ui.add(widgets::checkbox(&mut d.settings.patterns, tl!("Pattern strokes"))); + }); + ui.label(egui::RichText::new(tl!("Lines drawn inside Type 3 glyphs and tiling patterns; off by default.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = count > 0 && d.settings.width > 0.0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Fix"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Fix: count first (the edit refuses when there is nothing to fix), then apply and say. + pub(crate) fn fix_hairlines(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.hairlines_draft.pages(self.views[i].current, count); + let settings = self.hairlines_draft.settings.clone(); + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + self.notify_tr("No hairlines found on those pages"); + return; + } + if self.apply_edit(Edit::FixHairlines { pages, settings }) { + let (n, p) = (found.lines.to_string(), found.pages.to_string()); + match (found.lines, found.pages) { + // One stroke can only be on one page. + (1, _) => self.notify_tr("Fixed 1 hairline on 1 page"), + (_, 1) => self.notify_fmt("Fixed {n} hairlines on 1 page", &[("n", &n)]), + _ => self.notify_fmt("Fixed {n} hairlines on {p} pages", &[("n", &n), ("p", &p)]), + } + } + } +} diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index ea985f3207..830144277c 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -741,6 +741,8 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, }); ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); + ctx.checkbox(ui, &mut app.text_links, tl!("Open web and email addresses written in the page text"), t); + ctx.label_secondary(ui, tl!("Only web (http, https) and email addresses open, and PdfCraft asks first, as for other links."), t); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index fff7eea9a9..91e069bca3 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -75,7 +75,7 @@ pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) -> pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(req) = app.close_request else { return }; let index = match req { - CloseRequest::Tab(id) => app.views.iter().position(|v| v.id == id), + CloseRequest::Tab(id) => app.asking_tab(id), CloseRequest::Quit | CloseRequest::All => app.first_dirty(), }; let Some(name) = index.and_then(|i| app.views.get(i)).and_then(|v| app.session.get(v.id)).map(|d| d.name.clone()) else { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index c205eb377c..4588db9a06 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -292,13 +292,13 @@ impl PdfCraftApp { fn undo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; - let doc = view.id; + let (doc, place) = (view.id, view.place()); if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { - ed.claim_typing(&ctx, doc); - let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; + ed.claim_typing(&ctx, doc, place); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, true) else { return false }; let before = std::mem::replace(&mut ed.text, text); ed.selection = None; - ed.follow_typed_rows(&ctx, &before); + ed.follow_typed_rows(&ctx, &before, place); return true; } if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { @@ -327,13 +327,13 @@ impl PdfCraftApp { fn redo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; - let doc = view.id; + let (doc, place) = (view.id, view.place()); if let Some(ed) = view.line_editor.as_mut() { - ed.claim_typing(&ctx, doc); - let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; + ed.claim_typing(&ctx, doc, place); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); ed.selection = None; - ed.follow_typed_rows(&ctx, &before); + ed.follow_typed_rows(&ctx, &before, place); return true; } if let Some(d) = view.content.draft.as_mut() { @@ -432,7 +432,8 @@ impl PdfCraftApp { /// text. pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { let focused = ctx.memory(|m| m.focused()); - focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + let paragraph = self.active.and_then(|i| self.views.get(i)).map(|v| crate::edit_text_ui::input_id(v.place())); + focused.is_some() && (focused == paragraph || focused == Some(crate::content_ui::input_id())) } /// Whether the focused box on the page has typing its own Redo would put back: after Undo in @@ -441,10 +442,10 @@ impl PdfCraftApp { pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool { let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; let focused = ctx.memory(|m| m.focused()); - let (id, text) = if focused == Some(crate::edit_text_ui::input_id()) + let (id, text) = if focused == Some(crate::edit_text_ui::input_id(view.place())) && let Some(ed) = &view.line_editor { - (crate::edit_text_ui::input_id(), &ed.text) + (crate::edit_text_ui::input_id(view.place()), &ed.text) } else if focused == Some(crate::content_ui::input_id()) && let Some(d) = &view.content.draft { @@ -462,7 +463,7 @@ impl PdfCraftApp { let Some(ctx) = &self.ctx else { return false }; let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; if let Some(ed) = &view.line_editor { - return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text); + return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(view.place()), &ed.text); } view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text)) } @@ -479,7 +480,11 @@ impl PdfCraftApp { /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { let Some(i) = self.active else { return true }; - let applied = self.apply_queued_edit(i); + // Split view: the other pane's tab can hold an edit too (its field committed when the + // click that brought this pane forward took the focus). It goes in first, as it came first. + let (other_applied, stay) = self.apply_other_pane_edit(i); + let modal = self.modal_open(); + let applied = self.apply_queued_edit(i) && other_applied; if !applied { // Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal. self.deferred_commands.clear(); @@ -519,9 +524,56 @@ impl PdfCraftApp { } None => {} } + // That tab comes forward when its value was refused (its editor is open again with the + // text) or its edit opened a dialog for its document, unless this tab's action opened one. + if let Some(key) = stay + && self.active == Some(i) + && (modal || !self.modal_open()) + { + self.active = self.tab_with_key(key).or(self.active); + } applied } + /// Split view: apply the edit queued on the tab the other pane shows, with that tab active + /// while it goes in (tab `i`, the active one, is active again afterwards). Left queued, it was + /// lost when the next frame replaced that tab to follow a change made through tab `i` + /// (`settle_panes`). Returns false when a field refused it, and that tab's view key when it + /// should come forward: its value was refused, or its edit opened a dialog. + fn apply_other_pane_edit(&mut self, i: usize) -> (bool, Option) { + use crate::panes::Place; + let Some(place) = self.views.get(i).map(|v| v.place()) else { return (true, None) }; + let other = if place == Place::Main { Place::Second } else { Place::Main }; + let Some((k, key, id)) = self + .panes + .split + .and_then(|_| self.pane_front(other)) + .filter(|&k| k != i) + .and_then(|k| self.views.get(k).filter(|v| v.pending_edit.is_some()).map(|v| (k, v.view_key(), v.id))) + else { + return (true, None); + }; + let generation = |app: &Self| app.session.get(id).map(|d| d.edit_generation()); + let (before, modal) = (generation(self), self.modal_open()); + self.active = Some(k); + let applied = self.apply_queued_edit(k); + let opened = !modal && self.modal_open(); + self.active = Some(i); + // Tab `i` on the same document catches up now, keeping what it holds (this frame's click + // may have queued an edit or opened a field): the next frame replaces only the tab that + // isn't active, as if this change had come through the active one. + if generation(self) != before + && let Some(doc) = self.session.get(id) + { + for (j, v) in self.views.iter_mut().enumerate() { + if j != k && v.id == id { + v.document_changed(&doc.info); + } + } + } + (applied, (!applied || opened).then_some(key)) + } + /// Enhance ▸ Camera image ▸ clean-up slider: undo the enhancement (and the turns made since) /// and enhance again from the photo at the new level, turning it as before. Only when the /// top of the undo history is still those steps; otherwise the slider goes back. @@ -752,8 +804,27 @@ impl PdfCraftApp { /// [`Self::commit_form_typing`] for document `id`, which may not be the active tab (a save /// panel answering later, Close All). If its tab has typing or a queued edit, the tab is /// brought forward first, so the commit and any field scripts act on that document. + /// + /// Each tab of the document holds typing of its own (split view). The tab in use stays active + /// when the others have been committed. pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool { - let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; + let keep = self.active_ids().filter(|(_, active)| *active == id).and_then(|(i, _)| self.views.get(i)).map(|v| v.pane.key); + let tabs: Vec = self.views.iter().filter(|v| v.id == id).map(|v| v.pane.key).collect(); + for key in tabs { + if let Some(i) = self.tab_with_key(key) + && !self.commit_typing_of_tab(i, id) + { + return false; + } + } + if let Some(i) = keep.and_then(|k| self.tab_with_key(k)) { + self.active = Some(i); + } + true + } + + /// [`Self::commit_typing_in`] for one tab of the document. + fn commit_typing_of_tab(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { // Added text the standard fonts can't draw can't be saved: show it rather than drop it. // Its page is brought into view, so the text, the warning and Discard are on screen. let blocked = self.views.get_mut(i).and_then(|v| { @@ -782,15 +853,15 @@ impl PdfCraftApp { } self.active = Some(i); } - self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id) + self.commit_form_typing() && self.commit_open_fill_text(i, id) && self.commit_open_text_edits(i, id) } /// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on /// click-away. Save does that first, so the typed text is in the file instead of being left /// open on the screen (#3496). - fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool { + fn commit_open_text_edits(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { let Some(doc) = self.session.get(id) else { return true }; - let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true }; + let Some(view) = self.views.get_mut(i) else { return true }; // Both are taken out of the view before either edit runs, so keep what is needed to put // back whichever one the document refuses: typed text must not be lost. let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone()); @@ -798,7 +869,7 @@ impl PdfCraftApp { let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit); let added_ok = added.is_none_or(|edit| self.apply_edit(edit)); let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit)); - if let Some(view) = self.views.iter_mut().find(|v| v.id == id) { + if let Some(view) = self.views.get_mut(i) { if !added_ok { (view.content.draft, view.content.select_added) = (draft, select_added); } @@ -811,9 +882,8 @@ impl PdfCraftApp { /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in /// the file (and can be flattened) instead of being left on the screen. - fn commit_open_fill_text(&mut self, id: pdfcraft_engine::DocId) -> bool { - let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; - let Some(tb) = self.views[i].fill_text.clone() else { return true }; + fn commit_open_fill_text(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(tb) = self.views.get(i).and_then(|v| v.fill_text.clone()) else { return true }; let text = tb.text.trim().to_string(); if text.is_empty() { if let Some(view) = self.views.get_mut(i) { @@ -849,8 +919,9 @@ impl PdfCraftApp { let after = self.session.get(id).map(|d| d.edit_generation()); if before != after && let Some(doc) = self.session.get(id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + && let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i)) { + // The tab in use: `sync_views` brings any other tab of the document up to date. view.document_changed(&doc.info); } true @@ -858,10 +929,16 @@ impl PdfCraftApp { /// Whether tab `index` has work that isn't saved: edits, or form typing not committed yet. pub(crate) fn has_unsaved_work(&self, index: usize) -> bool { + self.views.get(index).and_then(|v| self.session.get(v.id)).is_some_and(|doc| doc.dirty) || self.has_unsaved_typing(index) + } + + /// Whether tab `index` holds work of its own that isn't in its document yet: form typing not + /// committed, an edit waiting, text being typed. (The document's own edits are shared with + /// any other tab of it, in split view.) + pub(crate) fn has_unsaved_typing(&self, index: usize) -> bool { let Some(v) = self.views.get(index) else { return false }; let Some(doc) = self.session.get(v.id) else { return false }; - doc.dirty - || v.pending_edit.is_some() + v.pending_edit.is_some() || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index a6803e9dd4..c6d91b64e8 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -20,10 +20,15 @@ impl PdfCraftApp { if self.hold_close_for_ocr_review(index) { return; } - // Text typed into a field counts as an unsaved change. - if self.has_unsaved_work(index) + // Text typed into a field counts as an unsaved change. Another tab of the document (split + // view) keeps the document's changes open, so closing this one asks only about its own. + if self.asks_before_closing(index) && let Some(id) = self.views.get(index).map(|v| v.id) { + if self.has_twin(index) { + // The question is about this tab of the document (`asking_tab`). + self.active = Some(index); + } self.close_request = Some(CloseRequest::Tab(id)); } else { self.close_tab(index); @@ -107,7 +112,7 @@ impl PdfCraftApp { // The next question (another dirty document) starts on Save again. self.save_prompt_button = Default::default(); let index = match req { - CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) { + CloseRequest::Tab(id) => match self.asking_tab(id) { Some(i) => i, None => return, // already closed }, @@ -142,7 +147,7 @@ impl PdfCraftApp { // document open. let ctx = ctx.clone(); self.save_then(index, SaveTarget::InPlace, move |app| { - if let Some(i) = app.views.iter().position(|v| v.id == id) { + if let Some(i) = app.asking_tab(id) { app.close_and_continue(&ctx, i, req); } }); @@ -150,6 +155,12 @@ impl PdfCraftApp { } } + /// The tab a close question about document `id` is about: the active one when it shows `id` + /// (in split view two tabs can), else the document's first. + pub(crate) fn asking_tab(&self, id: pdfcraft_engine::DocId) -> Option { + self.active.filter(|&a| self.views.get(a).is_some_and(|v| v.id == id)).or_else(|| self.views.iter().position(|v| v.id == id)) + } + /// Close tab `index` for a close request, then ask about the next dirty document, or quit. /// The save it waited for may have taken a later frame (the user choosing where), and an OCR /// review started meanwhile holds the close as it holds a new one: the document stays open, diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index b6c04dd9e0..35867559f6 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -57,7 +57,9 @@ impl PdfCraftApp { (_, _, Some(p)) => p.clone(), // An email attachment's temporary copy is no place to keep work: ask, as for a new // document (`is_mail_temp_copy`). - (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) => p, + // Neither is a path read back from a recovery sidecar until a save confirms it: + // anyone who can write the recovery folder can name any file there (#3923). + (SaveTarget::InPlace, Some(p), _) if !is_mail_temp_copy(&p) && !self.recovered_paths.contains(&id) => p, _ => { let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") }; let dialog = rfd::AsyncFileDialog::new().add_filter_any_case("PDF", &["pdf"]).set_file_name(name); @@ -164,11 +166,12 @@ impl PdfCraftApp { if !self.commit_typing_in(id) || !self.flatten_fill_sign_for_save(id) { return false; } + let recovered = self.recovered_paths.contains(&id); let Some((name, full)) = self.session.get(id).map(|d| { - // Save As to another file is a clean copy, without the earlier revisions that still - // hold deleted pages or replaced text (#2165). A signed document stays incremental so - // its signatures keep covering the file. - let full = target == SaveTarget::As + // Save As to another file and a recovered document are clean copies, without earlier + // revisions that may retain deleted pages or replaced text (#2165, #3923). A signed + // document stays incremental so its signatures keep covering the file. + let full = (target == SaveTarget::As || recovered) && !d.path.as_deref().is_some_and(|p| crate::same_file(p, dest)) && d.read_only_reason.is_none() && !d.is_signed(); @@ -194,11 +197,14 @@ impl PdfCraftApp { match self.session.mark_saved(id, bytes, Some(dest.to_string())) { Ok(()) => { self.forget_recovery(id); + // The tab in use: `sync_views` brings any other tab of the document up to date. if let Some(doc) = self.session.get(id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + && let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i)) { view.document_changed(&doc.info); - // Saving over a version that changed on disk keeps this one: nothing to ask. + } + // Saving over a version that changed on disk keeps this one: nothing to ask. + for view in self.views.iter_mut().filter(|v| v.id == id) { view.disk_changed = false; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); diff --git a/crates/ui-egui/src/home/folders_ui.rs b/crates/ui-egui/src/home/folders_ui.rs index fe9b2c3706..60e130420b 100644 --- a/crates/ui-egui/src/home/folders_ui.rs +++ b/crates/ui-egui/src/home/folders_ui.rs @@ -55,6 +55,8 @@ pub struct PinnedFolders { pub listings: Vec, /// Folders whose "Show more" is open. pub expanded: Vec, + /// Folders folded down to their heading (#5295); kept in settings. + pub collapsed: Vec, /// A listing in progress: the worker fills the slot. scan: Option>>>>, /// When (egui time) the last listing started. @@ -86,8 +88,34 @@ impl PinnedFolders { self.folders = folders; self.listings.clear(); self.expanded.clear(); + self.collapsed.clear(); self.stale = true; } + + /// Restore which folders are collapsed, after [`Self::restore`]: only folders that are + /// pinned, each once, so untrusted settings can't grow the list. + pub(crate) fn restore_collapsed(&mut self, value: &serde_json::Value) { + let Some(items) = value.as_array() else { return }; + self.collapsed.clear(); + for folder in items.iter().filter_map(serde_json::Value::as_str) { + if self.folders.iter().any(|f| f == folder) && !self.collapsed.iter().any(|f| f == folder) { + self.collapsed.push(folder.to_string()); + } + } + } + + /// Fold pinned `folder` down to its heading (`collapsed`) or open it again. Returns false, + /// changing nothing, when `folder` isn't pinned. + pub(crate) fn set_collapsed(&mut self, folder: &str, collapsed: bool) -> bool { + if !self.folders.iter().any(|f| f == folder) { + return false; + } + self.collapsed.retain(|f| f != folder); + if collapsed { + self.collapsed.push(folder.to_string()); + } + true + } } /// `path` as a folder to pin: trimmed, without a trailing separator, and absolute; `None` if it @@ -221,6 +249,25 @@ impl PdfCraftApp { self.pinned.folders.retain(|f| f != folder); self.pinned.listings.retain(|l| l.folder != folder); self.pinned.expanded.retain(|f| f != folder); + self.pinned.collapsed.retain(|f| f != folder); + } + + /// `ui.set folder-collapsed =on|off` (CodeRabbit on #5341): the control channel's + /// way to fold a pinned folder down to its heading or open it again, as its chevron does. + /// `ui.state` lists the pinned folders and which are collapsed. The folder is named by its + /// path as pinned; the last `=` splits it from the state, so a path may hold `=` too. + pub(crate) fn set_folder_collapsed(&mut self, value: &str) -> Result<(), String> { + let (folder, state) = value.rsplit_once('=').ok_or("folder-collapsed must be =on or =off")?; + let collapsed = match state { + "on" => true, + "off" => false, + _ => return Err("folder-collapsed must be =on or =off".into()), + }; + if self.pinned.set_collapsed(folder, collapsed) { + Ok(()) + } else { + Err(format!("folder-collapsed: {folder} is not a pinned folder (ui.state lists them)")) + } } /// List the pinned folders again if the last listing is older than a few seconds or the @@ -308,10 +355,27 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let mut open = None; let mut unpin = None; let mut toggle = None; + let mut fold = None; for folder in &app.pinned.folders { let name = folder_name(folder); + // Buttons name their folder; two pinned folders of the same name add where each is. + let shared = app.pinned.folders.iter().any(|f| f != folder && folder_name(f) == name); + let named = if shared { format!("{name} ({})", location(folder)) } else { name.clone() }; + let collapsed = app.pinned.collapsed.contains(folder); ui.add_space(6.0); ui.horizontal(|ui| { + let (chevron, tip, label) = if collapsed { + ("chevron-right", tl!("Expand"), tl!("Expand {folder}")) + } else { + ("chevron-down", tl!("Collapse"), tl!("Collapse {folder}")) + }; + let resp = icons::button(ui, chevron, 22.0, false, tip); + // Every folder has one: say which folder it folds. + let label = crate::i18n::fmt(label, &[("folder", &named)]); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); + if resp.clicked() { + fold = Some(folder.clone()); + } let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); icons::paint(ui, icon, "folder", 20.0, t.icon); // Folder names and paths are file/path chrome: explicitly left to right, so an @@ -327,13 +391,16 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let resp = widgets::ghost_button(ui, "x", tl!("Unpin")); // Every folder has an Unpin button: say which one it is. - let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]); + let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &named)]); resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label)); if resp.clicked() { unpin = Some(folder.clone()); } }); }); + if collapsed { + continue; + } let note = |ui: &mut egui::Ui, text: &str| { ui.add_space(2.0); ui.label(egui::RichText::new(text).color(t.text_muted)); @@ -405,6 +472,10 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) { app.pinned.expanded.push(folder); } } + if let Some(folder) = fold { + let collapsed = app.pinned.collapsed.contains(&folder); + app.pinned.set_collapsed(&folder, !collapsed); + } if let Some(path) = open { app.open_recent(&path); } diff --git a/crates/ui-egui/src/hooks.rs b/crates/ui-egui/src/hooks.rs index 8a79506a1a..3499ca778c 100644 --- a/crates/ui-egui/src/hooks.rs +++ b/crates/ui-egui/src/hooks.rs @@ -11,6 +11,9 @@ pub type FailedInbox = std::sync::Arc>>; pub enum OsEvent { /// Open these files. Open(Vec), + /// Add these files to Combine files and show it (`pdfcraft --combine`, File Explorer's + /// "Combine files in PdfCraft", #585). + Combine(Vec), /// Quit (the Dock's Quit, logging out), asking about unsaved changes first. Quit, } diff --git a/crates/ui-egui/src/hotbar.rs b/crates/ui-egui/src/hotbar.rs index 077d899dfe..34b3480b40 100644 --- a/crates/ui-egui/src/hotbar.rs +++ b/crates/ui-egui/src/hotbar.rs @@ -549,7 +549,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, area: Rect, page_left: Option, ui let mut bar = std::mem::take(&mut app.hotbar); bar.normalize(); let t = Tokens::get(&ctx); - let id = egui::Id::unique("quick-bar"); + let id = app.panes.area_id("quick-bar"); let full_side = bar.full_side && bar.dock.is_some(); let bounds = if full_side { area } else { area.shrink(10.0) }; if let Some(side) = bar.dock.clone() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d1440afda6..0e47d4baff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,7 @@ mod editing; mod home; mod hotbar; mod panels; +pub mod panes; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -110,6 +111,7 @@ use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; use dialogs::credits; +use dialogs::hairlines_ui; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; @@ -195,6 +197,7 @@ use workflows::ocr_verify; #[cfg(target_arch = "wasm32")] use workflows::ocr_web; use workflows::optimize_ui; +use workflows::print_paper; use workflows::print_ui; use workflows::scan_clean_ui; use workflows::web_print; @@ -281,6 +284,8 @@ struct DeferredOpen { pub type HtmlClipboard = Box bool>; pub struct PdfCraftApp { + /// Split view (`panes`); which pane shows each tab is its view's. + pub panes: panes::Panes, /// Tools side and pane widths; which panes are open is `left_open` and `right`. pub side_panels: side_panels::SidePanels, pub session: Session, @@ -420,6 +425,9 @@ pub struct PdfCraftApp { /// Preferences: tell the user, above the pages, that a document is secured (its security /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. pub show_secured_notice: bool, + /// Preferences: web and email addresses written as plain page text open on a click, like + /// links (#4207). + pub text_links: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, @@ -672,6 +680,9 @@ pub struct PdfCraftApp { /// Entries left by a previous session, offered in the Recovery dialog. pub recoverable: Vec, recovery_keys: std::collections::HashMap, + /// Recovered documents whose path came from a recovery sidecar and no save has confirmed + /// yet: their first Save asks where, starting at that path, instead of writing it (#3923). + recovered_paths: std::collections::HashSet, last_autosave: Option, allow_quit: bool, /// Shortcuts pressed while a text field had the keyboard, run on the next frame (see @@ -708,6 +719,7 @@ pub struct PdfCraftApp { /// Number pages dialog settings (1-based pages). pub number_draft: NumberDraft, pub(crate) word_count_draft: word_count_ui::Draft, + pub hairlines_draft: hairlines_ui::HairlinesDraft, /// Protect Using Password dialog state. pub protect_draft: protect::ProtectDraft, /// New bookmarks from text styles dialog state (#1693). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 02c8de225f..6987ba3b36 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -171,7 +171,7 @@ const DISPLAY: &[&str] = &[ "view.fit_one_page", ]; const THEMES: &[&str] = &["view.theme.system", "view.theme.light", "view.theme.dark"]; -const PANES: &[&str] = &["view.side_panel", "view.tools_pane"]; +const PANES: &[&str] = &["view.side_panel", "view.tools_pane", "view.split"]; const PANE_COMMANDS: &[&str] = &["comment.list", "form.fields", "ui.dock.reset"]; const MODES: &[&str] = &["view.full_screen", "view.read_mode"]; const PAGES: &[&str] = &[ @@ -512,6 +512,7 @@ impl PdfCraftApp { "view.read_mode" => Some(self.mode == crate::Mode::Read), "view.side_panel" => Some(self.right.is_some()), "view.tools_pane" => Some(self.left_open), + "view.split" => Some(self.panes.split.is_some()), "view.theme.system" => Some(self.theme_preference == ThemePreference::System), "view.theme.light" => Some(self.theme_preference == ThemePreference::Light), "view.theme.dark" => Some(self.theme_preference == ThemePreference::Dark), diff --git a/crates/ui-egui/src/menus/tab_context.rs b/crates/ui-egui/src/menus/tab_context.rs index 9590969baf..fa1c05ec7d 100644 --- a/crates/ui-egui/src/menus/tab_context.rs +++ b/crates/ui-egui/src/menus/tab_context.rs @@ -2,19 +2,31 @@ use pdfcraft_engine::DocId; pub(crate) enum Action { - Close(DocId), + /// Close a tab, by its view key (`DocView::view_key`: split view can show one document in two + /// tabs). + Close(u64), Reopen, Reveal(DocId), + /// Split right (or down) with a tab, by its view key. + Split { + tab: u64, + down: bool, + }, } impl crate::PdfCraftApp { pub(crate) fn tab_action(&mut self, action: Action) { match action { - Action::Close(id) => { - if let Some(i) = self.views.iter().position(|v| v.id == id) { + Action::Close(tab) => { + if let Some(i) = self.tab_with_key(tab) { self.request_close_tab(i); } } + Action::Split { tab, down } => { + if let Some(i) = self.tab_with_key(tab) { + self.split_tab(i, down); + } + } Action::Reopen => { #[cfg(not(target_arch = "wasm32"))] if let Some(path) = self.closed_tabs.last().cloned() { @@ -162,15 +174,15 @@ mod tests { fn confirmed_close_uses_id_and_cancel_keeps_history_empty() { let mut app = crate::PdfCraftApp::new(); app.open_bytes("first.pdf", Some("/tmp/first.pdf".into()), PDF.to_vec()).unwrap(); - let first = app.views[0].id; + let (first, first_tab) = (app.views[0].id, app.views[0].view_key()); app.open_bytes("second.pdf", None, PDF.to_vec()).unwrap(); app.session.apply(first, pdfcraft_engine::Edit::InsertBlankPage { at: 0, width: 300.0, height: 400.0 }).unwrap(); - app.tab_action(Action::Close(first)); + app.tab_action(Action::Close(first_tab)); assert_eq!(app.views.len(), 2); assert!(app.closed_tabs.is_empty()); app.resolve_close(&egui::Context::default(), None); assert!(app.closed_tabs.is_empty()); - app.tab_action(Action::Close(first)); + app.tab_action(Action::Close(first_tab)); app.resolve_close(&egui::Context::default(), Some(false)); assert_eq!(app.views.len(), 1); assert_eq!(app.closed_tabs, ["/tmp/first.pdf"]); diff --git a/crates/ui-egui/src/panels/comments_panel.rs b/crates/ui-egui/src/panels/comments_panel.rs index f6b0043637..cc328db5ab 100644 --- a/crates/ui-egui/src/panels/comments_panel.rs +++ b/crates/ui-egui/src/panels/comments_panel.rs @@ -1,16 +1,16 @@ //! The Comments panel (Acrobat's right-hand comment list; 11-visual-spec §3, audit `a15-*`). //! -//! Header "Comments N" (comments, as Acrobat counts them) with "N replies" under it and search; -//! an "Add a comment" box that posts a sticky note on the current page; cards grouped by page. -//! Selecting a card selects the comment on the page and opens its reply box and "…" menu (Edit, -//! Set status, Delete), and gives each reply a "…" of its own (Edit, Copy text, Delete). Status -//! replies show as a badge. +//! Header "Comments N" (comments) with "N replies" under it and search; an "Add a comment" box +//! that posts a sticky note on the current page; cards grouped by page. Selecting a card selects +//! the comment on the page and opens its reply box and "…" menu (Edit, Set status, Delete), and +//! gives each reply a "…" of its own (Edit, Copy text, Delete). Status replies show as one badge +//! per reviewer. use std::collections::HashMap; use egui::{Align, Color32, CornerRadius, Layout, Sense, Stroke, vec2}; use pdfcraft_engine::{Edit, NoteIcon, ReviewState, Shape}; -use pdfcraft_render::{Annotation, DocInfo}; +use pdfcraft_render::{Annotation, DocInfo, ReviewStates}; use crate::canvas::DocView; use crate::comments::{CommentPrefs, CommentTool, SortBy, color32, status_badge}; @@ -84,13 +84,7 @@ fn check_label(marked: bool) -> &'static str { if marked { "Checked" } else { "Unchecked" } } -/// The latest checkmark reply in a thread says "Marked". -pub fn is_marked(thread: &[&Annotation]) -> bool { - thread.iter().rev().find(|r| r.is_mark()).is_some_and(|r| r.state.as_deref() == Some("Marked")) -} - -/// The number shown in the header: comments, not their replies or status changes (as Acrobat -/// counts them). +/// The number shown in the header: comments, not their replies or status changes. pub fn count(info: &DocInfo) -> usize { info.annotations.iter().filter(|a| a.is_comment()).count() } @@ -178,15 +172,18 @@ pub(crate) fn show( let mut page_counts: HashMap = HashMap::new(); // Replies find their parent by reference (`/NM` need not be unique) and keep source order. // A reply at any depth joins the thread of the comment it starts from, as comment_list lists - // it. A status or checkmark stays with the annotation it was set on (its `/IRT` names it, - // ISO 32000-2 §12.5.6.2): one set on a reply, at any depth, is that reply's and not the - // comment's, so the card's badge and checkmark leave it out (comment_list reports it on the - // reply). Page badges count roots before filtering. + // it. Status and checkmark replies are read through `states`: each sets the state of the + // annotation its `/IRT` chain refers to past earlier state replies (ISO 32000-2 §12.5.6.3), + // so one set on a reply, at any depth, is that reply's and not the comment's, and the card's + // badges and checkmark leave it out (comment_list reports it on the reply). Page badges count + // roots before filtering. let tops = pdfcraft_render::thread_tops(&info.annotations); + let states = ReviewStates::new(&info.annotations); for a in &info.annotations { if let Some(parent) = a.parent { - let key = if a.state.is_none() { tops.get(&(a.page, a.index)).copied().unwrap_or(parent) } else { parent }; - replies.entry(key).or_default().push(a); + if a.state.is_none() { + replies.entry(tops.get(&(a.page, a.index)).copied().unwrap_or(parent)).or_default().push(a); + } } else if a.is_comment() { let count = page_counts.entry(a.page).or_default(); *count = count.saturating_add(1); @@ -194,18 +191,31 @@ pub(crate) fn show( } let replies_of = |a: &Annotation| -> &[&Annotation] { replies.get(&(a.page, a.index)).map(Vec::as_slice).unwrap_or(&[]) }; let cv = &view.comments; - let status_of = - |a: &Annotation| replies_of(a).iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.clone()).unwrap_or_else(|| "None".into()); + // Hidden by the status filter when every reviewer's status is hidden ("None" when nobody set one). + let status_hidden = |a: &Annotation| { + let all = states.statuses(a); + if all.is_empty() { + cv.hidden_statuses.iter().any(|s| s == "None") + } else { + all.iter().all(|(_, s)| cv.hidden_statuses.iter().any(|h| h == s)) + } + }; let mut roots: Vec<&Annotation> = info .annotations .iter() .filter(|a| a.is_comment()) - .filter(|a| query.as_deref().is_none_or(|q| matches(a, q) || replies_of(a).iter().any(|r| matches(r, q)))) + // Search finds a comment by its text, its replies' and the status replies set on any of + // them: one set on a reply is that reply's (`states`), but it belongs to this thread. + .filter(|a| { + query.as_deref().is_none_or(|q| { + std::iter::once(*a).chain(replies_of(a).iter().copied()).any(|x| matches(x, q) || states.history(x).iter().any(|r| matches(r, q))) + }) + }) .filter(|a| !cv.hidden_types.iter().any(|t| t == kind_label(a))) .filter(|a| !cv.hidden_authors.contains(&a.author.clone().unwrap_or_default())) - .filter(|a| !cv.hidden_statuses.contains(&status_of(a))) + .filter(|a| !status_hidden(a)) .filter(|a| !cv.hidden_colors.contains(&color_key(a))) - .filter(|a| !cv.hidden_checks.contains(&check_label(is_marked(replies_of(a))).to_string())) + .filter(|a| !cv.hidden_checks.contains(&check_label(states.marked_by(a, &prefs.author)).to_string())) .collect(); let sort = cv.sort; match sort { @@ -282,7 +292,7 @@ pub(crate) fn show( // A card with an editor open on it or on a reply is always drawn too (CodeRabbit on // #5583): a kept height would show it at its size before the editor opened. let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key || thread.iter().any(|r| (r.page, r.index) == (*p, *i))); - let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread), selected, editing)); + let id = ui.scope_id().with(("comment-card", view.id, key, card_signature(a, thread, &states), selected, editing)); // The selected card is always drawn, so it can scroll itself into view and keep its menu // and editor. if virtual_list && !selected && !editing && skip_if_hidden(ui, id) { @@ -290,7 +300,9 @@ pub(crate) fn show( } let rotation = info.pages.get(a.page).map_or(0, |p| p.rotation); let top = ui.cursor().min.y; - if let Some(e) = card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, doc, prefs, allowed, nav }) { + if let Some(e) = + card(ui, view, CardContext { theme: t, annotation: a, rotation, thread, order: &order, states: &states, doc, prefs, allowed, nav }) + { edit = Some(e); } if virtual_list { @@ -332,14 +344,18 @@ fn page_header(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, page: usize, n: us } /// What a card's height depends on in the document: the comment and its replies as shown (their -/// kind, author, text, date, status, colour and lock). Two cards with the same signature lay out +/// kind, author, text, date, status, colour and lock), and the status and checkmark replies set on +/// each, which the thread leaves out and `states` reads. Two cards with the same signature lay out /// alike at one width. -fn card_signature(a: &Annotation, thread: &[&Annotation]) -> u64 { +fn card_signature(a: &Annotation, thread: &[&Annotation], states: &ReviewStates) -> u64 { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); for x in std::iter::once(a).chain(thread.iter().copied()) { (&x.subtype, &x.author, &x.contents, &x.modified, &x.state, x.locked).hash(&mut h); x.color.map(|c| c.map(f32::to_bits)).hash(&mut h); + for r in states.history(x) { + (&r.subtype, &r.author, &r.modified, &r.state).hash(&mut h); + } } h.finish() } @@ -381,6 +397,7 @@ struct CardContext<'a> { thread: &'a [&'a Annotation], /// The cards in the order shown, for a ⇧-click range. order: &'a [(usize, usize)], + states: &'a ReviewStates<'a>, doc: &'a pdfcraft_engine::Document, prefs: &'a CommentPrefs, allowed: bool, @@ -388,7 +405,7 @@ struct CardContext<'a> { } fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Option { - let CardContext { theme: t, annotation: a, rotation, thread, order, doc, prefs, allowed, nav } = context; + let CardContext { theme: t, annotation: a, rotation, thread, order, states, doc, prefs, allowed, nav } = context; let info = &doc.info; let mut edit = None; let key = (a.page, a.index); @@ -397,9 +414,11 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti // the handles. let primary = view.comments.selected == Some(key); let color = a.color.map(|c| color32(c.map(f64::from))).unwrap_or(t.accent); - let status = thread.iter().rev().filter(|r| !r.is_mark()).find_map(|r| r.state.as_deref()); - let marked = is_marked(thread); - let replies: Vec<&&Annotation> = thread.iter().filter(|r| r.state.is_none()).collect(); + // Each reviewer's status, and the checkmark of the reviewer using this copy (prefs.author): + // checkmarks are each reviewer's own (ISO 32000-2 Table 174, "marked by the user"). + let statuses = states.statuses(a); + let marked = states.marked_by(a, &prefs.author); + let replies: Vec<&&Annotation> = thread.iter().collect(); let editing = view.comments.editing.as_ref().is_some_and(|(p, i, _)| (*p, *i) == key); let fill = if selected { t.hover.gamma_multiply(0.7) } else { Color32::TRANSPARENT }; let mut open_menu = false; @@ -460,11 +479,15 @@ fn card(ui: &mut egui::Ui, view: &mut DocView, context: CardContext<'_>) -> Opti widgets::wrapped_text(ui, body, theme::regular(13.0), t.text, !selected); } } - if let Some((icon, label, c)) = status.and_then(status_badge) { + // One badge per reviewer, with their name: a state is "for the specified user" + // (ISO 32000-2 §12.5.6.3). + for (who, state) in &statuses { + let Some((icon, label, c)) = status_badge(state) else { continue }; ui.add_space(4.0); ui.horizontal(|ui| { ui.add(icons::image(icon, 13.0, c)); - ui.label(egui::RichText::new(tl!(label)).font(theme::medium(11.5)).color(c)); + let text = if who.is_empty() { tl!(label).to_string() } else { format!("{who}: {}", tl!(label)) }; + ui.label(egui::RichText::new(text).font(theme::medium(11.5)).color(c)); }); } }); diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index fce62fb2ab..98063e6ef4 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -229,6 +229,10 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static } // Prepare a form: Preview fills the form as a reader would; Edit goes back. if g.id == "form" { + if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { + ui.add(widgets::checkbox(&mut view.prepare.show_tab_numbers, tl!("Show tab numbers"))); + ui.add_space(4.0); + } ui.horizontal(|ui| { let label = if app.form_preview { "Edit fields" } else { "Preview" }; if widgets::pill_button(ui, tl!(label), app.form_preview).on_hover_text(tl!("Try the form as people filling it in will see it")).clicked() diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs new file mode 100644 index 0000000000..9dd906d725 --- /dev/null +++ b/crates/ui-egui/src/panes.rs @@ -0,0 +1,641 @@ +//! Split view (`view.split-window`): the main window's page area in two panes, and which pane +//! shows each tab. +//! +//! Every tab is a [`DocView`](crate::DocView) in `PdfCraftApp::views`, and its [`Place`] says where +//! it shows. The window's tab strip holds the [`Place::Main`] tabs; split view adds a second pane, +//! to the right or below, with a small strip of its own ([`Place::Second`]). Each pane shows the +//! tab of it that was active last, and `PdfCraftApp::active` is the tab of the pane last clicked: +//! menus, keyboard shortcuts and the side panels act on it. +//! +//! One document can show in both panes at once (page 2 beside page 40): two views with one +//! `DocId`. The document, its edits and its undo history are the session's, so an edit made in one +//! view is the other's too ([`PdfCraftApp::settle_panes`] refreshes the other view), while each view +//! keeps its own scroll, zoom and selection. Closing one of them closes only that view. +//! +//! Nothing here is kept across restarts: the second pane's tabs are ordinary tabs, so the last +//! session reopens them in the one strip, unsplit. + +use std::collections::{HashMap, HashSet}; + +use egui::{Pos2, Rect, Sense, Stroke, vec2}; +use pdfcraft_engine::DocId; + +use crate::theme::Tokens; +use crate::{CloseRequest, PdfCraftApp}; + +/// Where a tab is shown. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Place { + /// The window's tab strip and page area (the left or top pane when split). + #[default] + Main, + /// The second pane of split view (right or bottom). + Second, +} + +/// A tab's place among the panes (`DocView::pane`). +#[derive(Clone, Debug)] +pub(crate) struct ViewPane { + /// This view among all of the app's views, unique for the app's life: two views of one + /// document keep their own scroll position and floating bars under it (`DocView::view_key`). + pub(crate) key: u64, + /// Where the tab shows. + pub(crate) place: Place, + /// Whether `place` was chosen yet (a new tab opens in the pane the user was working in). + pub(crate) placed: bool, + /// When this tab was last the active one (each pane shows its latest). + pub(crate) shown_seq: u64, + /// The document's render queue was last set for both of its views at once. + pub(crate) shared: bool, +} + +impl Default for ViewPane { + fn default() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + Self { key: NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), place: Place::Main, placed: false, shown_seq: 0, shared: false } + } +} + +/// The page area in two panes. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Split { + /// The second pane is below the first (Split down) rather than beside it (Split right). + pub down: bool, + /// The first pane's share of the page area. + pub fraction: f32, +} + +impl Split { + fn new(down: bool) -> Self { + Self { down, fraction: 0.5 } + } +} + +/// How far the divider between the panes may go, as a share of the page area. +const FRACTION: std::ops::RangeInclusive = 0.15..=0.85; +/// The divider's width, and the height of the second pane's tab strip. +const DIVIDER: f32 = 1.0; +const PANE_STRIP: f32 = 30.0; + +/// Where a dragged tab goes if it is let go now. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum TabDrop { + /// Its own strip: it moves along it (the strip does that). + Strip, + /// The other pane. + Pane(Place), + /// The right half of a page area that isn't split: split right with it. + SplitRight, +} + +/// Split view's state: see the module docs. +#[derive(Default)] +pub struct Panes { + /// Split view, while it is on. + pub split: Option, + /// The latest [`ViewPane::shown_seq`] handed out. + top_seq: u64, + /// The pane of the tab active last: a new tab opens there. + focus: Place, + /// The main tab strip and the page area, last frame. + pub(crate) main_strip: Option, + pub(crate) page_rect: Option, + /// Each open document's edit generation as last seen. + generations: HashMap, + /// The pane being drawn now (for what belongs to one pane only). + pub(crate) drawing: Place, + /// Where a dragged tab would land, painted over the page area this frame. + pub(crate) drop_hint: Option, +} + +impl Panes { + /// An egui id for something drawn once per pane (the floating tools bar): the main pane keeps + /// the plain id. + pub(crate) fn area_id(&self, name: &'static str) -> egui::Id { + match self.drawing { + Place::Main => egui::Id::unique(name), + place => egui::Id::unique((name, place)), + } + } +} + +/// The two panes and the divider between them, in `area`. +fn layout(area: Rect, split: Split) -> (Rect, Rect, Rect) { + let f = if split.fraction.is_finite() { split.fraction.clamp(*FRACTION.start(), *FRACTION.end()) } else { 0.5 }; + if split.down { + let y = (area.top() + area.height() * f).round(); + ( + Rect::from_min_max(area.min, egui::pos2(area.right(), y)), + Rect::from_min_max(egui::pos2(area.left(), y), egui::pos2(area.right(), y + DIVIDER)), + Rect::from_min_max(egui::pos2(area.left(), y + DIVIDER), area.max), + ) + } else { + let x = (area.left() + area.width() * f).round(); + ( + Rect::from_min_max(area.min, egui::pos2(x, area.bottom())), + Rect::from_min_max(egui::pos2(x, area.top()), egui::pos2(x + DIVIDER, area.bottom())), + Rect::from_min_max(egui::pos2(x + DIVIDER, area.top()), area.max), + ) + } +} + +impl PdfCraftApp { + /// The tabs shown in `place`, in their strip's order (indexes into `views`). + pub fn tabs_in(&self, place: Place) -> Vec { + self.views.iter().enumerate().filter(|(_, v)| v.pane.place == place).map(|(i, _)| i).collect() + } + + /// The tab `place` shows: the active tab when it is there, else the one active there last. + /// `None` for the main pane while Home (or Combine files) shows. + pub fn pane_front(&self, place: Place) -> Option { + if let Some(a) = self.active + && self.views.get(a).is_some_and(|v| v.pane.place == place) + { + return Some(a); + } + if place == Place::Main && self.active.is_none() { + return None; + } + self.latest_in(place) + } + + /// The tab of `place` active last (the first one when none has been). + fn latest_in(&self, place: Place) -> Option { + self.views + .iter() + .enumerate() + .filter(|(_, v)| v.pane.place == place) + .max_by_key(|(i, v)| (v.pane.shown_seq, std::cmp::Reverse(*i))) + .map(|(i, _)| i) + } + + /// Where tab `index` shows. + pub fn place_of(&self, index: usize) -> Option { + self.views.get(index).map(|v| v.pane.place) + } + + /// Whether another tab shows the same document as tab `index`. + pub(crate) fn has_twin(&self, index: usize) -> bool { + let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; + self.views.iter().enumerate().any(|(k, v)| k != index && v.id == id) + } + + /// The tab with view key `key` ([`crate::DocView::view_key`]). + pub(crate) fn tab_with_key(&self, key: u64) -> Option { + self.views.iter().position(|v| v.pane.key == key) + } + + fn active_key(&self) -> Option { + self.active.and_then(|i| self.views.get(i)).map(|v| v.pane.key) + } + + /// Bring forward the tab `place` shows (a click in that pane). + pub fn activate_place(&mut self, place: Place) { + if self.active.and_then(|i| self.views.get(i)).is_some_and(|v| v.pane.place == place) { + return; + } + if let Some(i) = self.latest_in(place) { + self.active = Some(i); + self.combine_tab.focused = false; + } + } + + /// Keep the panes consistent, once a frame before anything is drawn and before every change + /// here: new tabs go to the pane the user was working in, a pane left without tabs ends split + /// view, the views of a document changed through another view catch up, and the active tab + /// becomes its pane's latest. + pub(crate) fn settle_panes(&mut self) { + let split = self.panes.split.is_some(); + let focus = if split { self.panes.focus } else { Place::Main }; + for v in &mut self.views { + if !v.pane.placed { + v.pane.place = focus; + v.pane.placed = true; + } + if v.pane.place == Place::Second && !split { + v.pane.place = Place::Main; + } + } + if split { + let has = |p: Place| self.views.iter().any(|v| v.pane.place == p); + if !has(Place::Main) || !has(Place::Second) { + self.unsplit(); + } + } + self.sync_views(); + if let Some(v) = self.active.and_then(|i| self.views.get_mut(i)) { + self.panes.focus = v.pane.place; + if v.pane.shown_seq != self.panes.top_seq || self.panes.top_seq == 0 { + self.panes.top_seq = self.panes.top_seq.saturating_add(1); + v.pane.shown_seq = self.panes.top_seq; + } + } + } + + /// A document changed through one of its views (an edit, Undo, a reload): its other view + /// starts afresh at the same place, as after a reload. + fn sync_views(&mut self) { + let mut seen = HashSet::new(); + for k in 0..self.views.len() { + let Some(id) = self.views.get(k).map(|v| v.id) else { continue }; + if !seen.insert(id) { + continue; + } + let Some(doc) = self.session.get(id) else { continue }; + let generation = doc.edit_generation(); + let before = self.panes.generations.insert(id, generation); + if before.is_none_or(|g| g == generation) { + continue; + } + // The view the change came through: the active one, else the first (as the code that + // made the change finds a document's tab). + let acting = self.active.filter(|&a| self.views.get(a).is_some_and(|v| v.id == id)).unwrap_or(k); + for (j, v) in self.views.iter_mut().enumerate() { + if j != acting && v.id == id { + v.follow_document(&doc.info); + } + } + } + self.panes.generations.retain(|id, _| seen.contains(id)); + } + + /// Move tab `from` along its strip: `to` is where it lands among the strip's tabs (`slots`, the + /// strip's tabs in order, from first to last; `chrome::drop_index`). In split view a strip + /// holds only some of the tabs, so this goes by the tab it lands next to rather than by index + /// in `views`. + pub(crate) fn move_tab_in_strip(&mut self, from: usize, slots: &[(usize, Rect)], to: usize) { + let others: Vec = slots.iter().map(|(i, _)| *i).filter(|&i| i != from).collect(); + let (anchor, before) = match others.get(to) { + Some(&a) => (a, true), + None => match others.last() { + Some(&a) => (a, false), + None => return, + }, + }; + if from >= self.views.len() || slots.iter().position(|(i, _)| *i == from) == Some(to) { + return; + } + let Some(anchor) = self.views.get(anchor).map(|v| v.pane.key) else { return }; + let active = self.active_key(); + let view = self.views.remove(from); + let Some(a) = self.tab_with_key(anchor) else { + self.views.insert(from.min(self.views.len()), view); + return; + }; + let at = if before { a } else { a.saturating_add(1) }; + self.views.insert(at.min(self.views.len()), view); + self.active = active.and_then(|k| self.tab_with_key(k)); + } + + /// Remove tab `index` but not its document, which another tab still shows. When it was the + /// active tab, that other tab of the document becomes active. + fn drop_view(&mut self, index: usize) { + let Some((key, id)) = self.views.get(index).map(|v| (v.pane.key, v.id)) else { return }; + let active = self.active_key(); + self.views.remove(index); + self.active = if active == Some(key) { self.views.iter().position(|v| v.id == id) } else { active.and_then(|k| self.tab_with_key(k)) }; + } + + /// Tab `index`'s right-click Split right (or Split down). The tab the main pane shows gets a + /// second view in the second pane (the same document, twice); another tab moves there (two + /// documents side by side). A tab of the second pane only turns the split. + pub fn split_tab(&mut self, index: usize, down: bool) { + self.settle_panes(); + let Some(place) = self.place_of(index) else { return }; + match place { + Place::Second => { + if let Some(s) = self.panes.split.as_mut() { + s.down = down; + } + self.active = Some(index); + } + Place::Main => { + let alone = self.tabs_in(Place::Main).len() <= 1; + let shown = self.pane_front(Place::Main) == Some(index) || (self.active.is_none() && self.latest_in(Place::Main) == Some(index)); + match self.panes.split.as_mut() { + Some(s) => s.down = down, + None => self.panes.split = Some(Split::new(down)), + } + if shown || alone { + self.show_twin(index, Place::Second); + } else { + self.move_tab_to(index, Place::Second); + } + } + } + } + + /// Show tab `index`'s document in `place` too (a second view of it), or bring forward the + /// view of it already there. + fn show_twin(&mut self, index: usize, place: Place) { + let Some(view) = self.views.get(index) else { return }; + let id = view.id; + if let Some(there) = self.views.iter().position(|v| v.id == id && v.pane.place == place) { + self.active = Some(there); + return; + } + let Some(doc) = self.session.get(id) else { return }; + let mut twin = view.twin(&doc.info); + twin.pane.place = place; + twin.pane.placed = true; + self.views.push(twin); + self.active = Some(self.views.len() - 1); + } + + /// View ▸ Split (⌘\): split the current document into two panes, or end split view. + pub fn toggle_split(&mut self) { + self.settle_panes(); + if self.panes.split.is_some() { + self.unsplit(); + } else if let Some(i) = self.active { + self.split_tab(i, false); + } + } + + /// End split view: the second pane's tabs join the main strip, except second views of + /// documents the main pane has open already, which close. + pub fn unsplit(&mut self) { + self.panes.split = None; + for i in (0..self.views.len()).rev() { + let Some((id, place)) = self.views.get(i).map(|v| (v.id, v.pane.place)) else { continue }; + if place != Place::Second { + continue; + } + if self.views.iter().any(|v| v.id == id && v.pane.place == Place::Main) { + self.drop_view(i); + } else if let Some(v) = self.views.get_mut(i) { + v.pane.place = Place::Main; + } + } + } + + /// Move tab `index` to `place` and make it active. A pane that shows its document already + /// keeps the view it has, and the moving one closes. Returns the tab's index afterwards. + pub fn move_tab_to(&mut self, index: usize, place: Place) -> Option { + let (id, key, from) = self.views.get(index).map(|v| (v.id, v.pane.key, v.pane.place))?; + if from == place { + self.active = Some(index); + return Some(index); + } + if place == Place::Second && self.panes.split.is_none() { + self.panes.split = Some(Split::new(false)); + } + if let Some(there) = self.views.iter().position(|v| v.id == id && v.pane.place == place && v.pane.key != key) { + let keep = self.views.get(there).map(|v| v.pane.key); + self.drop_view(index); + self.active = keep.and_then(|k| self.tab_with_key(k)); + return self.active; + } + if let Some(v) = self.views.get_mut(index) { + v.pane.place = place; + v.pane.placed = true; + } + self.active = Some(index); + self.combine_tab.focused = false; + Some(index) + } + + /// Quitting or File ▸ Close all with unsaved documents: one tab per document first, so each + /// document is asked about once (a tab with typing of its own stays, to be asked about). + fn one_tab_per_document(&mut self) { + for i in (0..self.views.len()).rev() { + let Some(id) = self.views.get(i).map(|v| v.id) else { continue }; + if self.views.iter().take(i).any(|v| v.id == id) && !self.has_unsaved_typing(i) { + self.drop_view(i); + } + } + } + + /// The tabs, one per document (the last session lists each file once). + pub(crate) fn first_views(&self) -> impl Iterator { + self.views.iter().enumerate().filter(|(i, v)| !self.views.iter().take(*i).any(|w| w.id == v.id)).map(|(_, v)| v) + } + + /// Whether closing tab `index` asks about unsaved changes first: the document's changes are + /// asked about by its last tab only (another tab of it keeps them open); typing a tab holds + /// of its own, by that tab. + pub(crate) fn asks_before_closing(&self, index: usize) -> bool { + if self.has_twin(index) { self.has_unsaved_typing(index) } else { self.has_unsaved_work(index) } + } + + /// The tab that comes forward when the active tab closes (it was at `index`, in `place`, and + /// is gone from `views`): the one that took its place in the same strip, else the strip's last, + /// else the tab the other pane shows. + pub(crate) fn after_close(&self, place: Place, index: usize) -> Option { + let tabs = self.tabs_in(place); + tabs.iter().copied().find(|&i| i >= index).or_else(|| tabs.last().copied()).or_else(|| match place { + Place::Main if self.panes.split.is_some() => self.latest_in(Place::Second), + Place::Main => None, + Place::Second => self.latest_in(Place::Main), + }) + } + + /// Where a tab dragged from `from`'s strip lands if let go at `pos`: the other pane, the right + /// half of a page area that isn't split, or else its own strip (as before split view, the + /// tab moves along the strip wherever it is let go). + pub(crate) fn drop_target(&self, pos: Pos2, from: Place) -> TabDrop { + if from != Place::Main && self.panes.main_strip.is_some_and(|r| r.contains(pos)) { + return TabDrop::Pane(Place::Main); + } + let Some(page) = self.panes.page_rect.filter(|r| r.contains(pos)) else { return TabDrop::Strip }; + match self.panes.split { + Some(split) => { + let (first, _, second) = layout(page, split); + if second.contains(pos) && from != Place::Second { + TabDrop::Pane(Place::Second) + } else if first.contains(pos) && from != Place::Main { + TabDrop::Pane(Place::Main) + } else { + TabDrop::Strip + } + } + None if pos.x >= page.center().x => TabDrop::SplitRight, + None => TabDrop::Strip, + } + } + + /// The area a [`TabDrop`] would fill, for the hint drawn while dragging. + pub(crate) fn drop_hint_rect(&self, drop: TabDrop) -> Option { + let page = self.panes.page_rect?; + match drop { + TabDrop::Pane(p) => { + let split = self.panes.split?; + let (first, _, second) = layout(page, split); + Some(if p == Place::Second { second } else { first }) + } + TabDrop::SplitRight => Some(Rect::from_min_max(egui::pos2(page.center().x, page.top()), page.max)), + TabDrop::Strip => None, + } + } + + /// Let tab `index` go at `drop`. + pub(crate) fn drop_tab(&mut self, index: usize, drop: TabDrop) { + match drop { + TabDrop::Strip => {} + TabDrop::Pane(place) => { + self.move_tab_to(index, place); + } + TabDrop::SplitRight => self.split_tab(index, false), + } + } + + /// The tabs on screen now: what each pane shows (and a comparison's other half). + pub(crate) fn views_on_screen(&self) -> Vec { + let mut shown = Vec::new(); + let mut add = |i: Option| { + if let Some(i) = i + && !shown.contains(&i) + { + shown.push(i); + } + }; + let main = if self.full_screen { self.active } else { self.pane_front(Place::Main) }; + add(main); + add(main.and_then(|i| crate::compare_ui::side_by_side(self, i)).map(|(old, _)| old)); + if !self.full_screen && self.panes.split.is_some() { + add(self.pane_front(Place::Second)); + } + shown + } +} + +/// Once a frame, before anything is drawn: the panes settle (and a quit or Close all asks about +/// each document once). +pub(crate) fn frame(app: &mut PdfCraftApp) { + if matches!(app.close_request, Some(CloseRequest::Quit | CloseRequest::All)) { + app.one_tab_per_document(); + } + app.settle_panes(); +} + +/// The page area: the main pane's tab (Home or Combine files while it has none, a comparison's +/// two halves), or split view's two panes. +pub(crate) fn page_area(app: &mut PdfCraftApp, ui: &mut egui::Ui) { + app.panes.page_rect = Some(ui.max_rect()); + app.panes.drawing = Place::Main; + if let Some(hint) = app.panes.drop_hint.take() { + let t = Tokens::get(ui.ctx()); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("pane-drop-hint"))); + painter.rect(hint.shrink(4.0), 6.0, t.accent.gamma_multiply(0.12), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + } + let main = app.pane_front(Place::Main); + match app.panes.split.and_then(|s| Some((s, app.pane_front(Place::Second)?))) { + Some((split, second)) => split_area(app, split, main, second, ui), + None => main_pane(app, main, ui), + } +} + +/// What the main pane shows: tab `main`, or Home or Combine files while it has none. +fn main_pane(app: &mut PdfCraftApp, main: Option, ui: &mut egui::Ui) { + match main { + None if app.combine_showing() => crate::combine_ui::page(app, ui), + None => crate::home::show(app, ui), + Some(index) => match crate::compare_ui::side_by_side(app, index) { + Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui), + None => crate::canvas::document_area(app, index, ui), + }, + } +} + +/// Whether a press this frame began in `rect` on the page area itself, or on one of the floating +/// bars of the view drawn there (not on a dialog, menu or panel over it). +fn pressed_in(ui: &egui::Ui, rect: Rect, place: Place, view: Option<&crate::DocView>) -> bool { + let Some(pos) = ui.input(|i| if i.pointer.any_pressed() { i.pointer.press_origin() } else { None }) else { return false }; + if !rect.contains(pos) { + return false; + } + match ui.ctx().layer_id_at(pos) { + None => true, + Some(layer) => { + let quick = if place == Place::Main { egui::Id::unique("quick-bar") } else { egui::Id::unique(("quick-bar", place)) }; + layer == ui.layer_id() + || [quick, egui::Id::unique("camera-bar")].contains(&layer.id) + || view.is_some_and(|v| egui::Id::unique(("find-bar", v.view_key())) == layer.id) + } + } +} + +/// Split view: the main pane, the divider and the second pane with its strip. A press in a pane +/// makes it the active one before it is drawn, so the click acts there. +fn split_area(app: &mut PdfCraftApp, split: Split, main: Option, second: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + let area = ui.max_rect(); + let (first, divider, other) = layout(area, split); + if !app.modal_open() && !app.palette_open { + // (While Home shows in the main pane, a click there stays on Home.) + if main.is_some() && pressed_in(ui, first, Place::Main, main.and_then(|i| app.views.get(i))) { + app.activate_place(Place::Main); + } else if pressed_in(ui, other, Place::Second, app.views.get(second)) { + app.activate_place(Place::Second); + } + } + // The divider, dragged to share the area differently. + let grab = if split.down { divider.expand2(vec2(0.0, 3.0)) } else { divider.expand2(vec2(3.0, 0.0)) }; + let response = ui.interact(grab, ui.scope_id().with("pane-divider"), Sense::drag()); + if response.hovered() || response.dragged() { + ui.ctx().set_cursor_icon(if split.down { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + } + if response.dragged() + && let Some(p) = ui.ctx().pointer_latest_pos() + && let Some(s) = app.panes.split.as_mut() + { + let f = if split.down { (p.y - area.top()) / area.height().max(1.0) } else { (p.x - area.left()) / area.width().max(1.0) }; + if f.is_finite() { + s.fraction = f.clamp(*FRACTION.start(), *FRACTION.end()); + } + } + ui.painter().rect_filled(divider, 0.0, if response.dragged() || response.hovered() { t.accent } else { t.divider }); + let active = app.active.and_then(|i| app.views.get(i)).map(|v| v.pane.place); + pane(app, ui, first, Place::Main, main, active == Some(Place::Main)); + pane(app, ui, other, Place::Second, Some(second), active == Some(Place::Second)); +} + +/// One pane of split view: the second has its own tab strip above its page area. +fn pane(app: &mut PdfCraftApp, ui: &mut egui::Ui, rect: Rect, place: Place, front: Option, active: bool) { + let t = Tokens::get(ui.ctx()); + let salt = if place == Place::Second { "second-pane" } else { "main-pane" }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect).id_salt(salt)); + child.set_clip_rect(rect.intersect(ui.clip_rect())); + let body = if place == Place::Second { + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), PANE_STRIP)); + crate::chrome::pane_tab_strip(app, &mut child, strip, place); + Rect::from_min_max(egui::pos2(rect.left(), strip.bottom()), rect.max) + } else { + rect + }; + // The strip may have closed or moved a tab just now: look the pane's tab up again (a pane + // left empty ends split view next frame). + let front = if place == Place::Second { app.pane_front(place) } else { front.filter(|&i| i < app.views.len()) }; + let mut body_ui = child.new_child(egui::UiBuilder::new().max_rect(body).id_salt("body")); + body_ui.set_clip_rect(body.intersect(ui.clip_rect())); + app.panes.drawing = place; + match (place, front) { + (Place::Main, _) => main_pane(app, front, &mut body_ui), + (Place::Second, Some(index)) => crate::canvas::document_area(app, index, &mut body_ui), + (Place::Second, None) => {} + } + app.panes.drawing = Place::Main; + // The active pane is marked along its top edge. + if active { + let edge = Rect::from_min_size(rect.min, vec2(rect.width(), 2.0)); + ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(("active-pane", salt)))).rect_filled(edge, 0.0, t.accent); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_panes_share_the_area_and_the_divider_stays_inside() { + let area = Rect::from_min_size(egui::pos2(0.0, 0.0), vec2(1000.0, 600.0)); + let (a, d, b) = layout(area, Split { down: false, fraction: 0.5 }); + assert_eq!(a.right(), d.left()); + assert_eq!(d.right(), b.left()); + assert_eq!(b.right(), 1000.0); + let (a, _, b) = layout(area, Split { down: true, fraction: 2.0 }); + assert!(a.height() <= 600.0 * 0.85 + 1.0 && b.height() > 0.0, "a fraction past the end is held back"); + let (a, _, _) = layout(area, Split { down: false, fraction: f32::NAN }); + assert_eq!(a.width(), 500.0, "a fraction that isn't a number shares evenly"); + } +} diff --git a/crates/ui-egui/src/persist/last_session.rs b/crates/ui-egui/src/persist/last_session.rs index b46de0de7a..da0cb5c5a3 100644 --- a/crates/ui-egui/src/persist/last_session.rs +++ b/crates/ui-egui/src/persist/last_session.rs @@ -63,7 +63,7 @@ impl PdfCraftApp { #[cfg(not(target_arch = "wasm32"))] { let path = |v: &crate::DocView| self.session.get(v.id).and_then(|d| d.path.as_deref()).map(absolute); - let files = self.views.iter().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect(); + let files = self.first_views().filter_map(|v| Some(SessionFile { path: path(v)?, page: v.current })).take(MAX_FILES).collect(); let active = self.active.and_then(|i| self.views.get(i)).and_then(path); LastSession { files, active } } diff --git a/crates/ui-egui/src/persist/recovery.rs b/crates/ui-egui/src/persist/recovery.rs index 34b6bdf76a..aa10cceb19 100644 --- a/crates/ui-egui/src/persist/recovery.rs +++ b/crates/ui-egui/src/persist/recovery.rs @@ -5,6 +5,9 @@ //! `.pdf` plus `.json` (name, original path, time). Saving, discarding or closing a //! document removes its entry. At startup the app offers snapshots even when their metadata is //! missing or damaged; those recover without an original path, so saving asks for a destination. +//! A recovered document's original path is only a suggestion: the folder is the user's, but +//! anyone who can write it can name any file in a sidecar, so the first Save after recovering +//! asks where, starting at that path, and later saves write where the user chose (#3923). //! //! Encrypted documents are stored encrypted (the working file is), so recovery never writes //! plaintext of a protected document to disk. The web build has no recovery store yet. @@ -32,7 +35,8 @@ pub const AUTOSAVE_SECS: f64 = 60.0; pub struct RecoveryMeta { pub key: String, pub name: String, - /// Where the document was saved before (Save writes back there after recovery). + /// Where the document was saved before. After recovery the first Save offers it in the + /// Save As dialog rather than writing it unasked (#3923). pub path: Option, /// Seconds since the Unix epoch when the snapshot was taken. pub saved_at: u64, @@ -230,8 +234,7 @@ impl RecoveryStore { continue; } let Some(key) = path.file_stem().and_then(std::ffi::OsStr::to_str) else { continue }; - let meta = std::fs::read(path.with_extension("json")) - .and_then(|b| serde_json::from_slice::(&b).map_err(std::io::Error::other)) + let meta = read_meta(&path.with_extension("json")) .and_then(|m| if m.key == key { Ok(m) } else { Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "snapshot key mismatch")) }); out.push(meta.unwrap_or_else(|e| { log::warn!("recovering snapshot without metadata: {}: {e}", path.display()); @@ -261,7 +264,7 @@ impl RecoveryStore { /// recovered without metadata), so damaged metadata doesn't stay behind. The PDF is untouched. pub fn repair_meta(&self, meta: &RecoveryMeta) { let path = self.dir.join(format!("{}.json", meta.key)); - let readable = std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::(&b).ok()).is_some_and(|m| m.key == meta.key); + let readable = read_meta(&path).is_ok_and(|m| m.key == meta.key); if readable { return; } @@ -273,8 +276,25 @@ impl RecoveryStore { } } + /// + /// Only a regular file is read, and no more than it held when it was opened: anyone who can + /// write the recovery folder can put a FIFO there, which would hold the read forever, or a + /// link to a device such as `/dev/zero`, which never ends (#3923). Snapshots have no size + /// limit of their own, as autosave writes whatever the document holds. pub fn read(&self, key: &str) -> std::io::Result> { - std::fs::read(self.dir.join(format!("{key}.pdf"))) + use std::io::Read; + let path = self.dir.join(format!("{key}.pdf")); + let meta = std::fs::metadata(&path)?; + if !meta.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the recovery snapshot is not a regular file")); + } + let len = meta.len(); + let mut bytes = Vec::new(); + File::open(&path)?.take(len.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > len { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "the recovery snapshot grew while it was read")); + } + Ok(bytes) } /// Remove every entry (clean quit, or "Discard all"), and metadata left without a snapshot. @@ -296,6 +316,22 @@ impl RecoveryStore { } } +/// The most a sidecar may hold. One PdfCraft writes is a name, a path and a few fields, well under +/// this; anything longer is not one of ours (#3923). +const MAX_META_BYTES: u64 = 64 * 1024; + +/// A sidecar's metadata, read through [`MAX_META_BYTES`] so a planted file can't make the start-up +/// listing read gigabytes. A longer file is refused, and its snapshot recovers without metadata. +fn read_meta(path: &Path) -> std::io::Result { + use std::io::Read; + let mut bytes = Vec::new(); + File::open(path)?.take(MAX_META_BYTES.saturating_add(1)).read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_META_BYTES { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "recovery metadata is too large")); + } + serde_json::from_slice(&bytes).map_err(std::io::Error::other) +} + fn now_secs() -> u64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) } @@ -329,6 +365,9 @@ impl PdfCraftApp { /// Drop a document's recovery entry (it was saved, discarded or closed). pub(crate) fn forget_recovery(&mut self, doc: pdfcraft_engine::DocId) { + // Saved (to a destination the user chose), closed or reloaded from its file: the path is + // no longer only what the sidecar said. + self.recovered_paths.remove(&doc); if let (Some(store), Some(key)) = (&self.recovery, self.recovery_keys.remove(&doc)) { store.remove(&key); } @@ -422,6 +461,9 @@ impl PdfCraftApp { pub(crate) fn finish_recovery(&mut self, meta: &RecoveryMeta) { let Some(id) = self.views.last().map(|v| v.id) else { return }; self.session.mark_recovered(id, meta.path.clone()); + if meta.path.is_some() { + self.recovered_paths.insert(id); + } self.recovery_keys.insert(id, meta.key.clone()); // A snapshot recovered without readable metadata gets a fresh sidecar now, so the // damaged one doesn't linger until the next autosave (or forever, if none comes). @@ -462,13 +504,13 @@ mod idle_tests { use super::*; use std::time::Duration; - struct TestDir(PathBuf); + pub(super) struct TestDir(PathBuf); impl TestDir { /// A new folder of this test's own. The tests run on parallel threads of one process, and /// two can read the same clock time (it moves in microseconds on macOS), so a counter /// tells their folders apart; the time keeps runs of the process apart. - fn new() -> Self { + pub(super) fn new() -> Self { static NEXT: AtomicU64 = AtomicU64::new(0); let n = NEXT.fetch_add(1, Ordering::Relaxed); let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); @@ -478,7 +520,7 @@ mod idle_tests { } /// The folder. - fn path(&self) -> &Path { + pub(super) fn path(&self) -> &Path { &self.0 } } @@ -519,6 +561,31 @@ mod idle_tests { out.viewport_output[&egui::ViewportId::ROOT].repaint_delay } + /// #3923 (review on #4327): a snapshot that is not a regular file is refused before it is + /// opened. A FIFO in the recovery folder would otherwise hold the read, and the recovery with + /// it, until something wrote to it. + #[cfg(unix)] + #[test] + fn a_snapshot_that_is_not_a_regular_file_is_refused_at_once() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let fifo = dir.path().join("planted.pdf"); + match std::process::Command::new("mkfifo").arg(&fifo).status() { + Ok(s) if s.success() => {} + _ => return, // No mkfifo here: nothing to plant. + } + let (tx, rx) = std::sync::mpsc::channel(); + let reader = store.clone(); + std::thread::spawn(move || { + let _ = tx.send(reader.read("planted").map(|b| b.len())); + }); + let read = rx.recv_timeout(Duration::from_secs(5)).expect("the read returned instead of waiting on the FIFO"); + assert_eq!(read.map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + // A directory under the name is refused the same way. + std::fs::create_dir(dir.path().join("folder.pdf")).unwrap(); + assert_eq!(store.read("folder").map_err(|e| e.kind()), Err(std::io::ErrorKind::InvalidInput)); + } + /// Check that an idle dirty document schedules a bounded write and autosaves without input, then stops /// waking the window. #[test] @@ -624,3 +691,73 @@ mod idle_tests { } } } + +#[cfg(test)] +mod sidecar_tests { + use super::idle_tests::TestDir; + use super::*; + use crate::SaveTarget; + + /// #3923: a sidecar names a file the user can write (here, one that is not the document's). + /// After Recover, Save asks where instead of writing that file; the file the user picks is + /// the document's file from then on. + #[test] + fn the_first_save_after_recovering_asks_instead_of_writing_the_sidecars_path() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path().join("recovery")); + let named = dir.path().join("named.pdf"); + std::fs::write(&named, b"someone else's file").unwrap(); + { + let mut crashed = PdfCraftApp::new(); + crashed.enable_recovery(store.clone()); + let bytes = crashed.session.create_blank(200.0, 300.0, 1).unwrap(); + crashed.open_bytes("work.pdf", Some(named.to_string_lossy().into_owned()), bytes.to_vec()).unwrap(); + assert!(crashed.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + crashed.autosave_now(); + } + + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.enable_recovery(store.clone()); + let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); + assert_eq!(keys.len(), 1); + app.recover(&keys); + assert_eq!(app.views.len(), 1); + let chosen = dir.path().join("chosen.pdf"); + app.pick_override = Some(vec![chosen.to_string_lossy().into_owned()]); + + assert!(!app.save_active(SaveTarget::InPlace), "Save asks instead of writing the sidecar's path"); + app.process_picked(); + assert_eq!(std::fs::read(&named).unwrap(), b"someone else's file", "the named file is untouched"); + assert!(std::fs::read(&chosen).unwrap().starts_with(b"%PDF"), "the document went where the user chose"); + + // The chosen file is the document's file now: the next Save writes it without asking. + app.pick_override = None; + assert!(app.apply_edit(pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 })); + assert!(app.save_active(SaveTarget::InPlace)); + } + + /// #3923: a sidecar longer than any PdfCraft writes is not read whole; its snapshot is + /// offered without metadata, so it has no path to save to. + #[test] + fn an_oversized_sidecar_is_not_read() { + let dir = TestDir::new(); + let store = RecoveryStore::new(dir.path()); + let meta = RecoveryMeta { + key: "planted".into(), + // Over 64 KiB, and still valid metadata that names a file. + name: "x".repeat(70_000), + path: Some(dir.path().join("named.pdf").to_string_lossy().into_owned()), + saved_at: 1, + encrypted: false, + instance: None, + }; + store.write(&meta, b"%PDF-1.7\n").unwrap(); + let listed = store.list(); + assert_eq!(listed.len(), 1); + assert_eq!((listed[0].name.as_str(), listed[0].path.as_deref()), ("planted.pdf", None)); + // A sidecar of ordinary size is still read. + store.write(&RecoveryMeta { name: "work.pdf".into(), ..meta.clone() }, b"%PDF-1.7\n").unwrap(); + assert_eq!(store.list()[0].path, meta.path); + } +} diff --git a/crates/ui-egui/src/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index bcf6783cdb..2f6630afdb 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -27,6 +27,9 @@ pub(crate) struct Updates { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, + /// How many times `on_exit` called [`Updates::exiting`]. + #[cfg(test)] + exits: u32, } impl Default for Updates { @@ -39,6 +42,20 @@ impl Default for Updates { startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, + #[cfg(test)] + exits: 0, + } + } +} + +impl Updates { + /// Starts the helper of an update that waits for the app to exit. Later calls do nothing. + pub(crate) fn exiting(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + self.updater.start_pending_update(); + #[cfg(test)] + { + self.exits += 1; } } } @@ -113,9 +130,25 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { app.updates.updater.dismiss_status(); } CheckResultResponse::CheckAgain => app.updates.check_requested = true, + CheckResultResponse::RestartToUpdate => { + app.updates.updater.restart_to_update(); + } } } } #[cfg(target_arch = "wasm32")] let _ = (app, ctx); } + +#[cfg(test)] +mod tests { + use super::*; + use eframe::App as _; + + #[test] + fn on_exit_calls_the_update_exit_hook_once() { + let mut app = PdfCraftApp::new(); + app.on_exit(None); + assert_eq!(app.updates.exits, 1); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 915d40e6ec..f03d2d4936 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Fix hairlines. + FixHairlines, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 41c3a1e6c5..837141b4d3 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -55,7 +55,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, anchor = anchor.clamp(viewport.min, viewport.max); } let pos = pos2(anchor.x + 12.0, anchor.y); - let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + let area = egui::Area::new(egui::Id::unique(("comment-composer", view.pane.key))).order(egui::Order::Foreground).constrain_to(viewport); area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index a2c38b2628..9a599b7796 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -118,8 +118,9 @@ pub(crate) fn context_menu( } }); }); - let thread: Vec<&pdfcraft_render::Annotation> = info.annotations.iter().filter(|r| r.replies_to(a)).collect(); - let marked = crate::comments_panel::is_marked(&thread); + // The checkmark of the reviewer using this copy: checkmarks are each reviewer's own + // (ISO 32000-2 Table 174: "The annotation has been marked by the user"). + let marked = pdfcraft_render::ReviewStates::new(&info.annotations).marked_by(a, &prefs.author); if ui.add_enabled(allowed, egui::Button::new(tl!(if marked { "Remove checkmark" } else { "Mark with checkmark" }))).clicked() { action = Some(CanvasAction::Edit(Box::new(Edit::MarkAnnotation { page, index, marked: !marked, author: prefs.author.clone() }))); ui.close(); @@ -460,6 +461,8 @@ pub fn status_badge(state: &str) -> Option<(&'static str, &'static str, Color32) "Rejected" => Some(("thumbs-down", "Rejected", Color32::from_rgb(0xD3, 0x2F, 0x2F))), "Cancelled" => Some(("circle-x", "Cancelled", Color32::from_rgb(0x8E, 0x8E, 0x8E))), "Completed" => Some(("check", "Completed", Color32::from_rgb(0x2D, 0x9D, 0x5B))), + // A status set back to None stays shown: None is a state (ISO 32000-2 Table 174). + "None" => Some(("minus", "None", Color32::from_rgb(0x8E, 0x8E, 0x8E))), _ => None, } } diff --git a/crates/ui-egui/src/tools/comments/tests.rs b/crates/ui-egui/src/tools/comments/tests.rs index acb4008d3b..6e4623f818 100644 --- a/crates/ui-egui/src/tools/comments/tests.rs +++ b/crates/ui-egui/src/tools/comments/tests.rs @@ -176,7 +176,7 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa }) .collect::>(); let n = glyphs.len(); - PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] } + PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n], truncated: false } } fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation { diff --git a/crates/ui-egui/src/tools/content_ui.rs b/crates/ui-egui/src/tools/content_ui.rs index a5cfbd4c2e..ddb4fdf88a 100644 --- a/crates/ui-egui/src/tools/content_ui.rs +++ b/crates/ui-egui/src/tools/content_ui.rs @@ -725,7 +725,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad // Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over // the page, so only where the document shows: text scrolled under the mode bar or a side // panel must leave them their pointer (#2755). - egui::Area::new(egui::Id::unique(("added-text", view.id.0))) + egui::Area::new(egui::Id::unique(("added-text", view.view_key()))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(at) diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index f0abd9f42a..fc2cf29471 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -105,9 +105,10 @@ pub struct LineEditor { typing_from: Option, } -/// The paragraph editor's text box. -pub(crate) fn input_id() -> egui::Id { - egui::Id::unique("edit-text-line-input") +/// The paragraph editor's text box: one for the tabs of a pane (they share it), and one for each pane +/// of split view, which can show an editor in each. +pub(crate) fn input_id(place: crate::panes::Place) -> egui::Id { + egui::Id::unique(("edit-text-line-input", place)) } /// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown @@ -354,8 +355,8 @@ impl LineEditor { /// Take the shared text box for this paragraph ([`forget_typing_before`]): its own history, /// or none once the page has its text. - pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId) { - forget_typing_before(ctx, input_id(), doc, &mut self.typing_from, &self.original); + pub(crate) fn claim_typing(&mut self, ctx: &egui::Context, doc: pdfcraft_engine::DocId, place: crate::panes::Place) { + forget_typing_before(ctx, input_id(place), doc, &mut self.typing_from, &self.original); } /// Adopt the rewritten paragraph's current geometry before the next overlay frame. @@ -488,8 +489,8 @@ impl LineEditor { /// The text changed from `before` through the box's own typing (typing, Undo, Redo): rows /// aligned on their own keep their alignment, and a row Undo brings back gets its own back. - pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str) { - follow_typing(ctx, input_id(), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); + pub(crate) fn follow_typed_rows(&mut self, ctx: &egui::Context, before: &str, place: crate::panes::Place) { + follow_typing(ctx, input_id(place), before, &self.text, &mut [&mut self.look.aligns, &mut self.look0.aligns]); } /// The text changed from `before`: rows aligned on their own keep their alignment. @@ -535,11 +536,12 @@ pub struct BlockDrag { grip: Grip, } -/// How far an edge grip reaches either side of a box's edge (screen pixels). -const EDGE_REACH: f32 = 6.0; +/// How far an edge grip reaches either side of a box's edge (screen pixels). Wide enough to +/// catch without aiming for the hairline (#2203); a narrow box still keeps its middle for moving. +const EDGE_REACH: f32 = 8.0; /// How far outside an open paragraph's frame the pointer still means the frame (screen pixels): /// the frame's Move cursor shows, and a press there takes hold of the box (`object_ui`). -pub(crate) const FRAME_REACH: f32 = 6.0; +pub(crate) const FRAME_REACH: f32 = 8.0; /// The rewrap handles drawn on a paragraph box's left and right edges (screen). fn width_handles(b: Rect) -> [Rect; 2] { @@ -563,12 +565,25 @@ fn edge_at(b: Rect, p: Pos2) -> Option { } } -/// The topmost box under `p` and what it grabs there; edges only on an upright page. +/// The box under `p` and what it grabs there; edges only on an upright page. An edge reaches a +/// little above and below its box, so where that reach overlaps another box the one `p` is level +/// with wins: a press beside one paragraph's edge doesn't rewrap the paragraph just under it. +/// Among boxes equally near, the topmost. fn grab_at(boxes: &[Rect], p: Pos2, upright: bool) -> Option<(usize, Grip)> { - boxes.iter().enumerate().rev().find_map(|(i, b)| match upright.then(|| edge_at(*b, p)).flatten() { - Some(g) => Some((i, g)), - None => b.contains(p).then_some((i, Grip::Move)), - }) + let mut best: Option<(f32, usize, Grip)> = None; + for (i, b) in boxes.iter().enumerate().rev() { + let grip = match upright.then(|| edge_at(*b, p)).flatten() { + Some(g) => g, + None if b.contains(p) => Grip::Move, + None => continue, + }; + // How far above or below the box `p` is: 0 within its height. + let off = (b.top() - p.y).max(p.y - b.bottom()).max(0.0); + if best.is_none_or(|(o, ..)| off < o) { + best = Some((off, i, grip)); + } + } + best.map(|(_, i, g)| (i, g)) } /// A paragraph's box on screen: its text rect (user space), with a little room around it. @@ -858,6 +873,9 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let away = |p: egui::Pos2| !view.viewport_rect().contains(p); let outside = ctx.input(|i| i.pointer.interact_pos()).is_some_and(away) || focus_pos(ctx).is_some_and(away); let page = view.line_editor.as_ref()?.page; + // Keyed by view: split view can show an editor in each pane. + let key = view.view_key(); + let place = view.place(); let xf = view.page_xform(page)?; let hold = view.objects.hold_editor; // An OCR word is invisible text over a scan: the textless background still shows the scanned @@ -909,7 +927,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - // Over the page, so only where the document shows: a paragraph scrolled under the mode bar or // a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::unique("edit-text-line")) + egui::Area::new(egui::Id::unique(("edit-text-line", key))) .order(egui::Order::Foreground) .fade_in(false) .fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top())) @@ -938,8 +956,12 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - let live = draft_rect.map_or(visible, |bounds| bounds.union(visible)); ui.painter().rect_stroke(live.expand(2.0), CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); // On the frame, a press takes hold of the box (a click selects it, a drag moves it): - // the pointer says so there, and reads as text inside. - if let Some(p) = ui.input(|i| i.pointer.hover_pos()) + // the pointer says so there, and reads as text inside. Once the text is changed, a + // press there applies the typing like any click off the text instead of taking the + // box (`object_ui` would drop the typing), so the pointer doesn't offer it then + // (review on #4624). + if !ed.has_changes() + && let Some(p) = ui.input(|i| i.pointer.hover_pos()) && ui.clip_rect().contains(p) && live.expand(2.0 + FRAME_REACH).contains(p) && !live.expand(2.0).contains(p) @@ -964,6 +986,15 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } else { caret_color_on(fill) }; + // An unchanged paragraph's galley is unseen over the page's own text. Selecting + // part of it must not paint its stand-in glyphs over that text in the selection + // colour (a serif paragraph turned sans when highlighted), nor hide it under an + // opaque fill: the selection is a tint the page shows through. + if unchanged { + let selection = &mut ui.visuals_mut().selection; + selection.stroke.color = Color32::TRANSPARENT; + selection.bg_fill = selection.bg_fill.gamma_multiply(0.45); + } let rows = 1; let snapshot = ed.clone(); let mut layouter = |ui: &egui::Ui, text: &dyn egui::TextBuffer, wrap_width: f32| { @@ -971,8 +1002,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) } }; let before = ed.text.clone(); - let input = input_id(); - ed.claim_typing(ui.ctx(), view_id); + let input = input_id(place); + ed.claim_typing(ui.ctx(), view_id, place); let output = egui::TextEdit::multiline(&mut ed.text) .id(input) .font(font.clone()) @@ -1010,7 +1041,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } } if r.changed() { - ed.follow_typed_rows(ctx, &before); + ed.follow_typed_rows(ctx, &before, place); // The next pass measures the updated draft with the Apply/Save rewrite. ctx.request_repaint(); } @@ -1078,6 +1109,27 @@ pub(crate) fn lost_focus_once(r: &egui::Response, had: &mut bool) -> bool { mod tests { use super::*; + /// Where an edge's reach above or below its box overlaps the next paragraph's edge, the + /// paragraph the pointer is level with takes the press, whichever is drawn on top (the play + /// session "resize grown neighbor" at 50% zoom). + #[test] + fn an_edge_goes_to_the_box_the_pointer_is_level_with() { + let upper = Rect::from_min_max(Pos2::new(100.0, 100.0), Pos2::new(200.0, 140.0)); + let lower = Rect::from_min_max(Pos2::new(100.0, 146.0), Pos2::new(201.0, 160.0)); + // Inside the upper box's height, 6 px above the lower one: within both edges' reach. + let p = Pos2::new(199.0, 140.0); + assert_eq!(edge_at(lower, p), Some(Grip::Right), "the lower edge reaches up to p"); + assert_eq!(grab_at(&[upper, lower], p, true), Some((0, Grip::Right))); + assert_eq!(grab_at(&[lower, upper], p, true), Some((1, Grip::Right))); + // Level with neither, 4 px below one and 2 px above the other: the nearer box. + assert_eq!(grab_at(&[lower, upper], Pos2::new(199.0, 144.0), true), Some((0, Grip::Right))); + // Overlapping boxes both level with p: the topmost, as before. + assert_eq!(grab_at(&[upper, upper.translate(egui::vec2(5.0, 0.0))], upper.center(), true), Some((1, Grip::Move))); + // A body under p beats an edge reaching over from a box above or below it. + let body = Rect::from_min_max(Pos2::new(150.0, 146.0), Pos2::new(260.0, 160.0)); + assert_eq!(grab_at(&[body, upper], Pos2::new(205.0, 147.0), true), Some((0, Grip::Move))); + } + /// Formatting applied restarts the rows' alignments by text with the box's history: a Redo /// past the format can't bring back an alignment from before it (FredApps on bfc6f8b1). #[test] diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index 9acca88022..50a514f90e 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -231,8 +231,6 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f sources.windows(2).map(|r| (r[0].0 - r[1].0).abs()).rfind(|v| *v > 0.0).unwrap_or(fallback) }; let g = Arc::make_mut(&mut galley); - let mut bounds = Rect::NOTHING; - let mut meshes = Rect::NOTHING; // Which line of the text (between `\n`s) each row is on: rows aligned on their own go where // the rewrite will put them, in the paragraph's width. let mut line = 0usize; @@ -274,6 +272,14 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f } } row.size.y = (ascent + descent).max(1.0); + } + if text == ed.original && ed.look == ed.look0 && ed.extras == ed.extras0 && block.rotation.abs() < 0.5 { + place_on_source_words(g, block, lines, text, scale); + } + let mut bounds = Rect::NOTHING; + let mut meshes = Rect::NOTHING; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); row.visuals.mesh_bounds = row.visuals.mesh.vertices.iter().fold(Rect::NOTHING, |r, v| r.union(Rect::from_min_max(v.pos, v.pos))); bounds = bounds.union(Rect::from_min_size(placed.pos, row.size)); meshes = meshes.union(row.visuals.mesh_bounds.translate(placed.pos.to_vec2())); @@ -283,6 +289,128 @@ pub(super) fn editor_galley(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f galley } +/// While a paragraph is unchanged the page shows its own text, and the editor's galley is drawn +/// unseen: it only places the caret and the selection. Put each source word's glyphs across that +/// word's box on the page, so a click or a drag lands on the word under the pointer. The editor's +/// face is often a stand-in whose advances differ from the PDF's (a font program it can't load, or +/// an OCR layer whose every word has its own size), and stretching each run to its width still let +/// every word boundary drift, so a drag over one word selected another two words along. +/// +/// A word takes its `ink` box when the run's words and boxes pair up, else the run's box spans all +/// its words. Within a word the stand-in's proportions are kept; spaces fill the gaps between +/// words. A row with a character this can't place, or with boxes out of reading order, is left as +/// laid out. +fn place_on_source_words(g: &mut egui::Galley, block: &pdfcraft_engine::TextBlock, lines: &[pdfcraft_engine::TextLine], text: &str, scale: f32) { + let starts: Vec = text.char_indices().map(|(at, _)| at).collect(); + let chars: Vec = text.chars().collect(); + let x = |user: f64| ((user - block.rect[0]) * f64::from(scale)) as f32; + // Each character's glyph advance, in the galley's character order (a newline has no glyph). + let mut advance = vec![0.0f32; chars.len()]; + let mut at = 0usize; + for placed in &g.rows { + for (glyph, slot) in placed.row.glyphs.iter().zip(advance.iter_mut().skip(at)) { + *slot = glyph.advance_width.max(0.0); + } + at = at.saturating_add(placed.row.glyphs.len()).saturating_add(usize::from(placed.ends_with_newline)); + } + if at != chars.len() { + return; + } + // Where each character goes: its left edge and width, in galley coordinates. + let mut target: Vec> = vec![None; chars.len()]; + let place = |range: std::ops::Range, left: f32, right: f32, target: &mut [Option<(f32, f32)>]| { + let natural: f32 = advance.get(range.clone()).map_or(0.0, |a| a.iter().sum()); + let count = range.len().max(1) as f32; + let mut pos = left; + for i in range { + let share = if natural > 0.0 { advance.get(i).copied().unwrap_or(0.0) / natural } else { 1.0 / count }; + let width = ((right - left) * share).max(0.0); + if let Some(slot) = target.get_mut(i) { + *slot = Some((pos, width)); + } + pos += width; + } + }; + for (range, index) in &block.spans { + let Some(line) = lines.get(*index) else { continue }; + let (first, end) = (starts.partition_point(|b| *b < range.start), starts.partition_point(|b| *b < range.end)); + let mut words = Vec::new(); + let mut i = first; + while i < end { + if chars.get(i).is_some_and(|c| c.is_whitespace()) { + i += 1; + continue; + } + let from = i; + while i < end && chars.get(i).is_some_and(|c| !c.is_whitespace()) { + i += 1; + } + words.push(from..i); + } + let (Some(head), Some(tail)) = (words.first().cloned(), words.last().cloned()) else { continue }; + if line.ink.len() == words.len() { + for (word, ink) in words.iter().zip(&line.ink) { + place(word.clone(), x(ink[0]), x(ink[2]), &mut target); + } + } else { + place(head.start..tail.end, x(line.rect[0]), x(line.rect[2]), &mut target); + } + } + let mut first = 0usize; + for placed in &mut g.rows { + let row = Arc::make_mut(&mut placed.row); + let range = first..first.saturating_add(row.glyphs.len()); + first = range.end.saturating_add(usize::from(placed.ends_with_newline)); + let Some(mut spots) = target.get(range.clone()).map(<[_]>::to_vec) else { continue }; + let row_chars = chars.get(range).unwrap_or_default(); + // Spaces between two placed characters share the gap between them; at the row's ends + // they sit against the nearest placed character. + let mut i = 0; + while i < spots.len() { + if spots.get(i).is_some_and(Option::is_some) { + i += 1; + continue; + } + let from = i; + while i < spots.len() && spots.get(i).is_some_and(Option::is_none) { + i += 1; + } + if row_chars.get(from..i).is_none_or(|c| !c.iter().all(|c| c.is_whitespace())) { + break; + } + let left = from.checked_sub(1).and_then(|p| spots.get(p).copied().flatten()).map(|(at, w)| at + w); + let right = spots.get(i).copied().flatten().map(|(at, _)| at); + let (left, right) = match (left, right) { + (Some(l), Some(r)) => (l, r.max(l)), + (Some(l), None) => (l, l), + (None, Some(r)) => (r, r), + (None, None) => break, + }; + let width = (right - left) / (i - from) as f32; + for (k, spot) in spots.iter_mut().enumerate().take(i).skip(from) { + *spot = Some((left + width * (k - from) as f32, width)); + } + } + let placed_all: Option> = spots.into_iter().collect(); + let Some(spots) = placed_all else { continue }; + if spots.iter().any(|(at, w)| !at.is_finite() || !w.is_finite()) || spots.windows(2).any(|p| p[1].0 + 0.01 < p[0].0) { + continue; + } + for (glyph, (at, width)) in row.glyphs.iter_mut().zip(spots) { + let new_x = at - placed.pos.x; + let dx = new_x - glyph.pos.x; + glyph.pos.x = new_x; + glyph.advance_width = width; + if !glyph.uv_rect.is_nothing() { + for vertex in row.visuals.mesh.vertices.iter_mut().skip(glyph.first_vertex as usize).take(4) { + vertex.pos.x += dx; + } + } + } + row.size.x = row.glyphs.iter().map(|g| g.pos.x + g.advance_width).fold(row.size.x.min(0.0), f32::max); + } +} + /// Caret colour that stays visible on a field filled with `fill`: black on light fills, white on dark ones. pub(crate) fn caret_color_on(fill: Color32) -> Color32 { let luma = 299 * u32::from(fill.r()) + 587 * u32::from(fill.g()) + 114 * u32::from(fill.b()); diff --git a/crates/ui-egui/src/tools/fill_sign.rs b/crates/ui-egui/src/tools/fill_sign.rs index c7e270fdb9..a230839041 100644 --- a/crates/ui-egui/src/tools/fill_sign.rs +++ b/crates/ui-egui/src/tools/fill_sign.rs @@ -614,7 +614,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, // Over the page, so only where the document shows: a box scrolled under the mode bar or a // side panel must leave them their pointer (#2755). It stays where it was placed, cut off as // the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::unique(("fill-text", view.id.0))) + egui::Area::new(egui::Id::unique(("fill-text", view.view_key()))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(pos) diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 1d31b81fbc..e365f3d191 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -95,6 +95,38 @@ fn fillable(f: &FormField) -> bool { !f.read_only() && !matches!(f.kind, FormFieldKind::PushButton | FormFieldKind::Signature) } +/// The fillable widgets in the order Tab and Shift+Tab visit them. +pub fn tab_order(form: &[FormField], info: &DocInfo) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, usize, usize)> = form + .iter() + .enumerate() + .filter(|(_, field)| fillable(field)) + .flat_map(|(fi, field)| { + field + .widgets + .iter() + .enumerate() + .filter(|(_, widget)| { + let Some(page) = widget.page.and_then(|page| info.pages.get(page)) else { return false }; + let [x0, y0, x1, y1] = widget.rect; + let [cx0, cy0, cx1, cy1] = page.crop.map(f64::from); + !widget.hidden + && !widget.read_only + && [x0, y0, x1, y1].into_iter().all(f64::is_finite) + && x0 < x1 + && y0 < y1 + && x1 > cx0 + && x0 < cx1 + && y1 > cy0 + && y0 < cy1 + }) + .map(move |(wi, widget)| (widget.tab, fi, wi)) + }) + .collect(); + order.sort_by_key(|(tab, _, _)| *tab); + order.into_iter().map(|(_, fi, wi)| (fi, wi)).collect() +} + /// Whether a combo box takes typed text as well as a choice from its list: the Edit flag /// (ISO 32000-2 Table 233), which only means something with Combo set. fn editable_combo(f: &FormField) -> bool { @@ -129,24 +161,29 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { } } -/// Select a radio button, if it isn't selected already. -fn select_edit(f: &FormField, w: &FormWidget) -> Option { - (w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) }) +/// Select radio button `wi`, if it isn't selected already. The button itself, not its on state: +/// buttons sharing a state are exclusive unless the group has RadiosInUnison (ISO 32000-2 +/// Table 229). +fn select_edit(f: &FormField, wi: usize) -> Option { + // A button read-only on its own (its widget's `/Ff`, Table 227) keeps its focus stop, but + // the form would refuse turning it on. + let selectable = f.widgets.get(wi).is_some_and(|w| w.on_state.is_some() && !w.read_only) && !f.widget_on(wi); + selectable.then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Widget(wi) }) } -/// What clicking a check box or radio button does. -fn toggle_edit(f: &FormField, w: &FormWidget) -> Option { +/// What clicking widget `wi` of a check box or radio button does. +fn toggle_edit(f: &FormField, wi: usize) -> Option { let value = match f.kind { FormFieldKind::CheckBox => FieldValue::Check(f.value.is_empty()), - FormFieldKind::Radio => { - let on = w.on_state.clone(); - // Clicking the selected button again turns it off unless the group forbids it. - let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on }; - if value.as_ref() == f.value.first() { + // Clicking the selected button again turns it off unless the group forbids it + // (NoToggleToOff, Table 229). + FormFieldKind::Radio if f.widget_on(wi) => { + if f.has(field_flags::NO_TOGGLE_TO_OFF) { return None; } - FieldValue::Radio(value) + FieldValue::Radio(None) } + FormFieldKind::Radio => return select_edit(f, wi), _ => return None, }; Some(Edit::SetFieldValue { name: f.name.clone(), value }) @@ -340,7 +377,8 @@ struct ComboEditor<'a> { fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_>) { let ComboEditor { form, field: f, widget: w, rect, zoom, index: widget } = editor; let mut enter = false; - egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) + let view_key = view.view_key(); + egui::Area::new(egui::Id::unique(("form-editor", view_key))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -358,7 +396,7 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, editor: ComboEditor<'_> .background_color(FIELD_FILL) .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) - .id(egui::Id::unique(("form-field", view.id.0, &f.name, widget))); + .id(egui::Id::unique(("form-field", view_key, &f.name, widget))); // The form field's own logical name; not a visual/preordered label. let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone()); if std::mem::take(&mut fx.request_focus) { @@ -400,7 +438,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st fx.calendar_text = Some(fx.text.clone()); } let mut picked = None; - let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0))) + let area = egui::Area::new(egui::Id::unique(("date-picker", view.view_key()))) .order(egui::Order::Foreground) .fixed_pos(field.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -436,7 +474,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st (Some(a), Some(b)) => (b - a).num_days() as usize, _ => 31, }; - let grid = egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| { + let grid = egui::Grid::new(("date-grid", view.view_key())).spacing([4.0, 2.0]).show(ui, |ui| { for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] { ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small()); } @@ -522,7 +560,7 @@ pub(crate) fn paint_page( painter.rect_stroke(r.expand(1.0), CornerRadius::same(2), Stroke::new(2.0, FOCUS_BLUE), egui::StrokeKind::Outside); } if view.forms.unbaked.contains(&(f.name.clone(), page)) { - paint_unbaked_widget(painter, xf, f, w, r); + paint_unbaked_widget(painter, xf, f, wi, w, r); } else if fillable(f) && hovered == Some((fi, wi)) { painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, FOCUS_BLUE.gamma_multiply(0.8)), egui::StrokeKind::Outside); } @@ -581,7 +619,7 @@ pub fn field_screen_font(painter: &egui::Painter, f: &FormField, w: &FormWidget, if font.is_nan() { 3.0 } else { font.clamp(3.0, MAX_SCREEN_FONT) } } -fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, w: &FormWidget, r: Rect) { +fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, wi: usize, w: &FormWidget, r: Rect) { match f.kind { FormFieldKind::Text | FormFieldKind::Combo => { let text = f.value.first().map(String::as_str).unwrap_or(""); @@ -649,7 +687,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, FormFieldKind::Radio => { painter.rect_filled(r, CornerRadius::same(1), Color32::from_rgb(0xFF, 0xFF, 0xF4)); painter.rect_stroke(r, CornerRadius::same(1), Stroke::new(1.0, Color32::from_gray(180)), egui::StrokeKind::Inside); - let selected = f.value.first().map(String::as_str) == w.on_state.as_deref(); + let selected = f.widget_on(wi); if selected { let radius = (r.height().min(r.width()) * 0.25).clamp(2.0, 16.0); painter.circle_filled(r.center(), radius, Color32::BLACK); @@ -718,27 +756,56 @@ fn settle_text_editor( /// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab /// order, returning the edit to apply (the commit's, or `edit`). -fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { +fn tab_to_next_field( + view: &mut DocView, + info: &DocInfo, + form: &[FormField], + f: &FormField, + widget: usize, + forward: bool, + edit: Option, +) -> Option { commit(view, form); let pending = view.pending_edit.take(); // The next field that can be filled in, check boxes and radio buttons included (#2223), in // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = - order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; + let order = tab_order(form, info); + let exact = order.iter().position(|(fi, wi)| form.get(*fi).is_some_and(|x| x.name == f.name && *wi == widget)); + let same_field = order.iter().position(|(fi, _)| form.get(*fi).is_some_and(|x| x.name == f.name)); + let next_after_removed = if exact.is_none() && same_field.is_none() { + form.iter().position(|field| field.name == f.name).and_then(|fi| { + let tab = form.get(fi)?.widgets.get(widget)?.tab; + let key = (tab, fi, widget); + let tab_key = |(fi, wi): &(usize, usize)| form.get(*fi).and_then(|field| field.widgets.get(*wi)).map(|other| (other.tab, *fi, *wi)); + if forward { + order.iter().position(|entry| tab_key(entry).is_some_and(|other| other > key)).or_else(|| (!order.is_empty()).then_some(0)) + } else { + order.iter().rposition(|entry| tab_key(entry).is_some_and(|other| other < key)).or_else(|| order.len().checked_sub(1)) + } + }) + } else { + None + }; + if let Some(pos) = exact.or(same_field).or(next_after_removed) { + // A focused widget that left the order is replaced by the field's first remaining stop. + let k = if next_after_removed.is_some() || (forward && exact.is_none()) { + pos + } else if forward { + (pos + 1) % order.len() + } else { + (pos + order.len() - 1) % order.len() + }; + let Some((fi, wi)) = order.get(k).copied() else { return edit.or(pending) }; + let Some(target) = form.get(fi) else { return edit.or(pending) }; view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() + if let Some(widget) = target.widgets.get(wi) + && let Some(page) = widget.page + && !field_screen_rect(view, info, target, wi).is_some_and(|rect| { + let viewport = view.viewport_rect(); + viewport.contains(rect.min) && viewport.contains(rect.max) + }) { - view.go_to_page(p); + view.reveal_rect(page, widget.rect.map(|coord| coord as f32), info); } } edit.or(pending) @@ -752,7 +819,7 @@ fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) - let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { view.forms.focus = Some(open_focus(f, wi)); - edit = select_edit(f, target); + edit = select_edit(f, wi); if let Some(p) = target.page && view.page_screen_rect(p).is_none() { @@ -814,7 +881,7 @@ pub(crate) fn overlay( // Over the page, so only where the document shows: a field scrolled under the mode bar // or a side panel must leave them their pointer (#2755). It stays on its field, cut off // as the page is, rather than pushed back into the window. - egui::Area::new(egui::Id::unique(("form-editor", view.id.0))) + egui::Area::new(egui::Id::unique(("form-editor", view.view_key()))) .fade_in(false) .order(egui::Order::Foreground) .fixed_pos(rect.min) @@ -839,7 +906,7 @@ pub(crate) fn overlay( .text_color(Color32::BLACK) .margin(egui::Margin::symmetric(3, 1)) .password(f.has(field_flags::PASSWORD)) - .id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget))); + .id(egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget))); if let Some(max) = f.max_len { te = te.char_limit(max); } @@ -894,7 +961,7 @@ pub(crate) fn overlay( if editable_combo(f) { combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); } - let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) + let resp = egui::Area::new(egui::Id::unique(("form-choices", view.view_key()))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -953,10 +1020,10 @@ pub(crate) fn overlay( FormFieldKind::CheckBox | FormFieldKind::Radio => { // Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so // Space, Tab and the arrows come here instead of turning or scrolling the page. - let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)); + let id = egui::Id::unique(("form-field", view.pane.key, &f.name, focus.widget)); let mut has_focus = false; // Not interactable, so clicks still reach the page under it. - let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false); + let area = egui::Area::new(egui::Id::unique(("form-toggle", view.view_key()))).order(egui::Order::Foreground).interactable(false); area.fixed_pos(rect.min).show(ctx, |ui| { let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); if let Some(fx) = view.forms.focus.as_mut() @@ -990,11 +1057,11 @@ pub(crate) fn overlay( } else if toggle && view.pending_edit.is_some() { view.forms.toggle = true; } else if toggle { - edit = toggle_edit(f, w); + edit = toggle_edit(f, focus.widget); } else if space { // Space toggles a check box and selects a radio button, as in browsers: it never // turns the selected button off. - edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) }; + edit = if f.kind == FormFieldKind::Radio { select_edit(f, focus.widget) } else { toggle_edit(f, focus.widget) }; } else if let Some(forward) = step { edit = step_radio(view, f, focus.widget, forward); } @@ -1005,7 +1072,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); + edit = tab_to_next_field(view, info, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 128e033dc2..1ab23a7fd6 100644 --- a/crates/ui-egui/src/tools/prepare.rs +++ b/crates/ui-egui/src/tools/prepare.rs @@ -148,6 +148,8 @@ enum Grab { #[derive(Clone, Debug, Default, PartialEq)] pub struct PrepareView { + /// Show each fillable widget's position in the same order used by Tab and Shift+Tab. + pub show_tab_numbers: bool, /// The selected field and which of its widgets (the anchor of Align and Match Size). pub selected: Option<(String, usize)>, /// More fields selected with Shift- or ⌘-click. @@ -279,6 +281,39 @@ fn dragged(r: Rect, grab: Grab, delta: egui::Vec2, shift: bool) -> Rect { } } +fn tab_badge_rect(widget: Rect, page: Rect, size: egui::Vec2) -> Option { + if !page.is_positive() || !size.is_finite() || size.x < 0.0 || size.y < 0.0 || size.x > page.width() || size.y > page.height() { + return None; + } + let max_x = page.right() - size.x; + let max_y = page.bottom() - size.y; + let min = egui::pos2((widget.left() + 3.0).clamp(page.left(), max_x), (widget.top() + 3.0).clamp(page.top(), max_y)); + Some(Rect::from_min_size(min, size)) +} + +fn place_tab_badge(widget: Rect, page: Rect, size: egui::Vec2, placed: &[Rect]) -> Option { + let preferred = tab_badge_rect(widget, page, size)?; + if placed.iter().all(|badge| !badge.intersects(preferred)) { + return Some(preferred); + } + + let gap = 2.0; + let step = size + vec2(gap, gap); + let columns = (((page.width() - size.x) / step.x).floor().max(0.0) as usize).saturating_add(1); + let rows = (((page.height() - size.y) / step.y).floor().max(0.0) as usize).saturating_add(1); + let attempts = columns.saturating_mul(rows).min(placed.len().saturating_add(1)); + for slot in 0..attempts { + let col = slot % columns; + let row = slot / columns; + let min = page.min + vec2(col as f32 * step.x, row as f32 * step.y); + let candidate = Rect::from_min_size(min, size); + if placed.iter().all(|badge| !badge.intersects(candidate)) { + return Some(candidate); + } + } + None +} + pub(crate) struct Outcome { /// The click or drag belonged to Prepare a form. pub consumed: bool, @@ -620,7 +655,7 @@ pub fn arrange(form: &[FormField], anchor: &(String, usize), others: &[(String, /// Fields with their light-blue fill and name tags, the selection with its handles, and the /// rectangle being drawn or dragged to the pages' `pointer`. -pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &DocView) { +pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], tab_order: &[(usize, usize)], view: &DocView) { let PageToolContext { ui, xf, page, info, ptr, .. } = cx; let painter = ui.painter(); let pointer = ptr.pos; @@ -664,6 +699,29 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc } } } + if prep.show_tab_numbers { + let mut placed = Vec::new(); + for (number, (fi, wi)) in tab_order.iter().copied().enumerate() { + let Some(field) = form.get(fi) else { continue }; + let Some(widget) = field.widgets.get(wi) else { continue }; + if widget.page != Some(page) { + continue; + } + let mut r = screen_rect(xf, info, page, widget.rect); + let selected = prep.selected.as_ref().is_some_and(|(name, index)| name == &field.name && *index == wi); + if selected && let (Some((_, grab, start)), Some(pointer)) = (grab, pointer) { + r = dragged(r, grab, pointer - start, ui.input(|i| i.modifiers.shift)); + } + let label = number.saturating_add(1).to_string(); + let font = theme::semibold((10.0 * zoom).clamp(8.0, 14.0)); + let galley = painter.layout_no_wrap(label, font, Color32::WHITE); + let Some(badge) = place_tab_badge(r, xf.rect, galley.size() + vec2(8.0, 5.0), &placed) else { continue }; + placed.push(badge); + let clipped = painter.with_clip_rect(painter.clip_rect().intersect(xf.rect)); + clipped.rect_filled(badge, CornerRadius::same(3), SELECT_BLUE); + clipped.galley(badge.min + vec2(4.0, 2.0), galley, Color32::WHITE); + } + } if let (Some((_, Grab::Draw(from) | Grab::Select(from), _)), Some(p)) = (grab, pointer) { let r = Rect::from_two_pos(from, p).intersect(xf.rect); painter.rect_filled(r, CornerRadius::ZERO, FIELD_FILL); @@ -855,4 +913,37 @@ mod tests { assert_eq!(lock_axis(None, vec2(40.0, 42.0), true), Some(Axis::Vertical), "and pressing it again chooses afresh"); assert_eq!(lock_axis(None, vec2(f32::NAN, 0.0), true), None, "a non-finite delta chooses nothing"); } + + #[test] + fn tab_badge_rect_tracks_dragged_rect_and_clamps_to_page_edges() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let widget = Rect::from_min_size(pos2(30.0, 40.0), vec2(20.0, 15.0)); + let size = vec2(24.0, 14.0); + let original = tab_badge_rect(widget, page, size).unwrap(); + let moved = dragged(widget, Grab::Move, vec2(12.0, 8.0), false); + assert_eq!(tab_badge_rect(moved, page, size).unwrap().min, original.min + vec2(12.0, 8.0)); + + let near_edge = Rect::from_min_size(pos2(100.0, 95.0), vec2(8.0, 8.0)); + let badge = tab_badge_rect(near_edge, page, size).unwrap(); + assert!(page.contains_rect(badge)); + } + + #[test] + fn tab_badge_is_omitted_when_it_cannot_fit_on_the_page() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(20.0, 12.0)); + let widget = Rect::from_min_size(pos2(10.0, 20.0), vec2(4.0, 4.0)); + assert_eq!(tab_badge_rect(widget, page, vec2(24.0, 14.0)), None); + } + + #[test] + fn tab_badges_near_the_right_edge_do_not_overlap() { + let page = Rect::from_min_size(pos2(10.0, 20.0), vec2(100.0, 80.0)); + let size = vec2(24.0, 14.0); + let first_widget = Rect::from_min_size(pos2(104.0, 45.0), vec2(8.0, 8.0)); + let second_widget = Rect::from_min_size(pos2(106.0, 45.0), vec2(8.0, 8.0)); + let first = place_tab_badge(first_widget, page, size, &[]).unwrap(); + let second = place_tab_badge(second_widget, page, size, &[first]).unwrap(); + assert!(!first.intersects(second), "{first:?} overlaps {second:?}"); + assert!(page.contains_rect(first) && page.contains_rect(second)); + } } diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 7b1bbeb9f3..f20be70394 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -15,6 +15,7 @@ pub(super) mod ocr_verify; #[cfg(target_arch = "wasm32")] pub(super) mod ocr_web; pub(super) mod optimize_ui; +pub(super) mod print_paper; pub(super) mod print_ui; pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index 7ef50d5ffc..2297e70933 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -401,6 +401,33 @@ impl PdfCraftApp { let _ = recursive; } + /// Add the files at `paths` to the Combine files list and show it: `pdfcraft --combine` and + /// File Explorer's "Combine files in PdfCraft" (#585), where each selected file arrives as a + /// launch of its own and the running PdfCraft collects them here. Nothing to add shows the + /// tab all the same. + #[cfg(not(target_arch = "wasm32"))] + pub fn combine_paths(&mut self, paths: &[String]) { + // A file still opening from before (another launch's Open), or waiting for its password, + // opens in the background, so finishing doesn't hide the tab shown here; a later Open + // still comes to the front. An open that something waits on keeps its focus: a chosen + // tool, a link's destination or a recovery runs on the document once it is in front. + for pending in &mut self.pending_opens { + if pending.then.is_none() && pending.after_open.is_none() && pending.recovered.is_none() { + pending.activate = false; + } + } + if let Some(prompt) = self.password_prompt.as_mut() + && prompt.then.is_none() + && prompt.after_open.is_none() + && prompt.recovered.is_none() + { + prompt.activate = false; + } + self.open_combine_tab(); + let paths: Vec = paths.iter().map(std::path::PathBuf::from).collect(); + self.use_paths(crate::files::FilePurpose::Combine, &paths); + } + /// Add the PDFs, images and text files in `dir` to the Combine files list, as one undo step. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) { diff --git a/crates/ui-egui/src/workflows/combine_ui/tests.rs b/crates/ui-egui/src/workflows/combine_ui/tests.rs index b882dcf52d..7df65d134b 100644 --- a/crates/ui-egui/src/workflows/combine_ui/tests.rs +++ b/crates/ui-egui/src/workflows/combine_ui/tests.rs @@ -210,3 +210,26 @@ fn removing_pages_drops_emptied_parts_and_joins_what_meets() { // A page that isn't there: nothing changes, and it says so. assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 9)]), &mut new_id), None); } + +/// #2503 review: a PDF waiting for its password when Combine arrives opens behind the Combine +/// files tab once unlocked, like one still opening; a prompt that a chosen tool waits on keeps +/// its focus, since the tool runs on the document in front. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_password_answered_after_combine_opens_behind_the_combine_tab() { + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + assert!(app.password_prompt.is_some() && app.views.is_empty()); + app.combine_paths(&[]); + assert!(app.combine_showing()); + app.submit_password(Some("pw".into())); + assert!(app.password_prompt.is_none()); + assert_eq!(app.views.len(), 1, "the document is open"); + assert!(app.combine_showing(), "and the Combine files tab still shows"); + + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app.password_prompt.as_mut().unwrap().then = Some("export.text"); + app.combine_paths(&[]); + assert!(app.password_prompt.as_ref().unwrap().activate, "a tool's open keeps its focus"); +} diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index f54fc177f0..6d46e5dfca 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -251,6 +251,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo ui.add(widgets::checkbox(&mut s.discard_thumbnails, tl!("Discard embedded page thumbnails"))); ui.add(widgets::checkbox(&mut s.discard_tags, tl!("Discard document tags"))); ui.add(widgets::checkbox(&mut s.discard_print_settings, tl!("Discard embedded print settings"))); + ui.add(widgets::checkbox(&mut s.discard_form_actions, tl!("Discard all form submission, import and reset actions"))); discard_box(ui, &mut d.discard, Hidden::Bookmarks, "Discard bookmarks"); discard_box(ui, &mut d.discard, Hidden::FormFields, "Flatten form fields"); discard_box(ui, &mut d.discard, Hidden::HiddenLayers, "Discard hidden layer content"); diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs new file mode 100644 index 0000000000..30edb4dc91 --- /dev/null +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -0,0 +1,224 @@ +//! The Print dialog's paper list and the paper each printer is remembered with (print.page-setup): +//! paper sizes of a person's own, added with Custom… beside Paper and kept with the settings, and +//! for each printer the paper chosen when "Remember this paper for …" was ticked at Print time. +//! Two-sided, grayscale, collate, sizing and comments & forms are left to the dialog itself. + +use std::collections::BTreeMap; + +use pdfcraft_engine::print::PAPERS; +use serde::{Deserialize, Serialize}; + +use crate::print_ui::PrintDraft; + +/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches). +pub const MAX_SIDE: f64 = 14_400.0; +/// The smallest side, in points (one inch). +pub const MIN_SIDE: f64 = 72.0; +/// How many custom papers the settings keep. +pub const MAX_CUSTOM: usize = 100; +/// The longest name a custom paper keeps, in characters. +const MAX_NAME: usize = 64; + +/// A paper size added in the Print dialog (points, portrait). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomPaper { + pub name: String, + pub width: f64, + pub height: f64, +} + +impl CustomPaper { + /// A custom paper from a name and two sides in points, portrait; None when the name is empty + /// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name + /// (any case), which would otherwise be found first when the paper is looked up by name. + pub fn new(name: &str, a: f64, b: f64) -> Option { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v); + let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name)); + (!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) }) + } +} + +/// The paper remembered for each printer, by the printer's name ("" is Save as PDF). +pub type RememberedPaper = BTreeMap; + +/// Custom papers read back from the settings, which are untrusted: each one is checked as if it +/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept. +pub fn read_custom(value: &serde_json::Value) -> Vec { + let Some(list) = value.as_array() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + for item in list { + let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue }; + if let Some(paper) = CustomPaper::new(name, w, h) + && !out.iter().any(|p| p.name == paper.name) + && out.len() < MAX_CUSTOM + { + out.push(paper); + } + } + out +} + +/// The remembered papers read back from the settings: printer name to paper name, strings only. +pub fn read_remembered(value: &serde_json::Value) -> RememberedPaper { + let Some(map) = value.as_object() else { return RememberedPaper::new() }; + map.iter().filter_map(|(printer, paper)| paper.as_str().map(|p| (printer.clone(), p.to_string()))).collect() +} + +impl PrintDraft { + /// The paper list: the standard sizes, then the custom ones. + pub fn paper_count(&self) -> usize { + PAPERS.len() + self.custom_papers.len() + } + + /// The chosen paper's size (points, portrait). + pub fn paper_size(&self) -> (f64, f64) { + match self.paper.checked_sub(PAPERS.len()) { + None => PAPERS[self.paper.min(PAPERS.len() - 1)].1, + Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)), + } + } + + /// The chosen paper's name (a standard name, or the custom paper's). + pub fn paper_name(&self) -> String { + match self.paper.checked_sub(PAPERS.len()) { + None => PAPERS[self.paper.min(PAPERS.len() - 1)].0.to_string(), + Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()), + } + } + + /// The index of the paper called `name`, standard or custom. + pub fn paper_index(&self, name: &str) -> Option { + PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i)) + } + + /// True when the chosen paper is a custom one (which can be removed). + pub fn paper_is_custom(&self) -> bool { + self.paper >= PAPERS.len() + } + + /// Add (or replace, by name) a custom paper and choose it. + pub fn add_custom_paper(&mut self, paper: CustomPaper) { + self.custom_papers.retain(|p| p.name != paper.name); + if self.custom_papers.len() >= MAX_CUSTOM { + self.custom_papers.remove(0); + } + self.custom_papers.push(paper); + self.paper = PAPERS.len() + self.custom_papers.len() - 1; + } + + /// Take the chosen custom paper off the list; the dialog goes back to the first size. + pub fn remove_custom_paper(&mut self) { + if let Some(i) = self.paper.checked_sub(PAPERS.len()) + && i < self.custom_papers.len() + { + self.custom_papers.remove(i); + self.paper = 0; + } + } + + /// The key the printer's paper is remembered under ("" is Save as PDF). + fn printer_key(&self) -> String { + self.printer.clone().unwrap_or_default() + } + + /// Start from the paper remembered for the printer chosen, if one was and it is still on the + /// list; the Remember box says whether one was. + pub fn apply_remembered(&mut self) { + let remembered = self.remembered_paper.get(&self.printer_key()).cloned(); + self.remember_paper = remembered.is_some(); + if let Some(i) = remembered.and_then(|name| self.paper_index(&name)) { + self.paper = i; + } + } + + /// At Print time: keep the paper for the printer when the box is ticked, forget it when not. + pub fn remember_now(&mut self) { + let key = self.printer_key(); + if self.remember_paper { + let name = self.paper_name(); + self.remembered_paper.insert(key, name); + } else { + self.remembered_paper.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() { + let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap(); + assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0)); + assert!(CustomPaper::new("", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none()); + assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none()); + assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none()); + assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME); + } + + #[test] + fn settings_are_read_as_untrusted() { + let v = serde_json::json!([ + { "name": "Label", "width": 288.0, "height": 432.0 }, + { "name": "Label", "width": 300.0, "height": 400.0 }, + { "name": "Bad", "width": -1.0, "height": 432.0 }, + { "name": 5, "width": 288.0, "height": 432.0 }, + "nonsense", + ]); + assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]); + assert!(read_custom(&serde_json::json!(5)).is_empty()); + let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect(); + assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM); + let r = read_remembered(&serde_json::json!({ "Office": "A4", "": "Label", "Odd": 3 })); + assert_eq!(r.len(), 2); + assert_eq!(r.get("").map(String::as_str), Some("Label")); + assert!(read_remembered(&serde_json::json!([1])).is_empty()); + } + + #[test] + fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() { + let mut d = PrintDraft::default(); + assert_eq!(d.paper_count(), PAPERS.len()); + d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap()); + assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0))); + assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4")); + assert_eq!(d.paper_index("Label"), Some(PAPERS.len())); + // The same name replaces the size; removing goes back to the first standard size. + d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap()); + assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0))); + d.remove_custom_paper(); + assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0)); + // A stale index reads as the first size, never out of bounds. + d.paper = PAPERS.len() + 7; + assert_eq!(d.paper_size(), PAPERS[0].1); + } + + #[test] + fn the_paper_is_remembered_per_printer_only_when_asked() { + let mut d = PrintDraft::default(); + let a4 = d.paper_index("A4").unwrap(); + d.printer = Some("Office".into()); + d.paper = a4; + d.remember_paper = true; + d.remember_now(); + d.printer = Some("Plotter".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, false), "another printer keeps the dialog's paper"); + d.printer = Some("Office".into()); + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (a4, true)); + d.remember_paper = false; + d.remember_now(); + assert!(d.remembered_paper.is_empty()); + // A remembered custom paper that was removed since leaves the paper alone. + d.remembered_paper.insert("Office".into(), "Gone".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, true)); + } +} diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 7ea7a18602..880dccbd00 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,8 +171,10 @@ impl PdfCraftApp { sheet: 0, selected, which, + custom_new: None, ..keep }; + self.print_draft.apply_remembered(); self.dialog = Some(crate::Dialog::Print); } @@ -314,7 +316,13 @@ impl PdfCraftApp { // A picked printer that is no longer listed falls back to the default too. if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; - d.printer = default_printer(&printers); + // Only a different printer starts over with its remembered paper; the + // same one keeps the paper and the Remember box as they were left. + let default = default_printer(&printers); + if default != d.printer { + d.printer = default; + d.apply_remembered(); + } } d.printers = printers; d.printers_loading = false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 690dc3142e..d38272a001 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,6 +158,7 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + d.apply_remembered(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -202,15 +203,35 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); + d.paper = d.paper.min(d.paper_count().saturating_sub(1)); let before = d.paper; - craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + ui.horizontal(|ui| { + // A standard size's name is translated; a custom paper keeps the name it was given. + let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() }; + craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| { + for (i, (name, _)) in PAPERS.iter().enumerate() { + ui.selectable_value(&mut d.paper, i, tl!(name)); + } + if !d.custom_papers.is_empty() { + ui.separator(); + } + for (i, p) in d.custom_papers.iter().enumerate() { + let label = format!("{} ({})", p.name, d.units.size(p.width, p.height)); + ui.selectable_value(&mut d.paper, PAPERS.len() + i, label); + } + }) + .response + .labelled_by(paper_label.id); + if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() { + d.custom_new = match d.custom_new { + Some(_) => None, + None => Some((String::new(), String::new(), String::new())), + }; } - }) - .response - .labelled_by(paper_label.id); + if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() { + d.remove_custom_paper(); + } + }); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -218,6 +239,11 @@ pub(crate) fn body( d.sheet = 0; } ui.end_row(); + if d.custom_new.is_some() { + ui.label(""); + custom_paper_row(ui, d, t); + ui.end_row(); + } }); // Windows says which drivers can't print two-sided as they're asked; they keep their // own setting, even when Two-sided is Off. @@ -559,6 +585,11 @@ pub(crate) fn body( }); ui.add_space(12.0); let (mut go, mut cancel) = (false, false); + // The paper is kept for this printer (or Save as PDF) when this is ticked at Print time. On its + // own row above the buttons: sharing their row moved them while the dialog settled. + let for_whom = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label); + ui.add(widgets::checkbox(&mut d.remember_paper, crate::i18n::fmt(tl!("Remember this paper for {printer}"), &[("printer", &for_whom)]))); + ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let looking = still_looking(d); let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") }; @@ -569,5 +600,35 @@ pub(crate) fn body( cancel = true; } }); + if go { + d.remember_now(); + } (go, cancel) } + +/// The row that adds a custom paper: name, width and height in the preferences' unit. +fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { + let unit = d.units; + let Some((name, width, height)) = d.custom_new.as_mut() else { return }; + let side = |s: &str| s.trim().replace(',', ".").parse::().ok().map(|v| v / unit.per_point()); + let paper = match (side(width), side(height)) { + (Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h), + _ => None, + }; + let (mut add, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name")); + ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width")); + ui.label("×"); + ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height")); + ui.label(egui::RichText::new(unit.label()).color(t.text_muted)); + add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if add && let Some(paper) = paper { + d.add_custom_paper(paper); + d.custom_new = None; + } else if cancel { + d.custom_new = None; + } +} diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 27080a7329..988880a550 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -62,7 +62,16 @@ pub struct PrintDraft { /// Print as image (Acrobat's Advanced Print Setup) at `dpi`. pub as_image: bool, pub dpi: f64, + /// Index into the paper list: the standard sizes, then `custom_papers`. pub paper: usize, + /// Paper sizes added with Custom…, kept with the settings. + pub custom_papers: Vec, + /// The paper each printer is remembered with, kept with the settings. + pub remembered_paper: crate::print_paper::RememberedPaper, + /// "Remember this paper for …", applied at Print time. + pub remember_paper: bool, + /// The custom paper being added: name, width and height as typed (the preferences' unit). + pub custom_new: Option<(String, String, String)>, /// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564). pub paper_before_label: Option, /// The sheet shown in the preview (0-based). @@ -126,6 +135,10 @@ impl Default for PrintDraft { as_image: false, dpi: 300.0, paper: 0, + custom_papers: Vec::new(), + remembered_paper: Default::default(), + remember_paper: false, + custom_new: None, paper_before_label: None, sheet: 0, current_page: 0, @@ -206,7 +219,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper: self.paper_size(), orientation: self.orientation, layout, content: self.content, diff --git a/crates/ui-egui/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index c9ab522ecd..67ea715fc2 100644 --- a/crates/ui-egui/tests/it/comments.rs +++ b/crates/ui-egui/tests/it/comments.rs @@ -1627,6 +1627,32 @@ fn the_panel_preserves_threads_source_order_and_unfiltered_page_counts() { assert_eq!(h.query_all_by_label("Needle first reply").count(), 0, "nothing replies to the unnamed root"); } +// Contributor-original PDF. A status is set on a reply (its /IRT names the reply, ISO 32000-2 +// §12.5.6.3), not on the comment; the status reply's text is found only there. +const REPLY_STATUS_FIXTURE: &[u8] = 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 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >> endobj +5 0 obj << /Type /Annot /Subtype /Text /Rect [20 140 40 160] /T (Alpha) /Contents (Plain root) >> endobj +6 0 obj << /Type /Annot /Subtype /Text /Rect [60 140 80 160] /T (Beta) /Contents (Other root) >> endobj +7 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 5 0 R /T (Reader) /Contents (Ordinary reply) >> endobj +8 0 obj << /Type /Annot /Subtype /Text /Rect [20 100 40 120] /IRT 7 0 R /T (Carol) /StateModel /Review /State /Accepted /Contents (Reply-status token) >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +#[test] +fn search_finds_a_comment_by_the_status_set_on_one_of_its_replies() { + let mut h = harness_with_bytes(REPLY_STATUS_FIXTURE.to_vec(), |app| { + app.set_option("panel", "comments").unwrap(); + app.views[0].comments.search = Some("reply-status token".into()); + }); + h.run_steps(2); + h.get_by_label("Plain root"); + h.get_by_label("Ordinary reply"); + assert_eq!(h.query_all_by_label("Other root").count(), 0, "the other comment has no such status"); + assert_eq!(h.query_all_by_label("No comments match.").count(), 0); +} + // Contributor-original PDF, shaped like PyMuPDF output: every page's first comment is named // `fitz-A0`, so only the `/IRT` reference tells which comment a reply belongs to. const PER_PAGE_NAMES: &[u8] = b"%PDF-1.7 @@ -1706,7 +1732,7 @@ fn one_reply_is_deleted_from_its_menu_and_the_thread_stays() { assert_eq!(h.query_all_by_label("Wrong reply").count(), 0); assert_eq!(h.query_all_by_label("Answer to the wrong reply").count(), 0); h.get_by_label("Right reply"); - h.get_by_label("Accepted"); + h.get_by_label("Ben: Accepted"); // One badge per reviewer. assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Delete comment"), "one undo step"); // Copy text copies the reply's text. @@ -2333,6 +2359,68 @@ fn the_panel_filters_by_colour_and_checkmark() { assert_eq!(cards(&h), before); } +/// ISO 32000-2 §12.5.6.3 and Table 174: a comment shows each reviewer's latest status, with +/// their name, and a status set back to None stays shown; a checkmark shows only for the +/// reviewer who set it. +#[test] +fn the_panel_shows_each_reviewers_status_and_only_my_checkmark() { + let mut h = harness(|app| { + app.set_option("panel", "comments").unwrap(); + app.set_option("quick", "square").unwrap(); + }); + drag_pt(&mut h, (40.0, 100.0), (140.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + let me = h.state().comment_prefs.author.clone(); + let other = format!("{me} (other)"); + for (state, author) in [ + (pdfcraft_engine::ReviewState::Accepted, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Carol"), + (pdfcraft_engine::ReviewState::Rejected, "Bob"), + (pdfcraft_engine::ReviewState::Completed, "Dan"), + (pdfcraft_engine::ReviewState::None, "Dan"), + ] { + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetAnnotationStatus { page: 0, index: 0, state, author: author.into() }); + } + h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: other.clone() }); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.run_steps(2); + h.get_by_label("Bob: Rejected"); + h.get_by_label("Carol: Completed"); + h.get_by_label("Dan: None"); + assert_eq!(h.query_all_by_label("Bob: Accepted").count(), 0, "only Bob's latest"); + assert!(h.query_all_by_label("Remove checkmark").next().is_none(), "another reviewer's checkmark isn't mine"); + h.get_by_label("Mark with checkmark"); + + // Filters: a comment stays while any reviewer's status is shown; one nobody set a status on + // counts as None; the checkmark filter goes by my checkmark only. + h.state_mut().set_option("quick", "circle").unwrap(); + drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0)); + h.state_mut().set_option("quick", "select").unwrap(); + h.state_mut().views[0].comments.selected = None; + h.run_steps(2); + let cards = |h: &Harness<'static, PdfCraftApp>| (h.query_all_by_label("Rectangle").count(), h.query_all_by_label("Oval").count()); + let shown = cards(&h); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "Carol's and Dan's statuses still show the rectangle"); + h.state_mut().views[0].comments.hidden_statuses = vec!["None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0, shown.1 - 1), "the oval has no status, so it counts as None"); + h.state_mut().views[0].comments.hidden_statuses = vec!["Rejected".into(), "Completed".into(), "None".into()]; + h.run_steps(2); + assert_eq!(cards(&h), (shown.0 - 1, shown.1 - 1), "every status hidden"); + h.state_mut().views[0].comments.hidden_statuses.clear(); + h.state_mut().views[0].comments.hidden_checks = vec!["Checked".into()]; + h.run_steps(2); + assert_eq!(cards(&h), shown, "another reviewer's checkmark isn't mine"); + + h.state_mut().views[0].comments.hidden_checks.clear(); + h.state_mut().views[0].comments.selected = Some((0, 0)); + h.state_mut().comment_prefs.author = other; + h.run_steps(2); + h.get_by_label("Remove checkmark"); +} + #[test] fn make_current_properties_default() { let mut h = harness(|app| { @@ -2580,17 +2668,14 @@ fn highlighting_an_area_off_the_text() { let q = c[0].quads[0]; assert!((q[0] - 40.0).abs() < 3.0 && (q[1] - 100.0).abs() < 3.0, "{q:?}"); // Dragging over text still highlights the text (once the text layer is in). - let mut glyphs = None; - for _ in 0..200 { - h.run_steps(1); + support::wait_until(&mut h, "the page's text layer", |app| { + let v = &app.views[0]; + v.glyph_screen_pos(0, 4).is_some() && v.glyph_screen_pos(0, 14).is_some() + }); + let (a, b) = { let v = &h.state().views[0]; - if let (Some(a), Some(b)) = (v.glyph_screen_pos(0, 4), v.glyph_screen_pos(0, 14)) { - glyphs = Some((a, b)); - break; - } - std::thread::sleep(std::time::Duration::from_millis(5)); - } - let (a, b) = glyphs.expect("text layer"); + (v.glyph_screen_pos(0, 4).expect("text layer"), v.glyph_screen_pos(0, 14).expect("glyph")) + }; drag(&mut h, a, b); assert_eq!(comments(&h).len(), 2); } diff --git a/crates/ui-egui/tests/it/control.rs b/crates/ui-egui/tests/it/control.rs index 764d4c6d24..dcb795f130 100644 --- a/crates/ui-egui/tests/it/control.rs +++ b/crates/ui-egui/tests/it/control.rs @@ -140,6 +140,22 @@ fn thumbnail_size_is_settable_and_inspectable_through_the_control_channel() { assert!(ok(&mut h, &c, "ui.state", json!({}))["active"]["thumbnail_zoom"].as_f64().unwrap() > 0.75); } +#[test] +fn form_tab_numbers_can_be_shown_and_captured_through_the_control_channel() { + let (mut h, c) = harness(); + h.state_mut().open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + h.run_steps(3); + ok(&mut h, &c, "ui.command", json!({"id": "form.prepare"})); + let control = ok(&mut h, &c, "ui.inspect", json!({"query": "Show tab numbers"})); + assert!(control["widgets"].as_array().unwrap().iter().any(|widget| widget["label"] == "Show tab numbers")); + ok(&mut h, &c, "ui.click", json!({"label": "Show tab numbers"})); + h.run_steps(2); + assert!(h.state().views[h.state().active.unwrap()].prepare.show_tab_numbers); + assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][1]["dirty"], false); + let shot = ok(&mut h, &c, "ui.screenshot", json!({})); + assert!(shot["png_base64"].as_str().is_some_and(|png| !png.is_empty()), "the control channel returns a nonempty screenshot"); +} + #[test] fn page_shadows_are_an_observable_app_wide_preference() { let (mut h, c) = harness(); @@ -953,6 +969,8 @@ fn inverted_middle_drag_moves_the_view_the_way_the_pointer_goes() { assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty); } +/// The control channel reports the open document, its page count and current page, and its +/// view options can be set: the page, and a right-hand panel by name (an unknown one is refused). #[test] fn state_and_view_options() { let (mut h, c) = harness(); @@ -969,6 +987,9 @@ fn state_and_view_options() { assert!(call(&mut h, &c, "ui.set", json!({ "key": "panel", "value": "nonsense" })).is_err()); } +/// In a Japanese interface, the mode tabs and Preferences show Japanese labels, the command +/// palette finds commands by their Japanese or English names or their ids, and switching back to +/// English keeps the tabs working. #[test] fn japanese_controls_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("japanese_controls_and_search_keep_command_ids") { @@ -1004,6 +1025,9 @@ fn japanese_controls_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools"); } +/// In a German interface, the mode tabs and Preferences show German labels, the language +/// selector switches to English and back, and the command palette finds Split document by its +/// German name, its English name or its command id. #[test] fn german_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("german_preferences_and_search_keep_command_ids") { @@ -1039,6 +1063,9 @@ fn german_preferences_and_search_keep_command_ids() { assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf"); } +/// In a Ukrainian interface, Preferences and dialogs show Ukrainian labels, the command palette +/// finds Split document by its Ukrainian name, its English name or its command id, and a refused +/// edit's notice names the command in Ukrainian. #[test] fn ukrainian_preferences_and_search_keep_command_ids() { if crate::support::skip_without_catalogs("ukrainian_preferences_and_search_keep_command_ids") { @@ -1075,6 +1102,10 @@ fn ukrainian_preferences_and_search_keep_command_ids() { assert_eq!(state["documents"][0]["name"], "doc.pdf"); } +/// In a Japanese interface, Preferences opened from the menu bar switches the language to English, +/// Ukrainian and back, and opens again with Cmd+,; the shortcuts list then reads in Japanese and +/// the document stays open. Preferences is in Edit on Windows and Linux, and in the app menu (as +/// Settings…) on macOS. #[test] fn preferences_menu_and_shortcut_allow_switching_interface_languages() { if crate::support::skip_without_catalogs("preferences_menu_and_shortcut_allow_switching_interface_languages") { @@ -1085,16 +1116,24 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() { } let (mut h, c) = harness(); ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" })); - // The menu bar's Edit menu holds Preferences. Titles and rows carry their mnemonic - // ("編集(E)", "環境設定(P)…"), so match by the start of the label. + // The menu bar's Edit menu holds Preferences on Windows and Linux. On macOS the in-window bar + // takes the Mac layout (`Target::current()`), which moves it to the app menu as Settings…. + // Titles and rows carry their mnemonic ("編集(E)", "環境設定(P)…"), so match by the start of + // the label. + let (menu, preferences) = if cfg!(target_os = "macos") { + let ja = pdfcraft_ui_egui::i18n::Lang::from_code("ja").unwrap(); + ("PdfCraft", pdfcraft_ui_egui::i18n::tr(ja, "Settings…").trim_end_matches('…')) + } else { + ("編集", "環境設定") + }; let menu_row = |text: &'static str| { move |n: &egui_kittest::kittest::AccessKitNode<'_>| { n.role() == egui::accesskit::Role::MenuItem && n.label().is_some_and(|l| l.starts_with(text)) } }; - h.get_by(menu_row("編集")).click(); + h.get_by(menu_row(menu)).click(); h.run_steps(2); - h.get_by(menu_row("環境設定")).click(); + h.get_by(menu_row(preferences)).click(); h.run_steps(2); for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")] { @@ -2153,6 +2192,47 @@ fn routine_continuous_scrolling_does_not_add_navigation_history() { assert!(h.state().views[0].forward.is_empty()); } +/// CodeRabbit on #5341: the control channel folds a pinned folder on Home down to its heading +/// and opens it again, as its chevron does, and `ui.state` says which are folded. Folders that +/// aren't pinned, and states other than on and off, are refused with the reason. +#[test] +fn control_collapses_and_expands_a_pinned_folder() { + let dir = std::env::temp_dir().join(format!("pdfcraft-ui-fold-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("inside.pdf"), fixture(1)).unwrap(); + let folder = dir.to_string_lossy().into_owned(); + let slot: Arc>> = Arc::default(); + let (s, pin) = (slot.clone(), folder.clone()); + let mut h = Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.run_inline = true; + app.pin_folder(&pin); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app + }); + h.run_steps(4); + let c = slot.lock().unwrap().take().unwrap(); + let folded = |h: &mut Harness<'static, PdfCraftApp>| ok(h, &c, "ui.state", json!({}))["pinned_folders"].clone(); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=on") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }])); + assert!(h.query_by_label("inside.pdf").is_none(), "a collapsed folder lists nothing"); + let unpinned = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": "/not/pinned=on" })).unwrap_err(); + assert!(unpinned.contains("not a pinned folder"), "{unpinned}"); + let bad = call(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=yes") })).unwrap_err(); + assert!(bad.contains("=on or"), "{bad}"); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": true }]), "refusals change nothing"); + ok(&mut h, &c, "ui.set", json!({ "key": "folder-collapsed", "value": format!("{folder}=off") })); + h.run_steps(2); + assert_eq!(folded(&mut h), json!([{ "folder": folder, "collapsed": false }])); + h.get_by_label("inside.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn accessible_labels_on_format_text_and_pin_dialog() { let (mut h, c) = harness(); diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 01660a3a60..982099aaf9 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -912,3 +912,129 @@ fn highlighted_link_can_be_requested_again_after_cancel() { assert_eq!(h.state().last_opened_url, None); } } + +/// One 300×400 page of plain text, no link annotations except over the fourth line: a web +/// address, a `www.` address, an email address, an address under a link annotation, and text +/// with no address (#4207). Each line starts at x 40, 14 points high, at the y given. +fn text_links_fixture() -> Vec { + let content = [ + (350, "https://example.com/plain-text-url."), + (300, "www.example.com/no-scheme"), + (250, "someone@example.com"), + (200, "https://example.org/covered"), + (150, "Nothing to open here"), + ] + .iter() + .map(|(y, s)| format!("BT /F1 14 Tf 40 {y} Td ({s}) Tj ET\n")) + .collect::(); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Type /Annot /Subtype /Link /Rect [30 195 290 215] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"), + ]; + 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 +} + +/// Click the text line whose baseline is at `y` until the page's text has arrived and the click +/// asks to open something, or 30 seconds pass. +fn click_text_until_pending(h: &mut Harness<'static, PdfCraftApp>, y: f32) -> bool { + support::step_until(h, |h| { + click_link_at(h, y + 4.0); + h.state().pending_link.is_some() + }) +} + +/// Web, `www.` and email addresses written as plain page text open like links (#4207): the same +/// confirmation, `https://` for `www.` and `mailto:` for email; a link annotation over an address +/// wins, text without an address opens nothing, and the setting turns it off. +#[test] +fn addresses_written_in_the_page_text_open_like_links() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + for (y, url) in [ + (350.0, "https://example.com/plain-text-url"), + (300.0, "https://www.example.com/no-scheme"), + (250.0, "mailto:someone@example.com"), + (200.0, LINK_URL), + ] { + assert!(click_text_until_pending(&mut h, y), "a click on the line at {y} asked to open nothing"); + assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Link), "line at {y}"); + assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers"); + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert_eq!(h.state().pending_link, None); + } + // The page's text is known by now: plain text with no address opens nothing. + click_link_at(&mut h, 154.0); + assert_eq!(h.state().pending_link, None, "text without an address"); + // Preferences can turn it off; the link annotation still works. + h.state_mut().set_option("text-links", "false").unwrap(); + h.run_steps(2); + click_link_at(&mut h, 354.0); + assert_eq!(h.state().pending_link, None, "turned off"); + click_link_at(&mut h, 204.0); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link), "a link annotation is unaffected"); +} + +/// A drag that starts on an address written as page text selects its text, as from any other +/// text; only a click opens it (#4207, review). +#[test] +fn a_drag_from_an_address_in_the_page_text_selects_it() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + // Once a click asks to open the address, its text and the address are known. + assert!(click_text_until_pending(&mut h, 350.0), "the address opens on a click"); + h.get_by_label("Cancel").click(); + h.run_steps(4); + // Glyphs 2 to 12 of the first line are inside "https://example.com/plain-text-url.". + let (from, to) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 2).expect("text layer"), v.glyph_screen_pos(0, 12).expect("glyph")) + }; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let selected = h.state().views[0].selected_text(); + assert!(selected.as_deref().is_some_and(|t| t.starts_with("tp")), "the drag selects the address's text: {selected:?}"); + assert_eq!(h.state().pending_link, None, "a drag opens nothing"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c5d197f64c..40a2da9b44 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -5426,6 +5426,67 @@ fn alt_arrows_navigate_instead_of_nudging_a_selected_image() { assert!((moved[0] - before[0] - 1.0).abs() < 0.01, "a plain → nudged a point: {before:?} → {moved:?}"); } +/// A paragraph's edge and an open paragraph's frame are caught a few pixels either side of +/// their hairline, and the cursor shows exactly where a press takes hold (#2203). +#[test] +fn paragraph_edges_and_frames_are_caught_without_aiming_for_the_hairline() { + let start = |h: &mut Harness<'static, PdfCraftApp>| { + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let id = h.state().views[0].id; + let b = h.state().session.get(id).unwrap().text_blocks(0)[0].clone(); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let k = r.width() / 200.0; + // The drawn box: the text on screen, 2 px larger each side. + let bx = egui::Rect::from_min_max( + egui::pos2(r.left() + b.rect[0] as f32 * k, r.top() + (300.0 - b.rect[3] as f32) * k), + egui::pos2(r.left() + b.rect[2] as f32 * k, r.top() + (300.0 - b.rect[1] as f32) * k), + ) + .expand(2.0); + (id, bx) + }; + // A closed paragraph's right edge, 7 px inside and 7 px outside: the rewrap arrow, and a + // drag from there changes the width rather than moving the box. + for dx in [-7.0f32, 7.0] { + let mut h = harness(1, |_| {}); + let (id, bx) = start(&mut h); + let p = egui::pos2(bx.right() + dx, bx.center().y); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal, "{dx} px from the edge"); + drag(&mut h, p, p + egui::vec2(40.0, 0.0)); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"), "{dx} px from the edge rewraps"); + } + // An open paragraph's frame, 7 px beyond its drawn line: the move arrow, and a press there + // selects the box instead of closing the paragraph. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + assert!(h.state().views[0].line_editor.is_some(), "a click on the text opens it for typing"); + let p = egui::pos2(bx.center().x, bx.top() - 7.0); + h.hover_at(p); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "the pointer shows the frame takes hold"); + click_at(&mut h, p); + assert!(h.state().views[0].line_editor.is_none()); + h.get_by_label("1 object selected"); + // Once the text is changed, a press on the frame doesn't take the box (selecting it would + // drop the typing): it applies the typing like any click off the text, so the pointer doesn't + // offer the move arrow there either. + let mut h = harness(1, |_| {}); + let (_, bx) = start(&mut h); + click_at(&mut h, bx.center()); + h.state_mut().views[0].line_editor.as_mut().expect("open for typing").text = "Page one".into(); + h.run_steps(2); + h.hover_at(p); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::Move, "no move arrow where a press can't take the box"); + click_at(&mut h, p); + let id = h.state().views[0].id; + assert_eq!(h.state().session.get(id).unwrap().text_blocks(0)[0].text, "Page one", "the click applies the typing, as any click off the text does"); + assert!(h.query_all_by_label_contains("object selected").next().is_none(), "and doesn't take the box"); +} + #[test] fn an_open_paragraph_moves_by_its_frame_and_by_the_arrow_keys() { let mut h = harness(2, |_| {}); @@ -6412,6 +6473,106 @@ fn the_caret_on_a_small_row_is_as_tall_as_that_row() { assert!(caret > row_height * 0.8 && caret < row_height + 4.0, "caret {caret} px on a {row_height} px row"); } +/// One paragraph whose every word is its own run at its own size, in a font the editor can't load +/// (not embedded, and not a standard font), with glyphs much wider than its stand-in face's: the +/// shape of an OCR layer, and of a Type 1 paragraph from LaTeX. Returns the PDF and its words. +fn stand_in_words_fixture() -> (Vec, Vec<&'static str>) { + let words: Vec<(&str, f64)> = vec![ + ("In", 10.0), + ("poker", 10.3), + ("a", 15.1), + ("full", 11.7), + ("house", 10.7), + ("is", 9.7), + ("a", 15.1), + ("set", 9.0), + ("of", 10.4), + ("cards", 10.0), + ("that", 9.4), + ("make", 9.0), + ("a", 12.4), + ("pair", 11.7), + ]; + let mut content = String::new(); + let mut x = 20.0; + for (word, size) in &words { + content.push_str(&format!("BT /F1 {size} Tf {x:.2} 200 Td ({word}) Tj ET\n")); + x += word.len() as f64 * 0.7 * size + 4.0; + } + let widths = vec!["700"; 95].join(" "); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 700 300] >>".into(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + format!("<< /Type /Font /Subtype /Type1 /BaseFont /CMR10 /FirstChar 32 /LastChar 126 /Widths [{widths}] >>"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + (out, words.into_iter().map(|(w, _)| w).collect()) +} + +/// A drag over a word of an unchanged paragraph selects that word. The editor lays the paragraph +/// out unseen in a stand-in face, and stretching each run to its width let every word boundary +/// drift: over an OCR layer, dragging across "kind" selected "of a", two words before it. +#[test] +fn dragging_over_a_word_selects_that_word_when_the_editor_uses_a_stand_in_face() { + let (bytes, words) = stand_in_words_fixture(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("ocr-like.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0).unwrap(); + let k = page.width() / 700.0; + let screen = |x: f64, y: f64| egui::pos2(page.left() + x as f32 * k, page.top() + (300.0 - y) as f32 * k); + let lines = h.state().session.get(h.state().views[0].id).unwrap().text_lines(0); + assert_eq!(lines.len(), words.len(), "one run per word: {:?}", lines.iter().map(|l| &l.text).collect::>()); + let first = lines[0].rect; + let at = screen((first[0] + first[2]) / 2.0, (first[1] + first[3]) / 2.0); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(4); + let opened = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone()); + assert_eq!(opened.as_deref(), Some(words.join(" ").as_str()), "the words open as one paragraph"); + // Drag across each of the last words, just inside its box, as a reader aims at what the page shows. + for target in [words.len() - 1, words.len() - 4, 9] { + let r = lines[target].rect; + let middle = (r[1] + r[3]) / 2.0; + let (from, to) = (screen(r[0] + 0.5, middle), screen(r[2] - 0.5, middle)); + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for step in 1..=6 { + h.hover_at(from + (to - from) * (step as f32 / 6.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let ed = h.state().views[0].line_editor.as_ref().expect("the paragraph stays open"); + let selected = ed.selection.clone().and_then(|s| ed.text.get(s)).unwrap_or_default(); + assert_eq!(selected, words[target], "dragging over word {target} ({:?})", words[target]); + } +} + /// Opt-in private-document regression. This never captures images or saves the source file. /// Run with PDFCRAFT_TEXT_REGRESSION_PDF=/path/to/document.pdf and --ignored. #[test] @@ -9428,6 +9589,75 @@ fn test_cropped_image_selection_and_bounding_box() { assert!(h.state().views[0].picked_image.is_none(), "clicking cropped-away region must not hit-test or select the image"); } +/// Print production ▸ Fix hairlines: the dialog widens zero-width and too-thin +/// strokes and says how many; with nothing to fix it says so and changes nothing. +#[test] +fn fix_hairlines_from_the_dialog() { + // A page with a zero-width line and a 2 pt line. + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(1))).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let cs = doc + .add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), b"0 w 10 10 m 100 10 l S 2 w 10 20 m 100 20 l S"))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::FixHairlines)); + h.get_by_label("Type 3 font glyphs"); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 1 hairline on 1 page"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines")); + // Nothing left: told, and no new undo step. + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "No hairlines found on those pages"); + assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Fix hairlines")); +} + +/// Several strokes on one page: the notice says "hairlines" and "1 page". +#[test] +fn fix_hairlines_notice_uses_singular_and_plural() { + let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(fixture(2))).unwrap(); + for (n, page) in pdfcraft_model::pages(&doc).into_iter().enumerate() { + let lines = if n == 0 { "0 w 10 10 m 100 10 l S 0 w 10 20 m 100 20 l S" } else { "2 w 10 10 m 100 10 l S" }; + let cs = doc.add(pdfcraft_cos::Object::Stream(pdfcraft_cos::Stream::flate(pdfcraft_cos::Dict::new(), lines.as_bytes()))); + doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Ref(cs))).unwrap(); + } + let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("lines.pdf", None, bytes.clone()).expect("opens"); + app + }); + h.run_steps(4); + // The dialog's default range is every page; only the first has hairlines. + assert!(h.state_mut().execute("prepress.hairlines")); + h.run_steps(2); + h.get_by_label("Fix").click(); + h.run_steps(3); + assert_eq!(h.state().toast.clone().unwrap().0, "Fixed 2 hairlines on 1 page"); +} + +/// The `dialog` option goes through the command: with no document it does not open the dialog. +#[test] +fn fix_hairlines_option_needs_a_document() { + let mut app = PdfCraftApp::new(); + app.set_option("dialog", "fix-hairlines").unwrap(); + assert_eq!(app.dialog, None); +} + /// The Read mode command (not the mode bar) applies a paragraph still open in the editor, as /// putting the Edit text tool down does; left open, it would stay drawn in Read mode with no tool. #[test] diff --git a/crates/ui-egui/tests/it/editor_taps.rs b/crates/ui-egui/tests/it/editor_taps.rs index 26a6f1f3d3..ccce838863 100644 --- a/crates/ui-egui/tests/it/editor_taps.rs +++ b/crates/ui-egui/tests/it/editor_taps.rs @@ -313,7 +313,8 @@ fn edit_paragraph(h: &mut Harness<'_, PdfCraftApp>) { let p = r.min + egui::vec2(60.0 * k, (400.0 - 304.0) * k); mouse_click(h, p); assert!(h.state().views[0].line_editor.is_some(), "the paragraph opens for typing"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("edit-text-line-input"))), "with the keyboard"); + let input = egui::Id::unique(("edit-text-line-input", h.state().views[0].place())); + assert!(h.ctx.memory(|m| m.has_focus(input)), "with the keyboard"); } /// The open paragraph's line spacing, if a paragraph is open. diff --git a/crates/ui-egui/tests/it/folders.rs b/crates/ui-egui/tests/it/folders.rs index 583cc35eaa..a3e954b6a2 100644 --- a/crates/ui-egui/tests/it/folders.rs +++ b/crates/ui-egui/tests/it/folders.rs @@ -140,6 +140,71 @@ fn unpinning_removes_the_folder_but_not_its_files() { assert!(path.exists(), "unpinning never touches the files"); } +/// #5295: a pinned folder folds down to its heading and back, the other folders stay as they +/// are, and the folded state is kept across a restart. +#[test] +fn a_pinned_folder_collapses_to_its_heading_and_stays_collapsed_after_a_restart() { + let (first, second) = (TempFolder::new("fold-a"), TempFolder::new("fold-b")); + first.file("alpha.pdf", FIXTURE, Duration::from_secs(10)); + second.file("beta.pdf", FIXTURE, Duration::from_secs(10)); + let (a, b) = (first.path(), second.path()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + let name = pdfcraft_ui_egui::folders_ui::folder_name(&a); + h.get_by_label(&format!("Collapse {name}")).click(); + h.run_steps(2); + assert!(h.query_by_label("alpha.pdf").is_none(), "a collapsed folder lists nothing"); + h.get_by_label("beta.pdf"); + h.get_by_label(&format!("Unpin {name}")); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a)); + + let mut restarted = PdfCraftApp::new(); + restarted.restore(&h.state().persist()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&a)); + + h.get_by_label(&format!("Expand {name}")).click(); + h.run_steps(2); + h.get_by_label("alpha.pdf"); + assert!(h.state().pinned.collapsed.is_empty()); + + // Settings are untrusted: only pinned folders, each once. + let settings = serde_json::json!({ "pinned_folders": [a, b], "pinned_folders_collapsed": [b, b, "/not/pinned", 5] }); + restarted.restore(&settings.to_string()); + assert_eq!(restarted.pinned.collapsed, std::slice::from_ref(&b)); + restarted.unpin_folder(&b); + assert!(restarted.pinned.collapsed.is_empty(), "unpinning forgets the folded state"); +} + +/// Two pinned folders of the same name: their buttons say where each one is, so a screen reader +/// can tell them apart. +#[test] +fn folders_of_the_same_name_have_buttons_that_tell_them_apart() { + let root = TempFolder::new("same-name"); + let (a, b) = (root.0.join("work").join("Scans"), root.0.join("home").join("Scans")); + std::fs::create_dir_all(&a).unwrap(); + std::fs::create_dir_all(&b).unwrap(); + let (a, b) = (a.to_string_lossy().into_owned(), b.to_string_lossy().into_owned()); + let pins = (a.clone(), b.clone()); + let mut h = harness(move |app| { + app.pin_folder(&pins.0); + app.pin_folder(&pins.1); + }); + h.run_steps(2); + for (folder, other) in [(&a, &b), (&b, &a)] { + let named = format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(folder)); + assert_ne!(named, format!("Scans ({})", pdfcraft_ui_egui::folders_ui::location(other))); + h.get_by_label(&format!("Collapse {named}")); + h.get_by_label(&format!("Unpin {named}")); + } + h.get_by_label(&format!("Collapse Scans ({})", pdfcraft_ui_egui::folders_ui::location(&a))).click(); + h.run_steps(2); + assert_eq!(h.state().pinned.collapsed, std::slice::from_ref(&a), "the button folds its own folder"); +} + #[test] fn pinned_folders_survive_a_restart() { let dir = TempFolder::new("restart"); diff --git a/crates/ui-egui/tests/it/forms.rs b/crates/ui-egui/tests/it/forms.rs index 72db13829b..0c64b3001e 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -18,6 +18,18 @@ fn harness() -> Harness<'static, PdfCraftApp> { h } +fn harness_with_bytes(name: &'static str, bytes: Vec) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes(name, None, bytes).unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + crate::support::settle(&mut h); + h +} + fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { let s = h.state(); s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() @@ -87,6 +99,41 @@ fn a_read_only_fields_tooltip_shows_on_hover() { assert!(h.query_all_by_label("Where you live").next().is_some()); } +#[test] +fn read_only_widgets_are_skipped_by_tab_navigation() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 7 0 R 8 0 R 9 0 R] >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 120] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R 10 0 R 7 0 R 8 0 R 9 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (first) /Rect [20 80 150 100] /P 3 0 R /F 4 >>", + "<< /FT /Tx /T (locked) /Kids [6 0 R 10 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Ff 1 /Rect [20 50 150 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (empty) /Rect [20 50 20 50] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (off-page) /Rect [220 50 240 70] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /FT /Tx /T (last) /Rect [20 20 150 40] /P 3 0 R /F 4 >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [20 35 150 45] /P 3 0 R /F 4 >>", + ]); + let mut h = harness_with_bytes("readonly-widget.pdf", bytes); + { + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + let locked = doc.form.iter().find(|field| field.name == "locked").unwrap(); + assert!(!locked.read_only(), "the field itself is editable"); + assert!(locked.widgets[0].read_only, "only its widget is read-only"); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.as_str(), wi))) + .collect::>(); + assert_eq!(order, vec![("first", 0), ("locked", 1), ("last", 0)], "read-only, empty and off-page widgets are excluded"); + } + click_field(&mut h, "first", 0); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("locked".into(), 1)), "the writable widget in the same field remains in tab order"); + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("last".into(), 0))); +} + /// A `/TU` of only spaces shows no tooltip (no empty box under the pointer). #[test] fn a_blank_tooltip_shows_nothing() { @@ -277,6 +324,84 @@ fn tab_and_space_fill_in_a_check_box_between_text_fields() { assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab comes back to it"); } +#[test] +fn tab_uses_the_first_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "pick", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "pick".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 230.0, 336.0, 246.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press(egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Tab replaces the off-page focus with the group's remaining widget"); +} + +#[test] +fn shift_tab_uses_the_previous_remaining_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "last", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "last".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 270.0, 340.0, 292.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("agree".into(), 0)), "Shift+Tab restores focus to the last stop before the removed widget"); +} + +#[test] +fn shift_tab_wraps_to_the_last_widget_when_focus_leaves_the_order() { + let mut h = check_box_harness(); + click_field(&mut h, "first", 0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "first".into(), + props: Box::new(pdfcraft_engine::FieldProps { rect: Some((0, [320.0, 350.0, 340.0, 372.0])), ..Default::default() }), + }); + h.run_steps(3); + + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(3); + assert_eq!(focused(&h), Some(("pick".into(), 1)), "Shift+Tab wraps to the last remaining stop"); +} + +#[test] +fn tab_and_shift_tab_follow_widget_order_across_pages() { + let mut h = check_box_harness(); + h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField { + page: 1, + rect: [20.0, 350.0, 180.0, 372.0], + kind: pdfcraft_engine::NewField::Text { multiline: false }, + name: Some("second_page".into()), + }); + h.run_steps(4); + let order = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info) + .into_iter() + .filter_map(|(fi, wi)| doc.form.get(fi).map(|field| (field.name.clone(), wi))) + .collect::>() + }; + assert_eq!(order.first().map(|entry| entry.0.as_str()), Some("first")); + assert_eq!(order.last().map(|entry| entry.0.as_str()), Some("second_page")); + click_field(&mut h, "first", 0); + for expected in order.iter().skip(1) { + h.key_press(egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "forward traversal: {order:?}"); + } + for expected in order.iter().rev().skip(1) { + h.key_press_modifiers(egui::Modifiers::SHIFT, egui::Key::Tab); + h.run_steps(4); + assert_eq!(focused(&h), Some(expected.clone()), "reverse traversal: {order:?}"); + } +} + #[test] fn clicking_a_check_box_while_typing_keeps_the_text_and_focuses_the_box() { // Issue #2223: the click ticked the box but left no field focused, so Tab started over. @@ -404,7 +529,7 @@ fn dragging_over_another_fields_text_focuses_it_and_selects_from_the_press() { let doc = s.session.get(s.views[0].id).unwrap(); let f = doc.form.iter().find(|f| f.name == "name").unwrap(); let rect = pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen"); - (rect, egui::Id::unique(("form-field", s.views[0].id.0, "name", 0usize))) + (rect, egui::Id::unique(("form-field", s.views[0].view_key(), "name", 0usize))) }; // From inside the first word ("Ad|a" at the field's auto size, 13.9 pt) to past the end of the text. let (from, to) = (egui::pos2(rect.left() + 38.0, rect.center().y), egui::pos2(rect.right() - 8.0, rect.center().y)); @@ -1579,3 +1704,128 @@ fn a_form_button_shows_and_hides_the_attachments_panel() { h.run_steps(2); assert_eq!(h.state().right, None); } + +/// Which widgets of field `name` are on (their `/AS` is their on state). +fn on_widgets(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().clone(); + (0..f.widgets.len()).map(|i| f.widget_on(i)).collect() +} + +/// Radio groups whose buttons share an on state: `plain` (Radio | NoToggleToOff, on states Yes, +/// Yes, No), `unison` (the same with RadiosInUnison) and `free` (Radio alone, S and M). +fn shared_state_radios() -> Harness<'static, PdfCraftApp> { + let kid = |n: u32, parent: u32, x: u32, state: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} {} {} {}] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>", + 350 - n * 40, + x + 16, + 366 - n * 40 + ) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 10 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R 9 0 R 11 0 R 12 0 R 13 0 R 15 0 R 16 0 R] >>".into(), + "<< >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(), + "<< /FT /Btn /T (plain) /Ff 49152 /V /Off /Kids [7 0 R 8 0 R 9 0 R] >>".into(), + kid(0, 6, 20, "Yes"), + kid(0, 6, 60, "Yes"), + kid(0, 6, 100, "No"), + "<< /FT /Btn /T (unison) /Ff 33603584 /V /Off /Kids [11 0 R 12 0 R 13 0 R] >>".into(), + kid(1, 10, 20, "Yes"), + kid(1, 10, 60, "Yes"), + kid(1, 10, 100, "No"), + "<< /FT /Btn /T (free) /Ff 32768 /V /Off /Kids [15 0 R 16 0 R] >>".into(), + kid(2, 14, 20, "S"), + kid(2, 14, 60, "M"), + ]; + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let bytes = pdf_from_objects(&objs); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("radios.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + h +} + +/// ISO 32000-2 Table 229: without RadiosInUnison, radio buttons sharing an on state are mutually +/// exclusive, so a click turns on only the button clicked; with it they turn on together. +/// NoToggleToOff keeps a clicked selected button on; without it the click deselects it. +#[test] +fn clicking_a_radio_button_that_shares_its_on_state() { + let mut h = shared_state_radios(); + click_field(&mut h, "plain", 0); + assert_eq!((on_widgets(&h, "plain"), value(&h, "plain")), (vec![true, false, false], vec!["Yes".to_string()]), "only the clicked Yes"); + click_field(&mut h, "plain", 1); + assert_eq!(on_widgets(&h, "plain"), [false, true, false], "the other Yes takes over"); + click_field(&mut h, "plain", 1); + assert_eq!(on_widgets(&h, "plain"), [false, true, false], "NoToggleToOff: clicking it again changes nothing"); + click_field(&mut h, "plain", 2); + assert_eq!((on_widgets(&h, "plain"), value(&h, "plain")), (vec![false, false, true], vec!["No".to_string()])); + click_field(&mut h, "unison", 0); + assert_eq!(on_widgets(&h, "unison"), [true, true, false], "RadiosInUnison: both Yes buttons"); + click_field(&mut h, "free", 0); + assert_eq!(value(&h, "free"), ["S"]); + click_field(&mut h, "free", 0); + assert!(value(&h, "free").is_empty(), "without NoToggleToOff a second click deselects it"); + // Undo takes the last click back; the exclusive pair stays as it was. + h.state_mut().undo(); + h.run_steps(2); + assert_eq!(value(&h, "free"), ["S"]); + assert_eq!(on_widgets(&h, "plain"), [false, false, true]); +} + +/// A radio button read-only on its own (its widget's `/Ff`, ISO 32000-2 Table 227) is still +/// focused by the arrows and Space, but neither tries to turn it on: the form would refuse the +/// edit and show a failure for a button the user can't change. A click says it is read-only. +#[test] +fn a_read_only_radio_button_is_focused_but_not_selected() { + let kid = |x: u32, state: &str, extra: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent 6 0 R /AS /Off {extra}/Rect [{x} 350 {} 366] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>", + x + 16 + ) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) >> >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(), + "<< >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(), + "<< /FT /Btn /T (size) /Ff 32768 /V /Off /Kids [7 0 R 8 0 R] >>".into(), + kid(20, "S", ""), + kid(60, "L", "/Ff 1 "), + ]; + let objs: Vec<&str> = objs.iter().map(String::as_str).collect(); + let bytes = pdf_from_objects(&objs); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("radios.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(6); + click_field(&mut h, "size", 0); + assert_eq!(value(&h, "size"), ["S"]); + let toast = |h: &Harness<'static, PdfCraftApp>| h.state().toast.as_ref().map(|(m, _)| m.clone()); + assert_eq!(toast(&h), None); + h.key_press(egui::Key::ArrowRight); + h.run_steps(3); + assert_eq!(focused(&h), Some(("size".into(), 1)), "the arrow still moves onto the read-only button"); + assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], None), "and leaves the selection alone"); + h.key_press(egui::Key::Space); + h.run_steps(3); + assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], None), "Space doesn't select it"); + // A click says why nothing happens, as for any read-only field, and is no failed edit. + click_field(&mut h, "size", 1); + assert_eq!((value(&h, "size"), toast(&h)), (vec!["S".to_string()], Some("size is read-only".to_string())), "nor does a click"); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 972bc78b82..f9ca6a2977 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -66,6 +66,8 @@ mod pointer; mod preferences_layout; mod prepare; mod print; +mod print_custom_paper_names; +mod print_late_printer_list; mod protect; mod reading_colors; mod recent_files; @@ -77,6 +79,10 @@ mod script_noop; mod scroll_prefetch; mod side_panels; mod signing; +mod split_view; +mod split_view_edits; +mod split_view_find; +mod split_view_render; mod stamps; mod status_layers; mod text_select; diff --git a/crates/ui-egui/tests/it/prepare.rs b/crates/ui-egui/tests/it/prepare.rs index d6abe1f8b3..50845e064a 100644 --- a/crates/ui-egui/tests/it/prepare.rs +++ b/crates/ui-egui/tests/it/prepare.rs @@ -55,6 +55,17 @@ fn rect_of(h: &Harness<'static, PdfCraftApp>, name: &str) -> [f64; 4] { s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().widgets[0].rect } +fn text_center_near(h: &Harness<'static, PdfCraftApp>, label: &str, expected: Pos2) -> Option { + h.output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) if text.galley.text() == label => Some(text.visual_bounding_rect().center()), + _ => None, + }) + .min_by(|a, b| a.distance(expected).total_cmp(&b.distance(expected))) +} + #[test] fn shift_selected_small_widgets_have_blue_tags_and_visible_outlines() { use egui::{Color32, Event, Modifiers, PointerButton, Shape, StrokeKind}; @@ -648,6 +659,99 @@ fn the_fields_panel_orders_tabs_manually() { assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Set tab order")); } +#[test] +fn tab_numbers_follow_fill_order_without_dirtying_the_document() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (order, rects, undo) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let rects = order + .iter() + .map(|(fi, wi)| { + let field = doc.form.get(*fi).expect("the traversal field exists"); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, *wi).unwrap() + }) + .collect::>(); + (order, rects, doc.can_undo()) + }; + assert!(h.state().views[0].prepare.show_tab_numbers); + assert_eq!(undo, None, "displaying the numbers does not edit the PDF"); + let size_widgets = order + .iter() + .filter(|(fi, _)| h.state().session.get(h.state().views[0].id).unwrap().form.get(*fi).is_some_and(|field| field.name == "size")) + .count(); + assert_eq!(size_widgets, 2, "each radio widget has its own tab stop"); + + let drawn: Vec<(String, egui::Pos2, egui::Rect, egui::Rect)> = h + .output() + .shapes + .iter() + .filter_map(|clipped| match &clipped.shape { + egui::Shape::Text(text) => { + Some((text.galley.text().to_owned(), text.visual_bounding_rect().center(), text.visual_bounding_rect(), clipped.clip_rect)) + } + _ => None, + }) + .collect(); + for (i, rect) in rects.iter().enumerate() { + let expected = i.saturating_add(1).to_string(); + let badge_center = rect.min + egui::vec2(3.0 + 4.0 + 3.0, 3.0 + 2.0 + 5.0); + let badge = drawn.iter().find(|(label, center, _, _)| label == &expected && center.distance(badge_center) < 8.0); + let Some((_, _, text_rect, clip_rect)) = badge else { + panic!("widget {:?} should show tab number {expected} near {:?}; drawn: {drawn:?}", order.get(i), rect.min); + }; + assert!(clip_rect.contains_rect(*text_rect), "the whole tab number {expected} should be visible"); + } +} + +#[test] +fn tab_badge_tracks_its_widget_during_a_drag() { + let mut h = harness(); + h.state_mut().execute("form.prepare"); + h.run_steps(2); + h.get_by_label("Show tab numbers").click(); + h.run_steps(3); + + let (name, widget, rect) = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let order = pdfcraft_ui_egui::forms_ui::tab_order(&doc.form, &doc.info); + let (fi, wi) = *order.first().expect("the form has a fillable widget"); + let field = doc.form.get(fi).expect("the first fillable field exists"); + (field.name.clone(), wi, pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, field, wi).unwrap()) + }; + let start = rect.center(); + let badge_before = text_center_near(&h, "1", rect.min + egui::vec2(10.0, 10.0)).expect("the first badge is painted"); + + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.drop_at(start); + h.run_steps(3); + assert_eq!(h.state().views[0].prepare.selected.as_ref().map(|selected| (selected.0.as_str(), selected.1)), Some((name.as_str(), widget))); + + let delta = egui::vec2(24.0, 16.0); + h.hover_at(start); + h.run_steps(1); + h.drag_at(start); + h.run_steps(1); + h.hover_at(start + delta); + h.run_steps(3); + + let badge_during_drag = text_center_near(&h, "1", badge_before + delta).expect("the badge stays painted during the drag"); + assert!(badge_during_drag.distance(badge_before + delta) < 8.0, "badge {badge_before:?} moved to {badge_during_drag:?}"); + assert!(badge_during_drag.distance(badge_before) > 12.0, "the badge follows the dragged widget"); + h.drop_at(start + delta); + h.run_steps(3); +} + #[test] fn duplicating_a_field_onto_every_page() { let mut h = harness(); diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 3e0fec7a6e..a6346ca1e2 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -918,6 +918,98 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { std::fs::remove_file(out).unwrap(); } +/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes +/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so +/// the dialog has settled where it stays. A click before then can land where a control was +/// (windows-latest CI). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); +} + +/// Page setup: a paper size of one's own joins the Paper list (Custom…), the paper is kept for +/// the printer when "Remember this paper for …" is ticked at Print time, starts the dialog next +/// time, survives a restart with the settings, and is forgotten when unticked; Remove takes a +/// custom size off the list. +#[test] +fn custom_papers_and_the_paper_remembered_for_a_printer() { + let mut h = harness(); + // No printers here: Save as PDF is the dialog's printer, now and when it opens again. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!(h.state().print_draft.printer, None); + h.get_by_label("Custom…").click(); + h.run_steps(2); + // Typed in the preferences' unit: 4 × 6 inches. + let per_point = h.state().print_draft.units.per_point(); + h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point))); + h.run_steps(2); + h.get_by_label("Add").click(); + h.run_steps(2); + { + let d = &h.state().print_draft; + assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name()); + let (w, l) = d.paper_size(); + assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}"); + } + // The custom row has closed: let the dialog settle at its new height before clicking. + h.run_steps(8); + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(h.state().print_draft.remember_paper, "ticked"); + let out = std::env::temp_dir().join(format!("pdfcraft-print-paper-test-{}.pdf", std::process::id())); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let bytes = std::fs::read(&out).expect("saved"); + let _ = std::fs::remove_file(&out); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let sheet = pdfcraft_model::pages(&saved)[0].display_size(&saved); + assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}"); + assert_eq!(h.state().print_draft.remembered_paper.get("").map(String::as_str), Some("Label 4×6")); + // Next time the dialog starts with it. + h.state_mut().print_draft.paper = 0; + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Label 4×6", true)); + // And after a restart. + let settings = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings); + assert_eq!(fresh.print_draft.paper_count(), h.state().print_draft.paper_count()); + assert_eq!(fresh.print_draft.remembered_paper, h.state().print_draft.remembered_paper); + // Unticked at Print time, the paper is forgotten for Save as PDF. + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(!h.state().print_draft.remember_paper, "unticked"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None, "saved"); + let _ = std::fs::remove_file(&out); + assert!(h.state().print_draft.remembered_paper.is_empty()); + // Remove takes the custom size off the list. + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + let custom = h.state().print_draft.paper_count() - 1; + h.state_mut().print_draft.paper = custom; + h.run_steps(2); + h.get_by_label("Remove").click(); + h.run_steps(2); + assert_eq!((h.state().print_draft.paper_count(), h.state().print_draft.paper), (custom, 0)); +} + /// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided /// while Booklet is chosen): the job flips on the short edge for both sides and prints /// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. diff --git a/crates/ui-egui/tests/it/print_custom_paper_names.rs b/crates/ui-egui/tests/it/print_custom_paper_names.rs new file mode 100644 index 0000000000..ba083b3c0b --- /dev/null +++ b/crates/ui-egui/tests/it/print_custom_paper_names.rs @@ -0,0 +1,62 @@ +//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the +//! standard size would be found first and a remembered custom paper would come back as it. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +#[test] +fn a_custom_paper_cannot_take_a_standard_name() { + let mut h = harness(); + // No printers here: the list is empty as soon as it is asked for. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); + h.get_by_label("Custom…").click(); + h.run_steps(2); + let before = h.state().print_draft.paper_count(); + let per_point = h.state().print_draft.units.per_point(); + let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point)); + // The name of a standard paper, in either case, is refused: Add stays disabled. + for name in ["A4", " a4 "] { + h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name"); + assert_eq!(h.state().print_draft.paper_count(), before); + } + // Any other name is accepted by the same row. + h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled()); + + // Settings read back are checked the same way: the custom "A4" is dropped, the rest kept. + let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 }); + let settings = serde_json::json!({ + "print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")], + "print_paper_by_printer": { "": "A4" }, + }); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings.to_string()); + let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Label"]); +} diff --git a/crates/ui-egui/tests/it/print_late_printer_list.rs b/crates/ui-egui/tests/it/print_late_printer_list.rs new file mode 100644 index 0000000000..b32772df20 --- /dev/null +++ b/crates/ui-egui/tests/it/print_late_printer_list.rs @@ -0,0 +1,66 @@ +//! A printer list that arrives late must not undo what was picked while it was being read, when +//! the printer it names is the one the dialog already has. + +use egui_kittest::Harness; +use pdfcraft_engine::print::spool::Printer; +use pdfcraft_ui_egui::PdfCraftApp; +use std::sync::{Arc, Mutex}; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); +} + +#[test] +fn a_late_list_keeps_the_paper_picked_meanwhile() { + let mut h = harness(); + // The source waits for the test to let it answer, once the first list is in. + let gate = Arc::new(Mutex::new(())); + let wait = gate.clone(); + h.state_mut().printer_source = Some(Arc::new(move || { + let _open = wait.lock(); + vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] + })); + assert!(h.state_mut().execute("print.dialog")); + wait_for_list(&mut h); + h.run_steps(4); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office")); + + // "Office" is remembered with Tabloid (the third size). + h.state_mut().print_draft.remembered_paper.insert("Office".into(), "Tabloid".into()); + h.state_mut().dialog = None; + let hold = gate.lock().unwrap(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(2); + assert!(h.state().print_draft.printers_loading, "the fresh list is still being read"); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once"); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Tabloid", true)); + + // Meanwhile the person picks another paper and unticks Remember. + { + let d = &mut h.state_mut().print_draft; + d.paper = 0; + d.remember_paper = false; + } + drop(hold); + wait_for_list(&mut h); + h.run_steps(2); + let d = &h.state().print_draft; + assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer"); + assert_eq!((d.paper, d.remember_paper), (0, false), "the paper and the Remember box are as they were left"); +} diff --git a/crates/ui-egui/tests/it/recovery.rs b/crates/ui-egui/tests/it/recovery.rs index 93ea045b6e..57500b310c 100644 --- a/crates/ui-egui/tests/it/recovery.rs +++ b/crates/ui-egui/tests/it/recovery.rs @@ -90,7 +90,7 @@ fn a_crashed_session_is_offered_and_recovered_unsaved() { assert_eq!(app.views.len(), 1); let doc = app.session.get(app.views[0].id).unwrap(); assert!(doc.dirty, "recovered changes are unsaved"); - assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "Save goes back to the original file"); + assert_eq!(doc.path.as_deref(), Some("/docs/work.pdf"), "the original path is kept, as what Save offers (#3923)"); assert_eq!(doc.info.pages.len(), 2, "the edit survived the crash"); assert_eq!(s.list().len(), 1, "the entry stays until the document is saved or closed"); } diff --git a/crates/ui-egui/tests/it/split_view.rs b/crates/ui-egui/tests/it/split_view.rs new file mode 100644 index 0000000000..2cbde22eb6 --- /dev/null +++ b/crates/ui-egui/tests/it/split_view.rs @@ -0,0 +1,317 @@ +//! Split view (`view.split-window`): a tab's menu splits right or down, ⌘\ splits the current +//! document and ends split view again, each pane keeps its own zoom, the same document in both +//! panes is one document (one edit, one Undo), the pane last clicked is the one menus and keys act +//! on, and a tab dropped on the right half of the page splits right with it. + +use egui::accesskit::Role; +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::panes::Place; + +/// `pages` portrait pages (300 × 400) with a line of text each. +fn fixture(pages: usize) -> Vec { + let mut objs: Vec = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()]; + let kids: Vec = (0..pages).map(|k| format!("{} 0 R", 4 + 2 * k)).collect(); + objs.push(format!("<< /Type /Pages /Kids [{}] /Count {pages} /MediaBox [0 0 300 400] >>", kids.join(" "))); + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into()); + for k in 0..pages { + objs.push(format!("<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> >>", 5 + 2 * k)); + let content = format!("BT /F1 18 Tf 40 300 Td (Page {}) Tj ET", k + 1); + objs.push(format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())); + } + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +fn harness(names: &'static [&'static str]) -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.run_inline = true; + app.set_option("language", "en").unwrap(); + for name in names { + app.open_bytes(name, None, fixture(3)).unwrap(); + } + app + }); + h.run_steps(4); + h +} + +fn index(h: &Harness<'static, PdfCraftApp>, name: &str) -> usize { + let s = h.state(); + s.views.iter().position(|v| s.session.get(v.id).is_some_and(|d| d.name == name)).expect("tab open") +} + +fn places(h: &Harness<'static, PdfCraftApp>) -> Vec { + h.state().views.iter().map(|v| v.place()).collect() +} + +/// Run frames until `ready`, rendering in between (renders arrive between frames). +fn wait(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + for _ in 0..600 { + h.run_steps(1); + if ready(h.state()) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("timed out waiting for {what}"); +} + +/// A press and release at `at` (a click on the page area). +fn click_at(h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2) { + h.event(egui::Event::PointerMoved(at)); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn drag_tab(h: &mut Harness<'static, PdfCraftApp>, name: &str, to: egui::Pos2) { + let from = h.get_by_role_and_label(Role::Tab, name).rect().center(); + h.event(egui::Event::PointerMoved(from)); + h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for k in 1..=8 { + h.event(egui::Event::PointerMoved(from.lerp(to, k as f32 / 8.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); +} + +#[test] +fn split_right_from_the_tab_menu_shows_two_panes_each_with_its_own_zoom() { + let mut h = harness(&["quote.pdf", "order.pdf"]); + // The menu of quote.pdf, not the tab the main pane shows (order.pdf, opened last): it moves + // into the second pane. + h.get_by_role_and_label(Role::Tab, "quote.pdf").click_secondary(); + h.run_steps(2); + h.get_by_label("Split right").click(); + h.run_steps(4); + let (quote, order) = (index(&h, "quote.pdf"), index(&h, "order.pdf")); + let split = h.state().panes.split.expect("split view is on"); + assert!(!split.down); + assert_eq!(h.state().views[order].place(), Place::Main); + assert_eq!(h.state().views[quote].place(), Place::Second); + // Side by side: two page areas, the second to the right of the first. + let (left, right) = (h.state().views[order].viewport_rect(), h.state().views[quote].viewport_rect()); + assert!(left.width() > 200.0 && right.width() > 200.0, "{left:?} {right:?}"); + assert!(right.left() >= left.right(), "{left:?} {right:?}"); + // The second pane has its own strip with its tab; the main strip no longer shows it. + assert_eq!(h.query_all_by_role_and_label(Role::Tab, "quote.pdf").count(), 1); + assert_eq!(h.state().active, Some(quote), "the tab split into the pane is active"); + // ⌘+ zooms the active pane only. + let before = (h.state().views[order].zoom, h.state().views[quote].zoom); + h.key_press_modifiers(Modifiers::COMMAND, Key::Plus); + h.run_steps(3); + assert_eq!(h.state().views[order].zoom, before.0, "the other pane keeps its zoom"); + assert!(h.state().views[quote].zoom > before.1, "the active pane zoomed in"); + // Split down turns the panes. + h.get_by_role_and_label(Role::Tab, "quote.pdf").click_secondary(); + h.run_steps(2); + h.get_by_label("Split down").click(); + h.run_steps(4); + assert!(h.state().panes.split.is_some_and(|s| s.down)); + let (top, bottom) = (h.state().views[order].viewport_rect(), h.state().views[quote].viewport_rect()); + assert!(bottom.top() >= top.bottom(), "{top:?} {bottom:?}"); +} + +#[test] +fn the_same_document_in_both_panes_is_one_document_with_one_undo() { + let mut h = harness(&["contract.pdf"]); + // ⌘\ splits the current document: a second view of it in the second pane. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + assert_eq!(places(&h), [Place::Main, Place::Second]); + let (first, second) = (0, 1); + assert_eq!(h.state().views[first].id, h.state().views[second].id, "one document"); + assert_eq!(h.state().session.docs().len(), 1); + // Both panes draw the portrait page. + let portrait = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| hgt > w); + let landscape = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| w > hgt); + wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second)); + // Each pane scrolls on its own: the second goes to page 3. + h.state_mut().views[second].go_to_page(2); + h.run_steps(4); + assert_eq!(h.state().views[first].current, 0); + assert_eq!(h.state().views[second].current, 2); + // An edit made in the first pane shows in the second pane too. (The second view of the + // document is the one the app's own edit code doesn't find by the document's id.) + h.state_mut().views[second].go_to_page(0); + h.run_steps(3); + let at = h.state().views[first].viewport_rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().active, Some(first), "a click makes the pane active"); + assert!(h.state_mut().execute("page.rotate")); + wait(&mut h, "the rotated page in both panes", |s| landscape(s, first) && landscape(s, second)); + let id = h.state().views[first].id; + assert!(h.state().session.get(id).unwrap().dirty); + // One Undo, from the other pane, puts it back in both. + let at = h.state().views[second].viewport_rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().active, Some(second)); + assert!(h.state_mut().execute("edit.undo")); + wait(&mut h, "the page upright again in both panes", |s| portrait(s, first) && portrait(s, second)); + assert!(h.state().session.get(id).unwrap().can_undo().is_none(), "it was one step"); + // The other pane redraws after a change that leaves its page's size alone too (a comment + // added in the first pane): its page goes out of date at once, then comes back. + wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second)); + let at = h.state().views[first].viewport_rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().active, Some(first)); + wait(&mut h, "both panes drawn", |s| portrait(s, first) && portrait(s, second)); + use pdfcraft_engine::{Edit, NewAnnotation, Shape, Style}; + let shape = Shape::Rectangle { rect: [40.0, 40.0, 200.0, 200.0] }; + let comment = NewAnnotation { page: 0, style: Style::default_for(&shape), shape, contents: String::new(), author: String::new() }; + assert!(h.state_mut().apply_edit(Edit::AddAnnotation(comment))); + h.run_steps(1); + assert!(h.state().views[second].drawn_page_size(0).is_none(), "the second pane's page is being drawn again"); + wait(&mut h, "both panes drawn again", |s| portrait(s, first) && portrait(s, second)); +} + +#[test] +fn split_again_or_closing_a_panes_last_tab_ends_split_view() { + let mut h = harness(&["a.pdf", "b.pdf"]); + // ⌘\ twice: split, then back to one pane; the second view of the document closes, the + // document stays open. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(3); + assert!(h.state().panes.split.is_some()); + assert_eq!(h.state().views.len(), 3); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(3); + assert!(h.state().panes.split.is_none()); + assert_eq!(h.state().views.len(), 2); + assert_eq!(places(&h), [Place::Main, Place::Main]); + assert_eq!(h.state().session.docs().len(), 2); + // Split right with a.pdf (not the tab shown): it moves to the second pane. Closing it there, + // the pane's last tab, ends split view and closes only that tab. + let a = index(&h, "a.pdf"); + h.state_mut().split_tab(a, false); + h.run_steps(3); + assert_eq!(h.state().views[index(&h, "a.pdf")].place(), Place::Second); + let a = index(&h, "a.pdf"); + h.state_mut().request_close_tab(a); + h.run_steps(3); + assert!(h.state().panes.split.is_none()); + assert_eq!(h.state().views.len(), 1); + assert_eq!(places(&h), [Place::Main]); + // The same document in both panes: closing one view keeps the document and its edits. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(3); + assert!(h.state_mut().execute("page.rotate")); + h.run_steps(2); + let second = h.state().views.iter().position(|v| v.place() == Place::Second).unwrap(); + h.state_mut().request_close_tab(second); + h.run_steps(3); + assert!(h.state().close_request.is_none(), "no save prompt: the document is still open"); + assert!(h.state().panes.split.is_none()); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().session.get(h.state().views[0].id).unwrap().dirty, "its edit is still there"); + // Typing a view holds of its own (not in the document yet) is asked about, even with the + // document open in the other pane; Don't save closes that view, not the other. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(3); + let second = h.state().views.iter().position(|v| v.place() == Place::Second).unwrap(); + let (first_key, id) = (h.state().views[0].view_key(), h.state().views[0].id); + let typed = pdfcraft_ui_egui::fill_sign::TypeBox { + page: 0, + at: [40.0, 40.0], + text: "typed".into(), + size: 10.0, + color: [0.0; 3], + border: Default::default(), + focus: false, + }; + h.state_mut().views[second].fill_text = Some(typed); + h.state_mut().request_close_tab(second); + assert_eq!(h.state().close_request, Some(pdfcraft_ui_egui::CloseRequest::Tab(id)), "the typing is asked about"); + let ctx = h.ctx.clone(); + h.state_mut().resolve_close(&ctx, Some(false)); + h.run_steps(3); + assert_eq!(h.state().views.len(), 1); + assert_eq!(h.state().views[0].view_key(), first_key, "the view asked about closed, the other stayed"); + assert!(h.state().session.get(id).unwrap().dirty, "the document and its edit are still open"); +} + +#[test] +fn the_pane_last_clicked_is_the_one_menus_and_keys_act_on() { + // left.pdf, opened last, is the tab shown; right.pdf goes to the second pane. + let mut h = harness(&["right.pdf", "left.pdf"]); + let right = index(&h, "right.pdf"); + h.state_mut().split_tab(right, false); + h.run_steps(4); + let (left, right) = (index(&h, "left.pdf"), index(&h, "right.pdf")); + let (lid, rid) = (h.state().views[left].id, h.state().views[right].id); + // A click in the left pane makes it active; a command from the menu acts on its document. + let at = h.state().views[left].viewport_rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().active, Some(left)); + h.state_mut().run_command("page.rotate"); + h.run_steps(2); + assert!(h.state().session.get(lid).unwrap().dirty); + assert!(!h.state().session.get(rid).unwrap().dirty); + // Then the right pane: the active tab follows the click, and so does the next command. + let at = h.state().views[right].viewport_rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().active, Some(right)); + h.state_mut().run_command("page.rotate"); + h.run_steps(2); + assert!(h.state().session.get(rid).unwrap().dirty); + // The main strip still marks the tab its pane shows, and the window title names it. + assert_eq!(h.get_by_role_and_label(Role::Tab, "left.pdf (edited)").accesskit_node().is_selected(), Some(true)); + assert!(h.state().window_title.starts_with("left.pdf"), "{}", h.state().window_title); +} + +#[test] +fn a_tab_dropped_on_the_right_half_of_the_page_splits_right_with_it() { + let mut h = harness(&["one.pdf", "two.pdf"]); + let page = h.state().views[index(&h, "two.pdf")].viewport_rect(); + drag_tab(&mut h, "one.pdf", egui::pos2(page.right() - 120.0, page.center().y)); + assert!(h.state().panes.split.is_some(), "dropped on the right half: split view"); + assert_eq!(h.state().views[index(&h, "one.pdf")].place(), Place::Second); + assert_eq!(h.state().views[index(&h, "two.pdf")].place(), Place::Main); + // Dragged back onto the main strip, it rejoins it and split view ends. + let strip = h.get_by_role_and_label(Role::Tab, "two.pdf").rect(); + drag_tab(&mut h, "one.pdf", strip.right_center() + egui::vec2(40.0, 0.0)); + assert!(h.state().panes.split.is_none()); + assert_eq!(places(&h), [Place::Main, Place::Main]); + // Let go outside the window, a tab moves along the strip as before split view (here to the + // front), and nothing splits. + let ids: Vec<_> = h.state().views.iter().map(|v| v.id).collect(); + drag_tab(&mut h, "two.pdf", egui::pos2(-200.0, 300.0)); + assert!(h.state().panes.split.is_none()); + assert_eq!(h.state().views.iter().map(|v| v.id).collect::>(), [ids[1], ids[0]]); +} + +/// Regression: closing the second pane's tab from its own strip, with the same document in both +/// panes, drew that pane with the closed tab's index ("index out of bounds"). +#[test] +fn closing_the_second_panes_tab_from_its_strip_keeps_the_program_running() { + let mut h = harness(&["a.pdf"]); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(3); + assert_eq!(places(&h), [Place::Main, Place::Second]); + let main_right = h.state().views[0].viewport_rect().right(); + let tab = h.get_all_by_role_and_label(Role::Tab, "a.pdf").find(|t| t.rect().left() >= main_right).expect("the second pane's tab"); + tab.click_secondary(); + h.run_steps(2); + h.get_by_label("Close Tab").click(); + h.run_steps(3); + assert!(h.state().panes.split.is_none()); + assert_eq!(h.state().views.len(), 1); +} diff --git a/crates/ui-egui/tests/it/split_view_edits.rs b/crates/ui-egui/tests/it/split_view_edits.rs new file mode 100644 index 0000000000..bfef041e8b --- /dev/null +++ b/crates/ui-egui/tests/it/split_view_edits.rs @@ -0,0 +1,117 @@ +//! Split view: typing in a field of one pane goes into the document when the next click goes to +//! the other pane, even when that click changes the document too. + +use egui::{Key, Modifiers, Pos2}; +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; +use pdfcraft_ui_egui::panes::Place; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.run_inline = true; + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + // The whole 300×400 pt page in either pane. + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + h +} + +/// The centre of field `name`'s first widget in tab `view`, once that tab has drawn its page. +fn field_center(h: &Harness<'static, PdfCraftApp>, view: usize, name: &str) -> Option { + let s = h.state(); + let doc = s.session.get(s.views[view].id)?; + let f = doc.form.iter().find(|f| f.name == name)?; + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[view], &doc.info, f, 0).map(|r| r.center()) +} + +fn value(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == name).unwrap().value.clone() +} + +/// A press and release at `p`. +fn click(h: &mut Harness<'static, PdfCraftApp>, p: Pos2) { + h.hover_at(p); + h.run_steps(1); + h.drag_at(p); + h.run_steps(1); + h.drop_at(p); + h.run_steps(3); +} + +/// Typing in the second pane's field, then clicking a check box in the first pane: the click +/// makes the first pane active before the second is drawn, and the field commits as it loses the +/// focus, so its edit is queued on the tab that is no longer active. It used to wait there until +/// the check box's edit made that tab start afresh next frame, and the typing was gone. +#[test] +fn typing_in_one_pane_survives_an_edit_in_the_other() { + let mut h = harness(); + // ⌘\ splits the document: a second view of it in the second pane, the active one. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + let (first, second) = (0, 1); + assert_eq!(h.state().views.iter().map(|v| v.place()).collect::>(), [Place::Main, Place::Second]); + assert_eq!(h.state().views[first].id, h.state().views[second].id, "one document"); + for _ in 0..100 { + if field_center(&h, first, "agree").is_some() && field_center(&h, second, "name").is_some() { + break; + } + h.run_steps(1); + } + let name = field_center(&h, second, "name").expect("the second pane shows the field"); + let agree = field_center(&h, first, "agree").expect("the first pane shows the check box"); + assert!(h.state().views[second].viewport_rect().contains(name), "{name:?}"); + assert!(h.state().views[first].viewport_rect().contains(agree), "{agree:?}"); + + click(&mut h, name); + assert_eq!(h.state().active, Some(second)); + assert!(h.state().views[second].forms.focus.is_some(), "the editor opened"); + h.event(egui::Event::Text("Typed in the second pane".into())); + h.run_steps(2); + assert!(value(&h, "name").is_empty(), "not committed yet"); + + click(&mut h, agree); + h.run_steps(3); + assert_eq!(h.state().active, Some(first), "the click made the first pane active"); + assert_eq!(value(&h, "agree"), ["Yes"], "the click's own edit"); + assert_eq!(value(&h, "name"), ["Typed in the second pane"], "the typing in the other pane went into the document"); + assert!(h.state().views[second].pending_edit.is_none(), "nothing left queued"); +} + +/// Save from the second pane, with the same document in both: what is typed there goes into the +/// file. It used to look for the typing in the document's first tab, the first pane's, find none, +/// and write the file without it. +#[test] +fn save_in_the_second_pane_keeps_its_typing() { + let dir = std::env::temp_dir().join(format!("pdfcraft-split-save-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let out = dir.join("out.pdf"); + let mut h = harness(); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + let second = 1; + for _ in 0..100 { + if field_center(&h, second, "name").is_some() { + break; + } + h.run_steps(1); + } + let name = field_center(&h, second, "name").expect("the second pane shows the field"); + click(&mut h, name); + h.event(egui::Event::Text("Typed in the second pane".into())); + h.run_steps(2); + + h.key_press_modifiers(Modifiers::COMMAND, Key::S); + h.run_steps(4); + assert_eq!(value(&h, "name"), ["Typed in the second pane"], "the save took the typing"); + assert_eq!(h.state().active, Some(second), "the pane worked in stays the active one"); + let s = h.state(); + assert!(!s.session.get(s.views[second].id).unwrap().dirty, "and wrote it"); + assert!(std::fs::metadata(&out).is_ok_and(|m| m.len() > 0)); +} diff --git a/crates/ui-egui/tests/it/split_view_find.rs b/crates/ui-egui/tests/it/split_view_find.rs new file mode 100644 index 0000000000..93226862b8 --- /dev/null +++ b/crates/ui-egui/tests/it/split_view_find.rs @@ -0,0 +1,81 @@ +//! Split view: each pane's find field has its own id, so two find bars open at once do not clash +//! and do not share the focus; Find and Close act on the pane in use; and AltGr, which Windows +//! reports as Ctrl+Alt, does not run ⌘\ while it types a backslash. + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.run_inline = true; + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app + }); + h.run_steps(4); + h +} + +#[test] +fn each_panes_find_input_has_its_own_id() { + let mut h = harness(); + // ⌘\ splits the document: a second view of it in the second pane. + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + assert_eq!(h.state().views.len(), 2); + let (first, second) = (h.state().views[0].find_input_id(), h.state().views[1].find_input_id()); + assert_ne!(first, second, "one find field id per pane"); + + // Find open in both panes, the second one asking for the focus last. + h.state_mut().views[0].open_find(); + h.state_mut().views[1].open_find(); + h.run_steps(4); + let focused = h.ctx.memory(|m| m.focused()); + assert_eq!(focused, Some(second), "the focus is in the pane that asked last"); + assert!(!h.ctx.memory(|m| m.has_focus(first)), "the other pane's field does not share it"); + + // Typing goes to one query only. + h.event(egui::Event::Text("abc".into())); + h.run_steps(2); + assert_eq!(h.state().views[1].find.as_ref().unwrap().query, "abc"); + assert!(h.state().views[0].find.as_ref().unwrap().query.is_empty()); +} + +/// With the same document in both panes, Find and Close act on the second pane, the one in use. +#[test] +fn find_and_close_act_on_the_second_pane() { + let mut h = harness(); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + assert_eq!(h.state().active, Some(1), "the new pane is the one in use"); + + h.key_press_modifiers(Modifiers::COMMAND, Key::F); + h.run_steps(4); + assert!(h.state().views[1].find.is_some(), "Find opens in the pane in use"); + assert!(h.state().views[0].find.is_none(), "and not in the other"); + + let (kept, id) = (h.state().views[0].view_key(), h.state().views[0].id); + h.key_press_modifiers(Modifiers::COMMAND, Key::W); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "Close closes that pane's tab"); + assert_eq!(h.state().views[0].view_key(), kept, "and not the first pane's"); + assert!(h.state().session.get(id).is_some(), "the document stays open"); +} + +/// German and Spanish keyboards type `\` with AltGr, which reaches us as Ctrl+Alt: that is typing, +/// not ⌘\. The real Ctrl+\ still splits. +#[test] +fn altgr_backslash_does_not_split() { + let mut h = harness(); + let altgr = Modifiers { alt: true, ctrl: true, command: true, ..Modifiers::NONE }; + h.key_press_modifiers(altgr, Key::Backslash); + h.run_steps(4); + assert_eq!(h.state().views.len(), 1, "AltGr+key types a backslash"); + assert!(h.state().panes.split.is_none()); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + assert!(h.state().panes.split.is_some(), "Ctrl+backslash splits"); +} diff --git a/crates/ui-egui/tests/it/split_view_render.rs b/crates/ui-egui/tests/it/split_view_render.rs new file mode 100644 index 0000000000..80045fc68f --- /dev/null +++ b/crates/ui-egui/tests/it/split_view_render.rs @@ -0,0 +1,64 @@ +//! Split view: the two views of one document render through its one pool, and an edit made after +//! they collected the frame's demand (#3149) leaves nothing of the old pages in that pool. + +use egui::{Key, Modifiers}; +use egui_kittest::Harness; +use pdfcraft_ui_egui::PdfCraftApp; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.run_inline = true; + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +/// Run frames until `ready`, rendering in between (renders arrive between frames). +fn wait(h: &mut Harness<'static, PdfCraftApp>, what: &str, ready: impl Fn(&PdfCraftApp) -> bool) { + for _ in 0..600 { + h.run_steps(1); + if ready(h.state()) { + return; + } + std::thread::sleep(std::time::Duration::from_millis(5)); + } + panic!("timed out waiting for {what}"); +} + +/// The palette runs its command after the page area was drawn, as a dialog's OK does: the page +/// turns after both views asked for this frame's rasters at the old size. Neither view may keep +/// that demand (the single-view path drops it; the shared one handed it to the pool). +#[test] +fn a_late_edit_drops_both_views_stale_demand() { + let mut h = harness(); + h.key_press_modifiers(Modifiers::COMMAND, Key::Backslash); + h.run_steps(4); + assert_eq!(h.state().views.len(), 2); + assert_eq!(h.state().views[0].id, h.state().views[1].id, "one document in both panes"); + let portrait = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| hgt > w); + let landscape = |s: &PdfCraftApp, i: usize| s.views[i].drawn_page_size(0).is_some_and(|[w, hgt]| w > hgt); + wait(&mut h, "both panes drawn", |s| portrait(s, 0) && portrait(s, 1)); + let id = h.state().views[0].id; + let rotation = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(id).unwrap().info.pages[0].rotation; + h.state_mut().set_option("palette", "rotate pages clockwise").unwrap(); + h.run_steps(2); + assert!(h.state().palette_open); + h.key_press(Key::Enter); + // Frame by frame, to look at the one that turned the page. + for _ in 0..10 { + h.run_steps(1); + if rotation(&h) == 90 { + break; + } + } + assert_eq!(rotation(&h), 90, "the palette turned the page"); + for (k, v) in h.state().views.iter().enumerate() { + assert_eq!(v.raster_memory().pending_requests, 0, "view {k} kept demand made for the page before it turned"); + } + // And both panes draw the turned page. + wait(&mut h, "both panes drawn again", |s| landscape(s, 0) && landscape(s, 1)); +} diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 6c50af0f3c..4bae9a3154 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -440,7 +440,8 @@ fn selected_text_copy_keeps_focused_input_clipboard_ownership() { h.state_mut().views[0].open_find(); h.state_mut().views[0].find.as_mut().unwrap().query = "widget café".into(); h.run_steps(3); - h.ctx.memory_mut(|m| m.request_focus(egui::Id::unique("find-input"))); + let find_input = h.state().views[0].find_input_id(); + h.ctx.memory_mut(|m| m.request_focus(find_input)); h.run_steps(2); h.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.run_steps(2); diff --git a/crates/ui-egui/tests/it/ui.rs b/crates/ui-egui/tests/it/ui.rs index a464595615..8b58a08aa5 100644 --- a/crates/ui-egui/tests/it/ui.rs +++ b/crates/ui-egui/tests/it/ui.rs @@ -1278,6 +1278,77 @@ fn files_and_quit_from_the_operating_system() { assert!(closing); } +/// #585: File Explorer's "Combine files in PdfCraft" starts one launch per selected file, and the +/// running PdfCraft collects them (an `OsEvent::Combine` each) into its Combine files tab. +#[test] +fn files_to_combine_from_the_operating_system() { + use pdfcraft_ui_egui::OsEvent; + let dir = std::env::temp_dir().join(format!("pdfcraft-os-combine-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["one.pdf", "two.pdf"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, FIXTURE).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + let queue = std::rc::Rc::new(std::cell::RefCell::new(vec![OsEvent::Combine(vec![paths[0].clone()])])); + let q = queue.clone(); + let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + assert!(h.state().combine_showing(), "the Combine files tab shows"); + assert!(h.state().views.is_empty(), "nothing opens as a document"); + queue.borrow_mut().push(OsEvent::Combine(vec![paths[1].clone()])); + h.run_steps(3); + let _ = std::fs::remove_dir_all(&dir); + let names: Vec<_> = h.state().combine_draft.iter().map(|f| f.name.clone()).collect(); + assert_eq!(names, ["one.pdf", "two.pdf"], "each launch's file joins the list, in order"); + assert!(h.state().combine_showing() && h.state().views.is_empty()); +} + +/// #2503 review: a PDF still opening when Combine arrives opens behind the Combine files tab +/// instead of hiding it; an Open that comes after Combine still shows its document. +#[test] +fn an_open_finishing_after_combine_keeps_the_combine_tab() { + use pdfcraft_ui_egui::OsEvent; + let dir = std::env::temp_dir().join(format!("pdfcraft-os-combine-pending-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let paths: Vec = ["opening.pdf", "combined.pdf", "later.pdf"] + .iter() + .map(|n| { + let p = dir.join(n); + std::fs::write(&p, FIXTURE).unwrap(); + p.to_string_lossy().into_owned() + }) + .collect(); + let queue = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let q = queue.clone(); + let mut h = harness(move |app| app.os_events = Some(Box::new(move || q.borrow_mut().drain(..).collect()))); + h.run_steps(2); + // Both in one frame: the open goes to the worker and is still pending when Combine shows. + queue.borrow_mut().extend([OsEvent::Open(vec![paths[0].clone()]), OsEvent::Combine(vec![paths[1].clone()])]); + for _ in 0..200 { + h.run_steps(1); + if !h.state().views.is_empty() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(h.state().views.len(), 1, "the earlier open finished"); + assert!(h.state().combine_showing(), "and the Combine files tab still shows"); + queue.borrow_mut().push(OsEvent::Open(vec![paths[2].clone()])); + for _ in 0..200 { + h.run_steps(1); + if h.state().views.len() == 2 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(h.state().views.len(), 2); + assert!(!h.state().combine_showing() && h.state().active == Some(1), "a later Open shows its document"); +} + #[test] fn default_workspace_mode_persists_and_tolerates_invalid_settings() { use pdfcraft_ui_egui::Mode; diff --git a/crates/ui-egui/tests/it/view.rs b/crates/ui-egui/tests/it/view.rs index d912fa18a4..de90573ea1 100644 --- a/crates/ui-egui/tests/it/view.rs +++ b/crates/ui-egui/tests/it/view.rs @@ -2630,3 +2630,61 @@ fn a_document_press_releases_pages_keyboard_focus_without_escape() { sidebar_key(&mut h, Key::A, Modifiers::COMMAND); assert!(h.state().views[0].selected.is_empty(), "Ctrl+A now belongs to the viewer, not page thumbnails"); } + +/// A one-page PDF whose content inflates past the renderer's per-stream limit (#623): a window +/// of whitespace the interpreter can never complete, so the page is cut and says so. +fn cut_content_pdf() -> Vec { + use std::io::Write as _; + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + pdf +} + +/// A page whose content was cut at the renderer's limits is named in a notice, and listed for +/// automation (`ui.state`, by page number), instead of looking complete (#623). +#[test] +fn a_page_whose_content_was_cut_is_named_in_a_notice() { + let _gpu = gpu(); + let pdf = cut_content_pdf(); + let slot: std::sync::Arc>> = Default::default(); + let s = slot.clone(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + *s.lock().unwrap() = Some(app.attach_control(&cc.egui_ctx)); + app.open_bytes("cut.pdf", None, pdf).expect("opens"); + app + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while h.state().views[0].cut_pages().is_empty() { + assert!(std::time::Instant::now() < deadline, "the cut page was never reported; errors={:?}", h.state().views[0].page_errors()); + h.run_steps(2); + } + h.run_steps(2); + assert_eq!(h.state().views[0].cut_pages(), [0]); + assert!(h.state().views[0].page_errors().is_empty(), "a cut page still draws"); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + // Automation counts pages from 1, as the notice does (review on #2113). + let client = slot.lock().unwrap().take().expect("the control channel"); + let rx = client.send("ui.state", serde_json::json!({})); + // Bounded by wall-clock time rather than frames, so a busy runner doesn't fail it, and a + // dropped channel fails at once instead of stalling the run. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let state = loop { + h.run_steps(1); + match rx.try_recv() { + Ok(reply) => break reply.expect("ui.state"), + Err(std::sync::mpsc::TryRecvError::Disconnected) => panic!("ui.state: the control channel closed without a reply"), + Err(std::sync::mpsc::TryRecvError::Empty) => assert!(std::time::Instant::now() < deadline, "ui.state: no reply within 30 s"), + } + }; + assert_eq!(state["active"]["cut_pages"], serde_json::json!([1]), "{state}"); +} diff --git a/crates/ui-egui/tests/it/viewer.rs b/crates/ui-egui/tests/it/viewer.rs index 42991aff2b..fb94cc2c5b 100644 --- a/crates/ui-egui/tests/it/viewer.rs +++ b/crates/ui-egui/tests/it/viewer.rs @@ -181,7 +181,8 @@ fn find_shortcuts_advance_and_retreat_matches() { assert_eq!(n, 15); // Surrender text edit focus so canvas shortcut handler receives key events. h.state_mut().views[0].find.as_mut().unwrap().focus = false; - h.ctx.memory_mut(|m| m.surrender_focus(egui::Id::unique("find-input"))); + let find_input = h.state().views[0].find_input_id(); + h.ctx.memory_mut(|m| m.surrender_focus(find_input)); h.run_steps(2); let start = h.state().views[0].find.as_ref().unwrap().current.unwrap(); // ⌘G advances to next match. @@ -654,6 +655,24 @@ fn page_label_input_resolves_labels_numbers_and_restores_invalid_input() { assert_eq!(h.state().views[0].current, 0, "the current fallback number must not jump to another page labelled 1"); } +/// A page's label and its position need not coincide (ISO 32000-2 §12.4.2). Typed in the page +/// box, "(n)" goes to the nth page whatever its label, as print ranges take it, while a bare +/// number is still a label first. On these pages (i, ii, 1, 2, 3) "1" is the third page and +/// "(1)" the first; a position past the end, or anything else in parentheses, leaves the page. +#[test] +fn page_label_input_takes_a_position_in_parentheses() { + let mut h = harness(); + restart_page_labels(&mut h); + h.run_steps(3); + for (typed, page, displayed) in [("(1)", 0, "i"), ("1", 2, "1"), ("(4)", 3, "2"), ("( 2 )", 1, "ii"), ("(9)", 1, "ii"), ("(x)", 1, "ii")] { + type_page_input(&mut h, typed); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(h.state().views[0].current, page, "{typed}"); + assert_eq!(h.state().views[0].page_input, displayed, "{typed}"); + } +} + #[test] fn page_label_input_preserves_literal_whitespace_before_trimmed_lookup() { use pdfcraft_render::{DocInfo, PageInfo}; diff --git a/crates/xfa/README.md b/crates/xfa/README.md index 29f3cb166a..46c2c948f0 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -97,6 +97,7 @@ be measured exponentially often in their nesting. A measurement budget backs thi | `/PCSom` | generated fields | the field's SOM path, so values go back to the data | | `/PCXfaLayout` | AcroForm | what laying out produced, so a saved form is not laid out again | | `/PCXfaOverrides` | AcroForm | `<< /Presence << /som (hidden) … >> /Access << /som (readOnly) … >> >>`: presence and access scripts set, by SOM path (instance 0 of a repeating subform stands for all); applied to the template before every layout, so undo, save and reopen keep them. Adobe's viewers draw from the XFA packets and don't read it; at most 10 000 entries are read or written | + | `/PCXfaDate` | generated date fields | `true`: text the field's date pattern doesn't read is kept, and shown, as typed rather than refused, as an XFA form keeps it; the forms layer reads it | | `/PCXfaNode` | generated fields above a signature field | `true`: the field names a step of the signature field's SOM path; a new layout replaces it with the fields it holds | | `/PCXfaClick` | generated push buttons | `true`: the template has a click script for this button. Such buttons carry no `/A`: XFA JavaScript is not Acrobat JavaScript, so it is never written as a `/S /JavaScript` action other viewers would run. PdfCraft finds the script by the field's `/PCSom` | diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 565c44117d..e1c30f3f5a 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -37,7 +37,7 @@ pub(crate) fn data_to_iso(value: &str, data_pattern: Option<&str>) -> String { } /// An ISO date as the data should hold it: in `data_pattern` when the field has one and the -/// date reads back from it unchanged (a two-digit year holds only 1950 to 2049), else as ISO. +/// date reads back from it unchanged (a two-digit year holds only 1930 to 2029), else as ISO. pub(crate) fn iso_to_data(iso: String, data_pattern: Option<&str>) -> String { match data_pattern { Some(p) => { @@ -53,6 +53,11 @@ pub(super) fn data_date_pattern(tpl: Option<&Template>, som: &str) -> Option Option { rest = &rest[digits.len()..]; match t.as_str() { "yyyy" => y = Some(v), - "yy" => y = Some(if v < 50 { 2000 + v } else { 1900 + v }), + "yy" => y = Some(if v < CENTURY_SPLIT { 2000 + v } else { 1900 + v }), "mm" | "m" => m = Some(v), _ => d = Some(v), } diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 8f6e0f7ee8..d2a67fccf8 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -565,6 +565,25 @@ pub fn date_alternatives_template() -> String { .to_string() } +/// Date fields with each kind of picture: `display` a display picture only, `edit` an edit and a +/// display picture, `either` an edit picture of two alternatives, `bare` none. +pub fn date_pictures_template() -> String { + r##" + + + +"## + .to_string() +} + /// A form of `sections` sections of `rows` rows, each row with a calculated total (half in /// JavaScript, half in FormCalc) and a section sum over its rows: 2 × sections × rows + sections /// calculate scripts, every one reading values the earlier ones settled. diff --git a/crates/xfa/src/layout.rs b/crates/xfa/src/layout.rs index 7eea18f198..1e37bbad30 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -135,6 +135,10 @@ struct Frame { /// the area's bottom (a table row taller than an area, an overflow leader and the row after it /// that don't fit together). The border is left out ([`Layouter::finish_frame`]). lost: bool, + /// A piece of a field being split sits in the part on this content area, whether or not it + /// drew anything there (a widget with no border or fill draws nothing below the break): the + /// part counts as holding something, as one that got items does. + held: bool, } struct Cursor { @@ -146,6 +150,15 @@ struct Cursor { placed: bool, } +/// An empty field a page break is splitting ([`Layouter::splitting`]): its widget's SOM path, its +/// box where it was drawn, and its pieces so far (page index, the page's first item of the piece, +/// the piece's box). +struct Splitting { + som: String, + rect: Rect, + pieces: Vec<(usize, usize, Rect)>, +} + struct Layouter<'t> { tpl: &'t Template, data: Option<&'t DataNode>, @@ -173,6 +186,10 @@ struct Layouter<'t> { /// The fields and draws of the block being placed: (first item, one past the last, top, /// bottom) on the last page. A page break keeps each whole (see `split_block`). leaves: Vec<(usize, usize, f64, f64)>, + /// The empty field a page break is splitting (see `flow_block` and `split_block`). + splitting: Option, + /// The items `draw_field` drew for the border of the field drawn last: (first, one past the last). + field_border: Option<(usize, usize)>, /// The flowed subforms being laid out, outermost first: a break between their children /// closes their parts on one content area and opens new ones on the next. frames: Vec, @@ -221,6 +238,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result measures: Cell::new(0), drawn_chars: Cell::new(0), leaves: Vec::new(), + splitting: None, + field_border: None, frames: Vec::new(), }; let ctx = Ctx { font: Font::default(), para: Para::default(), som: String::new() }; @@ -235,6 +254,8 @@ pub fn layout(tpl: &Template, data: Option<&DataNode>) -> Result for p in &mut l.pages { fill_page_count(&mut p.items, &count); } + #[cfg(test)] + LAID_OUT_ITEMS.with(|c| c.set(l.items)); Ok(Form { pages: l.pages, fields: l.fields, warnings: l.warnings }) } @@ -254,3 +275,9 @@ fn fill_page_count(items: &mut [Item], count: &str) { thread_local! { pub(crate) static EMBED_COPIED: std::cell::Cell = const { std::cell::Cell::new(0) }; } + +// The item count the last layout on this thread ended with, against `MAX_ITEMS`. +#[cfg(test)] +thread_local! { + pub(crate) static LAID_OUT_ITEMS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index a213ba1172..83fd395d40 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,4 +1,4 @@ -use super::items::{Action, Item, Rounded}; +use super::items::{Action, BorderShape, Item, Rounded, Widget, WidgetBorder}; use super::{DataNode, DataPath, HashMap, Layout, Node, Rect, Script, Template, Ui, data_path, date_letters, iso_to_pattern}; use crate::data::{data_date_letters, data_to_iso, fields_with_ids}; @@ -143,6 +143,59 @@ pub(super) fn divide(item: Item, cut: f64) -> (Option, Option) { } } +/// How much of a widget a page break must leave above it for the field to split there: a line of +/// its text. +pub(super) fn split_line(w: &Widget) -> f64 { + (w.face.size * 1.2).max(1.0) +} + +/// The rest of an empty field's widget below a page break: its box and the look of its border. +pub(super) type WidgetRest = (Rect, WidgetBorder); + +/// An empty field's widget cut at `cut`: the widget down to the cut, and the rest of its box below +/// it to go on in the following content areas ([`widget_box`] draws each piece of it). A widget the +/// cut doesn't cross stays whole on its side, as does one with no room above the cut for a line of +/// its text: the second item is the widget when it all goes below. +pub(super) fn split_widget(item: Item, cut: f64) -> (Option, Option, Option) { + let Item::Widget(mut w) = item else { return (Some(item), None, None) }; + let line = split_line(&w); + if w.rect.bottom() <= cut + 0.01 { + return (Some(Item::Widget(w)), None, None); + } + if w.rect.y + line > cut { + return (None, Some(Item::Widget(w)), None); + } + let rest = Rect::new(w.rect.x, cut, w.rect.w, w.rect.bottom() - cut); + w.rect.h = cut - w.rect.y; + let border = w.border.clone(); + (Some(Item::Widget(w)), None, Some((rest, border))) +} + +/// One piece of a split widget's box at `rect`: its fill, and its border closed on every side (a +/// border's default `break="close"`, XFA 3.3 pp. 629–630). +pub(super) fn widget_box(rect: Rect, border: &WidgetBorder) -> Vec { + let mut items = Vec::new(); + if let Some(color) = border.fill { + items.push(Item::Fill { rect, color }); + } + if let Some(color) = border.color { + let width = border.width.max(0.0); + let i = width / 2.0; + let (x0, y0, x1, y1) = (rect.x + i, rect.y + i, rect.right() - i, rect.bottom() - i); + // The border's corners in drawing order: each edge joins two in a row. + let corners: &[(f64, f64)] = match border.shape { + BorderShape::Full => &[(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)], + BorderShape::Underline => &[(x0, y1), (x1, y1)], + BorderShape::None => &[], + }; + items.extend(corners.windows(2).filter_map(|edge| match *edge { + [from, to] => Some(Item::Line { from, to, width, color, dashed: false }), + _ => None, + })); + } + items +} + /// `item` moved by (`dx`, `dy`). pub(super) fn moved(mut item: Item, dx: f64, dy: f64) -> Item { match &mut item { diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index fd83d88f26..8218affe2c 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -20,7 +20,9 @@ impl Layouter<'_> { if let Some(b) = &f.common.border && f.ui != Ui::Button { + let start = self.item_count(); self.draw_border(b, rect)?; + self.field_border = Some((start, self.item_count())); } let fctx = ctx.child(&f.common); let inner = rect.inset(&f.common.margin); diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index de7bde6c54..4fffeddf62 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,22 +1,40 @@ -use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; +use super::common::{WidgetRest, clamp_opt, divide, is_row, moved, solid_extent, split_line, split_widget, splittable, widget_box}; use super::items::Item; use super::{ - Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count, + Ctx, Frame, Layout, Layouter, MAX_DEPTH, MAX_INSTANCES, MAX_MEASURES, Node, Rect, SomIndices, Splitting, Subform, XfaError, data_path, + instance_count, }; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, /// in the next when it fits there, and across as many as it takes when it is taller than a /// content area (a positioned or fixed-height subform holding more than a page). - fn flow_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + fn flow_block(&mut self, node: &Node, x: f64, size: (f64, f64), ctx: &Ctx, depth: usize, sib: usize) -> Result<(), XfaError> { + self.place_block(node, x, size, ctx, depth, sib, true) + } + + /// [`Self::flow_block`]; `may_split` is false when a field that might have been split turned + /// out not to be (see below), to place it as any other block. + #[allow(clippy::too_many_arguments)] + fn place_block(&mut self, node: &Node, x: f64, (w, h): (f64, f64), ctx: &Ctx, depth: usize, sib: usize, may_split: bool) -> Result<(), XfaError> { let (rect, y, _) = self.cursor(); - if y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { + // A field a page break may split starts here, and what doesn't fit goes on in the next + // content area (XFA 3.3 pp. 291–292), rather than the whole field moving there. + let splits = may_split && matches!(node, Node::Field(f) if f.splits_at_page_break()); + if !splits && y + h > rect.bottom() + 0.01 && h <= rect.h + 0.01 { self.make_room(h, 0.0)?; } let (rect, y, _) = self.cursor(); let first = self.pages.last().map_or(0, |p| p.items.len()); + // What drawing it changes besides the page, to undo if it then isn't split: only a field that + // doesn't fit here can turn out so. The item count goes back with the items taken off the + // page; the measurement budget doesn't, as that work was done. (A text field draws no + // picture, so the picture budget is untouched.) + let undo = (splits && y + h > rect.bottom() + 0.01).then(|| (self.fields, self.names.clone(), self.items)); self.leaves.clear(); + self.field_border = None; self.draw_node(node, Rect::new(x, y, w, h), ctx, depth, sib)?; + let border_items = self.field_border.take(); let page_h = self.pages.last().map_or(f64::INFINITY, |p| p.height); // What was drawn, when it is lower than the block's own height says (content that // outgrows a fixed height) and ends off the page. @@ -34,7 +52,59 @@ impl Layouter<'_> { self.place(h); return Ok(()); } - self.split_block(first, y, drawn) + let Some(som) = (if splits { self.splittable_widget_since(first, rect.bottom() - self.frames_bottom()) } else { None }) else { + if let Some((fields, names, items)) = undo { + // Not split after all (a value, or no room for a line of it here): undone, and + // placed as before, moving to the next content area whole. + if let Some(p) = self.pages.last_mut() { + p.items.truncate(first); + } + self.fields = fields; + self.names = names; + self.items = items; + self.leaves.clear(); + return self.place_block(node, x, (w, h), ctx, depth, sib, false); + } + return self.split_block(first, y, drawn); + }; + // The field breaks into pieces, each with the field's border all round (a border's + // default `break="close"`, XFA 3.3 pp. 629–630): the border drawn round the whole field + // comes out, to be drawn round each piece once the break is known. + self.leaves.clear(); + let border = match (node, border_items) { + (Node::Field(f), Some((start, end))) if first <= start => { + if let Some(p) = self.pages.last_mut() + && let Some(drawn) = p.items.get(start..end) + && !drawn.is_empty() + { + p.items.drain(start..end); + } + f.common.border.as_ref() + } + _ => None, + }; + self.splitting = Some(Splitting { som, rect: Rect::new(x, y, w, h), pieces: Vec::new() }); + let split = self.split_block(first, y, drawn); + let pieces = self.splitting.take().map(|s| s.pieces).unwrap_or_default(); + split?; + if let Some(b) = border { + // The last piece first: a piece's place on its page stays where it was found. + for (page, at, piece) in pieces.into_iter().rev() { + self.insert_border(b, piece, page, at)?; + } + } + Ok(()) + } + + /// The SOM path of the empty widget drawn on the last page since item `first`, when the page + /// break at `bottom` leaves room above it for a line of its text: only then is the field split. + /// A value can't run on from one widget into the next piece, and a field with no line on this + /// page moves on whole. + fn splittable_widget_since(&self, first: usize, bottom: f64) -> Option { + self.pages.last()?.items.get(first..)?.iter().find_map(|item| match item { + Item::Widget(w) => (w.value.as_deref().is_none_or(|v| v.trim().is_empty()) && w.rect.y + split_line(w) <= bottom).then(|| w.som.clone()), + _ => None, + }) } /// Remember that the items the last page got since `start` are one field or draw at @@ -70,12 +140,19 @@ impl Layouter<'_> { slots.fill(Some((top, bottom))); } } + // The empty field being split: its widget is no solid item, so the break may go through it. + let (som, field) = match &self.splitting { + Some(s) => (Some(s.som.clone()), Some(s.rect)), + None => (None, None), + }; + let loose = |item: &Item| matches!((item, &som), (Item::Widget(w), Some(som)) if w.som == *som); let mut rest: Vec<(Item, Option<(f64, f64)>)> = items.into_iter().zip(extents).collect(); let origin_x = self.cursor().0.x; // An item's place on the current page is its drawn place moved by (dx, -shift). let (mut dx, mut shift) = (0.0, 0.0); let mut seg_top = top; let mut first_segment = true; + let mut widget_rest: Option = None; loop { // Each pass rescans every item still to be placed. A block split across very many // content areas, and so across very many pages, would do this once per page, costing @@ -86,7 +163,24 @@ impl Layouter<'_> { return Err(XfaError::TooLarge("a subform is split across too many content areas to lay out".into())); } let (rect, _, placed) = self.cursor(); - let limit = rect.bottom() + shift; + // A field being split leaves room for the bottom margins of the flowed subforms round it. + let margins = if field.is_some() { self.frames_bottom() } else { 0.0 }; + let mut limit = rect.bottom() - margins + shift; + if field.is_some() && !first_segment && limit <= seg_top + 0.01 { + // The subforms' margins leave nothing of this content area (a smaller one than + // the field started in): breaking on would leave the same nothing in each like it. + // The field goes on past their bottom margins, and from the top of the area when + // their top margins took all of it, and they, which can't hold it here, are drawn + // without their border, as round anything that runs out of them. + limit = rect.bottom() + shift; + if limit <= seg_top + 0.01 { + shift = seg_top - rect.y; + limit = rect.bottom() + shift; + } + for f in &mut self.frames { + f.lost = true; + } + } let mut cut = limit; if rect.h < 1.0 { // No room to split into: leave the rest here. @@ -95,8 +189,12 @@ impl Layouter<'_> { // The lowest break at or above the bottom that crosses no solid item: going // through them from the lowest top up, a break moved to an item's top can't // cross one already passed. - let mut solid: Vec<(f64, f64)> = - rest.iter().filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))).filter(|e| e.0 < limit - 0.01).collect(); + let mut solid: Vec<(f64, f64)> = rest + .iter() + .filter(|(item, _)| !loose(item)) + .filter_map(|(item, kept)| kept.or_else(|| solid_extent(item))) + .filter(|e| e.0 < limit - 0.01) + .collect(); solid.sort_by(|a, b| b.0.total_cmp(&a.0)); for (t, b) in solid { if t < cut - 0.01 && b > cut + 0.01 { @@ -111,7 +209,28 @@ impl Layouter<'_> { } let mut here = Vec::new(); let mut below = Vec::new(); + // The rest of the split widget's box: a piece down to this break, closed on every + // side, and what is left of it goes on. + if let Some((r, border)) = widget_rest.take() { + let bottom = r.bottom().min(cut); + if bottom - r.y > 0.01 { + here.extend(widget_box(Rect::new(r.x, r.y, r.w, bottom - r.y), &border)); + } + if r.bottom() > cut + 0.01 { + let y = cut.max(r.y); + widget_rest = Some((Rect::new(r.x, y, r.w, r.bottom() - y), border)); + } + } for (item, kept) in rest { + if loose(&item) { + let (upper, whole, rest_box) = split_widget(item, cut); + here.extend(upper); + below.extend(whole.map(|it| (it, None))); + if rest_box.is_some() { + widget_rest = rest_box; + } + continue; + } if let Some((top, _)) = kept { if top < cut - 0.01 { here.push(item) @@ -130,10 +249,28 @@ impl Layouter<'_> { (None, None) => {} } } + // The piece of the field being split on this page, where its border goes. + if let Some(f) = field { + let (from, to) = (f.y.max(seg_top), f.bottom().min(cut)); + let page = self.pages.len().saturating_sub(1); + let at = self.pages.last().map_or(0, |p| p.items.len()); + if to - from > 0.01 + && let Some(s) = &mut self.splitting + { + s.pieces.push((page, at, Rect::new(f.x + dx, from - shift, f.w, to - from))); + // The flowed subforms round it hold this piece here, even if it draws nothing. + let here: Vec = self.frames.iter().map(|fr| self.on_area(&fr.part)).collect(); + for (fr, here) in self.frames.iter_mut().zip(here) { + fr.held |= here; + } + } + } if let Some(p) = self.pages.last_mut() { p.items.extend(here.into_iter().map(|it| moved(it, dx, -shift))); } - if below.is_empty() { + // A field being split goes on to its own bottom, even when what is left of it draws + // nothing (no fill, no border). + if below.is_empty() && widget_rest.is_none() && !field.is_some_and(|f| f.bottom() > cut + 0.01) { if let Some(c) = &mut self.cur { c.y = (end - shift).min(c.rect.bottom()).max(c.y); c.placed = true; @@ -144,9 +281,23 @@ impl Layouter<'_> { rest = below; seg_top = cut; first_segment = false; - self.next_area()?; - let (rect, _, _) = self.cursor(); - shift = cut - rect.y; + let (rect, y) = if field.is_some() { + // A field breaks the flowed subforms round it as a field moving on whole does: + // each closes its part at the break and opens a new one, after its top margin, + // in the next content area. + if let Some(c) = &mut self.cur { + c.y = (cut - shift).min(c.rect.bottom()).max(c.y); + c.placed = true; + } + self.break_frames(0.0, false)?; + let (rect, y, _) = self.cursor(); + (rect, y) + } else { + self.next_area()?; + let (rect, _, _) = self.cursor(); + (rect, rect.y) + }; + shift = cut - y; dx = rect.x - origin_x; } } @@ -188,7 +339,7 @@ impl Layouter<'_> { // Its border and fill go round what it takes from here, a part on each content area. let framed = match self.flow_mark() { Some(part) => { - self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false }); + self.frames.push(Frame { x, w, margin: m, border: sf.common.border.clone(), part, parts: Vec::new(), lost: false, held: false }); true } None => false, diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 0f93eb07f5..940c76d246 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -165,7 +165,7 @@ impl Layouter<'_> { } /// Whether the flow is still in the content area `m` was taken in. - fn on_area(&self, m: &FlowMark) -> bool { + pub(super) fn on_area(&self, m: &FlowMark) -> bool { self.pages.len() == m.page && self.cur.as_ref().is_some_and(|c| c.area == m.area && c.content == m.content) } @@ -186,7 +186,7 @@ impl Layouter<'_> { let (rect, mut y, _) = self.cursor(); let page = self.pages.len().saturating_sub(1); let here: Vec = self.frames.iter().map(|f| self.on_area(&f.part)).collect(); - let filled: Vec = self.frames.iter().map(|f| self.has_items_from(page, f.part.items)).collect(); + let filled: Vec = self.frames.iter().map(|f| f.held || self.has_items_from(page, f.part.items)).collect(); for ((f, here), filled) in self.frames.iter_mut().zip(here).zip(filled).rev() { y = (y + f.margin.bottom).min(rect.bottom()); if !here { @@ -206,6 +206,7 @@ impl Layouter<'_> { let Some(mark) = self.flow_mark() else { break }; let Some(f) = self.frames.get_mut(i) else { break }; f.part = mark; + f.held = false; // `max` before `min`: a NaN margin counts as none and leaves `room` a number. let top = f.margin.top.max(0.0).min(room); room = (room - top).max(0.0); @@ -214,6 +215,13 @@ impl Layouter<'_> { Ok(()) } + /// The bottom margins of the flowed subforms being laid out, which a break leaves below their + /// content ([`Self::break_frames`]). + pub(super) fn frames_bottom(&self) -> f64 { + // `max` first: a NaN margin counts as none. + self.frames.iter().map(|f| f.margin.bottom.max(0.0)).sum() + } + /// Close flowed subform `f` once its content is placed: its last part ends where the flow /// is, and its border (edges and fill) is drawn round each part, closed on every side, under /// that part's content. One holding something that didn't fit its content area ([`Frame::lost`]) @@ -230,7 +238,7 @@ impl Layouter<'_> { let page = self.pages.len().saturating_sub(1); if !self.on_area(&f.part) { f.lost = true; - } else if self.has_items_from(page, f.part.items) && end - f.part.y >= 0.01 { + } else if (f.held || self.has_items_from(page, f.part.items)) && end - f.part.y >= 0.01 { f.parts.push((page, f.part.items, Rect::new(f.x, f.part.y, f.w, end - f.part.y))); } if f.lost { @@ -246,7 +254,7 @@ impl Layouter<'_> { /// Draw border `b` round `rect` on page `page` (an index), under what that page got from item /// `at` on. - fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { + pub(super) fn insert_border(&mut self, b: &Border, rect: Rect, page: usize, at: usize) -> Result<(), XfaError> { let before = self.pages.last().map_or(0, |p| p.items.len()); self.draw_border(b, rect)?; let drawn: Vec = match self.pages.last_mut() { diff --git a/crates/xfa/src/lib.rs b/crates/xfa/src/lib.rs index abf3851db1..b8450d4217 100644 --- a/crates/xfa/src/lib.rs +++ b/crates/xfa/src/lib.rs @@ -38,6 +38,7 @@ pub(crate) const MAX_XML_DEPTH: usize = 1_024; /// cannot under-count a real element; a stray `<` in text is invalid XML and only makes it /// over-count, which errs towards refusing the document. pub(crate) fn xml_nesting_depth(text: &str) -> usize { + /// Where `needle` next starts in `hay` at or after `from`. fn find(hay: &[u8], from: usize, needle: &[u8]) -> Option { (from..hay.len()).find(|&p| hay[p..].starts_with(needle)) } @@ -99,7 +100,7 @@ pub use data::{ pub use layout::{Action, BorderShape, Form, Item, MAX_PAGES, Page, Widget, WidgetKind, formatted_value, layout}; pub use packets::{Encoding, Packets, decode as decode_packet, encode as encode_packet, read_packets}; pub use parse::{measure, parse}; -pub use pdf::{CLICK_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; +pub use pdf::{CLICK_KEY, DATE_KEY, LAYOUT_KEY, NODE_KEY, SOM_KEY, Stripped, XfaShape, existing_layout, strip, xfa_shape}; pub use picture::typed_date; pub use script::{ FormNode, LiveForm, MAX_FORM_NODES, NodeKind, OVERRIDES_KEY, Overrides, ScriptEvent, apply_overrides, data_of, embedded_fields, diff --git a/crates/xfa/src/model.rs b/crates/xfa/src/model.rs index 5e085113e0..51fca2f8eb 100644 --- a/crates/xfa/src/model.rs +++ b/crates/xfa/src/model.rs @@ -562,6 +562,9 @@ pub struct Field { /// The display picture clause (``), e.g. `date{YYYYMMDD}`: how the value /// shows when not being edited, and what scripts read as `formattedValue`. pub display_picture: Option, + /// `` or `"pageArea"`: a page break doesn't split the field (XFA 3.3 + /// p. 292). A field's default is `none`. + pub keep_intact: bool, } impl Field { @@ -570,6 +573,13 @@ impl Field { self.ui == Ui::TextEdit && self.multiline && self.common.h.is_none() } + /// Whether a page break may split the field (XFA 3.3 pp. 291–292): text splits between its + /// lines, and a field's `keep intact` is `none` unless it says otherwise. Only a multi-line + /// text field that grows with its text is split. + pub(crate) fn splits_at_page_break(&self) -> bool { + self.grows_with_text() && !self.keep_intact + } + /// A choice list's value as saved to the data: an item's shown text becomes its saved /// value (an editable list keeps typed text); several values stay on separate lines. /// Other fields keep `v`. diff --git a/crates/xfa/src/packets.rs b/crates/xfa/src/packets.rs index f79b071c0e..4202ffbb55 100644 --- a/crates/xfa/src/packets.rs +++ b/crates/xfa/src/packets.rs @@ -7,6 +7,8 @@ use crate::XfaError; /// Most decoded packet bytes read in total. const MAX_XDP: usize = 64 << 20; +/// Most bytes all filter stages of all packets may decode together. +const MAX_DECODE_WORK: usize = 4 * MAX_XDP; /// What was found in the XFA entry. #[derive(Clone, Debug, Default, PartialEq)] @@ -116,13 +118,17 @@ pub fn read_packets(doc: &Document) -> Result, XfaError> { // A stream listed again adds nothing but another decode, which may cost far more than the // bytes it yields (a Flate stream of hex whitespace). let mut seen = std::collections::HashSet::new(); + // One decoding allowance for every packet, charged for what each filter stage produced (a + // stage a later one shrinks included): a fresh MAX_XDP per stream let many packets of + // compressed bulk that decode to little cost far more than the packets kept. + let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_DECODE_WORK, 0); let mut read = |o: &Object| -> Result, XfaError> { if o.as_ref().is_some_and(|r| !seen.insert(r)) { return Ok(None); } let s = doc.resolve(o); let Object::Stream(s) = &*s else { return Ok(None) }; - let bytes = s.decoded_within(MAX_XDP)?; + let bytes = budget.decode_within(s, MAX_XDP)?; total = total.saturating_add(bytes.len()); if total > MAX_XDP { return Err(XfaError::TooLarge("the XFA packets are larger than 64 MB".into())); diff --git a/crates/xfa/src/parse.rs b/crates/xfa/src/parse.rs index 620cc49e7c..7f8a080be4 100644 --- a/crates/xfa/src/parse.rs +++ b/crates/xfa/src/parse.rs @@ -564,6 +564,7 @@ fn parse_field(n: XmlNode, warnings: &mut Vec) -> Field { validate_message, numeric, display_picture: child(n, "format").and_then(|f| child(f, "picture")).map(text_of).map(|p| p.trim().to_string()).filter(|p| !p.is_empty()), + keep_intact: child(n, "keep").and_then(|k| k.attribute("intact")).is_some_and(|v| v == "contentArea" || v == "pageArea"), } } @@ -661,7 +662,9 @@ fn parse_subform(n: XmlNode, warnings: &mut Vec, depth: usize) -> Subfor } let column_widths: Vec = n.attribute("columnWidths").map(|s| s.split_whitespace().filter_map(measure).map(|w| w.max(0.0)).take(256).collect()).unwrap_or_default(); - let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_none_or(|t| t == "pageArea" || t == "contentArea")); + // A break to a page or content area. With no targetType, the default is `auto`: "layout + // continues using the current layout container" (XFA 3.3 p. 638), so no break. + let break_to_page = |name: &str| children(n, name).any(|b| b.attribute("targetType").is_some_and(|t| t == "pageArea" || t == "contentArea")); Subform { common, layout: Layout::parse(n.attribute("layout")), diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index f1d9b9727e..4e87da5a7a 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -33,6 +33,9 @@ pub const LAYOUT_KEY: &[u8] = b"PCXfaLayout"; /// PDF action (the script is XFA's, not Acrobat JavaScript); PdfCraft runs the script by the /// field's SOM path. pub const CLICK_KEY: &[u8] = b"PCXfaClick"; +/// Private key on generated date fields: text the date pattern doesn't read is kept, and shown, +/// as typed, as an XFA form keeps it (the forms layer reads this key). +pub const DATE_KEY: &[u8] = b"PCXfaDate"; /// Private key on the fields generated to name a signature field by its SOM path, one for each /// step of the path above it (`form1[0]`, then `page1[0]`): a new layout replaces them with the /// fields they hold. @@ -62,7 +65,7 @@ pub const ROUND_KEY: &[u8] = b"PCXfaRound"; const IMAGE_SHA_KEY: &[u8] = b"PCXfaSHA256"; /// Most pages searched for earlier pictures. const MAX_PAGES_SEARCHED: usize = 10_000; -/// Most pictures remembered. +/// Most pictures remembered from earlier passes. const MAX_REMEMBERED_IMAGES: usize = 1000; struct Emitter<'a> { @@ -107,8 +110,9 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, let mut names = HashSet::new(); let Some(acro) = catalog.get(b"AcroForm").map(|a| doc.resolve(a)) else { return (out, names) }; let Some(fields) = acro.as_dict().and_then(|a| a.get(b"Fields")).map(|f| doc.resolve(f)) else { return (out, names) }; - let mut stack: Vec<(Object, String, bool, usize)> = - fields.as_array().into_iter().flatten().map(|f| (f.clone(), String::new(), false, 0)).collect(); + // The accumulated name is shared by the children it is queued for, not copied into each. + let mut stack: Vec<(Object, std::rc::Rc, bool, usize)> = + fields.as_array().into_iter().flatten().map(|f| (f.clone(), "".into(), false, 0)).collect(); let mut seen = 0usize; while let Some((field, prefix, parent_is_sig, depth)) = stack.pop() { seen += 1; @@ -121,7 +125,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, // Generated to name a signature field: only what it holds is looked at. if depth < MAX_FIELD_DEPTH { let kids = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()).unwrap_or_default(); - stack.extend(kids.into_iter().map(|k| (k, String::new(), false, depth + 1))); + stack.extend(kids.into_iter().map(|k| (k, "".into(), false, depth + 1))); } continue; } @@ -136,12 +140,12 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, continue; } let name = match d.get(b"T").map(|t| doc.resolve(t)).and_then(|t| t.as_string().map(|t| t.to_text())) { - Some(t) if prefix.is_empty() => t, - Some(t) => format!("{prefix}.{t}"), + Some(t) if prefix.is_empty() => t.into(), + Some(t) => format!("{prefix}.{t}").into(), None => prefix, }; if !name.is_empty() { - names.insert(name.clone()); + names.insert(name.to_string()); } let is_sig = match d.name(b"FT") { Some(ft) => ft == b"Sig", @@ -151,7 +155,7 @@ fn existing_fields(doc: &Document, catalog: &Dict) -> (HashMap, if is_sig && let Some(value) = d.get(b"V").filter(|v| doc.resolve(v).as_dict().is_some()) { // The appearance is on the field when it is its own widget, or on its one widget. let appearance = d.get(b"AP").cloned().or_else(|| kids.iter().find_map(|k| doc.resolve(k).as_dict().and_then(|k| k.get(b"AP").cloned()))); - out.entry(name.clone()).or_insert(Signed { value: value.clone(), appearance }); + out.entry(name.to_string()).or_insert(Signed { value: value.clone(), appearance }); } if depth < MAX_FIELD_DEPTH { stack.extend(kids.into_iter().map(|k| (k, name.clone(), is_sig, depth + 1))); @@ -206,6 +210,7 @@ impl Emitter<'_> { d.set(b"DV".to_vec(), Object::String(PdfString::text(v))); } if let WidgetKind::Date(pattern) = &w.kind { + d.set(DATE_KEY.to_vec(), Object::Bool(true)); let mut aa = Dict::new(); let pattern = js_string(pattern); aa.set(b"F".to_vec(), js_action(&format!("AFDate_FormatEx({pattern});"))); @@ -488,9 +493,9 @@ impl Emitter<'_> { return Some(r); } let r = self.new_image_xobject(data, content_type, &hash)?; - if self.images.len() < MAX_REMEMBERED_IMAGES { - self.images.insert(hash, r); - } + // Always remembered: each entry is a hash and a reference to an object this pass wrote + // anyway, and a picture past a cap would be decoded and embedded again at every use. + self.images.insert(hash, r); Some(r) } @@ -1036,7 +1041,7 @@ pub fn pdf_rect(r: Rect, page_height: f64) -> [f64; 4] { #[cfg(test)] mod tests { - use super::parse_hex32; + use super::*; #[test] fn parse_hex32_rejects_non_hex_digits() { @@ -1049,4 +1054,31 @@ mod tests { ok.resize(64, b'0'); assert_eq!(parse_hex32(&ok).map(|h| h[0]), Some(0xff)); } + + #[test] + fn a_picture_past_the_earlier_pass_cap_is_still_embedded_once() { + let mut doc = Document::new_empty(); + let mut em = Emitter { + doc: &mut doc, + images: HashMap::new(), + fields: Vec::new(), + radio_groups: HashMap::new(), + radio_selected: HashMap::new(), + field_count: 0, + warnings: Vec::new(), + signed: Default::default(), + taken: Default::default(), + nodes: HashMap::new(), + }; + let png = crate::fixtures::tiny_png(); + let picture = |i: u32| [png.clone(), i.to_be_bytes().to_vec()].concat(); + let last = MAX_REMEMBERED_IMAGES as u32 + 10; + for i in 0..=last { + em.image_xobject(&picture(i), "image/png").expect("decodes"); + } + let before = em.doc.object_numbers().len(); + let again = em.image_xobject(&picture(last), "image/png").expect("decodes"); + assert_eq!(em.doc.object_numbers().len(), before, "no new object for a repeated picture"); + assert_eq!(em.image_xobject(&picture(last), "image/png"), Some(again)); + } } diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index b753510653..93f1a65307 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -1,4 +1,4 @@ -use super::{CLICK_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; +use super::{CLICK_KEY, DATE_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; /// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). pub(crate) const USAGE_RIGHTS_REMOVED: &str = @@ -151,6 +151,7 @@ pub fn strip(doc: &mut Document) -> Result { let fields = acro.get(b"Fields").map(|f| doc.resolve(f)).and_then(|f| f.as_array().cloned()).unwrap_or_default(); let mut seen = std::collections::HashSet::new(); let mut todo: Vec = Vec::new(); + let mut kids_seen = std::collections::HashSet::new(); for r in fields.iter().filter_map(Object::as_ref) { if seen.insert(r) && seen.len() <= MAX_STRIP_FIELDS { todo.push(r); @@ -164,8 +165,11 @@ pub fn strip(doc: &mut Document) -> Result { if d.contains(IMAGE_FIELD_KEY) { out.image_fields += 1; } - let private: Vec> = - [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, NODE_KEY, b"PCItems".as_slice()].iter().filter(|k| d.contains(k)).map(|k| k.to_vec()).collect(); + let private: Vec> = [SOM_KEY, CLICK_KEY, IMAGE_FIELD_KEY, DATE_KEY, NODE_KEY, b"PCItems".as_slice()] + .iter() + .filter(|k| d.contains(k)) + .map(|k| k.to_vec()) + .collect(); if !private.is_empty() { out.field_keys += private.len(); doc.update_dict(r, |d| { @@ -174,7 +178,9 @@ pub fn strip(doc: &mut Document) -> Result { } })?; } - if let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { + // An indirect /Kids array several fields share is read once: its entries are already queued. + let shared_seen = d.get(b"Kids").and_then(Object::as_ref).is_some_and(|k| !kids_seen.insert(k)); + if !shared_seen && let Some(kids) = d.get(b"Kids").map(|k| doc.resolve(k)).and_then(|k| k.as_array().cloned()) { for k in kids.iter().filter_map(Object::as_ref) { if seen.insert(k) && seen.len() <= MAX_STRIP_FIELDS { todo.push(k); @@ -280,3 +286,34 @@ pub fn existing_layout(doc: &Document) -> Option { warnings, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fields_sharing_a_kids_array_still_lose_their_private_keys_once() { + let mut doc = Document::new_empty(); + let mut kid = Dict::new(); + kid.set(CLICK_KEY.to_vec(), Object::Int(1)); + let kid = doc.add(Object::Dict(kid)); + let kids = doc.add(Object::Array(vec![Object::Ref(kid)])); + let parents: Vec = (0..3) + .map(|_| { + let mut p = Dict::new(); + p.set(b"Kids".to_vec(), Object::Ref(kids)); + Object::Ref(doc.add(Object::Dict(p))) + }) + .collect(); + let mut acro = Dict::new(); + acro.set(b"XFA".to_vec(), Object::Array(Vec::new())); + acro.set(b"Fields".to_vec(), Object::Array(parents)); + let root = doc.root().unwrap(); + let mut catalog = doc.get(root).as_dict().cloned().unwrap(); + catalog.set(b"AcroForm".to_vec(), Object::Dict(acro)); + doc.set(root, Object::Dict(catalog)); + let out = strip(&mut doc).unwrap(); + assert_eq!(out.script_buttons, 1); + assert!(!doc.get(kid).as_dict().unwrap().contains(CLICK_KEY)); + } +} diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index f698938299..0407baa937 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1192,19 +1192,34 @@ fn node_at<'a>(live: &'a crate::script::FormNode, som: &str) -> Option<&'a crate live.children.iter().find_map(|c| node_at(c, som)) } -/// A date its data picture can't hold (2050 in a two-digit year) is written as ISO and reads +/// A date its data picture can't hold (2030 in a two-digit year) is written as ISO and reads /// back as written. #[test] fn a_date_its_data_picture_cant_hold_is_written_as_iso() { use crate::data::{data_to_iso, iso_to_data}; assert_eq!(iso_to_data("2026-01-02".into(), Some("yymmdd")), "260102"); - // A two-digit year reads back as 1950 to 2049: 2050 and 1949 keep ISO, and read back as written. - for iso in ["2050-01-02", "1949-12-31"] { + // A two-digit year reads back as 1930 to 2029, so those years are written in it. + for (iso, data) in [("2029-12-31", "291231"), ("1930-01-01", "300101"), ("1949-12-31", "491231"), ("1950-01-01", "500101")] { + assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), data, "{iso}"); + } + // 2030 and 1929 keep ISO, and read back as written. + for iso in ["2030-01-01", "1929-12-31", "2050-01-02"] { assert_eq!(iso_to_data(iso.into(), Some("yymmdd")), iso); assert_eq!(data_to_iso(iso, Some("yymmdd")), iso); } } +/// A two-digit year splits its century at 30, the XFA picture clause default: `00` to `29` are +/// 2000 to 2029 and `30` to `99` are 1930 to 1999, typed and in the data alike. +#[test] +fn a_two_digit_year_splits_its_century_at_30() { + use crate::data::data_to_iso; + for (text, iso) in [("291231", "2029-12-31"), ("300101", "1930-01-01"), ("491231", "1949-12-31"), ("500101", "1950-01-01")] { + assert_eq!(pattern_to_iso(text, "yymmdd").as_deref(), Some(iso), "{text}"); + assert_eq!(data_to_iso(text, Some("yymmdd")), iso, "{text}"); + } +} + /// The datasets fill the fields and add the rows of repeating subforms. #[test] fn month_and_weekday_names_in_date_patterns() { @@ -4222,6 +4237,8 @@ fn a_typed_date_is_rewritten_only_by_date_clauses_into_a_first_pattern_that_keep let short = "form[0].short[0]"; assert_eq!(typed(short, "20261018").as_deref(), Some("261018"), "a year the first pattern keeps"); assert_eq!(typed(short, "19011018"), None, "1901 as 01 would read back as 2001"); + assert_eq!(typed(short, "19451018").as_deref(), Some("451018"), "45 reads back as 1945"); + assert_eq!(typed(short, "20301018"), None, "2030 as 30 would read back as 1930"); // DDMMYYYY reads 20011203 as 1203-01-20, which YYMMDD can't keep; YYYYMMDD reads 2001-12-03. assert_eq!(typed("form[0].pick[0]", "20011203").as_deref(), Some("011203"), "the next pattern that the first keeps"); } @@ -4509,6 +4526,67 @@ fn clear_password_values_clears_static_xfa_password_fields() { assert!(!packets.xdp.contains("hunter2"), "{}", packets.xdp); } +/// The pages of `form[0].last[0].f[0]` and of the field `after` that follows `last`, when `last` +/// holds `before` ahead of `f` and `after` behind it. A breakAfter acts after its subform, so the +/// page of `after` shows whether it broke. +fn pages_around_break(before: &str, after: &str) -> (Option, Option) { + let xml = format!( + r#""# + ); + let t = crate::parse(&xml).unwrap().0; + let form = crate::layout::layout(&t, None).unwrap(); + let page_of = |som: &str| form.pages.iter().position(|p| widgets(p).iter().any(|w| w.som == som)); + (page_of("form[0].last[0].f[0]"), page_of("form[0].after[0]")) +} + +/// A `` without a targetType breaks nothing: its default is `auto`, layout goes on +/// in the current container (XFA 3.3 p. 638), as a public form's last section expects. Written +/// out as `auto` it breaks nothing either. One to a page area, or a content area, still starts a +/// new page; so does a `breakAfter` to one. +#[test] +fn a_break_before_without_a_target_type_does_not_start_a_page() { + let page_before = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page_before(""), Some(0), "auto: no break"); + assert_eq!(page_before(r#""#), Some(0)); + assert_eq!(page_before(r#""#), Some(1)); + assert_eq!(page_before(r#""#), Some(1)); + let page_after = |brk: &str| pages_around_break("", brk).1; + assert_eq!(page_after(""), Some(0), "auto: no break"); + assert_eq!(page_after(r#""#), Some(0)); + assert_eq!(page_after(r#""#), Some(1)); + assert_eq!(page_after(r#""#), Some(1)); +} + +/// targetType is one of `auto`, `contentArea` and `pageArea` (XFA 3.3 pp. 636-638). An empty or +/// unknown value, in either direction, names neither a page nor a content area, so it breaks +/// nothing; the names are case-sensitive. +#[test] +fn a_break_with_an_empty_or_unknown_target_type_does_not_start_a_page() { + for value in ["", " ", "page", "pagearea", "PageArea", "next"] { + let before = format!(r#""#); + assert_eq!(pages_around_break(&before, "").0, Some(0), "breakBefore targetType={value:?}"); + let after = format!(r#""#); + assert_eq!(pages_around_break("", &after).1, Some(0), "breakAfter targetType={value:?}"); + } +} + +/// The deprecated `` form (XFA 3.3 pp. 633-634): `auto`, its default, forces +/// no transition; `pageArea` and `contentArea` start a new page; an empty or unknown value breaks +/// nothing. +#[test] +fn the_deprecated_break_before_form_breaks_only_to_a_page_or_content_area() { + let page = |brk: &str| pages_around_break(brk, "").0; + assert_eq!(page(""), Some(0), "auto: no break"); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(0)); + assert_eq!(page(r#""#), Some(1)); + assert_eq!(page(r#""#), Some(1)); +} + /// A form with `draws` in a subform placed by position, on a letter page whose content area is a /// quarter inch in, with `master` on the page itself. fn form_of_draws(master: &str, draws: &str) -> String { @@ -4895,6 +4973,589 @@ fn a_container_is_as_wide_as_a_turned_draw_stands() { } } +/// A page break splits an empty multi-line field that grows with its text (XFA 3.3 pp. 291–292: +/// text splits between lines, and a field's `keep intact` is `none`): it starts where it is, its +/// widget runs to the bottom of the content area, and the rest of its box, closed on every side, +/// goes on at the top of the next. Kept intact, holding a value or on one line, the field moves +/// to the next content area whole, as before. +#[test] +fn an_empty_growing_field_is_split_by_a_page_break() { + let form = |field_extra: &str, multi: &str| { + let body = format!( + r#"above +{field_extra} + + +"# + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let notes = |page: &Page| widgets(page).into_iter().find(|w| w.name == "notes").map(|w| w.rect); + // The content area runs from 36 to 756; the field starts at 36 + 576 = 612 and is 216 tall. + let split = form("", r#"multiLine="1""#); + assert_eq!(split.pages.len(), 2); + let first = notes(&split.pages[0]).expect("the widget starts on the first page"); + assert!((first.y - 648.0).abs() < 0.5 && (first.bottom() - 756.0).abs() < 0.5, "down to the bottom of the area: {first:?}"); + assert!(notes(&split.pages[1]).is_none(), "one widget"); + let edges: Vec<((f64, f64), (f64, f64))> = + split.pages[1].items.iter().filter_map(|i| if let Item::Line { from, to, .. } = i { Some((*from, *to)) } else { None }).collect(); + assert_eq!(edges.len(), 4, "the rest of the box, closed: {edges:?}"); + let (top, bottom) = edges.iter().flat_map(|(a, b)| [a.1, b.1]).fold((f64::INFINITY, 0.0_f64), |(t, b), y| (t.min(y), b.max(y))); + assert!((top - 36.0).abs() < 1.0 && (bottom - 108.0).abs() < 1.0, "at the top of the next area, 72 tall: {top}..{bottom}"); + for (extra, multi, why) in [ + (r#""#, r#"multiLine="1""#, "kept intact"), + (r#"typed"#, r#"multiLine="1""#, "holding a value"), + ("", "", "on one line"), + ] { + let whole = form(extra, multi); + assert_eq!(whole.pages.len(), 2, "{why}"); + assert!(notes(&whole.pages[0]).is_none(), "{why}: nothing of it on the first page"); + let moved = notes(&whole.pages[1]).unwrap_or_else(|| panic!("{why}: the field is on the second page")); + assert!((moved.y - 72.0).abs() < 0.5, "{why}: whole, under its caption at the top: {moved:?}"); + } +} + +/// A field split by a page break has its own border drawn round each piece (a border's default +/// `break="close"`, XFA 3.3 pp. 629–630): an edge at the bottom of the first page and one at the +/// top of the next. One whose widget would have no room above the break for a line of its text +/// moves to the next page whole, its caption and border with it. +#[test] +fn a_split_field_closes_its_border_on_each_piece_and_a_sliver_moves_whole() { + let form = |filler: &str| { + let body = format!( + r#"above + + + + +"# + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + // Level lines across the field (its border's top and bottom) on `page`, top to bottom. + let levels = |page: &Page| -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && (to.0 - from.0).abs() > 500.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys + }; + // The field starts at 612, with 144 left in the area (down to 756). + let split = form("8in"); + assert_eq!(split.pages.len(), 2); + let (first, next) = (levels(&split.pages[0]), levels(&split.pages[1])); + let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0); + assert!(near(&first, &[612.0, 756.0]), "first piece closed, once: {first:?}"); + assert!(near(&next, &[36.0, 108.0]), "next piece closed, once: {next:?}"); + // 9.9in of filler leaves 0.1in above the break, under the widget's 0.5in caption: no room. + let sliver = form("9.9in"); + assert_eq!(sliver.pages.len(), 2); + assert!(levels(&sliver.pages[0]).is_empty(), "nothing of the field left on the first page: {:?}", levels(&sliver.pages[0])); + assert!(!texts(&sliver.pages[0]).iter().any(|t| t == "Notes"), "its caption moves with it"); + let whole = levels(&sliver.pages[1]); + assert!(whole.iter().any(|y| (y - 36.0).abs() < 1.0) && whole.iter().any(|y| (y - 252.0).abs() < 1.0), "whole on the next page: {whole:?}"); +} + +/// A field split inside a bordered section breaks the section with it, as a field moving on whole +/// does: the section's border closes on the first page under its bottom margin, which the field +/// leaves free, and opens again on the next, where the rest of the field sits under its top margin. +#[test] +fn a_field_split_inside_a_bordered_section_keeps_the_section_border_on_each_page() { + let body = r#" + + + above + + + + + +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + // Level lines `width` long on `page`, top to bottom: the section's are 540 long, the field's 504. + let levels = |page: &Page, width: f64| -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - width).abs() < 1.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys + }; + let near = |got: &[f64], want: &[f64]| got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0); + // The section opens at 36 and its filler ends at 36 + 18 + 576 = 630, where the field starts. + let (sec, field) = (levels(&form.pages[0], 540.0), levels(&form.pages[0], 504.0)); + assert!(near(&sec, &[36.0, 756.0]), "the section closed at the bottom of the area: {sec:?}"); + assert!(near(&field, &[630.0, 738.0]), "the field's first piece ends above the section's bottom margin: {field:?}"); + // 216 - 108 = 108 of the field are left, under the section's top margin. + let (sec, field) = (levels(&form.pages[1], 540.0), levels(&form.pages[1], 504.0)); + assert!(near(&sec, &[36.0, 180.0]), "the section opened again round the rest: {sec:?}"); + assert!(near(&field, &[54.0, 162.0]), "the rest of the field under the section's top margin: {field:?}"); +} + +/// A field with no room above the break for a line of its widget is placed exactly as one that +/// can't split: inside a bordered section with a top margin that goes on to the next page, it +/// sits where a field kept intact does, under the section's margin there. +#[test] +fn a_sliver_field_moves_on_as_a_field_kept_intact_does() { + let form = |keep: &str| { + let body = format!( + r#" + + + above + {keep} + + + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let at = form.pages.iter().enumerate().find_map(|(i, p)| widgets(p).into_iter().find(|w| w.name == "notes").map(|w| (i, w.rect))); + (form.pages.len(), at) + }; + let (pages, at) = form(""); + assert_eq!((pages, at), form(r#""#), "the sliver field and one kept intact"); + assert_eq!(at.map(|(page, _)| page), Some(1), "on the second page"); +} + +/// #5525 review: a field laid out again to move on whole (no room above the break for a line) +/// counts its items once against `MAX_ITEMS`, as a field kept intact does: what the first try +/// drew is taken off the page, and its count with it. +#[test] +fn a_field_laid_out_again_counts_its_items_once() { + let laid_out = |keep: &str| { + let body = format!( + r#" + above + {keep} + + + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + let on_pages: usize = form.pages.iter().map(|p| p.items.len()).sum(); + (form.pages.len(), on_pages, crate::layout::LAID_OUT_ITEMS.with(std::cell::Cell::get)) + }; + let (pages, on_pages, counted) = laid_out(""); + assert_eq!(pages, 2, "the field moves on to page 2"); + assert_eq!((pages, on_pages, counted), laid_out(r#""#), "laid out again, as kept intact"); +} + +/// #5525 review: the level lines about `len` long on `page` (a border's top and bottom edges), +/// top to bottom. +fn level_lines(page: &Page, len: f64) -> Vec { + let mut ys: Vec = page + .items + .iter() + .filter_map(|i| match i { + Item::Line { from, to, .. } if (from.1 - to.1).abs() < 0.01 && ((to.0 - from.0).abs() - len).abs() < 1.0 => Some(from.1), + _ => None, + }) + .collect(); + ys.sort_by(f64::total_cmp); + ys +} + +/// #5525 review: whether `got` holds the values of `want`, each within a point. +fn near_all(got: &[f64], want: &[f64]) -> bool { + got.len() == want.len() && got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1.0) +} + +/// #5525 review: the pages (by index) holding the widget at SOM path `som`, with its box on each. +/// A repeated field's PDF name is made up, so the widget is found by its SOM path. +fn widget_pieces(form: &Form, som: &str) -> Vec<(usize, Rect)> { + form.pages.iter().enumerate().flat_map(|(i, p)| widgets(p).into_iter().filter(|w| w.som == som).map(move |w| (i, w.rect))).collect() +} + +/// #5525 review: the baseline of the first text span `text` on `page`. +fn baseline_of(page: &Page, text: &str) -> Option { + page.items.iter().find_map(|i| match i { + Item::Text(s) if s.text == text => Some(s.baseline), + _ => None, + }) +} + +/// #5525 review: an empty growing field `name` with its own border all round and no widget +/// border, `min_h` tall, `extra` inside it. +fn bordered_notes(name: &str, min_h: &str, extra: &str) -> String { + format!( + r#"{extra} + + +"# + ) +} + +/// #5525 review: a field split over two pages. With 144 points left on the first page, a 216 +/// point field runs to the bottom of it and its other 72 points go on at the top of the next, +/// its border closed round each piece; what follows the field starts under that rest, not +/// under a whole field moved to the next page. +#[test] +fn a_field_split_over_two_pages_has_what_follows_start_under_its_rest() { + let body = format!( + r#"above +{} +after"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one widget, on the first page: {pieces:?}" + ); + let (first, next) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0)); + assert!(near_all(&first, &[612.0, 756.0]), "first piece, closed: {first:?}"); + assert!(near_all(&next, &[36.0, 108.0]), "the other 72 points, closed: {next:?}"); + let after = baseline_of(&form.pages[1], "after").expect("what follows is on the second page"); + assert!(after > 108.0 && after < 144.0, "under the rest of the field: {after}"); +} + +/// #5525 review: a field split over three pages. A 936 point field with 144 points left on the +/// first page fills that, the whole of the second page's content area, and 72 points of the +/// third; its border is closed round each of the three pieces, and what follows starts under +/// the last. +#[test] +fn a_field_split_over_three_pages_closes_its_border_round_every_piece() { + let body = format!( + r#"above +{} +after"#, + bordered_notes("notes", "13in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one widget, on the first page: {pieces:?}" + ); + let levels: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &[612.0, 756.0]), "first piece: {levels:?}"); + assert!(near_all(&levels[1], &[36.0, 756.0]), "a whole content area: {levels:?}"); + assert!(near_all(&levels[2], &[36.0, 108.0]), "the last 72 points: {levels:?}"); + let after = baseline_of(&form.pages[2], "after").expect("what follows is on the third page"); + assert!(after > 108.0 && after < 144.0, "under the last piece: {after}"); +} + +/// #5525 review: a field taller than a page that starts at the top of one (the page break is +/// right above it) splits at the bottom of that content area rather than running off the page: +/// 864 points become 720 here and 144 on the next page. +#[test] +fn a_field_taller_than_a_page_starting_at_its_top_splits_at_its_bottom() { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "12in", ""))).unwrap(); + assert_eq!(form.pages.len(), 2); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "the widget fills the first content area, no more: {pieces:?}" + ); + assert!(near_all(&level_lines(&form.pages[0], 540.0), &[36.0, 756.0]), "{:?}", level_lines(&form.pages[0], 540.0)); + assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 180.0]), "{:?}", level_lines(&form.pages[1], 540.0)); +} + +/// #5525 review: a field that starts exactly at a page break (what is above it fills the content +/// area) has no room at all on that page: it moves to the next page whole, its border with it, +/// and nothing of it is left behind. +#[test] +fn a_field_starting_exactly_at_a_page_break_moves_on_whole() { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + assert!(level_lines(&form.pages[0], 540.0).is_empty(), "no border left behind: {:?}", level_lines(&form.pages[0], 540.0)); + let pieces = widget_pieces(&form, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.bottom() - 252.0).abs() < 0.5), + "whole at the top of the next page: {pieces:?}" + ); + assert!(near_all(&level_lines(&form.pages[1], 540.0), &[36.0, 252.0]), "{:?}", level_lines(&form.pages[1], 540.0)); +} + +/// #5525 review: the room a field needs above a page break to split there is one line of its +/// text (10 point Courier, 12 points). With exactly 12 points left the field splits, its widget +/// on the last 12 points of the page; with 11 it has no room and moves to the next page whole. +#[test] +fn a_field_splits_with_one_line_of_room_and_moves_on_whole_with_less() { + let form = |filler: &str| { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", "") + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let line = form("708pt"); + let pieces = widget_pieces(&line, "form[0].notes[0]"); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 744.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "one line on the first page: {pieces:?}" + ); + assert!(near_all(&level_lines(&line.pages[1], 540.0), &[36.0, 240.0]), "the other 204 points: {:?}", level_lines(&line.pages[1], 540.0)); + let less = form("709pt"); + let pieces = widget_pieces(&less, "form[0].notes[0]"); + assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "whole on the next page: {pieces:?}"); + assert!(level_lines(&less.pages[0], 540.0).is_empty(), "nothing of it left on the first page"); +} + +/// #5525 review: two such fields in a row are split by their own page breaks, and the second +/// starts from nothing of the first: the first (216 points) splits over pages one and two, the +/// second (792 points) starts under the first's rest and splits over pages two and three, and +/// each page holds only the borders of its own pieces. A second field that fits after a split +/// one is drawn whole, once. +#[test] +fn two_fields_in_a_row_are_each_split_by_their_own_page_break() { + let form = |second: &str| { + let body = format!( + r#"above +{} +{}"#, + bordered_notes("a", "3in", ""), + bordered_notes("b", second, "") + ); + layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap() + }; + let both = form("11in"); + assert_eq!(both.pages.len(), 3); + let a = widget_pieces(&both, "form[0].a[0]"); + let b = widget_pieces(&both, "form[0].b[0]"); + assert!(matches!(a.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "a: {a:?}"); + assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "b: {b:?}"); + let levels: Vec> = both.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &[612.0, 756.0]), "a's first piece: {levels:?}"); + assert!(near_all(&levels[1], &[36.0, 108.0, 108.0, 756.0]), "a's rest and b's first piece: {levels:?}"); + assert!(near_all(&levels[2], &[36.0, 180.0]), "b's other 144 points: {levels:?}"); + let fits = form("1in"); + assert_eq!(fits.pages.len(), 2); + let b = widget_pieces(&fits, "form[0].b[0]"); + assert!(matches!(b.as_slice(), [(1, r)] if (r.y - 108.0).abs() < 0.5 && (r.h - 72.0).abs() < 0.5), "b whole: {b:?}"); + let levels = level_lines(&fits.pages[1], 540.0); + assert!(near_all(&levels, &[36.0, 108.0, 108.0, 180.0]), "a's rest, and b once: {levels:?}"); +} + +/// #5525 review: the field of the second instance of a repeated subform splits like any other, +/// found by its SOM path (its PDF name is made up), while the first instance's field, which +/// fits, stays whole. +#[test] +fn a_field_in_a_repeated_subform_splits_in_the_instance_that_reaches_the_break() { + let body = format!( + r#"above + +{} +"#, + bordered_notes("notes", "3in", "") + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 2); + let first = widget_pieces(&form, "form[0].row[0].notes[0]"); + let second = widget_pieces(&form, "form[0].row[1].notes[0]"); + assert!(matches!(first.as_slice(), [(0, r)] if (r.y - 468.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "first whole: {first:?}"); + assert!(matches!(second.as_slice(), [(0, r)] if (r.y - 684.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), "second split: {second:?}"); + let (page0, page1) = (level_lines(&form.pages[0], 540.0), level_lines(&form.pages[1], 540.0)); + assert!(near_all(&page0, &[468.0, 684.0, 684.0, 756.0]), "{page0:?}"); + assert!(near_all(&page1, &[36.0, 180.0]), "{page1:?}"); +} + +/// #5525 review: a field split inside two nested sections, each with a quarter-inch bottom and +/// top margin, leaves both bottom margins free under its first piece (it ends 36 points above +/// the bottom of the content area) and goes on under both top margins on the next page. +#[test] +fn a_field_split_inside_nested_sections_leaves_room_for_both_margins() { + let body = r#" + + + + above + + + + + +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 2); + // The filler starts under both top margins, at 72, so the field starts at 648. + let (first, next) = (level_lines(&form.pages[0], 504.0), level_lines(&form.pages[1], 504.0)); + assert!(near_all(&first, &[648.0, 720.0]), "first piece ends above both bottom margins: {first:?}"); + assert!(near_all(&next, &[72.0, 216.0]), "the other 144 points under both top margins: {next:?}"); +} + +/// #5525 review: `` decides whether a page break may split the field (XFA 3.3 +/// p. 292): `contentArea` and `pageArea` keep it whole, moving it to the next page, while +/// `none` and a value XFA doesn't define leave a field's default, `none`, and it splits. +#[test] +fn keep_intact_values_decide_whether_the_field_splits() { + let at = |keep: &str| { + let body = format!( + r#"above +{}"#, + bordered_notes("notes", "3in", &format!(r#""#)) + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + widget_pieces(&form, "form[0].notes[0]") + }; + for keep in ["contentArea", "pageArea"] { + let pieces = at(keep); + assert!(matches!(pieces.as_slice(), [(1, r)] if (r.y - 36.0).abs() < 0.5 && (r.h - 216.0).abs() < 0.5), "{keep}: whole: {pieces:?}"); + } + for keep in ["none", "sideways", ""] { + let pieces = at(keep); + assert!( + matches!(pieces.as_slice(), [(0, r)] if (r.y - 612.0).abs() < 0.5 && (r.bottom() - 756.0).abs() < 0.5), + "{keep:?}: split: {pieces:?}" + ); + } +} + +/// #5525 review: extreme and malformed sizes. A 1000 inch empty field splits over exactly 100 +/// pages, its widget on the first only and its border closed on each; a `minH` past the largest +/// length a measurement may have (100,000 points) splits over 139 pages; fields that would need +/// more pages than a form may have (500) are refused with an error rather than a panic or a +/// hang; and a negative, NaN or non-numeric `minH`, or a content area too short for a line, +/// lays out without panicking. +#[test] +fn a_split_field_survives_huge_and_malformed_sizes() { + let huge = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "1000in", ""))).unwrap(); + assert_eq!(huge.pages.len(), 100); + assert!(matches!(widget_pieces(&huge, "form[0].notes[0]").as_slice(), [(0, _)]), "one widget, on the first page"); + for (i, p) in huge.pages.iter().enumerate() { + assert!(near_all(&level_lines(p, 540.0), &[36.0, 756.0]), "page {i}: {:?}", level_lines(p, 540.0)); + } + let capped = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", "100000in", ""))).unwrap(); + assert_eq!(capped.pages.len(), 139, "100,000 points over 720 point content areas"); + let four: String = (0..4).map(|i| bordered_notes(&format!("n{i}"), "100000in", "")).collect(); + let too_many = layout_xml(&one_area_form(r#"layout="tb""#, &four)); + assert!(matches!(too_many, Err(XfaError::TooLarge(_))), "{:?}", too_many.map(|f| f.pages.len())); + for min_h in ["-3in", "NaNin", "abc", "0"] { + let form = layout_xml(&one_area_form(r#"layout="tb""#, &bordered_notes("notes", min_h, ""))).unwrap(); + assert!(!form.pages.is_empty(), "{min_h}"); + } + let tiny = one_area_form(r#"layout="tb""#, &bordered_notes("notes", "3in", "")).replace(r#"h="10in""#, r#"h="0.5pt""#); + let _ = layout_xml(&tiny); +} + +/// #5525 review: the widget's own border (the text edit's) of a field split over three pages is +/// closed round every piece below the widget, not only the first: the middle page gets a box +/// from the top of its content area to the bottom, and the last page one round the last 72 +/// points, each with its top and bottom edge. +#[test] +fn a_widget_border_split_over_three_pages_is_closed_round_every_piece() { + let body = r#"above +"#; + let form = layout_xml(&one_area_form(r#"layout="tb""#, body)).unwrap(); + assert_eq!(form.pages.len(), 3); + // The edges are half a point wide, drawn a quarter of a point inside the box. + let (middle, last) = (level_lines(&form.pages[1], 539.5), level_lines(&form.pages[2], 539.5)); + assert!(near_all(&middle, &[36.25, 755.75]), "the middle piece, closed at the bottom of the page: {middle:?}"); + assert!(near_all(&last, &[36.25, 107.75]), "the last piece, closed at its top: {last:?}"); +} + +/// #5525 review: a bordered section round an empty field split over three pages keeps its +/// border on every page, even when what is left of the field draws nothing there (no widget +/// border or fill): the middle page and the last still hold a part of the section. With or +/// without the field's own border, the section is closed on page one under its bottom margin, +/// runs the whole content area on page two, and closes under the field's last 144 points on +/// page three. +#[test] +fn a_bordered_section_round_a_field_split_over_three_pages_is_closed_on_every_page() { + for field_border in ["", ""] { + let body = format!( + r#" + + + above + {field_border} + + +"# + ); + let form = layout_xml(&one_area_form(r#"layout="tb""#, &body)).unwrap(); + assert_eq!(form.pages.len(), 3, "{field_border:?}"); + let sec: Vec> = form.pages.iter().map(|p| level_lines(p, 540.0)).collect(); + // The field starts at 36 + 18 + 576 = 630 and is 936 tall: 108 on page one (down to + // 738), 684 on page two (54 to 738), and the last 144 on page three (54 to 198). + assert!(near_all(&sec[0], &[36.0, 756.0]), "{field_border:?}: page one: {sec:?}"); + assert!(near_all(&sec[1], &[36.0, 756.0]), "{field_border:?}: page two, a whole content area: {sec:?}"); + assert!(near_all(&sec[2], &[36.0, 216.0]), "{field_border:?}: page three, under the field's last piece: {sec:?}"); + if !field_border.is_empty() { + let field: Vec> = form.pages.iter().map(|p| level_lines(p, 504.0)).collect(); + assert!(near_all(&field[0], &[630.0, 738.0]), "{field:?}"); + assert!(near_all(&field[1], &[54.0, 738.0]), "{field:?}"); + assert!(near_all(&field[2], &[54.0, 198.0]), "{field:?}"); + } + } +} + +/// #5525 review: a field split into content areas of different sizes keeps moving down even +/// where the margins of the sections round it leave no room. The first page area (used once) has +/// a 720 point content area; the next has one only 36 points tall, under a section's 72 point +/// bottom margin (or with its 72 point top margin taking the whole of it). The field still goes on +/// 36 points a page, from the top of each small area, instead of breaking to new pages until the +/// form has too many; the section, which can't hold it there, is drawn without its border, and +/// the layout says so. +#[test] +fn a_field_split_into_areas_smaller_than_its_sections_margins_still_moves_on() { + let form = |margin: &str, border: &str| { + let xml = format!( + r##" + + + +"##, + bordered_notes("notes", "3in", "") + ); + layout_xml(&xml) + }; + // Under a 72 point bottom margin the field starts at 612 and its first piece ends at 684; under + // a 72 point top margin it starts at 684 and its first piece ends at 756. Either way 144 points + // are left, four small areas' worth. + for (margin, first) in [(r#"bottomInset="1in""#, [612.0, 684.0]), (r#"topInset="1in""#, [684.0, 756.0])] { + let laid_out = form(margin, ""); + let pages = match &laid_out { + Ok(f) => &f.pages, + Err(e) => panic!("{margin}: {e:?}"), + }; + assert_eq!(pages.len(), 5, "{margin}"); + let levels: Vec> = pages.iter().map(|p| level_lines(p, 540.0)).collect(); + assert!(near_all(&levels[0], &first), "{margin}: first piece: {levels:?}"); + for (i, l) in levels.iter().enumerate().skip(1) { + assert!(near_all(l, &[36.0, 72.0]), "{margin}: page {i}, the whole small area: {levels:?}"); + } + let bordered = form(margin, "").unwrap_or_else(|e| panic!("{margin}: bordered: {e:?}")); + assert_eq!(bordered.pages.len(), 5, "{margin}: bordered"); + assert!(bordered.warnings.iter().any(|w| w.contains("without its border")), "{margin}: {:?}", bordered.warnings); + } +} + /// Only a template that is a packet of the XDP is the form's: one inside imported data (a /// dataset holding XML that looks like a template) is not read, and neither are its scripts. #[test] diff --git a/crates/xfdf/src/fdf.rs b/crates/xfdf/src/fdf.rs index dffd3f9098..6c2f169931 100644 --- a/crates/xfdf/src/fdf.rs +++ b/crates/xfdf/src/fdf.rs @@ -42,13 +42,23 @@ struct FdfCopy<'a> { objects: Vec, numbers: HashMap, pending: Vec<(ObjRef, usize)>, + /// The leaf pages of the source document, by identity: whatever their `/Type` says (or does not). + source_pages: HashSet, nodes: usize, bytes: usize, } impl<'a> FdfCopy<'a> { fn new(doc: &'a Document) -> Self { - Self { doc, objects: Vec::new(), numbers: HashMap::new(), pending: Vec::new(), nodes: MAX_FDF_OBJECTS, bytes: MAX_FDF_BYTES } + Self { + doc, + objects: Vec::new(), + numbers: HashMap::new(), + pending: Vec::new(), + source_pages: HashSet::new(), + nodes: MAX_FDF_OBJECTS, + bytes: MAX_FDF_BYTES, + } } fn charge(&mut self, bytes: usize) -> Result<(), DataError> { @@ -181,7 +191,7 @@ impl<'a> FdfCopy<'a> { while let Some((source, index)) = self.pending.get(cursor).copied() { // ISO 32000-2 §7.3.10: an absent or stale reference denotes null. Keep reader // errors distinct so damaged objects cannot be silently lost during export. - let value = if doc.generation(source.num) != source.generation { + let value = if doc.generation(source.num) != source.generation || self.source_pages.contains(&source) { std::sync::Arc::new(Object::Null) } else { match doc.try_get(source.num) { @@ -250,6 +260,8 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> R // Keep the page/array owners once. Handles retain positions, never annotation // bodies: the document cache can evict and reparse even a repeated source reference. let pages = pdfcraft_model::pages(doc); + // Page discovery accepts a leaf with no /Type: the export keeps these out by reference too. + copy.source_pages = pages.iter().map(|p| p.obj).collect(); let mut arrays = Vec::new(); let mut picked: Vec<(Option, usize, usize)> = Vec::new(); for (pi, page) in pages.iter().enumerate() { diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2c1c06c988..3823c60413 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1706,6 +1706,43 @@ fn xml_form_import_preserves_text_around_comments_and_instructions() { } } +/// Page discovery accepts a leaf without /Type, so a comment's nested reference to one must not +/// carry the page or its content into the export. +#[test] +fn fdf_export_nulls_a_nested_link_to_a_page_without_a_type() { + let source = { + let mut doc = Document::new_empty(); + let root = doc.root().unwrap(); + let tree = doc.get(root).as_dict().unwrap().reference(b"Pages").unwrap(); + let contents = doc.add(pdfcraft_cos::Stream::from_raw(Dict::new(), b"% private-page-content\n".repeat(4_096))); + let mut page = Dict::new(); + page.set(b"Parent".to_vec(), Object::Ref(tree)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + page.set(b"Contents".to_vec(), Object::Ref(contents)); + let page = doc.add(page); + doc.update_dict(tree, |d| { + d.set(b"Type".to_vec(), Object::name("Pages")); + d.set(b"Count".to_vec(), Object::Int(1)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + }) + .unwrap(); + let mut target = Dict::new(); + target.set(b"Label".to_vec(), PdfString::text("kept")); + target.set(b"Target".to_vec(), Object::Ref(page)); + fdf_note_with_data(&mut doc, Object::Array(vec![Object::Dict(target)])); + write_full(&doc, &SaveOptions::default()).unwrap() + }; + let source = Document::open(Arc::new(source)).unwrap(); + assert_eq!(pdfcraft_model::pages(&source).len(), 1); + let bytes = export_fdf(&source, true, false, "source.pdf").unwrap(); + assert!(bytes.len() < 4_096, "the untyped page's content must stay in the PDF ({} bytes)", bytes.len()); + let fdf = Document::open(Arc::new(bytes)).unwrap(); + let data = exported_note_data(&fdf); + let target = data.as_array().unwrap()[0].as_dict().unwrap(); + assert_eq!(target.get(b"Label").unwrap().as_string().unwrap().to_text(), "kept"); + assert_eq!(*fdf.resolve(target.get(b"Target").unwrap()), Object::Null); +} + #[test] fn fdf_export_nulls_nested_page_links_without_copying_the_source_page_tree() { for indirect_type in [false, true] { diff --git a/docs/gaps.md b/docs/gaps.md index e11eef9642..0fe476d087 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -348,7 +348,8 @@ update the numbers. ### 16. UI precision gaps - **Missing:** moving several selected objects across pages (same-page moves shipped, #844), single-key tool accelerators, - rulers, grids and guides, loupe and dynamic zoom, multiple windows and split view, comment + rulers, grids and guides, loupe and dynamic zoom, multiple windows and spreadsheet split (split + view shipped), comment pop-ups that can be moved, connector lines, customisable quick tools; shortcut display and tool-mode bugs (#746, #721), macOS window controls (#789). - **Estimate:** 30–60 h. **Doc:** [ui-parity.md](ui-parity.md). @@ -363,9 +364,9 @@ update the numbers. ### 18. Print production tools absent - **Missing:** Output Preview, separations, total area coverage, overprint preview, ink manager, - colour conversion, printer marks, fix hairlines, flattener preview, colour management settings; - print as image; PostScript output. -- **Evidence:** 18 print-production features planned (L area). + colour conversion, printer marks, flattener preview, colour management settings; + print as image; PostScript output. Fix hairlines is in (Print production ▸ Fix hairlines…). +- **Evidence:** 17 print-production features planned (L area). - **Estimate:** 25–45 h (part of the L area's 30–55). **Doc:** [target-app-parity.md](target-app-parity.md). ### 19. Preferences mostly missing diff --git a/docs/releasing.md b/docs/releasing.md index eaeb4a63f1..c0bb41fd5b 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -130,13 +130,14 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. dialogs. Full UI confirms success with Finish; failures and cancellations have distinct messages. The welcome dialog has an "Install to" row (a `PathEdit`, and Browse… into `BrowseFolder`, a native folder browser: drive list, folder list, Up, New folder, typed path; #1139), a "Create a - desktop shortcut" checkbox and an "Edit with PdfCraft" right-click menu checkbox, both ticked by - default. Install sets the folder (`SetTargetPath`) before the dialog closes. `INSTALLFOLDER` is - secure, so the choice reaches the elevated install, and `INSTALLFOLDER=` works on the - command line. The row starts on the folder `install-policy.wxi` chose for the scope picked on the - first page (the recorded folder on an update), and an update installs into the recorded folder - unless the command line gives one (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create - both unless `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; + desktop shortcut" checkbox and an "Add Edit and Combine files to the right-click menu of PDF + files" checkbox, both ticked by default. Install sets the folder (`SetTargetPath`) before the + dialog closes. `INSTALLFOLDER` is secure, so the choice reaches the elevated install, and + `INSTALLFOLDER=` works on the command line. The row starts on the folder + `install-policy.wxi` chose for the scope picked on the first page (the recorded folder on an + update), and an update installs into the recorded folder unless the command line gives one + (`INSTALLATION.md`). `/qn` and `/qb` stay unattended and create both unless + `INSTALLDESKTOPSHORTCUT=0` or `INSTALLPDFCONTEXTMENU=0` is passed; `INSTALLIMAGECONTEXTMENU=0` (no checkbox) leaves out the image menu. An update (the launcher runs `msiexec /passive` with no properties) keeps the desktop shortcut only if `PdfCraft.lnk` is still on the all-users desktop, so deleting it once keeps it away (#1058), and keeps each menu only if @@ -150,7 +151,10 @@ neither the MSI nor the portable zip needs the Visual C++ redistributable. limit for a `Document` verb) start one `pdfcraft.exe ""` each, and the single-instance hand-off opens them as tabs. On Windows 11 it is under "Show more options"; a first-level entry needs an `IExplorerCommand` handler and package identity (a sparse MSIX package), which the MSI - doesn't have. + doesn't have. "Combine files in PdfCraft" (#585) is a second verb in the same component, + `PdfCraft.Combine`, running `pdfcraft --combine ""` once per selected file; the + single-instance hand-off sends them with `"combine": true`, and the running app adds them to + its Combine files tab (`OsEvent::Combine`). Both verbs come and go with `INSTALLPDFCONTEXTMENU`. - The image right-click entry (#190) is the same kind of verb, `PdfCraft.CreatePdf`, for ten image types, in its own component (`PdfcraftImageContextMenu`) since it became optional. Its rows moved out of `PdfcraftApp`; that's safe because `MajorUpgrade` removes the old version before installing diff --git a/docs/ui-parity.md b/docs/ui-parity.md index a0ea857f1a..20144d6cf5 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -32,7 +32,7 @@ No dump of Acrobat's menu tree has been diffed against the registry yet (gap 24) | Command search | "Search tools" | command palette (⌘K), matches translated and English labels | shipped | | Document tabs | yes | yes, with unsaved marker | shipped | | Multiple windows, drag a tab out | yes | no | planned | -| Split window, spreadsheet split | yes | no | planned | +| Split window, spreadsheet split | yes | split right or down (⌘\\, tab menu, a tab dropped on the page), one document or two; no four-pane spreadsheet split | split shipped; spreadsheet split planned | | Read mode, full screen | yes | yes; full screen is one fitted page at a time on black, click/keys turn pages (#1459) | shipped; auto-advance, loop and transitions planned | | Light, dark, system theme | yes | yes | shipped | | Side panels: thumbnails, bookmarks, attachments, layers, comments, fields, signatures | yes | yes; panels keep their width (#704); the Pages inspector has a 50–150% thumbnail size slider and buttons (#3808) | shipped | diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml index 6e9f9fa607..d781ad6c7c 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.bundle.yml @@ -25,6 +25,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. diff --git a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml index c9086ab077..fba62fe382 100644 --- a/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml +++ b/packaging/linux/flatpak/ai.storyteller.pdfcraft.yml @@ -26,6 +26,10 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri + # Screen readers: AccessKit asks the accessibility bus (org.a11y.Bus) whether one is running + # and publishes the window there over AT-SPI. Without it the sandbox hides the bus and the + # app is invisible to Orca and other assistive technology (#4612). + - --talk-name=org.a11y.Bus # Printing: lpstat/lp (the cups-client module) reach the host's CUPS through its socket and # find driverless network printers through Avahi (`lpstat -e`). Without these the Print # dialog lists no printers. diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 43a3a7087d..328d5e760b 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -19,7 +19,7 @@ - + diff --git a/packaging/windows/pdfcraft.wxs b/packaging/windows/pdfcraft.wxs index f11ca83a6f..2d0d5f39fc 100644 --- a/packaging/windows/pdfcraft.wxs +++ b/packaging/windows/pdfcraft.wxs @@ -159,6 +159,13 @@ + + + + +
NotesNotesNotesNotesNotes