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 0c1bcdec8d..d94bef8484 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -4704,6 +4706,7 @@ dependencies = [ "skrifa 0.44.0", "thiserror 2.0.21", "unicode-bidi", + "url", ] [[package]] @@ -4767,6 +4770,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "fontconfig-parser", "getrandom 0.4.3", "image", 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..cb3365a4fe 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())?; @@ -421,6 +440,12 @@ pub(crate) fn render(args: &[String]) -> Result<(), CliError> { let _ = writeln!(std::io::stderr().lock(), "warning: page {page}: a colour that could not be computed was painted in a fallback colour"); } + pdfcraft_render::RenderWarning::MalformedIndexedImage => { + let _ = writeln!( + std::io::stderr().lock(), + "warning: page {page}: an image with a malformed Indexed colour table may be drawn wrongly or not at all" + ); + } pdfcraft_render::RenderWarning::Type3WidthsMissing => { let _ = writeln!( std::io::stderr().lock(), 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..59d30930ff 100644 --- a/crates/a11y/src/autotag.rs +++ b/crates/a11y/src/autotag.rs @@ -1,14 +1,21 @@ -//! Create a valid, reviewable structure tree for PDFs whose painted content is text only. +//! Create a valid, reviewable structure tree for untagged PDFs. //! -//! The writer refuses mixed content and existing tags. Each text object becomes a paragraph, -//! which can then be retagged with the existing tag-property tool after human review. +//! Each text object becomes a paragraph, which can then be retagged with the existing +//! tag-property tool after human review. Painted paths, shadings and images outside text objects +//! are wrapped in `/Artifact BMC … EMC` so the resulting page passes the tagged-content check. -use pdfcraft_content::{Op, parse, serialize_ops}; +use std::collections::{HashMap, HashSet}; + +use pdfcraft_content::{Op, Pieces, serialize_ops}; 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)] @@ -37,25 +44,27 @@ struct Plan { page: ObjRef, ops: Vec, spans: Vec<(usize, usize)>, + artifacts: HashMap, } fn unsupported(page: usize, reason: impl Into) -> AutoTagError { AutoTagError::Unsupported { page: page + 1, reason: reason.into() } } -fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result, AutoTagError> { - let Some(contents) = page.dict.get(b"Contents") else { return Ok(Vec::new()) }; +fn content_pieces(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> Result { + let Some(contents) = page.dict.get(b"Contents") else { return Ok(Pieces::join::<&[u8]>(&[])) }; let resolved = doc.resolve(contents); - let pieces: Vec = match &*resolved { + let items: Vec = match &*resolved { Object::Array(items) => items.clone(), Object::Stream(_) => vec![contents.clone()], _ => return Err(unsupported(index, "contents are not a stream or array of streams")), }; - let mut all = Vec::new(); - for item in pieces { + let mut pieces = Vec::with_capacity(items.len()); + let mut total = 0usize; + for item in items { let value = doc.resolve(&item); let Object::Stream(stream) = &*value else { return Err(unsupported(index, "contents include a non-stream object")) }; - let remaining = MAX_PAGE_BYTES.saturating_sub(all.len().saturating_add(1)); + let remaining = MAX_PAGE_BYTES.saturating_sub(total.saturating_add(1)); if remaining == 0 { return Err(unsupported(index, "decoding the content takes more than 16 MiB")); } @@ -75,10 +84,18 @@ fn content_bytes(doc: &Document, page: &pdfcraft_model::Page, index: usize) -> R unsupported(index, format!("content cannot be decoded strictly: {why}")) } })?; - all.extend_from_slice(&decoded); - all.push(b' '); + total = total.saturating_add(decoded.len()).saturating_add(1); + pieces.push(decoded); } - Ok(all) + Ok(Pieces::join(&pieces)) +} + +fn page_xobjects(doc: &Document, page: &Dict) -> Dict { + page.get(b"Resources") + .map(|r| doc.resolve(r)) + .and_then(|r| r.as_dict().and_then(|r| r.get(b"XObject").map(|x| doc.resolve(x)))) + .and_then(|x| x.as_dict().cloned()) + .unwrap_or_default() } fn harmless_outside(op: &[u8]) -> bool { @@ -106,9 +123,35 @@ fn harmless_outside(op: &[u8]) -> bool { | b"sc" | b"SCN" | b"scn" + | b"m" + | b"l" + | b"c" + | b"v" + | b"y" + | b"h" + | b"re" + | b"W" + | b"W*" + | b"n" ) } +fn painted_outside(doc: &Document, xobjects: &Dict, op: &Op) -> bool { + match op.op.as_slice() { + b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"sh" | b"BI" => true, + b"Do" => op + .name(0) + .and_then(|n| xobjects.get(n)) + .map(|o| doc.resolve(o)) + .and_then(|o| match &*o { + Object::Stream(s) => s.dict.name(b"Subtype").map(<[u8]>::to_vec), + _ => None, + }) + .is_some_and(|st| st == b"Image"), + _ => false, + } +} + fn harmless_inside(op: &[u8]) -> bool { matches!( op, @@ -138,17 +181,38 @@ fn harmless_inside(op: &[u8]) -> bool { ) } -/// The `(BT, ET)` operator index pairs of each text object that shows text, or why the page cannot be tagged. -fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagError> { - let mut start = None; - let mut has_text = false; - let mut spans = Vec::new(); - for (at, op) in ops.iter().enumerate() { +/// `totals` is the decoded bytes and operators of the pages planned so far, bounded by +/// `MAX_TOTAL_BYTES` and `MAX_TOTAL_OPERATORS`. +fn plan_page(doc: &Document, page: &pdfcraft_model::Page, index: usize, totals: &mut (usize, usize)) -> Result { + if page.dict.contains(b"StructParents") { + return Err(unsupported(index, "page already has a structure parent key")); + } + if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) { + Object::Array(items) => !items.is_empty(), + _ => true, + }) { + return Err(unsupported(index, "annotations need separate structure tags")); + } + let pieces = content_pieces(doc, page, index)?; + totals.0 = totals.0.saturating_add(pieces.data().len()); + let parsed = pieces.parse(); + if parsed.skipped > 0 || parsed.ambiguous_images > 0 || parsed.ops.len() > MAX_OPERATORS { + return Err(unsupported(index, "content has malformed or too many operators")); + } + totals.1 = totals.1.saturating_add(parsed.ops.len()); + if totals.0 > MAX_TOTAL_BYTES || totals.1 > MAX_TOTAL_OPERATORS { + return Err(unsupported(index, "the document has too much content to auto-tag in one edit")); + } + let xobjects = page_xobjects(doc, &page.dict); + let (mut start, mut has_text, mut spans, mut artifacts) = (None, false, Vec::new(), HashMap::new()); + // Marked-content boundaries must enclose the whole path, not just its paint operator. + let mut path = None; + for (at, op) in parsed.ops.iter().enumerate() { + if path.is_some() && matches!(op.op.as_slice(), b"BT" | b"Do" | b"BI" | b"sh") { + return Err(unsupported(index, "painted content interrupts an unfinished path")); + } match op.op.as_slice() { - b"BT" if start.is_none() => { - start = Some(at); - has_text = false; - } + b"BT" if start.is_none() => (start, has_text) = (Some(at), false), b"ET" => { let Some(first) = start.take() else { return Err(unsupported(index, "unmatched ET operator")) }; if has_text { @@ -160,14 +224,31 @@ fn text_spans(ops: &[Op], index: usize) -> Result, AutoTagEr } b"Tj" | b"TJ" | b"'" | b"\"" if start.is_some() => has_text = true, name if start.is_some() && harmless_inside(name) => {} - name if start.is_none() && harmless_outside(name) => {} + name if start.is_none() && harmless_outside(name) => match name { + b"m" | b"re" => { + path.get_or_insert(at); + } + b"n" => path = None, + _ => {} + }, + _ if start.is_none() && painted_outside(doc, &xobjects, op) => { + let first = if matches!(op.op.as_slice(), b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s") { + path.take().unwrap_or(at) + } else { + at + }; + artifacts.insert(first, at); + } _ => return Err(unsupported(index, format!("unsupported or painted operator {}", String::from_utf8_lossy(&op.op)))), } } if start.is_some() { return Err(unsupported(index, "unterminated BT text object")); } - Ok(spans) + if path.is_some() { + return Err(unsupported(index, "unterminated path object")); + } + Ok(Plan { page: page.obj, ops: parsed.ops, spans, artifacts }) } fn plan(doc: &Document) -> Result, AutoTagError> { @@ -186,24 +267,11 @@ fn plan(doc: &Document) -> Result, AutoTagError> { } let mut out = Vec::with_capacity(pages.len()); let mut total = 0usize; + let mut totals = (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")); - } - if page.dict.get(b"Annots").is_some_and(|a| match &*doc.resolve(a) { - Object::Array(items) => !items.is_empty(), - _ => true, - }) { - return Err(unsupported(index, "annotations need separate structure tags")); - } - let bytes = content_bytes(doc, page, index)?; - let parsed = parse(&bytes); - if parsed.skipped > 0 || parsed.ops.len() > MAX_OPERATORS { - return Err(unsupported(index, "content has malformed or too many operators")); - } - let spans = text_spans(&parsed.ops, index)?; - total = total.saturating_add(spans.len()); - out.push(Plan { page: page.obj, ops: parsed.ops, spans }); + let planned = plan_page(doc, page, index, &mut totals)?; + total = total.saturating_add(planned.spans.len()); + out.push(planned); } if total == 0 { return Err(AutoTagError::NoText); @@ -211,62 +279,36 @@ fn plan(doc: &Document) -> Result, AutoTagError> { Ok(out) } -/// Tag every text object in an untagged, text-only PDF as a paragraph. The full structure tree, -/// parent tree and marked-content IDs are written together. Mixed content is refused before any -/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. -pub fn autotag_text(doc: &mut Document) -> Result { - let plans = plan(doc)?; - let mut next = doc.clone(); - let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; - let root = next.add(Object::Null); - let document = next.add(Object::Null); - let mut children = Vec::new(); - let mut numbers = Vec::new(); - let mut tagged_pages = 0usize; - let mut paragraphs = 0usize; - for page in plans { - if page.spans.is_empty() { - continue; +fn mark_ops(ops: Vec, starts: &HashMap, ends: &HashSet, artifacts: &HashMap) -> Result, AutoTagError> { + let artifact_ends: HashSet = artifacts.values().copied().collect(); + let extra = starts.len().saturating_add(artifacts.len()).saturating_mul(2); + let mut marked = Vec::with_capacity(ops.len().saturating_add(extra)); + for (at, op) in ops.into_iter().enumerate() { + let is_artifact = artifacts.contains_key(&at); + if let Some(mcid) = starts.get(&at) { + let mut properties = Dict::new(); + properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); + marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); + } else if is_artifact { + marked.push(Op::new("BMC", vec![Object::name("Artifact")])); } - let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; - let mut parents = Vec::with_capacity(page.spans.len()); - let mut starts = std::collections::HashMap::new(); - let mut ends = std::collections::HashSet::new(); - for (mcid, (first, last)) in page.spans.iter().enumerate() { - let mut element = Dict::new(); - element.set(b"Type".to_vec(), Object::name("StructElem")); - element.set(b"S".to_vec(), Object::name("P")); - element.set(b"P".to_vec(), Object::Ref(document)); - element.set(b"Pg".to_vec(), Object::Ref(page.page)); - element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); - let reference = next.add(element); - parents.push(Object::Ref(reference)); - children.push(Object::Ref(reference)); - starts.insert(*first, mcid); - ends.insert(*last); - paragraphs = paragraphs.saturating_add(1); + marked.push(op); + if ends.contains(&at) || artifact_ends.contains(&at) { + marked.push(Op::new("EMC", Vec::new())); } - let mut marked = Vec::with_capacity(page.ops.len().saturating_add(page.spans.len().saturating_mul(2))); - for (at, op) in page.ops.into_iter().enumerate() { - if let Some(mcid) = starts.get(&at) { - let mut properties = Dict::new(); - properties.set(b"MCID".to_vec(), Object::Int(i64::try_from(*mcid).map_err(|_| AutoTagError::TooManyPages)?)); - marked.push(Op::new("BDC", vec![Object::name("P"), Object::Dict(properties)])); - } - marked.push(op); - if ends.contains(&at) { - marked.push(Op::new("EMC", Vec::new())); - } - } - let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked))); - next.update_dict(page.page, |d| { - d.set(b"Contents".to_vec(), Object::Ref(stream)); - d.set(b"StructParents".to_vec(), Object::Int(key)); - })?; - numbers.push(Object::Int(key)); - numbers.push(Object::Array(parents)); - tagged_pages += 1; } + Ok(marked) +} + +fn write_structure( + next: &mut Document, + catalog: ObjRef, + root: ObjRef, + document: ObjRef, + children: Vec, + numbers: Vec, + tagged_pages: usize, +) -> Result<(), AutoTagError> { let mut tree = Dict::new(); tree.set(b"Nums".to_vec(), Object::Array(numbers)); let parent_tree = next.add(tree); @@ -289,6 +331,50 @@ pub fn autotag_text(doc: &mut Document) -> Result { d.set(b"StructTreeRoot".to_vec(), Object::Ref(root)); d.set(b"MarkInfo".to_vec(), Object::Dict(mark_info)); })?; + Ok(()) +} + +/// Tag every text object in an untagged PDF as a paragraph and mark painted paths, shadings and +/// images outside text objects as artifacts. The full structure tree, parent tree and +/// marked-content IDs are written together. Unsupported operators are refused before any +/// mutation. On success the caller can use tag-property edits to assign reviewed heading roles. +pub fn autotag_text(doc: &mut Document) -> Result { + let plans = plan(doc)?; + let mut next = doc.clone(); + let catalog = next.root().ok_or(AutoTagError::NoCatalog)?; + let (root, document) = (next.add(Object::Null), next.add(Object::Null)); + let (mut children, mut numbers, mut tagged_pages, mut paragraphs) = (Vec::new(), Vec::new(), 0usize, 0usize); + for page in plans { + if page.spans.is_empty() && page.artifacts.is_empty() { + continue; + } + let (mut starts, mut ends) = (HashMap::new(), HashSet::new()); + if !page.spans.is_empty() { + let key = i64::try_from(tagged_pages).map_err(|_| AutoTagError::TooManyPages)?; + let mut parents = Vec::with_capacity(page.spans.len()); + for (mcid, (first, last)) in page.spans.iter().enumerate() { + let mut element = Dict::new(); + element.set(b"Type".to_vec(), Object::name("StructElem")); + element.set(b"S".to_vec(), Object::name("P")); + element.set(b"P".to_vec(), Object::Ref(document)); + element.set(b"Pg".to_vec(), Object::Ref(page.page)); + element.set(b"K".to_vec(), Object::Int(i64::try_from(mcid).map_err(|_| AutoTagError::TooManyPages)?)); + let reference = next.add(element); + parents.push(Object::Ref(reference)); + children.push(Object::Ref(reference)); + starts.insert(*first, mcid); + ends.insert(*last); + paragraphs = paragraphs.saturating_add(1); + } + next.update_dict(page.page, |d| d.set(b"StructParents".to_vec(), Object::Int(key)))?; + numbers.extend([Object::Int(key), Object::Array(parents)]); + tagged_pages += 1; + } + let marked = mark_ops(page.ops, &starts, &ends, &page.artifacts)?; + let stream = next.add(Stream::flate(Dict::new(), &serialize_ops(&marked))); + next.update_dict(page.page, |d| d.set(b"Contents".to_vec(), Object::Ref(stream)))?; + } + write_structure(&mut next, catalog, root, document, children, numbers, tagged_pages)?; *doc = next; Ok(AutoTagResult { pages: tagged_pages, paragraphs }) } 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..4eac87358f 100644 --- a/crates/a11y/src/tests.rs +++ b/crates/a11y/src/tests.rs @@ -142,17 +142,120 @@ fn autotag_text_says_why_it_cannot_read_a_page() { } #[test] -fn autotag_text_refuses_paint_and_does_not_modify_the_document() { +fn autotag_text_marks_vector_graphics_and_images_as_artifacts() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R 7 0 R] /Count 2 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im1 6 0 R >> >> >>".into(), + stream("0 0 200 200 m 200 0 l 200 200 l 0 200 l h f 10 10 50 50 re B BT /F1 12 Tf 20 100 Td (Hello) Tj ET q 20 0 0 20 80 80 cm /Im1 Do Q"), + FONT.into(), + "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 8 0 R >>".into(), + stream("0 0 100 100 re S"), + ], + "", + ); + let result = autotag_text(&mut doc).unwrap(); + assert_eq!(result, AutoTagResult { pages: 1, paragraphs: 1 }); + let report = check(&doc, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); + assert_eq!(status(&report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&report, Rule::TaggedContent), Status::Passed); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let reopened = Document::open(Arc::new(bytes)).unwrap(); + let reopened_report = check(&reopened, &Options { rules: [Rule::TaggedPdf, Rule::TaggedContent].into(), pages: None }); + assert_eq!(status(&reopened_report, Rule::TaggedPdf), Status::Passed); + assert_eq!(status(&reopened_report, Rule::TaggedContent), Status::Passed); +} + +#[test] +fn autotag_artifacts_wrap_complete_paths_and_leave_unpainted_clips_outside() { + let content = "0 0 m 10 0 l 10 10 l h f 0 0 20 20 re W B 0 0 50 50 re W* n /Shade sh BT /F1 12 Tf (Text) Tj ET 3 3 5 5 re S"; let mut doc = pdf( &[ "<< /Type /Catalog /Pages 2 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 5 0 R >> >> >>".into(), - stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET 0 0 50 50 re f"), + stream(content), FONT.into(), ], "", ); + autotag_text(&mut doc).unwrap(); + let bytes = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&doc).remove(0); + let content = doc.resolve(page.dict.get(b"Contents").unwrap()); + let Object::Stream(content) = &*content else { panic!("page stream") }; + let ops = pdfcraft_content::parse(&content.decoded_strict().unwrap()).ops; + let names: Vec<&[u8]> = ops.iter().map(|op| op.op.as_slice()).collect(); + assert_eq!( + names, + vec![ + b"BMC".as_slice(), + b"m", + b"l", + b"l", + b"h", + b"f", + b"EMC", + b"BMC", + b"re", + b"W", + b"B", + b"EMC", + b"re", + b"W*", + b"n", + b"BMC", + b"sh", + b"EMC", + b"BDC", + b"BT", + b"Tf", + b"Tj", + b"ET", + b"EMC", + b"BMC", + b"re", + b"S", + b"EMC", + ] + ); +} + +#[test] +fn autotag_rejects_text_interleaved_with_an_unfinished_path_atomically() { + for tail in ["10 10 l S", "n"] { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 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 5 0 R >> >> >>".into(), + stream(&format!("0 0 m BT /F1 12 Tf (Text) Tj ET {tail}")), + FONT.into(), + ], + "", + ); + let before = pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. }))); + assert_eq!(pdfcraft_cos::write_incremental(&doc, &pdfcraft_cos::SaveOptions::default()).unwrap(), before); + } +} + +#[test] +fn autotag_text_refuses_form_xobjects_and_does_not_modify_the_document() { + let mut doc = pdf( + &[ + "<< /Type /Catalog /Pages 2 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 5 0 R >> /XObject << /Fm1 6 0 R >> >> >>".into(), + stream("BT /F1 12 Tf 20 100 Td (Hello) Tj ET /Fm1 Do"), + FONT.into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 50 50] /Length 12 >>\nstream\n0 0 10 10 re f\nendstream".into(), + ], + "", + ); assert!(matches!(autotag_text(&mut doc), Err(AutoTagError::Unsupported { .. }))); assert!(!doc.get(doc.root().unwrap()).as_dict().unwrap().contains(b"StructTreeRoot")); assert_eq!(autotag_text(&mut pdf(&good(), "")), Err(AutoTagError::AlreadyTagged)); @@ -1763,3 +1866,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..98a9985a2a 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,9 +304,41 @@ 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::>()), + ); + } + // A check box whose kids have different on states (ISO 32000-2 §12.7.5.2.3): + // which one is ticked, and the names form_fill takes to tick each. One option + // per distinct on state: kids sharing a state tick together (EXAMPLE 2), so + // their `/Opt` exports are not separate choices. Exports name the options + // unless two states share one (Table 230 allows it) or an export would tick + // another state (form_fill reads a name as an on state first); then the + // states do. + FormFieldKind::CheckBox => { + let mut seen = std::collections::HashSet::new(); + let states: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| seen.insert(*s)).collect(); + if states.len() > 1 { + let mut exports = std::collections::HashSet::new(); + let by_export = states.iter().all(|s| { + let e = f.export_for_state(s); + exports.insert(e) && f.state_for(e) == Some(*s) + }); + let name = |s: &str| if by_export { f.export_for_state(s).to_owned() } else { s.to_owned() }; + // The ticked kid is the one whose `/AS` is its on state: §12.7.5.2.3 + // makes `/AS` decide where it and `/V` differ. + let on = (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.as_deref()); + o.insert("options".into(), json!(states.iter().map(|s| name(s)).collect::>())); + o.insert("checked".into(), json!(on.map(name))); + } } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); + // The selected options as 1-based positions in `options`: two options can share + // an export value, and `value` alone does not tell them apart. + o.insert("selected".into(), json!(f.selected.iter().map(|i| i.saturating_add(1)).collect::>())); o.insert("multi_select".into(), json!(f.has(field_flags::MULTI_SELECT))); o.insert("editable".into(), json!(f.has(field_flags::EDIT))); } 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/json.rs b/crates/automation/src/json.rs index 5231132421..6cd02115a8 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -334,6 +334,12 @@ pub(crate) fn info(d: &Document) -> Value { "target": link_target(i, &l.target), })).collect::>(), "layers": i.layers.iter().map(|l| json!({ "name": l.name, "visible": l.visible, "initial": l.initial })).collect::>(), + // The Layers panel's rows, the default configuration's /Order (ISO 32000-2 Table 99): a + // layer by its 1-based position in `layers`, or a label; `depth` 0 at the top. + "layer_order": i.layer_order.iter().map(|r| match &r.item { + pdfcraft_render::LayerItem::Layer(n) => json!({ "depth": r.depth, "layer": n.saturating_add(1), "name": i.layers.get(*n).map(|l| l.name.as_str()) }), + pdfcraft_render::LayerItem::Label(s) => json!({ "depth": r.depth, "label": s }), + }).collect::>(), // Full-screen presentation settings a page carries (ISO 32000-2 §12.4.4): only pages with // a `/Trans` or `/Dur` appear; `style` is null for a display time alone. "transitions": i.transitions.iter().map(|t| json!({ 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..a6228cc5bc 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -77,7 +77,8 @@ impl Automation { pub(crate) fn link_list(&self, a: &Args) -> Result { let doc = self.doc(a)?; - let targets: std::collections::HashMap<(usize, usize), &LinkTarget> = doc.info.links.iter().map(|t| ((t.page, t.index), &t.target)).collect(); + let viewed: std::collections::HashMap<(usize, usize), &pdfcraft_render::Link> = + doc.info.links.iter().map(|t| ((t.page, t.index), t)).collect(); let items: Vec = doc .links() .iter() @@ -85,14 +86,49 @@ impl Automation { // The link's page may be one the viewer could not read (#2660): rect unchanged then. let rect = crate::view_rect(&doc.info, l.page, l.rect.map(|x| x as f32)); let mut v = json!({ "page": l.page + 1, "index": l.index + 1, "rect": rect, "visible": l.style.visible }); + let link = viewed.get(&(l.page, l.index)); + // Where the link is active when that isn't its whole rect: its /QuadPoints + // (ISO 32000-2 Table 176), as the bounding box of each quadrilateral. + if let Some(link) = link.filter(|k| !k.quads.is_empty()) { + let areas: Vec = link + .quads + .iter() + .map(|q| { + let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]); + let fold = |v: [f32; 4], f: fn(f32, f32) -> f32, start: f32| v.into_iter().fold(start, f); + let box_ = [ + fold(xs, f32::min, f32::MAX), + fold(ys, f32::min, f32::MAX), + fold(xs, f32::max, f32::MIN), + fold(ys, f32::max, f32::MIN), + ]; + json!(crate::view_rect(&doc.info, l.page, box_)) + }) + .collect(); + v["active_areas"] = json!(areas); + } // Where an in-document link goes, as the viewer resolves it: named destinations // included, with the position on the page. - let target = targets.get(&(l.page, l.index)).and_then(|t| match t { + let target = link.and_then(|t| match &t.target { LinkTarget::Page(page, view) => Some((*page, view)), _ => None, }); match (&l.action, target) { - (LinkAction::Uri(u), _) => v["url"] = json!(u), + (LinkAction::Uri(u), _) => { + v["url"] = json!(u); + // A relative address, resolved against the catalog's /URI /Base + // (ISO 32000-2 Table 211): what the viewer offers to open. That is the + // address inspection kept, which stays raw once its text budget is spent; + // resolve afresh only for a link inspection did not list. + let opened = match link.map(|t| &t.target) { + Some(LinkTarget::Uri(kept)) => Some(kept.clone()), + Some(_) => None, + None => pdfcraft_render::resolve_uri(doc.info.uri_base.as_deref(), u), + }; + if let Some(opened) = opened.filter(|o| o != u) { + v["resolved_url"] = json!(opened); + } + } (action, Some((page, view))) => { v["to_page"] = json!(page + 1); if let Some(d) = doc.info.pages.get(page).and_then(|p| crate::destination(view, p)) { @@ -113,7 +149,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..7bab26e6c1 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. @@ -410,6 +414,7 @@ fn render_warning_text(warning: pdfcraft_render::RenderWarning) -> &'static str match warning { RenderWarning::ContentTruncated => "content past the page's safety budget was skipped", RenderWarning::ColourFallback => "a colour that could not be computed was painted in a fallback colour", + RenderWarning::MalformedIndexedImage => "an image with a malformed Indexed colour table may be drawn wrongly or not at all", RenderWarning::Type3WidthsMissing => "a Type 3 font has no /Widths; its glyphs were spaced by the widths their glyph procedures give", } } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 6ec66ce680..a806d04283 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -2,8 +2,8 @@ //! (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, - RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, + 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, redacted_file_name, }; use serde_json::{Value, json}; @@ -200,6 +200,8 @@ impl Automation { let after = self.doc(a)?.redaction_marks(); out["applied"] = json!(before - after); out["marks_pending"] = json!(after); + // The name to save the redacted copy under, as Save does in the app. + out["save_as"] = json!(redacted_file_name(&self.doc(a)?.name)); Ok(out) } @@ -235,6 +237,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 +258,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 +274,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..052ec6ac1e 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"])), @@ -164,7 +175,7 @@ pub fn tools() -> Vec { .cmd("file.open") .with(schema(json!({ "path": { "type": "string" }, "password": { "type": "string", "description": "User or owner password for encrypted files." } }), &["path"])), t("doc_list", "List open documents", "List the open documents with their ids, page counts and unsaved state.").ro().with(schema(json!({}), &[])), - t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers, page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.") + t("doc_info", "Inspect a document", "Metadata, page sizes and labels, bookmarks, annotations, form fields, links, layers and the Layers panel's tree (layer_order), page transitions and display times, the destination it opens at, attachments, fonts, security, repair notes and the objects that could not be read.") .ro() .cmd("file.properties") .with(schema(json!({ "doc": doc() }), &["doc"])), @@ -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). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none). Combo and list boxes include selected: the 1-based positions in options of the selected ones, which tells apart options that share an export value.") .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 (or one of a check box's options, to check that widget and clear the others), 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.") @@ -667,7 +678,7 @@ pub fn tools() -> Vec { t( "redact_apply", "Apply redactions", - "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content.", + "Apply the redaction marks (all, or those on pages): text, images, vectors, comments and form fields under them are removed for good and boxes are drawn in their place. A verification pass fails the operation if anything readable remains. Undoable until saved; the saved file no longer contains the content. The result's save_as is the document's name with _Redacted before the extension (contract.pdf gives contract_Redacted.pdf; a name that already ends in _Redacted is unchanged); pass it to doc_save as path to save under that name.", ) .destructive() .with(schema(json!({ "doc": doc(), "pages": pages("whose marks to apply (default: all)") }), &["doc"])), @@ -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!({ @@ -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). A relative url also has resolved_url, the address resolved against the document's base URI (catalog /URI /Base); a link active only in its /QuadPoints has active_areas, one rect per quadrilateral. With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() - .with(schema(json!({ "doc": doc() }), &["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..a0c5cfc81f 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2107,6 +2107,249 @@ 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}"); +} + +/// Table 211), and a link with `/QuadPoints` is active only there (§12.5.6.5, Table 176): both as +/// link_list and doc_info report them. +#[test] +fn links_resolve_relative_addresses_and_report_their_quad_points() { + let dir = workdir("link-base-quads"); + let objs: Vec<&str> = vec![ + "<< /Type /Catalog /Pages 2 0 R /URI << /Base (https://example.com/base/) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [72 700 300 716] /Border [0 0 0] /A << /S /URI /URI (page.html) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [72 670 300 686] /QuadPoints [72 670 120 670 120 686 72 686] /Border [0 0 0] /A << /S /URI /URI (https://example.org/q) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [72 640 300 656] /Border [0 0 0] /A << /S /URI /URI (javascript:alert\\(1\\)) >> >>", + ]; + 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("links.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let links = ok(&mut a, "link_list", json!({ "doc": doc })); + let [relative, quads, script] = [&links["links"][0], &links["links"][1], &links["links"][2]]; + assert_eq!( + (relative["url"].clone(), relative["resolved_url"].clone()), + (json!("page.html"), json!("https://example.com/base/page.html")), + "{links}" + ); + assert_eq!(relative.get("active_areas"), None, "{links}"); + // 792 − 686 = 106 points from the top: only the first word, not the whole rect. + assert_eq!(quads["active_areas"], json!([[72.0, 106.0, 120.0, 122.0]]), "{links}"); + assert_eq!(quads.get("resolved_url"), None, "an absolute address is not resolved"); + // A base never turns a script into a web address. + assert_eq!((script["url"].clone(), script.get("resolved_url")), (json!("javascript:alert(1)"), None), "{links}"); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!(info["links"][0]["target"]["uri"], "https://example.com/base/page.html", "{info}"); +} + +/// Once inspection's text budget is spent, a relative address stays raw, and the viewer opens it +/// as written: `link_list` reports that address, not a second resolution against `/URI /Base`. +#[test] +fn link_list_reports_the_address_the_viewer_opens_past_the_text_budget() { + let dir = workdir("link-base-budget"); + // Each link spends what the base adds to its address; well past 16 MiB of it in all. + let base = format!("https://example.com/{}/", "b".repeat(8000)); + let count = (16 << 20) / base.len() + 20; + let annots = "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Border [0 0 0] /A 4 0 R >> ".repeat(count); + let catalog = format!("<< /Type /Catalog /Pages 2 0 R /URI << /Base ({base}) >> >>"); + let page = format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"); + let objs = [catalog.as_str(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>", page.as_str(), "<< /S /URI /URI (p.html) >>"]; + 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("links.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "links.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(&mut a, "link_list", json!({ "doc": doc })); + let links = listed["links"].as_array().unwrap(); + assert_eq!(links.len(), count); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + let opened = info["links"].as_array().unwrap(); + assert_eq!(opened.len(), count); + let resolved = format!("{base}p.html"); + assert_eq!(links[0]["resolved_url"], json!(resolved), "the first links resolve"); + assert_eq!(opened[0]["target"]["uri"], json!(resolved)); + // Past the budget the viewer keeps (and opens) the raw address; the listing says so. + let last = count - 1; + assert_eq!(opened[last]["target"]["uri"], json!("p.html")); + assert_eq!((links[last]["url"].clone(), links[last].get("resolved_url")), (json!("p.html"), None)); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -2155,6 +2398,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 +3153,138 @@ 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}"); + } +} + +/// `form_fill` ticks one kid of a check box whose kids have different on states by naming it +/// (ISO 32000-2 §12.7.5.2.3, Table 230), and `form_fields` lists the names and the ticked one. +#[test] +fn form_fill_ticks_one_kid_of_a_check_box_by_its_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 (pair) /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 << /A 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 << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir("check-box-kids"); + 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 before = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!((&before["fields"][0]["options"], &before["fields"][0]["checked"]), (&json!(["A", "B"]), &Value::Null), "{before}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "A" } })); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": "B" } })); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!((&after["fields"][0]["value"], &after["fields"][0]["checked"]), (&json!(true), &json!("B")), "{after}"); + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": false } })); + assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["checked"], Value::Null); +} + +/// The options `form_fields` reports fill back through `form_fill` to the kid they name, also +/// where one kid's `/Opt` export is the other kid's on state: B exports `(A)`, and filling `A` +/// still ticks the kid whose on state is A (ISO 32000-2 §12.7.5.2.3, Table 230). +#[test] +fn form_fill_round_trips_the_reported_options_of_a_check_box_with_a_crossed_export() { + 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 (pair) /V /Off /Opt [(x) (A)] /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 << /A 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 << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >> +stream + +endstream", + ]; + let dir = workdir("check-box-crossed-export-fill"); + 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 before = ok(&mut a, "form_fields", json!({ "doc": doc })); + let options = before["fields"][0]["options"].clone(); + assert_eq!(options, json!(["A", "B"]), "{before}"); + for option in ["A", "B"] { + ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "pair": option } })); + let after = ok(&mut a, "form_fields", json!({ "doc": doc })); + assert_eq!(after["fields"][0]["checked"], json!(option), "filling {option}: {after}"); + } +} + +/// `form_fields` on a check box's kids: one option per distinct on state, named by its `/Opt` +/// export unless two states share one, and `checked` follows the kid whose `/AS` is on, which +/// ISO 32000-2 §12.7.5.2.3 makes decide where `/AS` and `/V` differ. +#[test] +fn form_fields_lists_a_check_box_by_on_state_and_reports_the_kid_that_is_on() { + // Each kid is (on state, /AS). + let listed = |name: &str, field: &str, (sa, asa): (&str, &str), (sb, asb): (&str, &str)| { + let kid = |on: &str, state: &str, x: u32| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /{state} /Rect [{x} 250 {} 266] /P 3 0 R /AP << /N << /{on} 8 0 R /Off 8 0 R >> >> >>", + x + 16 + ) + }; + let (wa, wb) = (kid(sa, asa, 10), kid(sb, asb, 40)); + 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) >>", + field, + &wa, + &wb, + "<< /Length 0 >>\nstream\n\nendstream", + ]; + let dir = workdir(name); + 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 r = ok(&mut a, "form_fields", json!({ "doc": doc })); + (r["fields"][0]["options"].clone(), r["fields"][0]["checked"].clone()) + }; + // Kids sharing the on state Yes tick together, whatever their exports: not two options. + let shared = + listed("check-box-shared-state", "<< /FT /Btn /T (pair) /V /Off /Opt [(x) (y)] /Kids [6 0 R 7 0 R] >>", ("Yes", "Off"), ("Yes", "Off")); + assert_eq!(shared, (Value::Null, Value::Null)); + // /V names B, but A's /AS is on: A is the one ticked. + let differ = listed("check-box-as-wins", "<< /FT /Btn /T (pair) /V /B /Kids [6 0 R 7 0 R] >>", ("A", "A"), ("B", "Off")); + assert_eq!(differ, (json!(["A", "B"]), json!("A"))); + // Two on states with one export: the states name the options. + let same = listed("check-box-same-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (x)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B")); + assert_eq!(same, (json!(["A", "B"]), json!("B"))); + // B's export is A's on state, so naming it would tick A: the states name the options. + let crossed = listed("check-box-crossed-export", "<< /FT /Btn /T (pair) /V /B /Opt [(x) (A)] /Kids [6 0 R 7 0 R] >>", ("A", "Off"), ("B", "B")); + assert_eq!(crossed, (json!(["A", "B"]), json!("B"))); +} + /// 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 +3847,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"); @@ -4836,6 +5356,28 @@ fn redacting_through_tools() { assert!(matches!(a.call("redact_mark", &json!({ "doc": doc })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn applying_redactions_proposes_the_name_with_redacted() { + let dir = workdir("redact-save-as"); + std::fs::write(dir.join("memo.txt"), "Call 555-123-4567 today").unwrap(); + let mut a = auto(&dir); + let made = ok(&mut a, "doc_create", json!({ "from": "text", "path": "memo.txt" }))["doc"].as_u64().unwrap(); + ok(&mut a, "doc_save", json!({ "doc": made, "path": "contract.pdf" })); + let original = std::fs::read(dir.join("contract.pdf")).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "contract.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": doc, "pattern": "phone" })); + let r = ok(&mut a, "redact_apply", json!({ "doc": doc })); + assert_eq!(r["save_as"], "contract_Redacted.pdf"); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": r["save_as"] })); + assert_eq!(std::fs::read(dir.join("contract.pdf")).unwrap(), original, "the opened file is left as it was"); + let bytes = std::fs::read(dir.join("contract_Redacted.pdf")).unwrap(); + assert!(!bytes.windows(4).any(|w| w == b"4567"), "the copy has no trace of the number"); + // Redacting the redacted copy again proposes the same name, with one suffix. + let copy = ok(&mut a, "doc_open", json!({ "path": "contract_Redacted.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "redact_mark", json!({ "doc": copy, "find": "today" })); + assert_eq!(ok(&mut a, "redact_apply", json!({ "doc": copy }))["save_as"], "contract_Redacted.pdf"); +} + #[test] fn redacting_with_a_caller_regex_through_tools() { let dir = workdir("redact-regex"); @@ -5059,6 +5601,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 +7926,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 +7972,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 +9571,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 +9687,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 +9899,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"); @@ -9402,6 +10162,38 @@ fn doc_info_describes_set_layer_links() { }) ); assert_eq!(info["layers"], json!([{ "name": "Red", "visible": true, "initial": true }, { "name": "Green", "visible": false, "initial": false }])); + // No /Order: the Layers panel presents none (ISO 32000-2 Table 99), though both are layers. + assert_eq!(info["layer_order"], json!([])); +} + +/// `doc_info`'s `layer_order` follows the default configuration's /Order (ISO 32000-2 Table 99): +/// a group by its 1-based position in `layers` and its name, a nested array one level deeper, +/// a label that starts one, and a group /Order does not list left out. +#[test] +fn doc_info_lists_the_layer_order_tree() { + let dir = workdir("layer-order"); + let pdf = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [4 0 R 5 0 R 6 0 R 7 0 R] /D << /Order [6 0 R [(Inks) 4 0 R [5 0 R]]] >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Type /OCG /Name (Red) >>", + "<< /Type /OCG /Name (Green) >>", + "<< /Type /OCG /Name (Blue) >>", + "<< /Type /OCG /Name (Unlisted) >>", + ]); + std::fs::write(dir.join("order.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "order.pdf" }))["doc"].as_u64().unwrap(); + let info = ok(&mut a, "doc_info", json!({ "doc": doc })); + assert_eq!( + info["layer_order"], + json!([ + { "depth": 0, "layer": 3, "name": "Blue" }, + { "depth": 0, "label": "Inks" }, + { "depth": 1, "layer": 1, "name": "Red" }, + { "depth": 2, "layer": 2, "name": "Green" }, + ]) + ); } mod close_argument_tests { @@ -10655,6 +11447,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 +12410,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/find_text.rs b/crates/automation/tests/it/find_text.rs new file mode 100644 index 0000000000..4ba2441305 --- /dev/null +++ b/crates/automation/tests/it/find_text.rs @@ -0,0 +1,84 @@ +//! `text_find` on a ligature glyph named in the encoding and on a word divided by a hyphen at a +//! line end. + +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-find-text-{}-{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: `ofce` in a Helvetica whose code 128 is named `/fi`, and `exam-` at the end of +/// one line with `ple` starting the next. +fn find_pdf() -> Vec { + let body = "BT /F2 14 Tf 90 700 Td (of\\200ce) Tj ET\nBT /F1 14 Tf 90 650 Td (exam-) Tj 0 -16 Td (ple) Tj ET\n"; + 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 +} + +/// "office" finds the ligature, and "example" both halves of the divided word, highlighted line by +/// line; the hyphenated form is still found as written. +#[test] +fn text_find_matches_ligatures_and_words_divided_at_a_line_end() { + let dir = tmp("forms"); + std::fs::write(dir.join("find.pdf"), find_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "find.pdf" }))["doc"].as_u64().unwrap(); + let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q })); + + let office = find(&mut a, "office"); + assert_eq!(office["count"], 1, "{office}"); + let rects = office["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 1); + assert!((rects[0][0].as_f64().unwrap() - 90.0).abs() < 0.5, "from the left edge of the o: {rects:?}"); + + let example = find(&mut a, "example"); + assert_eq!(example["count"], 1, "{example}"); + assert_eq!(example["matches"][0]["text"], "exam-\nple"); + let rects = example["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 2, "one highlight on each line: {rects:?}"); + let top = |k: usize| rects[k][1].as_f64().unwrap(); + assert!(top(1) > top(0) + 10.0, "the second on the next line down: {rects:?}"); + + for q in ["exam-ple", "Exam- ple", "exam-"] { + assert_eq!(find(&mut a, q)["count"], 1, "{q}"); + } + assert_eq!(find(&mut a, "examples")["count"], 0); + let _ = std::fs::remove_dir_all(&dir); +} 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/list_selection.rs b/crates/automation/tests/it/list_selection.rs new file mode 100644 index 0000000000..2c2dc041ef --- /dev/null +++ b/crates/automation/tests/it/list_selection.rs @@ -0,0 +1,81 @@ +//! Which options a list box has selected, through the tool table (ISO 32000-2 Table 234, `/I`). + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +fn tool(a: &mut Automation, name: &str, args: Value) -> Value { + match a.call(name, &args).unwrap().remove(0) { + Content::Json(value) => value, + other => panic!("expected JSON: {other:?}"), + } +} + +/// A PDF of the given objects (numbered from 1). +fn pdf_of(objs: &[&str]) -> Vec { + 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 open(test: &str) -> (Automation, u64, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("pdfcraft-list-selection-{test}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let bytes = pdf_of(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (D_dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (H_twovals) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 150 200 220] /P 3 0 R /F 4 >>", + ]); + std::fs::write(dir.join("choice.pdf"), bytes).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let id = tool(&mut a, "doc_open", json!({ "path": "choice.pdf" }))["doc"].as_u64().unwrap(); + (a, id, dir) +} + +fn field(a: &mut Automation, doc: u64, name: &str) -> Value { + let all = tool(a, "form_fields", json!({ "doc": doc })); + all["fields"].as_array().unwrap().iter().find(|f| f["name"] == name).unwrap().clone() +} + +/// Two options share the export value `1`: `selected` says which one `/I` picks, and filling the +/// field with an option's label picks that option. +#[test] +fn form_fields_reports_which_of_two_options_with_one_export_value_is_selected() { + let (mut a, doc, dir) = open("dup"); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["value"], "1"); + assert_eq!(d["selected"], json!([2]), "One-B, the second option: {d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-A" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!((d["value"].clone(), d["selected"].clone()), (json!("1"), json!([1])), "{d}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "D_dup": "One-B" } })); + let d = field(&mut a, doc, "D_dup"); + assert_eq!(d["selected"], json!([2])); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A list box without MultiSelect given `/V [(TW) (JP)]` reports Taiwan alone (ISO 32000-2 +/// Table 233: "at most one item shall be selected"), and filling it writes one value. +#[test] +fn form_fields_reports_one_value_for_a_single_select_list_given_two() { + let (mut a, doc, dir) = open("twovals"); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("TW"), json!([1])), "{h}"); + tool(&mut a, "form_fill", json!({ "doc": doc, "values": { "H_twovals": "United States" } })); + let h = field(&mut a, doc, "H_twovals"); + assert_eq!((h["value"].clone(), h["selected"].clone()), (json!("US"), json!([3])), "{h}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 92e54c99a6..7875b139ee 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -9,12 +9,15 @@ mod batch; mod batch_failures; mod batch_secrets; mod button_caption; +mod find_text; mod form_bulk; mod hostile_args; +mod list_selection; mod model_3d; mod model_render; 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/render_warnings.rs b/crates/automation/tests/it/render_warnings.rs index ff50c11e16..013c287d5d 100644 --- a/crates/automation/tests/it/render_warnings.rs +++ b/crates/automation/tests/it/render_warnings.rs @@ -96,6 +96,33 @@ fn a_clean_page_renders_to_the_png_alone() { let _ = std::fs::remove_dir_all(dir); } +/// An image whose Indexed lookup table is too short (ISO 32000-2 §8.6.6.3) is drawn recovered, and +/// `page_render` says so in the words `pdfcraft-cli render` prints. +#[test] +fn page_render_reports_a_recovered_malformed_indexed_image() { + let dir = std::env::temp_dir().join(format!("pdfcraft-render-warnings-indexed-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let content = "q 200 0 0 200 0 0 cm /Im Do Q"; + // hival 7 over DeviceRGB needs 24 bytes; the table holds 6. + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /XObject << /Im 5 0 R >> >> /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\ + 5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 1 /BitsPerComponent 8 /ColorSpace [/Indexed /DeviceRGB 7 ] /Filter /ASCIIHexDecode /Length 5 >> stream\n0001>\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len() + ); + std::fs::write(dir.join("indexed.pdf"), pdf).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = open(&mut a, "indexed.pdf"); + let out = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); + let Some(Content::Json(report)) = out.get(1) else { panic!("expected the warnings as JSON: {out:?}") }; + assert_eq!(report["warnings"], json!(["an image with a malformed Indexed colour table may be drawn wrongly or not at all"])); + let _ = std::fs::remove_dir_all(dir); +} + /// Over MCP the warnings are a text block after the image and the call's `structuredContent`. #[cfg(feature = "mcp")] #[test] diff --git a/crates/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 fbdec3ea9f..925a7c879d 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::{ @@ -694,23 +695,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)), @@ -784,6 +769,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)> { @@ -794,6 +802,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()) { @@ -801,7 +811,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 e1446e4fa7..68215efc98 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -51,6 +51,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..5d220d5592 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"; @@ -5561,6 +5787,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::>(); @@ -6604,6 +6844,142 @@ fn reading_follows_forms_however_their_resources_are_stored() { } } +/// #3503: a form XObject's `/BBox` clips its content, so text drawn outside it is not shown and +/// should not be read either -- matching the renderer, which already honours the clip. +#[test] +fn form_bbox_clips_text_from_reading() { + let page = stream("", "/Fm Do"); + let fm = stream( + "/Type /XObject /Subtype /Form /BBox [0 0 50 50] /Resources << /Font << /F1 6 0 R >> >>", + "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET", + ); + let 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 << /XObject << /Fm 5 0 R >> >> /Contents 4 0 R >>", + &page, + &fm, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE"]); +} + +/// #3503: a page's own `re`/`W`/`n` clip (not a form's `/BBox`) hides text outside it the same +/// way, and `Q` lifts it again -- the clip is scoped to its `q`/`Q` like the rest of the graphics +/// state, not a one-way switch. +#[test] +fn page_re_w_n_clip_hides_text_from_reading_until_q() { + let page = stream( + "", + "q 0 0 50 50 re W n BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 100 100 Td (AFTER) Tj ET", + ); + let 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 << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>", + &page, + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + ]); + let texts: Vec = text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect(); + assert_eq!(texts, ["INSIDE", "AFTER"]); +} + +/// The text `reading_blocks` reads on a 400 × 400 pt page drawing `content` with Helvetica as +/// `/F1`, and forms `/Fa` and `/Fb` (objects 6 and 7) with the given dictionary entries and content. +fn read_clipped(content: &str, forms: [(&str, &str); 2]) -> Vec { + let font = "/Font << /F1 5 0 R >>"; + let resources = format!("<< {font} /XObject << /Fa 6 0 R /Fb 7 0 R >> >>"); + let form = |(dict, body): (&str, &str)| stream(&format!("/Type /XObject /Subtype /Form {dict} /Resources {resources}"), body); + let doc = build(&[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + &format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources {resources} /Contents 4 0 R >>"), + &stream("", content), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + &form(forms[0]), + &form(forms[1]), + ]); + text::reading_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect() +} + +/// #5075 review: a `/BBox` with anything but four numbers is no bound. Reading the numbers it +/// happens to hold (`[0 /Bad 0 50 50]` as `[0 0 50 50]`) invented a clip and hid real text. +#[test] +fn a_malformed_form_bbox_clips_nothing() { + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 100 100 Td (OUTSIDE) Tj ET"; + for bbox in ["[0 /Bad 0 50 50]", "[0 0 50]", "[0 0 50 50 60]", "[0 (x) 50 50]"] { + let texts = read_clipped("/Fa Do", [(&format!("/BBox {bbox}"), body), ("", "")]); + assert_eq!(texts, ["INSIDE", "OUTSIDE"], "/BBox {bbox}"); + } +} + +/// #5075 review: a clipping path of several rectangles clips to all of them, not to the last one +/// drawn (text seen through the first rectangle was hidden), and not to the box around them +/// either (text in the gap between them was read, though nothing of it shows). +#[test] +fn a_clip_of_several_rectangles_keeps_text_in_each() { + let content = "q 0 0 50 50 re 100 100 50 50 re W n BT /F1 12 Tf 5 5 Td (FIRST) Tj ET BT /F1 12 Tf 105 105 Td (SECOND) Tj ET \ + BT /F1 12 Tf 60 60 Td (GAP) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET Q"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["FIRST", "SECOND"]); +} + +/// #5075 review: a form's `/BBox` clips however thin it is and wherever its matrix puts it. A box +/// one point by 1e-10 was taken for no box (its area fell under a fixed threshold), and one moved +/// 1e12 points away lost its area to rounding; either way the text outside it was read. +#[test] +fn a_thin_or_far_off_form_bbox_still_clips() { + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + let body = "BT /F1 12 Tf 5 5 Td (INSIDE) Tj ET BT /F1 12 Tf 300 300 Td (OUTSIDE) Tj ET"; + let sorted = |mut v: Vec| { + v.sort(); + v + }; + let thin = read_clipped(&format!("/Fa Do {after}"), [("/BBox [0 0 1 1e-10]", body), ("", "")]); + assert_eq!(thin, ["AFTER"], "a thin /BBox"); + let far = read_clipped(&format!("/Fa Do {after}"), [("/Matrix [1 0 0 1 1e12 1e12] /BBox [0 0 100 100]", body), ("", "")]); + assert_eq!(sorted(far), ["AFTER", "INSIDE"], "a /BBox 1e12 points away"); +} + +/// #5075 review: a clip of no area has no inside, so nothing drawn under it shows (ISO 32000-2 +/// §8.5.4). It was taken for no clip at all, and everything under it was read. +#[test] +fn a_clip_of_no_area_shows_nothing() { + let content = "q 0 0 100 0 re W n BT /F1 12 Tf 5 5 Td (HIDDEN) Tj ET Q BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + assert_eq!(read_clipped(content, [("", ""), ("", "")]), ["AFTER"]); +} + +/// #5075 review: what shows is the intersection of every clip in force. A long run that crosses a +/// page clip and, apart from it, a form's `/BBox` overlaps each of them but shows in neither, as it +/// does not when one form's `/BBox` lies apart from the one of the form it is drawn in. +#[test] +fn disjoint_clips_show_nothing_between_them() { + let run = "BT /F1 12 Tf 5 10 Td (AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA) Tj ET"; + let after = "BT /F1 12 Tf 5 300 Td (AFTER) Tj ET"; + // A page clip and a form's `/BBox` side by side. + let page_clip = format!("q 0 0 50 50 re W n /Fa Do Q {after}"); + assert_eq!(read_clipped(&page_clip, [("/BBox [100 0 150 50]", run), ("", "")]), ["AFTER"]); + // A form's `/BBox` and the `/BBox` of the form inside it, side by side. + let nested = format!("/Fa Do {after}"); + assert_eq!(read_clipped(&nested, [("/BBox [0 0 50 50]", "/Fb Do"), ("/BBox [100 0 150 50]", run)]), ["AFTER"]); +} + +/// #5075 review: a clip turned with its matrix keeps its turned shape. Its upright bounding box +/// takes in corners it does not cover, and text there was read although it is clipped away. +#[test] +fn a_turned_clip_hides_text_in_the_corners_of_its_bounding_box() { + // A 100 pt square turned 45° about (200, 0): a diamond with its lower corner at (200, 0). + // Undone straight after the clip, so the text is drawn upright on the page. + let (turn, undo) = + ("0.70710678 0.70710678 -0.70710678 0.70710678 200 0 cm", "0.70710678 -0.70710678 0.70710678 0.70710678 -141.421356 141.421356 cm"); + let texts = "BT /F1 6 Tf 132 3 Td (CORNER) Tj ET BT /F1 6 Tf 195 65 Td (CENTRE) Tj ET"; + let page_clip = format!("q {turn} 0 0 100 100 re W n {undo} {texts} Q"); + assert_eq!(read_clipped(&page_clip, [("", ""), ("", "")]), ["CENTRE"], "a turned page clip"); + // The same diamond as a form's `/BBox`, under its `/Matrix`. + let matrix = "/Matrix [0.70710678 0.70710678 -0.70710678 0.70710678 200 0] /BBox [0 0 100 100]"; + assert_eq!(read_clipped("/Fa Do", [(matrix, &format!("{undo} {texts}")), ("", "")]), ["CENTRE"], "a turned form /BBox"); +} + /// A chain of forms, each drawing the next twenty times, twelve deep: within the depth cap but /// 20^11 visits without a budget. Reading stops at the page's visit budget instead of hanging. #[test] @@ -6718,24 +7094,28 @@ fn forms_that_fan_out_a_hundred_ways_stop_quickly_and_say_so() { /// Forms that draw themselves or each other, have no resources, a malformed matrix, or are /// missing are read once (or skipped) without panicking or looping. +/// +/// `/BBox [0 0 20 420]` is wide enough to hold every form's own text below (#3503 clips a form's +/// content to its `/BBox`; this test is about the loop and resource handling, not clipping). #[test] fn hostile_form_xobjects_are_read_once() { let page = stream("", "/Loop Do /A Do /NoRes Do /Bad Do /Missing Do /F1 Do"); let font = "/Font << /F1 5 0 R >>"; let lp = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /Loop 6 0 R >> >>"), "/Loop Do BT /F1 10 Tf 10 10 Td (once) Tj ET", ); let a = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /B 8 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /B 8 0 R >> >>"), "/B Do BT /F1 10 Tf 10 100 Td (from a) Tj ET", ); let b = stream( - &format!("/Subtype /Form /BBox [0 0 9 9] /Resources << {font} /XObject << /A 7 0 R >> >>"), + &format!("/Subtype /Form /BBox [0 0 20 420] /Resources << {font} /XObject << /A 7 0 R >> >>"), "/A Do BT /F1 10 Tf 10 200 Td (from b) Tj ET", ); - let nores = stream("/Subtype /Form /BBox [0 0 9 9]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); - let bad = stream(&format!("/Subtype /Form /BBox [0 0 9 9] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); + let nores = stream("/Subtype /Form /BBox [0 0 20 420]", "BT /F1 10 Tf 10 300 Td (inherited) Tj ET"); + let bad = + stream(&format!("/Subtype /Form /BBox [0 0 20 420] /Matrix [1 0 0] /Resources << {font} >>"), "BT /F1 10 Tf 10 400 Td (bad matrix) Tj ET"); let doc = build(&[ "<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", @@ -9019,6 +9399,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 +9740,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 +11406,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..ada90d8484 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -155,6 +155,66 @@ impl TextLine { mod tests { use super::*; + /// The clip regions reading uses (#3503): intersected regions keep only what both cover, + /// regions apart leave nothing, turned ones keep their shape, and touching is not showing. + #[test] + fn clip_regions_intersect_and_show_what_they_cover() { + use interp::{intersect, region, shows}; + let square = |x: f64, y: f64| region(&Matrix::IDENTITY, [x, y, x + 10.0, y + 10.0]).unwrap(); + let area = |p: &[[f64; 2]]| p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| a[0] * b[1] - b[0] * a[1]).sum::().abs() / 2.0; + // Squares overlapping by half each way share a quarter. + let both = intersect(&square(0.0, 0.0), &square(5.0, 5.0)); + assert!((area(&both) - 25.0).abs() < 1e-9, "{both:?}"); + assert!(shows([6.0, 6.0, 7.0, 7.0], &both) && !shows([1.0, 1.0, 2.0, 2.0], &both)); + // Squares apart share nothing, so nothing shows, not even a box across both. + let none = intersect(&square(0.0, 0.0), &square(20.0, 0.0)); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &none), "{none:?}"); + // Squares that meet only along an edge share no area either: a box across that edge + // does not show (#5075 review). + let edge = intersect(&square(0.0, 0.0), &square(10.0, 0.0)); + assert!(!shows([8.0, 2.0, 12.0, 4.0], &edge), "{edge:?}"); + // Nothing intersected with anything is nothing, whichever side it is on. + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&square(0.0, 0.0), &Vec::new()))); + assert!(!shows([1.0, 1.0, 2.0, 2.0], &intersect(&Vec::new(), &square(0.0, 0.0)))); + // A box touching an edge shares no area; one a hair inside does. + assert!(!shows([10.0, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + assert!(shows([9.99, 0.0, 12.0, 5.0], &square(0.0, 0.0))); + // A square turned 45° keeps its diamond shape: a box in the corner of its bounding box is + // outside it, the centre is inside. + let c = std::f64::consts::FRAC_1_SQRT_2; + let diamond = region(&Matrix([c, c, -c, c, 0.0, 0.0]), [0.0, 0.0, 10.0, 10.0]).unwrap(); + assert!(!shows([-7.0, 0.0, -6.5, 0.5], &diamond) && shows([-0.5, 6.5, 0.5, 7.5], &diamond)); + // A corner repeated (an edge of no length) does not hide what the region covers. + let doubled = vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]; + assert!(shows([4.0, 4.0, 6.0, 6.0], &doubled)); + // A rectangle of no area is a region that covers nothing; a corner that isn't finite + // gives no region to clip with at all. + let flat = region(&Matrix::IDENTITY, [0.0, 0.0, 10.0, 0.0]).unwrap(); + assert!(!shows([-100.0, -100.0, 100.0, 100.0], &flat), "{flat:?}"); + assert!(region(&Matrix::IDENTITY, [0.0, 0.0, f64::INFINITY, 10.0]).is_none()); + // However thin, a rectangle with area keeps it, and so does one far from the origin. + let thin = region(&Matrix::IDENTITY, [0.0, 0.0, 1.0, 1e-10]).unwrap(); + assert!(shows([0.25, 0.0, 0.75, 1.0], &thin) && !shows([0.25, 1.0, 0.75, 2.0], &thin)); + let far = region(&Matrix([1.0, 0.0, 0.0, 1.0, 1e12, 1e12]), [0.0, 0.0, 100.0, 100.0]).unwrap(); + assert!(shows([1e12 + 10.0, 1e12 + 10.0, 1e12 + 20.0, 1e12 + 20.0], &far) && !shows([1e12 + 200.0, 1e12, 1e12 + 300.0, 1e12 + 10.0], &far)); + } + + /// A stream that sets clip after clip, each turned a little more, keeps its region bounded: + /// past the corner cap a region becomes its bounding box, which still holds all it held. + #[test] + fn clip_after_clip_keeps_the_region_bounded() { + use interp::{intersect, region, shows}; + let mut clip = region(&Matrix::IDENTITY, [-100.0, -100.0, 100.0, 100.0]).unwrap(); + for i in 1..=10_000 { + let (s, c) = (f64::from(i) * 0.001).sin_cos(); + let turned = region(&Matrix([c, s, -s, c, 0.0, 0.0]), [-60.0, -60.0, 60.0, 60.0]).unwrap(); + clip = intersect(&clip, &turned); + assert!(clip.len() <= 32 && clip.iter().flatten().all(|v| v.is_finite()), "clip {i}: {} corners", clip.len()); + } + // Every square is centred on the origin, so the middle still shows. + assert!(shows([-1.0, -1.0, 1.0, 1.0], &clip)); + } + fn block(text: &str, rect: [f64; 4]) -> TextBlock { TextBlock { text: text.into(), diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 789ffc5456..f7c795ca65 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -720,6 +720,13 @@ 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) } diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index 5114a76263..0bc9373899 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -218,9 +218,139 @@ pub(crate) fn union(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])] } +/// A convex part of the page, in page space: the corners of a convex polygon, in order. With fewer +/// than three corners it covers nothing. +pub(super) type Region = Vec<[f64; 2]>; + +/// A clip, as the union of the convex regions it leaves visible: one for each rectangle of its +/// path. With no parts it leaves nothing visible. +pub(super) type Clip = Vec; + +/// The most corners a clip region keeps. Each intersection with a four-cornered clip can add +/// corners, so past this a region becomes its bounding box: still all it held, and a stream that +/// sets clip after clip stays bounded. +const MAX_REGION_CORNERS: usize = 32; + +/// The most parts a clip keeps. Clips intersect part by part, so past this its parts become one, +/// the box around them: still all they held, and the work stays bounded. +const MAX_CLIP_PARTS: usize = 16; + +/// `r` (`[x0, y0, x1, y1]`, either way round) under `m`, keeping its shape when `m` turns or +/// skews it. A rectangle of no area is a region that covers nothing, however thin a rectangle +/// with area is; `None` when a corner, or its measure, is not finite. +pub(super) fn region(m: &Matrix, r: [f64; 4]) -> Option { + let corners: Region = [(r[0], r[1]), (r[2], r[1]), (r[2], r[3]), (r[0], r[3])] + .into_iter() + .map(|(x, y)| { + let (x, y) = m.apply(x, y); + [x, y] + }) + .collect(); + let finite = corners.iter().flatten().all(|v| v.is_finite()) && twice_area(&corners).is_finite() && perimeter(&corners).is_finite(); + finite.then(|| if encloses_area(&corners) { corners } else { Vec::new() }) +} + +/// The polygon's signed area, doubled: positive when its corners run anticlockwise (y up). +/// Measured from its first corner, so that far from the origin it keeps its precision. +fn twice_area(p: &[[f64; 2]]) -> f64 { + let Some(o) = p.first() else { return 0.0 }; + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (a[0] - o[0]) * (b[1] - o[1]) - (b[0] - o[0]) * (a[1] - o[1])).sum() +} + +fn perimeter(p: &[[f64; 2]]) -> f64 { + p.iter().zip(p.iter().cycle().skip(1)).map(|(a, b)| (b[0] - a[0]).hypot(b[1] - a[1])).sum() +} + +/// Whether the polygon encloses any area: its area against the square of its perimeter, so the +/// answer is the same at any scale and a thin box still has one. +fn encloses_area(p: &[[f64; 2]]) -> bool { + let around = perimeter(p); + p.len() >= 3 && twice_area(p).abs() > 1e-12 * around * around +} + +/// The four corners of the box around `points`. +fn bounding<'a>(points: impl Iterator) -> Region { + let b = points.fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])]); + vec![[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]]] +} + +/// The part of `a` inside `b`, both convex (Sutherland-Hodgman: `a` cut by each edge of `b`). Clips +/// in force intersect, so text shows only where all of them let it through. Regions that share no +/// area (apart, or meeting only along an edge) leave an empty one, as does an empty `a` or `b`. +pub(super) fn intersect(a: &Region, b: &Region) -> Region { + if a.len() < 3 || b.len() < 3 { + return Vec::new(); + } + let turn = twice_area(b).signum(); + let mut out = a.clone(); + for (p, q) in b.iter().zip(b.iter().cycle().skip(1)) { + // Positive on the side of the edge `p` → `q` that `b` lies on. + let inside = |v: &[f64; 2]| turn * ((q[0] - p[0]) * (v[1] - p[1]) - (q[1] - p[1]) * (v[0] - p[0])); + let cut = std::mem::take(&mut out); + for (s, e) in cut.iter().zip(cut.iter().cycle().skip(1)) { + let (ds, de) = (inside(s), inside(e)); + if ds >= 0.0 { + out.push(*s); + } + // The edge crosses the line: `ds - de` is not zero, as one is negative and one is not. + if (ds >= 0.0) != (de >= 0.0) { + let t = ds / (ds - de); + out.push([s[0] + t * (e[0] - s[0]), s[1] + t * (e[1] - s[1])]); + } + } + } + if !encloses_area(&out) { + return Vec::new(); + } + if out.len() > MAX_REGION_CORNERS { + out = bounding(out.iter()); + } + out +} + +/// The clip that leaves visible only what both `a` and `b` do: each part of one intersected with +/// each part of the other. +pub(super) fn clip_within(a: &Clip, b: &Clip) -> Clip { + let mut out: Clip = a.iter().flat_map(|p| b.iter().map(move |q| intersect(p, q))).filter(|r| !r.is_empty()).collect(); + if out.len() > MAX_CLIP_PARTS { + out = vec![bounding(out.iter().flatten())]; + } + out +} + +/// Whether the box `r` shows through `clip`: through any of its parts. +pub(super) fn clip_shows(r: [f64; 4], clip: &Clip) -> bool { + clip.iter().any(|part| shows(r, part)) +} + +/// Whether the box `r` (`[x0, y0, x1, y1]`) and `region` share some area, by the separating axis +/// test: two convex shapes are apart exactly when their shadows on some axis, one of the box's or +/// a normal of one of the region's edges, do not overlap. Touching is not sharing. +pub(super) fn shows(r: [f64; 4], region: &Region) -> bool { + if region.len() < 3 { + return false; + } + let corners = [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]]; + let apart = |axis: [f64; 2]| { + let shadow = |points: &[[f64; 2]]| { + points.iter().map(|p| p[0] * axis[0] + p[1] * axis[1]).fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(v), hi.max(v))) + }; + let ((a0, a1), (b0, b1)) = (shadow(&corners), shadow(region)); + a1 <= b0 || b1 <= a0 + }; + // An edge of no length (two corners in one place) has no normal to test. + let normals = region.iter().zip(region.iter().cycle().skip(1)).map(|(p, q)| [p[1] - q[1], q[0] - p[0]]).filter(|n| n[0] != 0.0 || n[1] != 0.0); + !([[1.0, 0.0], [0.0, 1.0]].into_iter().chain(normals).any(apart)) +} + #[derive(Clone)] pub(super) struct Ts { pub(super) ctm: Matrix, + /// The clip in force, if any, as what it leaves visible: the intersection of every + /// rectangular content-stream clip (`re` `W`/`W*` `n`, ISO 32000-2 §8.5.4) and every enclosing + /// form's `/BBox` ([`super::lines`]), each in the shape its matrix gives it. `q`/`Q` save and + /// restore it along with the rest of `Ts`. + pub(super) clip: Option, /// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`). pub(super) fill: Vec, pub(super) font: Option<(Vec, Rc)>, @@ -492,6 +622,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 @@ -503,6 +636,7 @@ impl Carry { pub(super) fn new() -> Self { let ts = Ts { ctm: Matrix::IDENTITY, + clip: None, fill: Vec::new(), font: None, face: None, @@ -522,7 +656,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 } } } @@ -567,32 +701,34 @@ impl MarkedContent { self.layers.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 +790,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 +801,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 +852,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 +864,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 +906,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 { @@ -810,6 +958,7 @@ fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), Edit hard_break: state.hard_break, soft_gap: state.soft_gap, }); + carry.gap_space = gap_space; Ok(()) } @@ -842,7 +991,57 @@ pub(super) fn interpret( // with no properties dict (a file saved before this was tracked, or by another writer) reads // as 1, the single space a rejoin always used before #2529. let mut soft_gap = 1usize; + // A pending path of `x y w h re`s, and whether a `W`/`W*` has marked it as the path that `n` + // turns into the new clip (ISO 32000-2 §8.5.4) -- the common "set a rectangular clip" idiom. + // The path is `Some(None)` once one of its rectangles can't be read, and then clips nothing. + // Each rectangle is a part of the clip; a path of more than `MAX_CLIP_PARTS` keeps the box + // around them, which still holds them all. Any other operator between `re` and `n` ends the + // idiom: the clip is not read then. + let mut pending_clip: Option>> = None; + let mut clip_pending = false; 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() { + b"re" => { + let rect = op.nums::<4>().map(|[x, y, w, h]| [x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)]); + pending_clip = Some(match (pending_clip.take(), rect) { + (None, Some(rect)) => Some(vec![rect]), + (Some(Some(mut path)), Some(rect)) => { + if path.len() >= MAX_CLIP_PARTS { + path = vec![path.iter().fold(rect, |all, r| union(all, *r))]; + } else { + path.push(rect); + } + Some(path) + } + _ => None, + }); + } + b"W" | b"W*" => clip_pending = pending_clip.is_some(), + b"n" => { + if clip_pending && let Some(Some(path)) = pending_clip.take() { + // A rectangle past what a matrix can hold leaves the clip unread. One of no + // area is a part that shows nothing, so a clip of no area hides everything. + let parts: Option = path.iter().map(|r| region(&ts.ctm, *r)).collect(); + if let Some(parts) = parts { + let new: Clip = parts.into_iter().filter(|part| !part.is_empty()).collect(); + ts.clip = Some(match &ts.clip { + Some(old) => clip_within(old, &new), + None => new, + }); + } + } + pending_clip = None; + clip_pending = false; + } + _ => { + pending_clip = None; + clip_pending = 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 +1205,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..9eadb65107 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use super::fallback::{same_font, same_paint}; -use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; +use super::interp::{Carry, Clip, Origin, Pen, Shown, TextState, Ts, clip_shows, clip_within, direction, interpret, region, to_frame, union}; use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { @@ -565,8 +565,9 @@ impl FormBudget { } } -/// A form XObject a page draws: its object, content, resources and its matrix composed with the -/// CTM at the `Do`. +/// A form XObject a page draws: its object, content, resources, its matrix composed with the +/// CTM at the `Do`, and the clip its `/BBox` makes (page space, under that same matrix, so a +/// turned form keeps its turned box; a box of no area hides everything) if it has a valid one. pub(crate) struct FormCall { pub(crate) obj: Option, pub(crate) data: Vec, @@ -575,6 +576,7 @@ pub(crate) struct FormCall { form: Arc, own: Option>, pub(crate) ctm: Matrix, + bbox: Option, } impl FormCall { @@ -644,6 +646,12 @@ pub(crate) fn form_call( <[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite())) }); let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r)); + // Four finite numbers, or no bound at all: a member that isn't one makes the box unknown, and + // reading the numbers around it would invent a clip. + let bbox = s.dict.get(b"BBox").map(|b| doc.resolve(b)).and_then(|b| { + let values: Vec = b.as_array()?.iter().map(|x| doc.resolve(x).as_f64().filter(|v| v.is_finite())).collect::>()?; + <[f64; 4]>::try_from(values).ok() + }); let data = match budget.decode.decode(s) { Ok(data) => data, Err(_) if budget.decode.remaining() == 0 => { @@ -653,7 +661,8 @@ pub(crate) fn form_call( Err(_) => Vec::new(), }; budget.visits = budget.visits.saturating_add(1); - Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) }) + let ctm = m.map_or(ctm, |m| Matrix(m).then(&ctm)); + Some(FormCall { obj, data, form, own, ctm, bbox: bbox.and_then(|b| region(&ctm, b)).map(|r| if r.is_empty() { Vec::new() } else { vec![r] }) }) } /// The lines a page shows as it reads, including text drawn by form XObjects (which @@ -684,6 +693,12 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec Result<(Vec Some(clip_within(outer, own)), + (outer, own) => outer.clone().or_else(|| own.clone()), + }; + let mut carry = Carry { + ts: Ts { ctm: form.ctm, clip, ..ts }, + stack: Vec::new(), + marked: Vec::new(), + layers: Vec::new(), + text_left: *text_left, + gap_space: false, + }; let stream = w.next_stream; w.next_stream = w.next_stream.saturating_add(1); if let Some(r) = form.obj { diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index d9cf616d4b..c146ffcbc0 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -649,12 +649,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..8923ed0c6d 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, @@ -131,6 +132,7 @@ pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as Re pub use pdfcraft_redact::patterns::{ MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list, }; +pub use pdfcraft_redact::redacted_file_name; pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden}; pub use pdfcraft_redact::user_regex::{UserRegex as RedactRegex, VALIDATORS as REDACT_VALIDATORS, Validator as RedactValidator}; pub use pdfcraft_sign as sign; 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/print_raster.rs b/crates/engine/src/print_raster.rs index 29df75b014..c5a1295e92 100644 --- a/crates/engine/src/print_raster.rs +++ b/crates/engine/src/print_raster.rs @@ -22,6 +22,9 @@ pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Cont let mut config = src.config.clone(); // The copy below holds only what prints, so the viewer's own comment hiding plays no part. config.hide_comments = false; + // Printing is not the View event: a layer the auto state turns off on screen keeps its + // configured state on paper, as in a print that goes out as vectors. + config.for_print = true; let (printed, layers) = guard(|| -> Result<(Vec, Option>), String> { let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?; pdfcraft_print::keep_printed(&mut cos, content); 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..532aa6ed78 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 { @@ -200,7 +211,13 @@ impl Session { Err(_) => return Vec::new(), }, }; - (0..doc.info.pages.len()).map(|i| pages.contains(&i) && pdfcraft_print::text_reads_up(&cos, i)).collect() + // The page tree is read once, and each page is looked up, not searched for, in `pages`. + let mut out = vec![false; doc.info.pages.len()]; + let wanted: Vec = pages.iter().copied().filter(|&p| p < out.len()).collect::>().into_iter().collect(); + for (p, up) in wanted.iter().zip(pdfcraft_print::texts_read_up(&cos, &wanted)) { + out[*p] = up; + } + out } /// Combine whole files, in order, into new PDF bytes (one bookmark per file). 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 d677e355d2..d9992bc2c5 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/engine/src/xfa.rs b/crates/engine/src/xfa.rs index 4246e5b504..2bdbcf10cc 100644 --- a/crates/engine/src/xfa.rs +++ b/crates/engine/src/xfa.rs @@ -1051,6 +1051,8 @@ mod tests { flags: 0, max_len: None, options: Vec::new(), + selected: Vec::new(), + single_value_is_array: false, da: String::new(), quadding: 0, tooltip: None, diff --git a/crates/engine/tests/it/dingbat_check_box.rs b/crates/engine/tests/it/dingbat_check_box.rs new file mode 100644 index 0000000000..87f33dd3a7 --- /dev/null +++ b/crates/engine/tests/it/dingbat_check_box.rs @@ -0,0 +1,71 @@ +//! A check box the file leaves to the viewer draws its `/MK /CA` caption `4` in ZapfDingbats +//! (ISO 32000-2 Table 192 and §12.7.5.2.3), and the standard font's bundled stand-in draws that +//! check mark (a20, Annex D.6) at its standard advance (§9.6.2.2): centred in the box, at the +//! `/DA` size, neither squeezed nor spilling past its advance. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, SaveOptions, write_incremental}; +use pdfcraft_fonts::Std14; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; + +/// The `/DA` size of the check, in points (and pixels, at scale 1). +const SIZE: f64 = 60.0; + +fn pdf(objects: &[&str]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +#[test] +fn a_check_box_caption_check_is_drawn_at_its_standard_advance() { + // A 100 pt box at (50, 50) on a 200 pt page, with no border, no background and no /AP. + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >>", + "<< /Fields [5 0 R] /NeedAppearances true >>", + "<< /Type /Annot /Subtype /Widget /FT /Btn /T (c) /V /Yes /AS /Yes /DA (/ZaDb 60 Tf 0 g) /MK << /CA (4) >> /Rect [50 50 150 150] /F 4 /P 3 0 R >>", + ]); + let doc = Document::open(Arc::new(bytes)).expect("the fixture opens"); + let shown = pdfcraft_forms::with_field_appearances(&doc).expect("the check box gets an appearance"); + let bytes = write_incremental(&shown, &SaveOptions::default()).expect("the display copy saves"); + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let page = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + + // The bounding box of the dark pixels, in pixels from the top left. + let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0); + for y in 0..page.height { + for x in 0..page.width { + let at = usize::try_from((y * page.width + x) * 4).unwrap_or(usize::MAX); + if page.rgba.get(at).is_some_and(|r| *r < 128) { + (x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y)); + } + } + } + assert!(x0 <= x1, "the check is drawn"); + let (x0, y0, x1, y1) = (f64::from(x0), f64::from(y0), f64::from(x1 + 1), f64::from(y1 + 1)); + + // The check's advance, centred across the box: 846 units at 60 pt is 50.76 pt. + let advance = Std14::ZapfDingbats.advance(b'4', SIZE); + assert_eq!(Std14::ZapfDingbats.width(b'4'), 846.0, "a20's standard width"); + let left = 50.0 + (100.0 - advance) / 2.0; + assert!(x0 >= left - 1.0 && x1 <= left + advance + 1.0, "the check stays within its advance {left}..{}: {x0}..{x1}", left + advance); + assert!(x1 - x0 >= 0.8 * advance, "the check is not squeezed: {} px of {advance}", x1 - x0); + assert!(((x0 + x1) / 2.0 - 100.0).abs() <= 3.0, "the check is centred across the box: {x0}..{x1}"); + // Inside the box vertically, and about as tall as the font size makes it. + assert!(y0 >= 50.0 && y1 <= 150.0, "the check stays inside the box: {y0}..{y1}"); + assert!((0.5 * SIZE..=0.85 * SIZE).contains(&(y1 - y0)), "the check is {} px tall at {SIZE} pt", y1 - y0); +} diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs index 8d9026600d..d60377b36e 100644 --- a/crates/engine/tests/it/main.rs +++ b/crates/engine/tests/it/main.rs @@ -4,6 +4,7 @@ mod support { pub mod font_fixture; } +mod dingbat_check_box; mod document_fonts; mod form_rotation; mod marked_content_isolation; 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/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 978b9d11ae..9d8d0d6b85 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -1423,6 +1423,115 @@ pub fn glyph_unicode(name: &str) -> Option { chars.next().filter(|_| chars.len() == 0) } +/// What each code of an embedded TrueType font that isn't flagged non-symbolic and names no base +/// encoding shows, by the `post` table name of the glyph its `cmap` selects (the way the renderer +/// selects it: the code, then at 0xF000, 0xF100 and 0xF200). Such a font's codes need not be +/// Standard or WinAnsi: FrameMaker files distilled with Distiller 3 number them in the Macintosh +/// glyph order, 29 below ASCII, so `Solid` read as `6ROLG` and typing set the wrong glyphs (#3962). +/// Codes without a glyph, and fonts whose `post` table names no glyphs, are left out (the latter +/// to [`symbolic_mac_roman_texts`]). A code whose glyph has no name with a Unicode value reads +/// as WinAnsi, as the renderer reads it (vendored hayro-interpret patch (79)), so the editor and +/// extraction agree: `€` for code 0x80, which Standard leaves without a character. +fn symbolic_glyph_texts(doc: &Document, font: &Dict) -> Vec<(u32, String)> { + use skrifa::GlyphId; + use skrifa::raw::{FontData, FontRead as _, TableProvider as _, tables::cmap::PlatformId, tables::post::Post, types::Tag}; + let Some(descriptor) = dict(doc, font.get(b"FontDescriptor")) else { return Vec::new() }; + // Flag bit 6, Nonsymbolic (ISO 32000-2 Table 121). + if descriptor_flags(doc, &descriptor) & 32 != 0 { + return Vec::new(); + } + let Some(object) = descriptor.get(b"FontFile2") else { return Vec::new() }; + let object = doc.resolve(object); + let Object::Stream(stream) = object.as_ref() else { return Vec::new() }; + let Ok(bytes) = doc.with_direct_parms(stream).decoded_within(MAX_SFNT) else { return Vec::new() }; + let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { return Vec::new() }; + let Ok(cmap) = face.cmap() else { return Vec::new() }; + // A `post` version written with its 16-bit halves swapped (`00 00 00 02` for 2.0, as ScanSoft + // PDF Create! writes it) is read as the version it means, as the renderer reads it (#2946, + // vendored hayro-interpret patch (49)); otherwise its names would be lost here alone. + let repaired = face.table_data(Tag::new(b"post")).and_then(|data| match data.as_bytes() { + [0, 0, 0, n @ 1..=3, rest @ ..] => Some([&[0, *n, 0, 0], rest].concat()), + _ => None, + }); + let post = match &repaired { + Some(bytes) => Post::read(FontData::new(bytes)), + None => face.post(), + }; + let Ok(post) = post else { return Vec::new() }; + // A font that names no glyphs (no `post` table of version 1.0, or one of version 2.0 with an + // empty name index) is left to `symbolic_mac_roman_texts` and the encoding's reading. + let names_glyphs = match post.version() { + skrifa::raw::types::Version16Dot16::VERSION_1_0 => true, + skrifa::raw::types::Version16Dot16::VERSION_2_0 => post.glyph_name_index().is_some_and(|index| !index.is_empty()), + _ => false, + }; + if !names_glyphs { + return Vec::new(); + } + let custom = post_custom_names(&post); + (0..=255u32) + .filter_map(|code| { + // The records in order, as the renderer's `map_code` reads a symbolic font: a Windows + // symbol or Unicode cmap with the code and the code at 0xF000, 0xF100 and 0xF200, a + // Macintosh or Unicode-platform one with the code alone; the first glyph found wins. + let glyph = cmap.encoding_records().iter().find_map(|record| { + let offsets: &[u32] = match (record.platform_id(), record.encoding_id()) { + (PlatformId::Windows, 0 | 1) => &[0, 0xF000, 0xF100, 0xF200], + (PlatformId::Macintosh | PlatformId::Unicode, 0) => &[0], + _ => return None, + }; + let subtable = record.subtable(cmap.offset_data()).ok()?; + offsets.iter().find_map(|o| subtable.map_codepoint(code + o).filter(|g| *g != GlyphId::NOTDEF)) + })?; + let glyph = u16::try_from(glyph.to_u32()).ok()?; + let winansi = || { + let u = crate::encodings::WIN_ANSI.get(usize::try_from(code).ok()?).copied().filter(|u| *u != 0)?; + char::from_u32(u).map(String::from) + }; + let text = post_name(&post, &custom, glyph).filter(|n| uppercase_hex_forms(n)).and_then(glyph_text); + Some((code, text.or_else(winansi)?)) + }) + .collect() +} + +/// Whether the `uni` and `u` components of a glyph name spell their hex digits in uppercase, as +/// the glyph list specification's forms do (ISO 32000-2 §9.10.2): `uni1eae` names nothing, so +/// its glyph's code reads as WinAnsi, as the renderer reads it (vendored hayro-interpret patch +/// (79)). A component that is a list name (`a`, `ecircumflex`) is read as such whatever its case. +fn uppercase_hex_forms(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default(); + stem.split('_').all(|part| { + // Outside the list, a component `glyph_text` reads is `u` or `uni` and hex digits, so a + // lowercase `a` to `f` in it is a lowercase digit. + crate::encodings::NAMES.binary_search_by(|(n, _)| (*n).cmp(part)).is_ok() || !part.bytes().any(|b| matches!(b, b'a'..=b'f')) + }) +} + +/// The names a version 2.0 `post` table spells out, in order, read once. `Post::glyph_name` walks +/// this data from its start for each glyph it is asked about, which over a font program of up to +/// 16 MiB, asked about 256 codes, is work an untrusted file can make large. Reading stops at the +/// first name that can't be read, as each lookup would. +fn post_custom_names<'a>(post: &skrifa::raw::tables::post::Post<'a>) -> Vec<&'a str> { + post.string_data().map(|names| names.iter().map_while(|name| name.ok().map(|n| n.as_str())).collect()).unwrap_or_default() +} + +/// `glyph`'s name in `post`, as `Post::glyph_name` gives it, with the table's own names from +/// [`post_custom_names`]. +fn post_name<'a>(post: &skrifa::raw::tables::post::Post<'a>, custom: &[&'a str], glyph: u16) -> Option<&'a str> { + use skrifa::raw::{tables::post::DEFAULT_GLYPH_NAMES, types::Version16Dot16}; + match post.version() { + Version16Dot16::VERSION_1_0 => DEFAULT_GLYPH_NAMES.get(usize::from(glyph)).copied(), + Version16Dot16::VERSION_2_0 => { + let index = usize::from(post.glyph_name_index()?.get(usize::from(glyph))?.get()); + match index.checked_sub(DEFAULT_GLYPH_NAMES.len()) { + None => DEFAULT_GLYPH_NAMES.get(index).copied(), + Some(i) => custom.get(i).copied(), + } + } + _ => None, + } +} + /// Code → Unicode for a font: its ToUnicode CMap (for a composite font, also the name /// `/Identity-H` or `/Identity-V`, read as code = Unicode), else (simple fonts) its encoding. fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { @@ -1465,7 +1574,12 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode { out.encoding.insert(code as u32, c.to_string()); } } + // A font named as Symbol or ZapfDingbats keeps reporting nothing: its names may be as + // un-Latin as its codes (WordPerfect's WPTypographicSymbols calls its apostrophe `equal`). + // A font whose `post` table names its glyphs reads by those names; one that names none + // and keeps the Macintosh order reads as Mac OS Roman, over the WinAnsi fallback. if base_name.is_none() && !symbolic_builtin { + out.encoding.extend(symbolic_glyph_texts(doc, font)); out.encoding.extend(symbolic_mac_roman_texts(doc, font)); } if let Some(Object::Dict(d)) = enc.as_deref() diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 9a0a32be6a..2d95465944 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -214,6 +214,194 @@ fn mac_subset_cmap_uses_pdf_unicode_without_changing_source_program() { assert!(append_editor_cmap(&mut broken, &std::collections::BTreeMap::from([(65, 1)])).is_none()); } +/// The names read once give what `Post::glyph_name` gives, for every glyph of a font with its +/// own names (Inter's version 2.0 table), and none past the last glyph. +#[test] +fn post_names_read_once_match_the_table() { + use skrifa::raw::TableProvider as _; + let face = skrifa::FontRef::new(include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap(); + let post = face.post().unwrap(); + let custom = post_custom_names(&post); + assert!(custom.len() > 100, "Inter names glyphs of its own: {}", custom.len()); + let glyphs = face.maxp().unwrap().num_glyphs(); + for g in 0..glyphs { + assert_eq!(post_name(&post, &custom, g), post.glyph_name(skrifa::raw::types::GlyphId16::new(g)), "glyph {g}"); + } + assert_eq!(post_name(&post, &custom, glyphs), None); +} + +/// #3962: a symbolic TrueType subset with no `/Encoding` whose codes are glyph positions in +/// the Macintosh order, 29 below ASCII, mapped by a Mac (1,0) cmap. Its text is what the +/// `post` table names the glyphs its codes select, and typing encodes back to those codes. +/// A (3,0) symbol cmap at 0xF000 + code is read too: code 0x20, `.notdef` in the Mac table, +/// shows `k` there, code 0xC0 `é`, and code 0xC1 the variant `a.1`, whose suffix is dropped. +/// Records are read in order, each with its offsets, as the renderer reads them: 0xC2 is the +/// symbol cmap's `x`, not a later (3,1) cmap's `z`. +#[test] +fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { + use skrifa::{MetadataProvider as _, raw::TableProvider as _}; + const TEXT: &str = "Solid state"; + let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); + let face = skrifa::FontRef::new(original).unwrap(); + let charmap = face.charmap(); + let post = face.post().unwrap(); + let variant = (1..face.maxp().unwrap().num_glyphs()) + .find(|&g| post.glyph_name(skrifa::raw::types::GlyphId16::new(g)) == Some("a.1")) + .expect("Inter has a.1"); + // A glyph whose name has no Unicode value: its code reads as WinAnsi, as the renderer reads it. + let unnamed = (1..face.maxp().unwrap().num_glyphs()) + .find(|&g| post.glyph_name(skrifa::raw::types::GlyphId16::new(g)).is_some_and(|n| !n.starts_with('.') && glyph_text(n).is_none())) + .expect("Inter has a glyph whose name has no text"); + // A format 6 Mac cmap over codes 3..=0x57: each letter at its code minus 29. + let (first, last) = (3u16, 0x57u16); + let mut glyphs = vec![0u16; usize::from(last - first + 1)]; + for c in TEXT.chars() { + glyphs[usize::from(c as u16 - 29 - first)] = u16::try_from(charmap.map(c).unwrap().to_u32()).unwrap(); + } + let mac_len = 10 + 2 * glyphs.len() as u16; + let gid = |c: char| u16::try_from(charmap.map(c).unwrap().to_u32()).unwrap(); + let mut mac = vec![0, 6]; + mac.extend_from_slice(&mac_len.to_be_bytes()); + mac.extend_from_slice(&[0, 0]); + mac.extend_from_slice(&first.to_be_bytes()); + mac.extend_from_slice(&(glyphs.len() as u16).to_be_bytes()); + glyphs.iter().for_each(|g| mac.extend_from_slice(&g.to_be_bytes())); + // A format 4 subtable: one segment per code, then the closing 0xFFFF segment. + let format4 = |map: &[(u16, u16)]| { + let segments: Vec<(u16, u16)> = map.iter().copied().chain([(0xFFFF, 0)]).collect(); + let n = segments.len() as u16; + let words: Vec = [4, 16 + 8 * n, 0, 2 * n, 2, 0, 2 * n - 2] + .into_iter() + .chain(segments.iter().map(|s| s.0)) + .chain([0]) + .chain(segments.iter().map(|s| s.0)) + .chain(segments.iter().map(|&(code, glyph)| if code == 0xFFFF { 1 } else { glyph.wrapping_sub(code) })) + .chain(segments.iter().map(|_| 0)) + .collect(); + words.iter().flat_map(|w| w.to_be_bytes()).collect::>() + }; + // A glyph whose name the table spells `uni1eae`, lowercase, below: the glyph list + // specification's hex digits are uppercase (ISO 32000-2 §9.10.2), so it names nothing. + let lowercase = (1..face.maxp().unwrap().num_glyphs()) + .find(|&g| post.glyph_name(skrifa::raw::types::GlyphId16::new(g)) == Some("uni1EAE")) + .expect("Inter has uni1EAE"); + // Symbol (3,0): 0xF020 → k, 0xF0C0 → é, 0xF0C1 → a.1, 0xF0C2 → x, 0xF0C4 → the unnamed glyph, + // 0xF0C5 → the one spelt in lowercase. + let symbol = format4(&[ + (0xF020, gid('k')), + (0xF0C0, gid('é')), + (0xF0C1, variant), + (0xF0C2, gid('x')), + (0xF0C3, gid('w')), + (0xF0C4, unnamed), + (0xF0C5, lowercase), + ]); + // Unicode (3,1), after it: 0xC2 → z. The renderer reads the (3,0) record, offsets and + // all, before this one, so code 0xC2 is `x`. + let unicode = format4(&[(0xC2, gid('z'))]); + // A Unicode-platform (0,3) record, first, which the renderer doesn't read: 0xC3 → q there, + // so code 0xC3 is the symbol cmap's `w`. + let platform0 = format4(&[(0xC3, gid('q'))]); + // Four records, in platform order as the cmap table requires. + let at = |n: usize| (36 + n) as u32; + let mut cmap = vec![0, 0, 0, 4]; + for (platform, encoding, offset) in [ + (0u16, 3u16, at(0)), + (1, 0, at(platform0.len())), + (3, 0, at(platform0.len() + mac.len())), + (3, 1, at(platform0.len() + mac.len() + symbol.len())), + ] { + cmap.extend_from_slice(&platform.to_be_bytes()); + cmap.extend_from_slice(&encoding.to_be_bytes()); + cmap.extend_from_slice(&offset.to_be_bytes()); + } + cmap.extend(platform0); + cmap.extend(mac); + cmap.extend(symbol); + cmap.extend(unicode); + let mut subset = original.to_vec(); + let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize; + let entry = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"cmap").unwrap(); + let offset = subset.len() as u32; + subset[entry + 8..entry + 12].copy_from_slice(&offset.to_be_bytes()); + subset[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + subset.extend_from_slice(&cmap); + // The `post` table's own spelling of that name, a Pascal string, lowercased in place. + let spelt = b"\x07uni1EAE"; + assert_eq!(subset.windows(spelt.len()).filter(|w| w == spelt).count(), 1); + let spot = subset.windows(spelt.len()).position(|w| w == spelt).unwrap(); + subset[spot..spot + spelt.len()].copy_from_slice(b"\x07uni1eae"); + let codes: Vec = TEXT.bytes().map(|b| b - 29).collect(); + let metrics = |flags: i64, encoding: Option| { + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(flags)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let mut entries = vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]; + if let Some(encoding) = encoding { + entries.push(("Encoding", encoding)); + } + let f = font(&mut doc, entries); + Metrics::from_dict(&doc, &f) + }; + let symbolic = metrics(4, None); + assert_eq!(symbolic.decode(&codes), TEXT); + // The same font with its `post` version halves swapped (`00 00 00 02`) reads the same, + // as the renderer reads it (#2946). + let post = (0..count).map(|i| 12 + i * 16).find(|i| &subset[*i..*i + 4] == b"post").unwrap(); + let at = u32::from_be_bytes(subset[post + 8..post + 12].try_into().unwrap()) as usize; + assert_eq!(subset[at..at + 4], [0, 2, 0, 0], "Inter's post is version 2.0"); + let mut swapped = subset.clone(); + swapped[at..at + 4].copy_from_slice(&[0, 0, 0, 2]); + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &swapped))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("FontDescriptor", Object::Dict(descriptor))]); + assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), TEXT, "a swapped post version"); + assert_eq!(symbolic.decode(&[0x20, 0xC0, 0xC1, 0xC2, 0xC3]), "kéaxw"); + assert_eq!(symbolic.decode(&[0xC4]), "Ä", "a glyph name with no text reads as WinAnsi, not Standard's `tilde`"); + assert_eq!(symbolic.decode(&[0xC5]), "Å", "`uni1eae` names nothing, so the code reads as WinAnsi, not U+1EAE"); + assert_eq!(symbolic.encode("Solid").as_deref(), Some(&codes[..5]), "typing sets the same glyphs"); + // A named encoding, or the Nonsymbolic flag, says what the codes are: they read through it. + assert_eq!(metrics(4, Some(Object::name("WinAnsiEncoding"))).decode(&codes), "6ROLGVWDWH"); + // `/Differences` still names its codes: 0x36 shows `A` there, not the glyph's `S`. + let mut differences = Dict::new(); + differences.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(0x36), Object::name("A")])); + assert_eq!(metrics(4, Some(Object::Dict(differences))).decode(&codes), "Aolid state"); + assert_eq!(metrics(32, None).decode(&codes), "6ROLGVWDWH"); + // A font named as a symbol font reports nothing, as before: its glyph names can't be + // trusted to be Latin either. + let mut doc = Document::new_empty(); + let program = doc.add(Object::Stream(Stream::flate(Dict::new(), &subset))); + let mut descriptor = Dict::new(); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(program)); + let f = font( + &mut doc, + vec![ + ("Subtype", Object::name("TrueType")), + ("BaseFont", Object::name("KNMDCP+WPTypographicSymbols")), + ("FontDescriptor", Object::Dict(descriptor)), + ], + ); + assert_eq!(Metrics::from_dict(&doc, &f).decode(&codes), ""); +} + +/// The glyph list specification's `uni` and `u` forms spell their hex digits in uppercase +/// (ISO 32000-2 §9.10.2); list names keep their own case. +#[test] +fn glyph_names_with_lowercase_hex_digits_name_nothing() { + for name in ["uni1EAE", "u1F600", "a", "ecircumflex", "f_i", "a.ss01", "uni0041_uni0042"] { + assert!(uppercase_hex_forms(name), "{name}"); + } + for name in ["uni1eae", "u1f600", "uni20ac.alt", "f_uni20ac", "uni0041_uni00e9"] { + assert!(!uppercase_hex_forms(name), "{name}"); + } +} + fn metrics_with_flags(flags: Option) -> Metrics { let mut doc = Document::new_empty(); let mut descriptor = Dict::new(); 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..ddbbe26590 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[..] @@ -858,11 +858,15 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] size = rows.size; let line = rows.row; let mut y = height - rows.inset; - for (export, display) in f.options.iter().skip(rows.top) { + // The field's own selection (its `/I`) when these are its values; values it doesn't + // hold yet select by export value alone. + let picked = if values == f.value.as_slice() { f.selected.clone() } else { crate::selected_options(&f.options, values, None) }; + for (i, (_, display)) in f.options.iter().enumerate().skip(rows.top) { if y - line < 0.0 { break; } - if values.contains(export) { + // Both sources list positions in ascending order. + if picked.binary_search(&i).is_ok() { c.push_str(&format!("0.6 0.75 0.86 rg\n{} {} {} {} re f\n", n(bw), n(y - line), n(width - 2.0 * bw), n(line))); } show(pad, y - line + size * 0.25, display); 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..2cf76c6f3e 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -42,10 +42,11 @@ mod values; pub use appearances::{FieldAppearanceCache, declares_pdfa, need_appearances, undrawn_fields, with_field_appearances, with_field_appearances_cached}; pub(crate) use read::page_refs; +pub use read::selected_options; pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopted}; #[cfg(test)] use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads}; -use read::{script, script_budget}; +use read::{first_by_export, script, script_budget}; pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders}; #[cfg(test)] use values::has_password_flag; @@ -159,6 +160,14 @@ pub struct Field { pub max_len: Option, /// Choice options: (export value, display text). pub options: Vec<(String, String)>, + /// Combo and list boxes: the selected options, as ascending positions in `options` (see + /// [`selected_options`]). Two options can share an export value, so `value` alone can't say + /// which of them is selected. Empty for other kinds. + pub selected: Vec, + /// A list box without MultiSelect whose `/V` is an array (a value of one option is a string, + /// ISO 32000-2 Table 233): `value` holds its first entry only, and picking that option again + /// writes the value as one string rather than leaving the array. + pub single_value_is_array: bool, /// Default appearance string (`/DA`, inherited from the form if absent). pub da: String, /// Quadding: 0 left, 1 centred, 2 right. @@ -229,18 +238,98 @@ 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 that gives this check box the stored value `value` (`/V`, `/DV`, imported or + /// script data): the on state it names (or whose `/Opt` export it is), so that of kids with + /// different on states the right one turns on (ISO 32000-2 §12.7.5.2.3, Table 230); checked + /// for any other value but Off, unchecked for none. + pub fn check_box_value(&self, value: Option<&str>) -> FieldValue { + match value.filter(|v| !v.is_empty() && *v != "Off") { + Some(v) => self.state_for(v).map_or(FieldValue::Check(true), |s| FieldValue::Text(s.to_owned())), + None => FieldValue::Check(false), + } + } + + /// Combo and list boxes: this field's option positions `picked` as positions in `other`'s + /// options, in the same order. Same-name twins are one field (ISO 32000-2 §12.7.3.1) but can + /// list their options in different orders, and a value given by position is read in the first + /// twin's options. `None` when an option picked has no match in `other`. + pub fn positions_in(&self, other: &Field, picked: &[usize]) -> Option> { + option_positions_in(picked, &self.options, &other.options) + } + + /// Combo and list boxes: the selected options in the order the value names them (`/V` keeps + /// the order they were picked in), so the last is the latest pick. + pub fn picked_in_order(&self) -> Vec { + let mut out: Vec = Vec::with_capacity(self.selected.len()); + let mut taken: std::collections::HashSet = std::collections::HashSet::with_capacity(self.selected.len()); + let mut unpicked = self.selected_by_export(); + for v in &self.value { + if let Some(i) = unpicked.get_mut(v.as_str()).and_then(std::collections::VecDeque::pop_front) { + taken.insert(i); + out.push(i); + } + } + // Positions `/I` holds beyond what the value names (none while the two agree). + out.extend(self.selected.iter().copied().filter(|i| !taken.contains(i))); + out + } + /// The value as one string: text, the state name, or the selected display texts. pub fn display_value(&self) -> String { match self.kind { - FieldKind::Combo | FieldKind::List => self - .value - .iter() - .map(|v| self.options.iter().find(|(e, _)| e == v).map_or(v.clone(), |(_, d)| d.clone())) - .collect::>() - .join(", "), + FieldKind::Combo | FieldKind::List => { + // Each value as the selected option it names (options can share an export + // value), in the value's order; past those, the first option exporting it. + let mut unpicked = self.selected_by_export(); + let first = first_by_export(&self.options); + self.value + .iter() + .map(|v| { + let i = + unpicked.get_mut(v.as_str()).and_then(std::collections::VecDeque::pop_front).or_else(|| first.get(v.as_str()).copied()); + i.and_then(|i| self.options.get(i)).map_or(v.clone(), |(_, d)| d.clone()) + }) + .collect::>() + .join(", ") + } _ => self.value.join(", "), } } + + /// The selected positions grouped by the export value of their option, each group + /// ascending, so a value takes the next selected option exporting it in one lookup. + fn selected_by_export(&self) -> std::collections::HashMap<&str, std::collections::VecDeque> { + let mut out: std::collections::HashMap<&str, std::collections::VecDeque> = std::collections::HashMap::new(); + for &i in &self.selected { + if let Some((e, _)) = self.options.get(i) { + out.entry(e.as_str()).or_default().push_back(i); + } + } + out + } } /// A value to put in a field. @@ -249,10 +338,19 @@ 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), + /// Combo box or list box: options by their zero-based position in `/Opt` (as `/I` numbers + /// them), which tells apart options that share an export value. Several only for + /// multi-select lists; `/V` takes them in this order. + ChoiceIndices(Vec), } // ── reading ───────────────────────────────────────────────────────────────────────────────── @@ -306,3 +404,14 @@ fn name_obj(text: &str) -> Object { fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } + +/// The positions in `target` of the options at positions `picked` in `source`, in the same order: +/// each goes to `target`'s first not yet taken option with the same export value and text, so two +/// options sharing both stay apart. `None` when one has no such option in `target`. +pub(crate) fn option_positions_in(picked: &[usize], source: &[(String, String)], target: &[(String, String)]) -> Option> { + let mut free: std::collections::HashMap<(&str, &str), std::collections::VecDeque> = std::collections::HashMap::new(); + for (i, (e, d)) in target.iter().enumerate() { + free.entry((e.as_str(), d.as_str())).or_default().push_back(i); + } + picked.iter().map(|&p| source.get(p).and_then(|(e, d)| free.get_mut(&(e.as_str(), d.as_str()))).and_then(|q| q.pop_front())).collect() +} diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 27dbc2d224..ddd71f0b05 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") { @@ -239,6 +243,69 @@ fn values_of(doc: &Document, o: Option<&Object>, budget: &mut DecodeBudget) -> V } } +/// A choice field's `/I` (ISO 32000-2 Table 234) as option positions, when it is usable: an array +/// of integers "sorted in ascending order", each naming one of `count` options. `None` when it is +/// absent or anything in it is not (a non-integer, a position out of range, out of order or +/// repeated), so the field falls back to `/V`. +pub(crate) fn listed_indices(doc: &Document, d: &Dict, count: usize) -> Option> { + let listed = doc.resolve(d.get(b"I")?); + let entries = listed.as_array()?; + // Strictly ascending positions below `count` number at most `count`. + if entries.len() > count { + return None; + } + let mut out: Vec = Vec::with_capacity(entries.len()); + for e in entries { + let i = usize::try_from(doc.resolve(e).as_int()?).ok().filter(|&i| i < count)?; + if out.last().is_some_and(|&last| last >= i) { + return None; + } + out.push(i); + } + Some(out) +} + +/// The options a choice field shows selected, as ascending positions in `options`, given its +/// values (`/V`) and its usable `/I` ([`listed_indices`]). +/// +/// `/I` decides when it names the same export values as `/V`: it "shall be used when two or more +/// elements in the Opt array have different names but the same export value", and "if the items +/// identified by this entry differ from those in the V entry …, the V entry shall be used" (ISO +/// 32000-2 Table 234). Otherwise each value selects the first option with that export value, as +/// the writer numbers `/I` when it is given only an export value, so a pick keeps showing the same +/// option when `/I` is lost. A value no option exports selects nothing. +pub fn selected_options(options: &[(String, String)], values: &[String], listed: Option<&[usize]>) -> Vec { + if let Some(listed) = listed { + // The same items, each as many times: `/V [(1) (1)]` names two items, which an `/I [1]` + // naming one doesn't, so `/V` decides then. + let mut named: Vec<&str> = values.iter().map(String::as_str).collect(); + named.sort_unstable(); + let exports: Option> = listed.iter().map(|&i| options.get(i).map(|(e, _)| e.as_str())).collect(); + if let Some(mut exports) = exports { + exports.sort_unstable(); + if exports == named { + return listed.to_vec(); + } + } + } + let first = first_by_export(options); + let mut out: Vec = values.iter().filter_map(|v| first.get(v.as_str()).copied()).collect(); + out.sort_unstable(); + out.dedup(); + out +} + +/// Each export value in `options` with the position of the first option exporting it, so +/// matching values against options takes one pass over each rather than one over the options per +/// value (a file can hold many of both). +pub(crate) fn first_by_export(options: &[(String, String)]) -> std::collections::HashMap<&str, usize> { + let mut first: std::collections::HashMap<&str, usize> = std::collections::HashMap::with_capacity(options.len()); + for (i, (e, _)) in options.iter().enumerate() { + first.entry(e.as_str()).or_insert(i); + } + first +} + 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() @@ -672,16 +739,36 @@ fn walk( Object::Array(pair) if pair.len() >= 2 => Some((text_of(&doc.resolve(&pair[0]))?, text_of(&doc.resolve(&pair[1]))?)), other => text_of(other).map(|t| (t.clone(), t)), }) - .collect(); + .collect::>(); + let mut value = values_of(doc, inh.v.as_ref(), &mut seen.values); + // A list box without MultiSelect selects one option: "if clear, at most one item shall be + // selected" (ISO 32000-2 Table 233). Given an array /V or /DV anyway, it holds the first + // value only, and shows and resets to it; the file keeps its entries until the field is filled. + let mut default = values_of(doc, inh.dv.as_ref(), &mut seen.values); + let single = kind == FieldKind::List && ff & flags::MULTI_SELECT == 0; + let single_value_is_array = single && inh.v.as_ref().is_some_and(|v| doc.resolve(v).as_array().is_some()); + if single { + value.truncate(1); + default.truncate(1); + } + let mut selected = match kind { + FieldKind::Combo | FieldKind::List => selected_options(&options, &value, listed_indices(doc, d, options.len()).as_deref()), + _ => Vec::new(), + }; + if single { + selected.truncate(1); + } out.push(Field { name: inh.name.clone(), obj: r, kind, - value: values_of(doc, inh.v.as_ref(), &mut seen.values), - default: values_of(doc, inh.dv.as_ref(), &mut seen.values), + value, + default, flags: ff, max_len: inh.max_len, options, + selected, + single_value_is_array, da: inh.da.clone().unwrap_or_else(|| "/Helv 0 Tf 0 g".into()), quadding: inh.q.unwrap_or(0), tooltip: d.get(b"TU").and_then(|o| text_of(&doc.resolve(o))), diff --git a/crates/forms/src/scripting.rs b/crates/forms/src/scripting.rs index 78dcdcff28..eda371c0a3 100644 --- a/crates/forms/src/scripting.rs +++ b/crates/forms/src/scripting.rs @@ -140,7 +140,7 @@ pub(crate) fn apply_changes(doc: &mut Document, changes: &[FieldChange], except: { let value = match f.kind { FieldKind::Text => FieldValue::Text(v.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!v.is_empty()), + FieldKind::CheckBox => f.check_box_value(v.first().map(String::as_str)), FieldKind::Radio => FieldValue::Radio(v.first().cloned()), FieldKind::Combo | FieldKind::List => FieldValue::Choice(v.clone()), _ => continue, 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..9c30d6a536 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(), value_set: false, 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,455 @@ 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]); +} + +/// Two check box fields of two kids each: `pair` with the on states `A` and `B` (and `/DV /B`), +/// `same` with `Yes` twice. +fn check_box_kids() -> Document { + 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} 10 0 R /Off 11 0 R >> >> >>", + x + 15 + ) + }; + let 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 8 0 R 9 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /T (pair) /V /Off /DV /B /Kids [6 0 R 7 0 R] >>".into(), // 5 + kid(5, 100, "A"), // 6 + kid(5, 200, "B"), // 7 + kid(12, 100, "Yes"), // 8 + kid(12, 200, "Yes"), // 9 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 10 + "<< /Length 0 >>\nstream\n\nendstream".into(), // 11 + "<< /FT /Btn /T (same) /V /Off /Kids [8 0 R 9 0 R] >>".into(), // 12 + ]; + document(&objs) +} + +/// ISO 32000-2 §12.7.5.2.3: a check box field's `/V` is the on state of its kid that is on, and +/// its kids can have different on states (Table 230). Naming `B` ticks `B` and clears `A`; +/// "checked" keeps the kid that is on; a reset goes back to `/DV /B`. Kids sharing `Yes` tick +/// together (EXAMPLE 2). +#[test] +fn check_box_kids_with_different_on_states_tick_one_at_a_time() { + let mut doc = check_box_kids(); + set_value(&mut doc, "pair", &FieldValue::Text("A".into())).unwrap(); + assert_eq!(on_widgets(&doc, "pair"), (vec![true, false], vec!["A".to_string()])); + set_value(&mut doc, "pair", &FieldValue::Text("B".into())).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "B, after a save"); + set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap(); + assert_eq!(on_widgets(&doc, "pair").0, [false, true], "checked keeps the kid that is on"); + set_value(&mut doc, "pair", &FieldValue::Check(false)).unwrap(); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, false], vec![])); + set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap(); + assert_eq!(on_widgets(&doc, "pair").0, [true, false], "from off, checked takes the first kid"); + reset(&mut doc, None).unwrap(); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "reset goes back to /DV /B"); + // Kids sharing an on state tick and untick together. + set_value(&mut doc, "same", &FieldValue::Text("Yes".into())).unwrap(); + assert_eq!(on_widgets(&doc, "same"), (vec![true, true], vec!["Yes".to_string()])); + set_value(&mut doc, "same", &FieldValue::Check(false)).unwrap(); + assert_eq!(on_widgets(&doc, "same").0, [false, false]); + assert!(matches!(set_value(&mut doc, "pair", &FieldValue::Text("C".into())), Err(FormError::Invalid(_)))); + // A script that sets the value to B ticks B. + let to_b = FieldChange { name: "pair".into(), value: Some(vec!["B".into()]), ..FieldChange::default() }; + set_value(&mut doc, "pair", &FieldValue::Text("A".into())).unwrap(); + apply_script_changes(&mut doc, &[to_b], &mut NoScripts).unwrap(); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "a script's B"); +} + +/// A one-page form with list box `dup` (single select unless `ff` says otherwise) whose first two +/// options share the export value `1`, plus `extra` entries (`/V`, `/I`) on the field. +fn duplicate_exports(ff: u32, extra: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Ff {ff} /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] {extra} /Rect [50 700 200 746] /P 3 0 R >>" + ), + ]; + document(&objs) +} + +/// The bottom edges of the highlighted rows in a list box appearance, top row first. +fn highlighted_rows(appearance: &str) -> Vec { + let lines: Vec<&str> = appearance.lines().collect(); + lines.windows(2).filter(|w| w[0] == "0.6 0.75 0.86 rg").map(|w| w[1].split(' ').nth(1).unwrap_or_default().to_string()).collect() +} + +/// The rows the display copy (`/NeedAppearances`) highlights in `dup`. +fn shown_rows(doc: &Document) -> Vec { + let copy = with_field_appearances(doc).expect("the form leaves its appearances to the viewer"); + highlighted_rows(&ap(©, &field(&fields(©), "dup").widgets[0])) +} + +/// ISO 32000-2 Table 234: `/I` "shall be used when two or more elements in the Opt array have +/// different names but the same export value". `/V (1)` with `/I [1]` selects One-B alone, not +/// every option exporting `1`. +#[test] +fn a_list_box_with_duplicate_export_values_selects_the_option_i_names() { + let doc = duplicate_exports(0, "/V (1) /I [1]"); + let dup = field(&fields(&doc), "dup").clone(); + assert_eq!(dup.selected, [1]); + assert_eq!(dup.display_value(), "One-B"); + let one_b = shown_rows(&doc); + assert_eq!(one_b.len(), 1, "one highlighted row"); + // The same field with /I [0] highlights the row above it. + let one_a = shown_rows(&duplicate_exports(0, "/V (1) /I [0]")); + assert_eq!(one_a.len(), 1); + assert_ne!(one_a, one_b, "One-A and One-B are different rows"); + let row = |s: &[String]| s.first().and_then(|y| y.parse::().ok()).unwrap(); + assert!(row(&one_b) < row(&one_a), "One-B is drawn below One-A"); +} + +/// Without a usable `/I`, `/V` decides (Table 234: "If the items identified by this entry differ +/// from those in the V entry …, the V entry shall be used"), and an export value two options +/// share selects the first of them. A malformed `/I` never panics. +#[test] +fn an_unusable_i_falls_back_to_the_first_option_with_the_value() { + for (extra, multi, want) in [ + ("/V (1)", 0, vec![0]), // no /I: the first option exporting 1 + ("/V (1) /I [5]", 0, vec![0]), // out of range + ("/V (1) /I [-1]", 0, vec![0]), // negative + ("/V (1) /I [(1)]", 0, vec![0]), // not an integer + ("/V (1) /I [1.5]", 0, vec![0]), // not a whole number + ("/V (1) /I 1", 0, vec![0]), // not an array + ("/V (1) /I [2]", 0, vec![0]), // names Two, which /V doesn't + ("/V (1) /I []", 0, vec![0]), // names nothing + ("/V [(1) (2)] /I [2 1]", 1 << 21, vec![0, 2]), // not ascending + ("/V [(1) (2)] /I [1 1 2]", 1 << 21, vec![0, 2]), // repeated + ("/V [(1) (2)] /I [1 2]", 1 << 21, vec![1, 2]), // usable: One-B and Two + ("/V [(1) (1)] /I [0 1]", 1 << 21, vec![0, 1]), // usable: both options exporting 1 + ("/V [(1) (1)] /I [1]", 1 << 21, vec![0]), // names one item where /V names two + ("/V [] /I [1]", 0, vec![]), // an empty value selects nothing + ("/V (9)", 0, vec![]), // no option exports 9 + ] { + let doc = duplicate_exports(multi, extra); + assert_eq!(field(&fields(&doc), "dup").selected, want, "{extra}"); + assert_eq!(shown_rows(&doc).len(), want.len(), "{extra}"); + } + // Pure: positions past the options or unlike the values are ignored. + let options = [("1".to_string(), "One-A".to_string()), ("1".to_string(), "One-B".to_string())]; + assert_eq!(selected_options(&options, &["1".into()], Some(&[1])), [1]); + assert_eq!(selected_options(&options, &["1".into()], Some(&[7])), [0]); + assert_eq!(selected_options(&options, &[], Some(&[])), Vec::::new()); +} + +/// Picking an option by position writes its export value to `/V` and its position to `/I`, so a +/// pick of One-A or One-B survives a save and reopen; picking by display text does the same. +#[test] +fn picking_one_of_two_options_with_the_same_export_value_round_trips() { + for (value, want) in [ + (FieldValue::ChoiceIndices(vec![0]), 0), + (FieldValue::ChoiceIndices(vec![1]), 1), + (FieldValue::Text("One-B".into()), 1), + (FieldValue::Text("One-A".into()), 0), + (FieldValue::Choice(vec!["1".into()]), 0), + ] { + let mut doc = duplicate_exports(0, "/V (1) /I [1]"); + set_value(&mut doc, "dup", &value).unwrap(); + let doc = reopen(&doc); + let dup = field(&fields(&doc), "dup").clone(); + let d = doc.get(dup.obj).as_dict().cloned().unwrap(); + assert_eq!(d.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some("1"), "{value:?}: /V is one string"); + let i: Vec = d.get(b"I").and_then(|i| i.as_array()).unwrap().iter().filter_map(Object::as_int).collect(); + assert_eq!(i, [want as i64], "{value:?}"); + assert_eq!(dup.selected, [want], "{value:?}"); + assert_eq!(highlighted_rows(&ap(&doc, &dup.widgets[0])).len(), 1, "{value:?}: its saved look highlights one row"); + } + // The saved look highlights the row picked, not the first with its value. + let saved = |pick: usize| { + let mut doc = duplicate_exports(0, ""); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![pick])).unwrap(); + highlighted_rows(&ap(&doc, &field(&fields(&doc), "dup").widgets[0])) + }; + assert_ne!(saved(0), saved(1)); + // Positions are checked: out of range, or several in a single-select list. + let mut doc = duplicate_exports(0, ""); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![3])), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![usize::MAX])), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![0, 1])), Err(FormError::Invalid(_)))); + // A multi-select list takes both options that share the value, in ascending /I. + let mut doc = duplicate_exports(1 << 21, ""); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![1, 0])).unwrap(); + let doc = reopen(&doc); + let dup = field(&fields(&doc), "dup").clone(); + assert_eq!(dup.value, ["1", "1"]); + assert_eq!(dup.selected, [0, 1]); + assert_eq!(dup.picked_in_order(), [0, 1]); + // A check box can't take positions. + let mut doc = fixture(); + assert!(matches!(set_value(&mut doc, "agree", &FieldValue::ChoiceIndices(vec![0])), Err(FormError::Invalid(_)))); +} + +/// A file can hold a list box with very many options and values. Matching values to options +/// takes one lookup per value, not a scan of the options (or of the selection) for each, so +/// reading and showing such a field stays fast. Two options share each export value, and every +/// result still names the first option exporting a value, or the next one selected. +#[test] +fn many_options_and_values_match_in_linear_time() { + const N: usize = 100_000; + let options: Vec<(String, String)> = (0..2 * N).map(|i| (format!("e{}", i / 2), format!("d{i}"))).collect(); + let values: Vec = (0..N).rev().map(|k| format!("e{k}")).collect(); + let started = std::time::Instant::now(); + let selected = selected_options(&options, &values, None); + assert_eq!(selected, (0..N).map(|k| 2 * k).collect::>(), "the first option exporting each value"); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(2), "selected_options took {took:?}"); + + let mut list = field(&fields(&fixture()), "toppings").clone(); + list.options = options; + list.value = values; + list.selected = (0..2 * N).collect(); + let started = std::time::Instant::now(); + let order = list.picked_in_order(); + let shown = list.display_value(); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(2), "picked_in_order and display_value took {took:?}"); + // The values name e{N-1} first: its first option, then down to e0; the second option of + // each value is selected without a value naming it, so it follows in ascending order. + assert_eq!(order.first(), Some(&(2 * (N - 1)))); + assert_eq!(order.get(N - 1), Some(&0)); + assert_eq!(order.get(N..), Some(&(0..N).map(|k| 2 * k + 1).collect::>()[..])); + assert!(shown.starts_with(&format!("d{}, d{}", 2 * (N - 1), 2 * (N - 2))), "{}", shown.get(..40).unwrap_or(&shown)); +} + +/// A one-page form with list box `pair` (single select unless `ff` says otherwise) of three +/// countries, plus `extra` entries (`/V`, `/DV`, `/I`) on the field. +fn countries(ff: u32, extra: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(), + "<< /Fields [6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + format!( + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Ff {ff} /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] {extra} /Rect [50 700 200 746] /P 3 0 R >>" + ), + ]; + document(&objs) +} + +/// ISO 32000-2 Table 233, MultiSelect: "if clear, at most one item shall be selected". A list box +/// without it given `/V [(TW) (JP)]` holds and shows Taiwan alone, and opening it changes nothing +/// in the file. +#[test] +fn a_single_select_list_given_several_values_shows_the_first() { + for (extra, multi, value, want) in [ + ("/V [(TW) (JP)]", 0, vec!["TW"], vec![0]), + ("/V [(JP) (TW)]", 0, vec!["JP"], vec![1]), + ("/V [(TW) (JP)] /I [0 1]", 0, vec!["TW"], vec![0]), // /I names more than the one value + ("/V [(XX) (JP)]", 0, vec!["XX"], vec![]), // the first value is no option + ("/V []", 0, vec![], vec![]), + ("/V [(TW) (JP)]", 1 << 21, vec!["TW", "JP"], vec![0, 1]), // multi-select keeps both + ] { + let doc = countries(multi, extra); + let pair = field(&fields(&doc), "pair").clone(); + assert_eq!(pair.value, value, "{extra}"); + assert_eq!(pair.selected, want, "{extra}"); + let copy = with_field_appearances(&doc).expect("left to the viewer"); + assert_eq!(highlighted_rows(&ap(©, &field(&fields(©), "pair").widgets[0])).len(), want.len(), "{extra}"); + // Reading and drawing the display copy leaves the field's own /V as it was. + let v = doc.get(pair.obj).as_dict().and_then(|d| d.get(b"V").cloned()).unwrap(); + assert!(matches!(v, Object::Array(_)), "{extra}: {v:?}"); + } + // Two options selected by /I alone in a single-select list: the first. + let doc = duplicate_exports(0, "/V (1) /I [0 1]"); + assert_eq!(field(&fields(&doc), "dup").selected, [0]); +} + +/// Picking a row in such a list writes one string to `/V` and that option's position to `/I`, +/// and Clear form goes back to the first value of an array `/DV` instead of refusing it. +#[test] +fn picking_in_a_single_select_list_given_several_values_writes_one() { + for (pick, export, index) in [(FieldValue::ChoiceIndices(vec![2]), "US", 2), (FieldValue::Choice(vec!["JP".into()]), "JP", 1)] { + let mut doc = countries(0, "/V [(TW) (JP)] /I [0 1]"); + set_value(&mut doc, "pair", &pick).unwrap(); + let doc = reopen(&doc); + let pair = field(&fields(&doc), "pair").clone(); + let d = doc.get(pair.obj).as_dict().cloned().unwrap(); + assert_eq!(d.get(b"V").and_then(|v| v.as_string()).map(|s| s.to_text()).as_deref(), Some(export), "{pick:?}: /V is one string"); + let i: Vec = d.get(b"I").and_then(|i| i.as_array()).unwrap().iter().filter_map(Object::as_int).collect(); + assert_eq!(i, [index], "{pick:?}"); + assert_eq!((pair.value, pair.selected), (vec![export.to_string()], vec![index as usize])); + } + let mut doc = countries(0, "/V (US) /DV [(JP) (TW)]"); + assert_eq!(reset(&mut doc, None).unwrap(), 1); + let pair = field(&fields(&doc), "pair").clone(); + assert_eq!((pair.value, pair.selected), (vec!["JP".to_string()], vec![1])); +} + +/// A position picked twice is written once in both `/V` and `/I`, so they still match item for +/// item and the option picked (the second of two sharing an export value) comes back on reopen. +#[test] +fn a_position_picked_twice_is_written_once_in_v_and_i() { + let mut doc = duplicate_exports(1 << 21, ""); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![1, 1])).unwrap(); + let dup = field(&fields(&doc), "dup").clone(); + let d = doc.get(dup.obj).as_dict().cloned().unwrap(); + let v: Vec = match d.get(b"V") { + Some(Object::Array(a)) => a.iter().filter_map(|o| o.as_string().map(|s| s.to_text())).collect(), + Some(o) => o.as_string().map(|s| vec![s.to_text()]).unwrap_or_default(), + None => Vec::new(), + }; + let i: Vec = d.get(b"I").and_then(|i| i.as_array()).map(|a| a.iter().filter_map(Object::as_int).collect()).unwrap_or_default(); + assert_eq!((v, i), (vec!["1".to_string()], vec![1]), "one entry each"); + assert_eq!(dup.selected, [1], "One-B, not One-A"); +} + +/// A same-name twin listing the options sharing an export value in another order is given the +/// option picked, not the one at the same position: `/I` is mapped to the twin's own options. +#[test] +fn a_twin_with_options_in_another_order_gets_the_option_picked() { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>".into(), + "<< /Fields [6 0 R 7 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Ff 0 /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /Rect [50 700 200 746] /P 3 0 R >>" + .into(), + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Ff 0 /Opt [[(1) (One-B)] [(1) (One-A)] [(2) (Two)]] /Rect [50 600 200 646] /P 3 0 R >>" + .into(), + ]; + let mut doc = document(&objs); + set_value(&mut doc, "dup", &FieldValue::ChoiceIndices(vec![1])).unwrap(); + let all = fields(&doc); + let twin = all.iter().find(|f| f.obj == pdfcraft_cos::ObjRef::new(7, 0)).unwrap(); + let i: Vec = doc + .get(twin.obj) + .as_dict() + .and_then(|d| d.get(b"I").and_then(|i| i.as_array().cloned())) + .unwrap_or_default() + .iter() + .filter_map(Object::as_int) + .collect(); + assert_eq!(i, [0], "One-B is first in the twin's options"); + assert_eq!(twin.selected, [0]); +} + /// 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..b4fa382189 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,5 +1,5 @@ use super::appearances::{declares_pdfa, need_appearances}; -use super::read::{acroform, field_flags, fields, listing_fields_named, page_refs}; +use super::read::{acroform, field_flags, fields, listed_indices, listing_fields_named, page_refs, selected_options}; use super::{ Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance, author, calc_order, flags, invalid, name_obj, scripting, @@ -117,7 +117,7 @@ fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: & if !twins.is_empty() { let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default(); for t in twins { - share_value(doc, f.obj, t, &taken, scripts)?; + share_value(doc, (f.obj, &f.options), t, &taken, scripts)?; } } // A field whose `/Kids` lists radio fields that don't name it as their `/Parent` has their @@ -133,17 +133,23 @@ fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: & Ok(()) } -/// Give `twin` the value `source` holds: its `/V` (and a choice's `/I`), drawn in the twin's own -/// widgets. -fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String], scripts: &mut dyn Scripts) -> Result<(), FormError> { +/// Give `twin` the value `source` (a field and its options) holds: its `/V`, and a choice's `/I` +/// mapped to the twin's own options, drawn in the twin's own widgets. +fn share_value( + doc: &mut Document, + (source, source_options): (ObjRef, &[(String, String)]), + twin: &Field, + value: &[String], + scripts: &mut dyn Scripts, +) -> Result<(), FormError> { 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() { @@ -153,18 +159,37 @@ fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String } FieldKind::Text | FieldKind::Combo | FieldKind::List => { let src = doc.get(source).as_dict().cloned().unwrap_or_default(); + // `/I` holds positions in the source's options: each goes to the twin's first unused option + // with the same export value and text, its options perhaps in another order. One with no + // match leaves the twin no `/I` (its `/V` picks), never part of one. + let positions = match twin.kind { + FieldKind::Combo | FieldKind::List => { + listed_indices(doc, &src, source_options.len()).and_then(|picked| twin_positions(&picked, source_options, &twin.options)) + } + _ => None, + }; doc.update_dict(twin.obj, |d| { - for key in [&b"V"[..], b"I"] { - match src.get(key) { - Some(v) => d.set(key.to_vec(), v.clone()), - None => { - d.remove(key); - } + match src.get(b"V") { + Some(v) => d.set(b"V".to_vec(), v.clone()), + None => { + d.remove(b"V"); + } + } + match &positions { + Some(p) => d.set(b"I".to_vec(), Object::Array(p.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect())), + None => { + d.remove(b"I"); } } d.remove(b"RV"); })?; - redraw(doc, twin, value, scripts) + // Drawn with the options that `/I` selects, not only the export values. + let mut shown = twin.clone(); + shown.value = value.to_vec(); + if matches!(twin.kind, FieldKind::Combo | FieldKind::List) { + shown.selected = selected_options(&twin.options, value, positions.as_deref()); + } + redraw(doc, &shown, value, scripts) } FieldKind::PushButton | FieldKind::Signature => Ok(()), } @@ -278,7 +303,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,25 +317,44 @@ 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 { - FieldValue::Check(on) => *on, - // The on-state name or a yes/no word also work (agents, FDF-style data). + // The kids of one check box field can have different on states, and the field's + // value is the on state of the kid that is on (ISO 32000-2 §12.7.5.2.3, Table 230). + // "Checked" keeps the state that is on, else takes the first kid's. + let first = f.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); + let checked = || (0..f.widgets.len()).find(|&i| f.widget_on(i)).and_then(|i| f.widgets.get(i)?.on_state.clone()).unwrap_or(first.clone()); + let state = match v { + FieldValue::Check(on) => on.then(checked), + // An on-state name (or `/Opt` export value) turns on the kids that have it; a + // yes/no word also works (agents, FDF-style data). FieldValue::Text(t) | FieldValue::Radio(Some(t)) => { let t = t.trim(); - if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { - true + if let Some(s) = f.state_for(t) { + Some(s.to_owned()) + } else if t == first || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) { + Some(checked()) } else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) { - false + None } else { return invalid(format!("{:?} is a check box: use true or false", f.name)); } } - FieldValue::Radio(None) => false, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + FieldValue::Radio(None) => None, + FieldValue::Choice(_) | FieldValue::ChoiceIndices(_) | 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, state.as_deref(), None) } (FieldKind::Radio, v) => { let choice = match v { @@ -331,39 +375,69 @@ 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 { - FieldValue::Choice(v) => v.clone(), - FieldValue::Text(t) if t.is_empty() => Vec::new(), - FieldValue::Text(t) => vec![t.clone()], - other => return invalid(format!("{:?} can't take {other:?}", f.name)), + (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_) | FieldValue::ChoiceIndices(_)) => { + let count = match value { + FieldValue::Choice(v) => v.len(), + FieldValue::ChoiceIndices(v) => v.len(), + _ => 1, }; - if vals.len() > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { + if count > 1 && !(f.kind == FieldKind::List && f.has(flags::MULTI_SELECT)) { return invalid(format!("{:?} takes a single value", f.name)); } - // Accept export values or display texts; free text only in editable combo boxes. + // Each value as its export value and, when it is one of the options, that option's + // position: two options can share an export value, and the position (`/I`) says + // which one is picked (ISO 32000-2 Table 234). let mut exports = Vec::new(); - for v in &vals { - match option_named(f, v).and_then(|i| f.options.get(i)) { - Some((e, _)) => exports.push(e.clone()), - None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), - None if f.options.is_empty() => exports.push(v.clone()), - None => { - let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); - return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + let mut picked: Vec = Vec::new(); + // Each option once in both `/V` and `/I`: a reader that finds them differ (a repeat in one) + // goes by the export values alone and may pick another option sharing one. + let mut seen = vec![false; f.options.len()]; + match value { + FieldValue::ChoiceIndices(positions) => { + for &i in positions { + let Some((e, _)) = f.options.get(i) else { + return invalid(format!("{:?} has no option {} (it has {})", f.name, i.saturating_add(1), f.options.len())); + }; + if first_pick(&mut seen, i) { + exports.push(e.clone()); + picked.push(i); + } + } + } + _ => { + let vals: Vec = match value { + FieldValue::Choice(v) => v.clone(), + FieldValue::Text(t) if t.is_empty() => Vec::new(), + FieldValue::Text(t) => vec![t.clone()], + other => return invalid(format!("{:?} can't take {other:?}", f.name)), + }; + // Accept export values or display texts; free text only in editable combo boxes. + for v in &vals { + match option_named(f, v) { + Some(i) => { + if first_pick(&mut seen, i) { + exports.push(f.options.get(i).map(|(e, _)| e.clone()).unwrap_or_else(|| v.clone())); + picked.push(i); + } + } + None if f.kind == FieldKind::Combo && f.has(flags::EDIT) => exports.push(v.clone()), + None if f.options.is_empty() => exports.push(v.clone()), + None => { + let opts: Vec<&str> = f.options.iter().map(|(_, d)| d.as_str()).collect(); + return invalid(format!("{:?} has no option {v:?} (options: {})", f.name, opts.join(", "))); + } + } } } } - let mut indices: Vec = - exports.iter().filter_map(|e| f.options.iter().position(|(x, _)| x == e)).map(|i| Object::Int(i as i64)).collect(); - // /I lists option indices in ascending order; /V keeps the caller's value order. - // Every entry above is an integer, so this sorts by its numeric option index. - indices.sort_unstable_by_key(Object::as_int); + // /I lists option indices in ascending order, each once; /V keeps the caller's order. + picked.sort_unstable(); + picked.dedup(); + let indices: Vec = picked.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect(); // A list box scrolls (`/TI`) so the picked options are among the rows it draws, as // in other viewers: a pick below the last visible row would otherwise not show (#1524). - let picked: Vec = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect(); let top = list_scroll(f, &picked, f.options.len()); // An empty value of its own keeps a parent field's /V from showing through. let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); @@ -388,7 +462,10 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } })?; scroll_widgets(doc, f, &picked, f.options.len())?; - redraw(doc, f, &exports, scripts) + let mut shown = f.clone(); + shown.value = exports.clone(); + shown.selected = picked; + redraw(doc, &shown, &exports, scripts) } (FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)), (FieldKind::Signature, _) => invalid(format!("{:?} is a signature field; sign it with Fill & Sign or a digital ID", f.name)), @@ -425,13 +502,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. @@ -753,7 +841,7 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result FieldValue::Text(f.default.first().cloned().unwrap_or_default()), - FieldKind::CheckBox => FieldValue::Check(!f.default.is_empty()), + FieldKind::CheckBox => f.check_box_value(f.default.first().map(String::as_str)), FieldKind::Radio => FieldValue::Radio(f.default.first().cloned()), FieldKind::Combo | FieldKind::List => FieldValue::Choice(f.default.clone()), FieldKind::PushButton | FieldKind::Signature => continue, @@ -767,3 +855,22 @@ pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result bool { + match seen.get_mut(i) { + Some(taken) if !*taken => { + *taken = true; + true + } + _ => false, + } +} + +/// The twin's positions for the source's picked positions `picked`, as +/// [`crate::option_positions_in`] maps them, in ascending order, as `/I` lists them. +fn twin_positions(picked: &[usize], source: &[(String, String)], twin: &[(String, String)]) -> Option> { + let mut out = crate::option_positions_in(picked, source, twin)?; + out.sort_unstable(); + Some(out) +} 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..c05aeaa1e3 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; @@ -42,4 +42,4 @@ pub use settings::{ }; #[cfg(test)] use sheets::text_direction; -pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up}; +pub use sheets::{LABEL_MARGIN, MARGIN, Placement, Sheet, layout, matching_paper, text_reads_up, texts_read_up}; 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..dfdf4b7644 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -7,7 +7,17 @@ use super::{DecodeBudget, Dict, Document, Matrix, Object}; /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects /// are followed a few levels deep. pub fn text_reads_up(doc: &Document, index: usize) -> bool { - let Some(page) = pdfcraft_model::pages(doc).into_iter().nth(index) else { return false }; + texts_read_up(doc, &[index]).first().copied().unwrap_or(false) +} + +/// [`text_reads_up`] for each of `indices`, reading the page tree once: once per page, the +/// whole tree would be walked for every page asked about. +pub fn texts_read_up(doc: &Document, indices: &[usize]) -> Vec { + let pages = pdfcraft_model::pages(doc); + indices.iter().map(|&i| pages.get(i).is_some_and(|page| page_reads_up(doc, page))).collect() +} + +fn page_reads_up(doc: &Document, page: &pdfcraft_model::Page) -> bool { let to_display = Matrix(page.view_matrix(doc)).invert().unwrap_or_default(); let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); let mut score = 0i64; @@ -20,7 +30,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 +50,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..4f77607bde 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,29 @@ 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))); +} + +/// Many pages asked about at once are answered from one walk of the page tree, each as +/// [`text_reads_up`] answers it alone; a page past the end is `false`. +#[test] +fn texts_read_up_answers_many_pages_from_one_walk() { + let doc = fixture(3_000); + let all: Vec = (0..3_001).collect(); + let started = std::time::Instant::now(); + let up = texts_read_up(&doc, &all); + assert!(started.elapsed() < std::time::Duration::from_secs(20), "{:?}", started.elapsed()); + assert_eq!(up.len(), all.len()); + assert!(up.iter().all(|u| !u), "upright text, and no page 3000"); + assert_eq!(up[1], text_reads_up(&doc, 1)); +} 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/lib.rs b/crates/redact/src/lib.rs index 19a0d188df..a607b5ba44 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -961,3 +961,15 @@ pub fn clear_marks(doc: &mut Document, pages: Option<&[usize]>) -> Result String { + let cut = name.len().saturating_sub(4); + let stem = match (name.get(..cut), name.get(cut..)) { + (Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => stem, + _ => name, + }; + if stem.ends_with("_Redacted") { format!("{stem}.pdf") } else { format!("{stem}_Redacted.pdf") } +} diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 3e7126c79c..73e8d9d0e6 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), @@ -778,37 +820,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(); - let kept_before = crate::globals::kept(doc)?; - // 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 { @@ -820,13 +840,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 { @@ -857,12 +878,61 @@ 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(); + // What a full save keeps before anything is removed: an image that leaves, by whatever route + // below, takes a shared JBIG2 dictionary with it (`unshare_unmarked`, last). + let kept_before = crate::globals::kept(doc)?; + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); + // Pages' annotation lists change only for these; otherwise every page is left as it is. + 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 4453d87aae..9e60668483 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -3371,6 +3371,17 @@ fn clearing_marks_leaves_the_annotation_list_as_it_was() { assert_eq!((doc.get(page), doc.get(list)), before, "the page points at its array, which lists the link alone"); } +#[test] +fn redacted_copies_are_named_with_redacted_before_the_extension() { + assert_eq!(redacted_file_name("contract.pdf"), "contract_Redacted.pdf"); + assert_eq!(redacted_file_name("SCAN.PDF"), "SCAN_Redacted.pdf"); + assert_eq!(redacted_file_name("Report.Pdf"), "Report_Redacted.pdf"); + // A document without the extension (opened from bytes) gets it. + assert_eq!(redacted_file_name("Untitled"), "Untitled_Redacted.pdf"); + // Redacting a redacted copy again does not add a second suffix. + assert_eq!(redacted_file_name("contract_Redacted.pdf"), "contract_Redacted.pdf"); +} + #[test] fn unreadable_content_fails_closed() { let mut doc = one_page(b"", "", vec![]); @@ -5202,6 +5213,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..a815802464 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -25,6 +25,9 @@ log = { workspace = true } serde = { workspace = true } # Reads rendered pixel words as bytes; already in the tree through hayro. bytemuck = { version = "1" } +# Resolves relative URI actions against the catalog's /URI /Base, by the rules the browser uses; +# already in the tree (pdfcraft-engine checks document links with it). +url = "2.5" # Tells combining marks (bidi class NSM: general categories Mn and Me) in extracted text; the # version pdfcraft-fonts already uses. unicode-bidi = "0.3" @@ -34,6 +37,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..d0cc5defff 100644 --- a/crates/render/src/inspect.rs +++ b/crates/render/src/inspect.rs @@ -36,14 +36,16 @@ pub use core::{check_pages, inspect, pretty_date}; #[cfg(test)] 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, + Annotation, Attachment, AttachmentSource, DestView, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, + OutlineItem, PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, link_replies, thread_tops, }; #[cfg(test)] use navigation::{OUTLINE_DEPTH, OUTLINE_WINDOW}; +#[cfg(test)] +use resources::LABEL_DECODES; pub use resources::attachment_data; #[cfg(test)] -use resources::{xfa_config_dynamic_render, xfa_packet_text}; +use resources::{MAX_LAYER_LABEL_CHARS, MAX_LAYER_ORDER_ROWS, xfa_config_dynamic_render, xfa_packet_text}; #[derive(Clone, Debug, Default)] pub struct DocInfo { @@ -70,10 +72,17 @@ pub struct DocInfo { pub annotations: Vec, pub fields: Vec, pub links: Vec, + /// The catalog's `/URI /Base` (ISO 32000-2 Table 211), the base for relative URI actions, when + /// it is a string of at most [`crate::MAX_URI_BASE_BYTES`]. + pub uri_base: Option, pub layers: Vec, /// Radio-button layer groups (`/OCProperties /D /RBGroups`): turning one layer of a group on /// turns the others off. pub layer_groups: Vec>, + /// The Layers panel's rows: the default configuration's `/Order` (ISO 32000-2 Table 99), a + /// tree of layers (indices into `layers`) and labels. A layer it doesn't list isn't presented, + /// though it still draws in its state; no `/Order` presents none. + pub layer_order: Vec, pub fonts: Vec, pub attachments: Vec, /// Pages with a presentation transition (`/Trans`) or display time (`/Dur`), in page order. diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 1cc7a6eae1..780ad71508 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}; @@ -15,11 +17,13 @@ const MAX_REMOTE_DESTINATION_NAME_BYTES: usize = 1024; /// are not listed: real comments stay far below, many small ones sharing one long string do not. pub(super) const MAX_ANNOTATION_BYTES: usize = 64 << 20; -/// Web-address text the bookmark and link listings, and name, description and embedded-file key -/// text the attachment listing, keep together. Many bookmarks, links or attachments can share one -/// long string, which each would otherwise copy. Past it, a bookmark or link to a web address lists -/// as a URI action without its address, an attachment under its generic name without a -/// description, and an embedded file whose key does not fit is not listed. +/// Web-address text and `/QuadPoints` geometry the bookmark and link listings, and name, +/// description and embedded-file key text the attachment listing, keep together. Many bookmarks, +/// links or attachments can share one long string or array, which each would otherwise copy. Past +/// it, a bookmark or link to a web address lists as a URI action without its address (or a link +/// keeps a relative address unresolved against the document's base), a link is active over its +/// whole `/Rect`, an attachment is listed under its generic name without a description, and an +/// embedded file whose key does not fit is not listed. pub(super) const MAX_LINK_ATTACHMENT_TEXT_BYTES: usize = 16 << 20; /// A colour array as RGB, read as pdfcraft-annot's appearance builder reads it (`appearance::color` @@ -85,6 +89,9 @@ impl<'a> Inspector<'a> { /// `text_budget` is what is left of [`MAX_LINK_ATTACHMENT_TEXT_BYTES`]. pub(super) fn annotations(&self, info: &mut DocInfo, text_budget: &mut usize) { + // Indirect `/QuadPoints` arrays whose quadrilaterals did not fit what was left of the budget: + // the budget only shrinks, so they are not read again (review on #4474). + let mut unfit_quads: HashSet = HashSet::new(); let mut pages: Vec<(ObjectId, usize)> = self.page_index.iter().map(|(&id, &page)| (id, page)).collect(); pages.sort_unstable_by_key(|&(_, page)| page); // An annotation, or a whole `/Annots` array, that several entries or pages list is on the @@ -136,7 +143,7 @@ impl<'a> Inspector<'a> { info.attachments.push(self.attachment(fs, AttachmentSource::Annotation { page, index }, text_budget, fallback)); } if subtype == "Link" { - if let Some(link) = self.link(page, index, d, text_budget) { + if let Some(link) = self.link(page, index, d, info.uri_base.as_deref(), text_budget, &mut unfit_quads) { if matches!(&link.target, LinkTarget::Other(s) if s == "JavaScript") { info.has_javascript = true; } @@ -215,7 +222,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"), @@ -236,16 +243,64 @@ impl<'a> Inspector<'a> { /// The link annotation `d`, at position `index` of page `page`'s `/Annots`: where its `/Dest` /// goes, else what its action `/A` does ([`Self::action_target`]). `None` when it has no - /// rectangle, or a destination or action that leads nowhere. - fn link(&self, page: usize, index: usize, d: &Dictionary, text_budget: &mut usize) -> Option { + /// rectangle, or a destination or action that leads nowhere. `base` is the catalog's + /// `/URI /Base`, which a relative URI action is resolved against. `unfit_quads` holds the + /// indirect `/QuadPoints` arrays already found too large for the budget. + fn link( + &self, + page: usize, + index: usize, + d: &Dictionary, + base: Option<&str>, + text_budget: &mut usize, + unfit_quads: &mut HashSet, + ) -> Option { let rect = rect4(self.resolve(d.get(b"Rect").ok()?)); let target = if let Ok(dest) = d.get(b"Dest") { let (page, view) = self.dest(dest, 0)?; LinkTarget::Page(page, view) } else { - self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? + match self.action_target(d.get(b"A").ok().and_then(|a| self.dict(a))?, text_budget)? { + LinkTarget::Uri(uri) => match crate::resolve_uri(base, &uri) { + // The raw address is spent already. What the base adds is spent too, or the + // link keeps the raw address. + Some(resolved) if resolved.len().saturating_sub(uri.len()) <= *text_budget => { + *text_budget = text_budget.saturating_sub(resolved.len().saturating_sub(uri.len())); + LinkTarget::Uri(resolved) + } + _ => LinkTarget::Uri(uri), + }, + other => other, + } }; - Some(Link { page, index, rect, target }) + // Not read at all once no quadrilateral would fit what is left of the budget, or when this + // shared array already did not fit: many links can share one long array, and reading it + // again for each would cost its length every time (review on #4474). + let shared = d.get(b"QuadPoints").ok().and_then(|o| o.as_reference().ok()); + let readable = *text_budget >= std::mem::size_of::<[f32; 8]>() && shared.is_none_or(|id| !unfit_quads.contains(&id)); + let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) { + // Read no further than one value past the cap: a longer array isn't used. + Ok(Object::Array(q)) if readable && q.len() <= crate::MAX_LINK_QUADS.saturating_mul(8) => { + #[cfg(test)] + QUAD_ARRAYS_READ.with(|n| n.set(n.get() + 1)); + let values: Vec> = q.iter().map(|o| self.resolve(o).as_float().ok()).collect(); + let quads = crate::link_quads(&values, rect); + // Many links can share one long array, which each would otherwise copy. Past the + // budget a link is active over its whole `/Rect`. + let bytes = std::mem::size_of_val(quads.as_slice()); + if bytes <= *text_budget { + *text_budget = text_budget.saturating_sub(bytes); + quads + } else { + if let Some(id) = shared { + unfit_quads.insert(id); + } + Vec::new() + } + } + _ => Vec::new(), + }; + Some(Link { page, index, rect, quads, target }) } /// What an action does when a link or a bookmark runs it. Only a go-to's `/D` is a place in @@ -641,3 +696,9 @@ fn rect4(o: &Object) -> [f32; 4] { _ => [0.0; 4], } } + +#[cfg(test)] +thread_local! { + /// `/QuadPoints` arrays whose values were read. + pub(super) static QUAD_ARRAYS_READ: std::cell::Cell = const { std::cell::Cell::new(0) }; +} diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index c5df792f23..e4ead8d264 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 @@ -221,6 +222,10 @@ impl<'a> Inspector<'a> { Ok(notes) => info.warnings.extend(notes.into_iter().map(|note| format!("Page labels: {note}."))), Err(reason) => info.warnings.push(format!("Page labels were skipped: {reason}; using physical page numbers.")), } + info.uri_base = catalog.get(b"URI").ok().and_then(|o| self.dict(o)).and_then(|d| match d.get(b"Base").map(|o| self.resolve(o)) { + Ok(Object::String(bytes, _)) if bytes.len() <= crate::MAX_URI_BASE_BYTES => std::str::from_utf8(bytes).ok().map(str::to_owned), + _ => None, + }); let mut text_budget = MAX_LINK_ATTACHMENT_TEXT_BYTES; if let Some(first) = catalog.get(b"Outlines").ok().and_then(|o| self.dict(o)).and_then(|d| d.get(b"First").ok()) { (info.outline, info.outline_more) = self.outline_window(first, &mut text_budget); @@ -266,6 +271,7 @@ impl<'a> Inspector<'a> { } self.layers(catalog, &mut info.layers); info.layer_groups = self.layer_groups(catalog); + info.layer_order = self.layer_order(catalog, &info.layers); self.fonts(&mut info.fonts); if let Some(names) = catalog.get(b"Names").ok().and_then(|o| self.dict(o)) { if let Some(ef) = names.get(b"EmbeddedFiles").ok().and_then(|o| self.dict(o)) { diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index 981de248ae..b721572790 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)] @@ -196,13 +196,39 @@ pub struct Link { /// Zero-based position in the page's raw `/Annots` array. pub index: usize, pub rect: [f32; 4], + /// Where the link is active when that isn't the whole of `rect`: its `/QuadPoints`, 8 numbers + /// each in user space (ISO 32000-2 Table 176). Empty when the link is active over `rect` + /// ([`crate::link_quads`]). + pub quads: Vec<[f32; 8]>, pub target: LinkTarget, } +impl Link { + /// Whether the user-space point `(x, y)` activates the link: inside one of its quadrilaterals, + /// or inside its rectangle when it has none. + pub fn contains(&self, x: f32, y: f32) -> bool { + let [x0, y0, x1, y1] = self.rect; + if self.quads.is_empty() { + return x >= x0 && x <= x1 && y >= y0 && y <= y1; + } + // Every kept quadrilateral lies within `/Rect` give or take the slack `link_quads` + // accepts, so a point outside that is in none of them: skip the scan (up to + // `MAX_LINK_QUADS` per link, run per link on every pointer move). + let s = crate::link_area::RECT_SLACK; + if !(x >= x0 - s && x <= x1 + s && y >= y0 - s && y <= y1 + s) { + return false; + } + self.quads.iter().any(|q| crate::quad_contains(q, x, y)) + } +} + #[derive(Clone, Debug, PartialEq)] pub enum LinkTarget { /// A destination in this document: the 0-based page and where on it the view goes. Page(usize, DestView), + /// A URI action's address, resolved against the catalog's `/URI /Base` when it is a relative + /// reference ([`crate::resolve_uri`]); not yet checked, as the opener must + /// (`pdfcraft_engine::links::document_url`). Uri(String), /// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in /// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned @@ -425,10 +451,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 +467,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)] @@ -507,6 +678,23 @@ pub struct Layer { pub initial: bool, } +/// One row of the Layers panel ([`DocInfo::layer_order`]). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayerRow { + /// Nesting level, 0 at the top: a nested array's entries are one deeper than the layer or + /// label before them. + pub depth: usize, + pub item: LayerItem, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum LayerItem { + /// A layer, by its index in [`DocInfo::layers`]. + Layer(usize), + /// A text label heading a nested array: not a layer, so it can't be selected or toggled. + Label(String), +} + #[derive(Clone, Debug)] pub struct Attachment { pub name: String, @@ -542,3 +730,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/inspect/resources.rs b/crates/render/src/inspect/resources.rs index b8d25efc02..75c65c6bf9 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -1,9 +1,9 @@ #[cfg(test)] use super::core::inspect; use super::core::{Inspector, load_options}; -use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; -use super::navigation::NameTreeSeen; -use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind}; +use super::model::{Attachment, AttachmentSource, FontInfo, Layer, LayerItem, LayerRow, PageTransition, TransitionStyle}; +use super::navigation::{NameTreeSeen, text_string}; +use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashMap, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to /// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. @@ -12,6 +12,25 @@ const MAX_XFA_CONFIG_BYTES: usize = 16 << 20; /// At most this many layers are read from all of `/RBGroups` together. const MAX_LAYER_GROUP_ENTRIES: usize = 4096; +/// The configuration's auto state (`/AS`): at most this many usage application dictionaries, +/// `/OCGs` entries across them, and `/Category` names in each are read. The renderer's +/// (vendored hayro-interpret patch (103)) have the same values. +const MAX_AUTO_STATE_APPLICATIONS: usize = 256; +const MAX_AUTO_STATE_GROUPS: usize = 4096; +const MAX_AUTO_STATE_CATEGORIES: usize = 16; + +/// The Layers panel's rows from `/Order`: at most this many rows, nested at most this deep, and +/// a label keeps at most this many characters. +pub(super) const MAX_LAYER_ORDER_ROWS: usize = 4096; +const MAX_LAYER_ORDER_DEPTH: usize = 32; +pub(super) const MAX_LAYER_LABEL_CHARS: usize = 256; + +#[cfg(test)] +thread_local! { + /// Layer labels decoded by [`Inspector::layer_order`] on this thread. + pub(super) static LABEL_DECODES: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + impl<'a> Inspector<'a> { // ── object helpers ────────────────────────────────────────────────────────────────────── @@ -100,11 +119,14 @@ impl<'a> Inspector<'a> { .and_then(|o| self.resolve(o).as_array().ok()) .map(|a| a.iter().filter_map(|x| x.as_reference().ok()).collect()) .unwrap_or_default(); + let auto = config.map(|c| self.view_auto_state(c)).unwrap_or_default(); let Ok(Object::Array(ocgs)) = props.get(b"OCGs").map(|o| self.resolve(o)) else { return }; for g in ocgs { let Some(d) = self.dict(g) else { continue }; let id = g.as_reference().ok(); + // The View auto state, when it manages the group, then the configuration. let visible = match id { + Some(id) if let Some(&state) = auto.get(&id) => state, Some(id) if off.contains(&id) => false, Some(id) if on.contains(&id) => true, _ => !base_off, @@ -114,6 +136,129 @@ impl<'a> Inspector<'a> { } } + /// The state the configuration's auto state (`/AS`) gives each group it manages for the View + /// event (ISO 32000-2 §8.11.4.4, Table 101), which overrides `/BaseState`, `/ON` and `/OFF` + /// as the document opens. Keep in step with the renderer's (vendored hayro-interpret patch + /// (103), `apply_view_auto_state`): each usage application with `/Event /View` and `/View` in + /// its `/Category` recommends the `/ViewState` of each group in its `/OCGs` whose `/Usage` + /// gives one; a group is on only if all its recommendations are. Groups with none are absent. + /// Other categories (`/Zoom`, `/User`, `/Language`) give no recommendation here. + fn view_auto_state(&self, config: &Dictionary) -> HashMap { + let mut out: HashMap = HashMap::new(); + let Some(applications) = config.get(b"AS").ok().and_then(|o| self.resolve(o).as_array().ok()) else { return out }; + let mut groups_left = MAX_AUTO_STATE_GROUPS; + for application in applications.iter().take(MAX_AUTO_STATE_APPLICATIONS) { + let Some(application) = self.dict(application) else { continue }; + if self.name(application, b"Event").as_deref() != Some("View") { + continue; + } + let views = application.get(b"Category").ok().and_then(|o| self.resolve(o).as_array().ok()).is_some_and(|categories| { + categories.iter().take(MAX_AUTO_STATE_CATEGORIES).any(|c| matches!(c, Object::Name(n) if n.as_slice() == b"View")) + }); + if !views { + continue; + } + let Some(groups) = application.get(b"OCGs").ok().and_then(|o| self.resolve(o).as_array().ok()) else { continue }; + for g in groups { + if groups_left == 0 { + return out; + } + groups_left -= 1; + let Ok(id) = g.as_reference() else { continue }; + let state = self + .dict(g) + .and_then(|d| d.get(b"Usage").ok()) + .and_then(|o| self.dict(o)) + .and_then(|u| u.get(b"View").ok()) + .and_then(|o| self.dict(o)) + .and_then(|v| self.name(v, b"ViewState")); + let on = match state.as_deref() { + Some("ON") => true, + Some("OFF") => false, + _ => continue, + }; + let entry = out.entry(id).or_insert(true); + *entry &= on; + } + } + out + } + + /// The Layers panel's rows: the default configuration's `/Order` (ISO 32000-2 Table 99). Its + /// entries are groups, shown by name, and nested arrays, shown as a tree one level deeper; + /// a nested array may start with a text string, a label that is not a group. Groups not + /// listed aren't presented, and `/Order` defaults to the empty array in the default + /// configuration, so a document without one presents no layers. An entry that is neither a + /// group in `/OCGs` nor an array is skipped. Bounded by `MAX_LAYER_ORDER_*`, an indirect + /// array is read once, so a cycle ends, and an indirect label is decoded once, however many + /// arrays it heads. + pub(super) fn layer_order(&self, catalog: &Dictionary, layers: &[Layer]) -> Vec { + let mut out = Vec::new(); + let Some(config) = catalog.get(b"OCProperties").ok().and_then(|o| self.dict(o)).and_then(|p| p.get(b"D").ok()).and_then(|o| self.dict(o)) + else { + return out; + }; + let Some(order) = config.get(b"Order").ok() else { return out }; + let index: HashMap = layers.iter().enumerate().map(|(i, l)| ((l.id.0, l.id.1), i)).collect(); + let mut seen = HashSet::new(); + if let Object::Reference(id) = order { + seen.insert(*id); + } + if let Ok(items) = self.resolve(order).as_array() { + self.layer_order_rows(items, 0, &index, &mut seen, &mut HashMap::new(), &mut out); + } + out + } + + /// `labels` holds each indirect label already decoded: one string object can head any + /// number of direct arrays, and each decode costs its full length before the cut. + fn layer_order_rows( + &self, + items: &'a [Object], + depth: usize, + index: &HashMap, + seen: &mut HashSet, + labels: &mut HashMap, + out: &mut Vec, + ) { + if depth > MAX_LAYER_ORDER_DEPTH { + return; + } + for item in items { + if out.len() >= MAX_LAYER_ORDER_ROWS { + return; + } + if let Object::Reference(id) = item { + if let Some(&layer) = index.get(id) { + out.push(LayerRow { depth, item: LayerItem::Layer(layer) }); + continue; + } + // An indirect nested array, read once. + if !seen.insert(*id) { + continue; + } + } + let Ok(nested) = self.resolve(item).as_array() else { continue }; + let rest = match nested.split_first() { + Some((first, rest)) if let Object::String(bytes, _) = self.resolve(first) => { + let decode = || { + #[cfg(test)] + LABEL_DECODES.with(|n| n.set(n.get().saturating_add(1))); + text_string(bytes).chars().take(MAX_LAYER_LABEL_CHARS).collect::() + }; + let label = match first { + Object::Reference(id) => labels.entry(*id).or_insert_with(decode).clone(), + _ => decode(), + }; + out.push(LayerRow { depth, item: LayerItem::Label(label) }); + rest + } + _ => nested.as_slice(), + }; + self.layer_order_rows(rest, depth.saturating_add(1), index, seen, labels, out); + } + } + /// The default configuration's radio-button groups (`/RBGroups`) of two or more layers. pub(super) fn layer_groups(&self, catalog: &Dictionary) -> Vec> { let groups = catalog diff --git a/crates/render/src/inspect/tests.rs b/crates/render/src/inspect/tests.rs index d39b84db02..5d20412202 100644 --- a/crates/render/src/inspect/tests.rs +++ b/crates/render/src/inspect/tests.rs @@ -769,6 +769,103 @@ trailer << /Root 1 0 R >> assert_eq!(layers, [("Red", true), ("Green", false), ("Blue", false)]); } +/// ISO 32000-2 Table 99 `/Order`: the Layers panel presents the groups it lists, as a tree, +/// with nested arrays one level deeper and a leading text string as a label. Groups it +/// doesn't list are not presented (they stay in `layers` and keep their state), and without +/// `/Order` none are. Malformed entries are skipped; cycles and depth are bounded. +#[test] +fn the_layers_panel_presents_the_order_tree() { + let rows = |order: &str, extra: &str| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R 8 0 R] /D << /OFF [8 0 R] {order} >> >> >> 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] >> endobj +5 0 obj << /Type /OCG /Name (A) >> endobj +6 0 obj << /Type /OCG /Name (B) >> endobj +7 0 obj << /Type /OCG /Name (C) >> endobj +8 0 obj << /Type /OCG /Name (D) >> endobj +9 0 obj << /Type /OCG /Name (Unlisted) >> endobj +{extra} +trailer << /Root 1 0 R >> +%%EOF" + ); + let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); + assert_eq!(info.layers.len(), 4, "every group in /OCGs keeps its state: {order}"); + info.layer_order + .iter() + .map(|r| match &r.item { + LayerItem::Layer(i) => format!("{}{}", " ".repeat(r.depth), info.layers.get(*i).map_or("?", |l| l.name.as_str())), + LayerItem::Label(s) => format!("{}[{s}]", " ".repeat(r.depth)), + }) + .collect::>() + }; + // A layer with sublayers, and a group not listed (D) that isn't presented. + assert_eq!(rows("/Order [5 0 R 6 0 R [7 0 R]]", ""), ["A", "B", " C"]); + // A labelled collection heads its own level, with a nested array under B. + assert_eq!(rows("/Order [5 0 R [(Set) 6 0 R [7 0 R]] 8 0 R]", ""), ["A", "[Set]", " B", " C", "D"]); + // Without /Order, or with an empty one, nothing is presented. + assert!(rows("", "").is_empty()); + assert!(rows("/Order []", "").is_empty()); + // An indirect /Order and nested array; a group outside /OCGs, a missing object and junk + // are skipped. + assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 11 0 R 9 0 R 12 0 R 1 (x) /N] endobj\n11 0 obj [6 0 R] endobj"), ["A", " B"]); + // Cycles: an array holding itself, and an /Order holding itself. + assert_eq!(rows("/Order 10 0 R", "10 0 obj [5 0 R 10 0 R 11 0 R] endobj\n11 0 obj [6 0 R 11 0 R 10 0 R] endobj"), ["A", " B"]); + // Nesting past the limit is cut off; a long label is cut short. + let deep = format!("/Order [5 0 R {}6 0 R{}]", "[".repeat(40), "]".repeat(40)); + assert_eq!(rows(&deep, ""), ["A"]); + let long = format!("/Order [[({}) 5 0 R]]", "x".repeat(1000)); + let got = rows(&long, ""); + assert_eq!(got.first().map(|s| s.len()), Some(MAX_LAYER_LABEL_CHARS + 2)); + // Many rows stop at the cap. + let many = format!("/Order [{}]", "5 0 R ".repeat(MAX_LAYER_ORDER_ROWS + 10)); + assert_eq!(rows(&many, "").len(), MAX_LAYER_ORDER_ROWS); + // One long indirect label heading many direct arrays is decoded once, and cut short in + // every row. + let shared = format!("/Order [{}]", "[13 0 R 5 0 R] ".repeat(100)); + let label = format!("13 0 obj ({}) endobj", "y".repeat(10_000)); + let before = LABEL_DECODES.with(std::cell::Cell::get); + let got = rows(&shared, &label); + assert_eq!(LABEL_DECODES.with(std::cell::Cell::get) - before, 1); + assert_eq!(got.len(), 200); + assert!(got.iter().step_by(2).all(|s| s.len() == MAX_LAYER_LABEL_CHARS + 2), "{:?}", got.first()); +} + +/// ISO 32000-2 §8.11.4.4: the View auto state (`/AS`) decides the initial state of the groups +/// it manages, over `/ON` and `/OFF`; the others keep the configuration's. Malformed entries +/// are ignored. +#[test] +fn initial_layer_visibility_applies_the_view_auto_state() { + let initial = |config: &str| { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R 6 0 R 7 0 R] /D {config} >> >> 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] >> endobj +5 0 obj << /Type /OCG /Name (ViewOff) /Usage << /View << /ViewState /OFF >> >> >> endobj +6 0 obj << /Type /OCG /Name (ViewOn) /Usage 8 0 R >> endobj +7 0 obj << /Type /OCG /Name (Plain) >> endobj +8 0 obj << /View << /ViewState /ON >> >> endobj +9 0 obj 9 0 R endobj +trailer << /Root 1 0 R >> +%%EOF" + ); + let info = inspect(Arc::new(pdf.into_bytes()), None).expect("opens"); + // Opened, so each is shown as it starts. + info.layers.iter().inspect(|l| assert_eq!(l.initial, l.visible, "{}", l.name)).map(|l| l.initial).collect::>() + }; + let managed = "/AS [<< /Event /View /Category [/View] /OCGs [5 0 R 6 0 R 7 0 R] >>]"; + assert_eq!(initial(&format!("<< /OFF [6 0 R 7 0 R] {managed} >>")), [false, true, false]); + assert_eq!(initial("<< /OFF [6 0 R 7 0 R] >>"), [true, false, false], "without /AS"); + assert_eq!(initial("<< /OFF [6 0 R] /AS [<< /Event /Print /Category [/View] /OCGs [5 0 R 6 0 R] >>] >>"), [true, false, true], "Print event"); + assert_eq!(initial("<< /AS [<< /Event /View /Category [/Zoom] /OCGs [5 0 R] >>] >>"), [true, true, true], "Zoom only"); + for bad in ["/AS 9 0 R", "/AS [9 0 R 1 null]", "/AS [<< /Event /View /OCGs [5 0 R] >>]", "/AS [<< /Event /View /Category [/View] /OCGs 9 0 R >>]"] + { + assert_eq!(initial(&format!("<< {bad} >>")), [true, true, true], "{bad}"); + } +} + const ORPHAN_FIELDS: &[u8] = b"%PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj @@ -1044,6 +1141,152 @@ fn a_named_attachment_leaves_its_contents_unspent() { assert!(matches!(&info.links[0].target, LinkTarget::Uri(u) if *u == uri)); } +#[test] +fn link_hit_test_rejects_points_outside_the_rect_before_scanning_quads() { + // A quadrilateral reaching past `/Rect` (hand-built: `link_quads` keeps none beyond the + // slack) shows whether the bounds gate runs before the quadrilateral scan. + let link = Link { + page: 0, + index: 0, + rect: [0.0, 0.0, 10.0, 10.0], + quads: vec![[0.0, 0.0, 50.0, 0.0, 50.0, 10.0, 0.0, 10.0]], + target: LinkTarget::Other("URI".into()), + }; + assert!(link.contains(5.0, 5.0), "inside the rect and the quadrilateral"); + assert!(link.contains(10.005, 5.0), "within the slack `link_quads` accepts"); + assert!(!link.contains(30.0, 5.0), "outside the rect: no quadrilateral is scanned"); + assert!(!link.contains(f32::NAN, 5.0)); +} + +#[test] +fn a_link_whose_quads_have_no_area_is_active_over_its_rect() { + let rect = [10.0, 10.0, 200.0, 30.0]; + let link = |values: &[f32]| Link { + page: 0, + index: 0, + rect, + quads: crate::link_quads(&values.iter().map(|v| Some(*v)).collect::>(), rect), + target: LinkTarget::Other("URI".into()), + }; + let flat = [10.0, 20.0, 60.0, 20.0, 60.0, 20.0, 10.0, 20.0]; + assert!(link(&flat).contains(100.0, 25.0), "no usable quad: the whole /Rect"); + let good = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]; + let mixed: Vec = flat.iter().chain(good.iter()).copied().collect(); + assert!(link(&mixed).contains(30.0, 25.0), "inside the usable quad"); + assert!(!link(&mixed).contains(100.0, 25.0), "the usable quad still gates the /Rect"); +} + +fn links_of(catalog_extra: &str, annots: &str) -> DocInfo { + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R {catalog_extra} >> 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 300 300] /Annots [{annots}] >> endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let page = PageInfo { width: 300.0, height: 300.0, crop: [0.0, 0.0, 300.0, 300.0], rotation: 0, label: String::new() }; + let mut info = DocInfo { pages: vec![page], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + info +} + +fn uris(info: &DocInfo) -> Vec { + info.links.iter().map(|l| if let LinkTarget::Uri(u) = &l.target { u.clone() } else { format!("{:?}", l.target) }).collect() +} + +/// ISO 32000-2 §12.6.4.8, Table 211: a relative URI action is resolved against the catalog's +/// `/URI /Base`; absolute addresses, and a base that isn't a web address, change nothing. +#[test] +fn relative_uri_actions_resolve_against_the_catalog_base() { + let annots = "<< /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (page.html) >> >> \ + << /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (javascript:alert(1)) >> >> \ + << /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.org/abs) >> >>"; + let info = links_of("/URI << /Base (https://example.com/base/) >>", annots); + assert_eq!(info.uri_base.as_deref(), Some("https://example.com/base/")); + assert_eq!(uris(&info), ["https://example.com/base/page.html", "javascript:alert(1)", "https://example.org/abs"]); + // An indirect URI dictionary is followed too. + let pdf_indirect = links_of("/URI 4 0 R >> endobj\n4 0 obj << /Base (https://example.com/b/) >> endobj\n5 0 obj <<", annots); + assert_eq!(uris(&pdf_indirect).first().map(String::as_str), Some("https://example.com/b/page.html")); + // Without a base, or with one that isn't a web address, the address stays as written. + assert_eq!(uris(&links_of("", annots)).first().map(String::as_str), Some("page.html")); + for base in ["/URI << /Base (file:///C:/docs/) >>", "/URI << /Base /NotAString >>", "/URI 7", "/URI << /Base (javascript:x//) >>"] { + assert_eq!(uris(&links_of(base, annots)).first().map(String::as_str), Some("page.html"), "{base}"); + } +} + +/// ISO 32000-2 §12.5.6.5, Table 176: a link is active in its `/QuadPoints`, or over its +/// `/Rect` when they are missing, malformed or reach outside it. +#[test] +fn links_are_active_in_their_quad_points() { + let link = |quads: &str| format!("<< /Subtype /Link /Rect [10 10 200 30] {quads} /A << /S /URI /URI (https://example.org/) >> >>"); + let info = links_of("", &link("/QuadPoints [10 10 60 10 60 30 10 30]")); + let l = &info.links[0]; + assert_eq!(l.quads, vec![[10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]]); + assert!(l.contains(30.0, 20.0), "the first word"); + assert!(!l.contains(150.0, 20.0), "inside /Rect but outside the quad"); + for fallback in [ + "", + "/QuadPoints [10 10 60 10 60 30 10]", + "/QuadPoints [10 10 60 10 60 30 10 (x)]", + "/QuadPoints [10 10 260 10 260 30 10 30]", + "/QuadPoints 5", + ] { + let info = links_of("", &link(fallback)); + let l = &info.links[0]; + assert!(l.quads.is_empty(), "{fallback}"); + assert!(l.contains(150.0, 20.0) && !l.contains(250.0, 20.0), "{fallback}: active over /Rect"); + } + let many = "10 10 60 10 60 30 10 30 ".repeat(crate::MAX_LINK_QUADS + 1); + assert!(links_of("", &link(&format!("/QuadPoints [{many}]"))).links[0].quads.is_empty(), "too many quads"); +} + +/// Links sharing one long `/QuadPoints` array, or a relative address resolved against a long +/// `/URI /Base`, keep their copies only within `MAX_LINK_ATTACHMENT_TEXT_BYTES`, and every +/// link is still listed. +#[test] +fn links_sharing_quad_points_or_a_long_base_keep_them_within_a_budget() { + let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() }; + let fill = |catalog_extra: &str, links: &str, object5: &str| { + let pdf = format!( + "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R {catalog_extra} >> 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] /Annots [{links}] >> endobj\n\ + 5 0 obj {object5} endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ); + let doc = Document::load_mem(pdf.as_bytes()).expect("loads"); + let mut info = DocInfo { pages: vec![page.clone()], ..Default::default() }; + Inspector::new(&doc).fill(&mut info); + info + }; + + let count = MAX_LINK_ATTACHMENT_TEXT_BYTES / (crate::MAX_LINK_QUADS * std::mem::size_of::<[f32; 8]>()) + 20; + let quads = format!("[{}]", "10 10 60 10 60 30 10 30 ".repeat(crate::MAX_LINK_QUADS)); + let links = "<< /Subtype /Link /Rect [0 0 100 100] /QuadPoints 5 0 R /A << /S /URI /URI (https://example.org/) >> >> ".repeat(count); + super::annotations::QUAD_ARRAYS_READ.with(|n| n.set(0)); + let info = fill("", &links, &quads); + assert_eq!(info.links.len(), count); + let kept: usize = info.links.iter().map(|l| std::mem::size_of_val(l.quads.as_slice())).sum(); + assert!(kept > 0 && kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} quad bytes kept"); + assert!(info.links.iter().any(|l| l.quads.is_empty()), "past the budget a link is active over its /Rect"); + // Review on #4474: the shared array is read once for each link it fits, and once more to find + // that it no longer does; not again for every link after that. + let fitted = info.links.iter().filter(|l| !l.quads.is_empty()).count(); + let read = super::annotations::QUAD_ARRAYS_READ.with(std::cell::Cell::get); + assert_eq!(read, fitted + 1, "{read} reads for {fitted} links within the budget, of {count}"); + + let base = format!("https://example.com/{}/", "b".repeat(crate::MAX_URI_BASE_BYTES - 32)); + let count = MAX_LINK_ATTACHMENT_TEXT_BYTES / base.len() + 20; + let links = "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >> ".repeat(count); + let info = fill(&format!("/URI << /Base ({base}) >>"), &links, "<< /S /URI /URI (p.html) >>"); + assert_eq!(info.links.len(), count); + let addresses = uris(&info); + let kept: usize = addresses.iter().map(String::len).sum(); + assert!(kept <= MAX_LINK_ATTACHMENT_TEXT_BYTES, "{kept} address bytes kept"); + assert_eq!(addresses.first(), Some(&format!("{base}p.html"))); + assert!(addresses.iter().any(|u| u == "p.html"), "past the budget a link keeps its raw address"); +} + /// Embedded files whose name-tree keys share one long string keep it, as key and as name, only /// within `MAX_LINK_ATTACHMENT_TEXT_BYTES`. #[test] diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6f9a3ac319..3cf941ecfe 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod inspect; +mod link_area; mod pixels; mod raster; mod reading; @@ -18,9 +19,11 @@ pub mod text; 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, + Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerItem, LayerOp, LayerRow, Link, LinkTarget, + OutlineItem, PageInfo, PageTransition, RemoteDestination, ReviewStates, TransitionStyle, Xfa, attachment_data, check_pages, inspect, + link_replies, pretty_date, thread_tops, }; +pub use link_area::{MAX_LINK_QUADS, MAX_URI_BASE_BYTES, link_quads, quad_contains, resolve_uri}; pub use pixels::Pixels; pub use raster::{ MAX_PIXELS, MAX_SIDE, PRINT_TAG, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderStats, RenderWarning, RenderedPage, RequestKind, diff --git a/crates/render/src/link_area.rs b/crates/render/src/link_area.rs new file mode 100644 index 0000000000..de0c1839a8 --- /dev/null +++ b/crates/render/src/link_area.rs @@ -0,0 +1,310 @@ +//! Where a link annotation is active, and where its URI action goes. +//! +//! - ISO 32000-2 §12.5.6.5, Table 176: a link's `/QuadPoints` are "the region in which the link +//! should be activated"; when they are absent, not understood, or any coordinate lies outside +//! `/Rect`, the activation region is `/Rect`. +//! - ISO 32000-2 §12.6.4.8, Table 211: the catalog's `/URI /Base` is "the base URI that shall be +//! used in resolving relative URI references" in URI actions. + +/// The most quadrilaterals a link's `/QuadPoints` may give before they are not used (the link is +/// then active over its `/Rect`, as for an array that isn't understood). +pub const MAX_LINK_QUADS: usize = 1024; + +/// The longest `/URI /Base` that is used. A longer base would only produce an address the link +/// check refuses as too long, so it is ignored. +pub const MAX_URI_BASE_BYTES: usize = 8192; + +/// How far (in points) a quadrilateral's corner may lie outside `/Rect` and still count as inside: +/// rounding in the writer, not a different region. +pub(crate) const RECT_SLACK: f32 = 0.01; + +/// A link's quadrilaterals from its `/QuadPoints` values (`None` for an entry that isn't a +/// number), or an empty list when the link is active over its whole `/Rect`: no values, a count +/// that isn't a multiple of 8, more than [`MAX_LINK_QUADS`], a value that isn't a finite number, +/// a corner outside `rect` (normalized, `[x0, y0, x1, y1]`), or no quadrilateral that encloses +/// an area. +/// +/// A quadrilateral no thicker than [`RECT_SLACK`] (its corners on one line, give or take +/// rounding) is left out: it adds nothing to the region the others make, and kept alone it would +/// leave the link active nowhere. When every quadrilateral is like that, the array is not one +/// that can be used and the link is active over `/Rect`. +pub fn link_quads(values: &[Option], rect: [f32; 4]) -> Vec<[f32; 8]> { + if values.is_empty() || !values.len().is_multiple_of(8) || values.len() / 8 > MAX_LINK_QUADS { + return Vec::new(); + } + let mut numbers = Vec::with_capacity(values.len()); + for v in values { + match v { + Some(n) if n.is_finite() => numbers.push(*n), + _ => return Vec::new(), + } + } + let [x0, y0, x1, y1] = rect; + let inside = numbers + .as_chunks::<2>() + .0 + .iter() + .all(|[x, y]| *x >= x0 - RECT_SLACK && *x <= x1 + RECT_SLACK && *y >= y0 - RECT_SLACK && *y <= y1 + RECT_SLACK); + if !inside { + return Vec::new(); + } + numbers.as_chunks::<8>().0.iter().filter(|q| encloses_area(q)).copied().collect() +} + +/// Whether the quadrilateral `q` (`x1 y1 … x4 y4`, finite) is thicker than [`RECT_SLACK`]: one of +/// the four triangles its corners make (whose union [`quad_contains`] tests) is more than that +/// high over its longest side. Worked in `f64`, where products of finite `f32` values neither +/// overflow nor become NaN. +fn encloses_area(q: &[f32; 8]) -> bool { + let p = [[q[0], q[1]], [q[2], q[3]], [q[4], q[5]], [q[6], q[7]]].map(|[x, y]| [f64::from(x), f64::from(y)]); + let len = |u: [f64; 2], v: [f64; 2]| (v[0] - u[0]).hypot(v[1] - u[1]); + [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]].iter().any(|&[a, b, c]| match (p.get(a), p.get(b), p.get(c)) { + (Some(&a), Some(&b), Some(&c)) => { + // Twice the triangle's area is its longest side times its height over that side. + let twice_area = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])).abs(); + let longest = len(a, b).max(len(b, c)).max(len(c, a)); + twice_area > f64::from(RECT_SLACK) * longest + } + _ => false, + }) +} + +/// Whether the point `(x, y)` lies in the quadrilateral `q` (`x1 y1 … x4 y4`), edges included. +/// Writers disagree on the corners' order (the specification says counterclockwise, text-markup +/// writers often go top-left, top-right, bottom-left, bottom-right), so the quadrilateral is taken +/// as the area its four corners enclose: the union of the four triangles they make. +pub fn quad_contains(q: &[f32; 8], x: f32, y: f32) -> bool { + if !(x.is_finite() && y.is_finite()) { + return false; + } + let p = [[q[0], q[1]], [q[2], q[3]], [q[4], q[5]], [q[6], q[7]]]; + [[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]].iter().any(|&[a, b, c]| match (p.get(a), p.get(b), p.get(c)) { + (Some(a), Some(b), Some(c)) => triangle_contains(*a, *b, *c, [x, y]), + _ => false, + }) +} + +/// Worked in `f64`, like [`encloses_area`]: in `f32` the cross products of far-apart finite +/// corners overflow to infinity and their differences to NaN, which no sign test rejects. +fn triangle_contains(a: [f32; 2], b: [f32; 2], c: [f32; 2], p: [f32; 2]) -> bool { + let [a, b, c, p] = [a, b, c, p].map(|[x, y]| [f64::from(x), f64::from(y)]); + let cross = |o: [f64; 2], u: [f64; 2], v: [f64; 2]| (u[0] - o[0]) * (v[1] - o[1]) - (u[1] - o[1]) * (v[0] - o[0]); + // A triangle with no area holds no point (it would otherwise hold its whole line). + if cross(a, b, c) == 0.0 { + return false; + } + let (d1, d2, d3) = (cross(a, b, p), cross(b, c, p), cross(c, a, p)); + let negative = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let positive = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(negative && positive) +} + +/// Whether `s` begins with an RFC 3986 scheme (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"`), +/// so it is an absolute URI rather than a relative reference. +fn has_scheme(s: &str) -> bool { + s.split_once(':').is_some_and(|(scheme, _)| { + scheme.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }) +} + +/// The address a URI action goes to when the document's catalog gives `base` (`/URI /Base`): +/// a relative reference (`page.html`, `../a/`, `?q=1`, `//host/x`) resolved against it by +/// RFC 3986 §5 (the WHATWG URL rules, which the browser applies too). `None` when the address is +/// used as written: it already has a scheme (`https:`, `mailto:`, `javascript:`…), there is no +/// usable base, or the result wouldn't be a web address. +/// +/// Only a web (`http`, `https`) base is used, and the result must keep the base's scheme, so a +/// base never turns a relative link into a `file:`, `javascript:` or other address. An address +/// with control characters or whitespace other than a plain space is left as written, since the +/// URL rules would drop them and read what is left (`java\nscript:`) differently. Either way the +/// address still goes through the same check and confirmation as any other link. +pub fn resolve_uri(base: Option<&str>, raw: &str) -> Option { + let reference = raw.trim(); + let base = base?.trim(); + if reference.is_empty() || base.len() > MAX_URI_BASE_BYTES || has_scheme(reference) { + return None; + } + let hidden = |s: &str| s.chars().any(|c| c.is_control() || (c.is_whitespace() && c != ' ')); + // The base must name its host as written (`https://host/…`): the URL rules would also accept + // `https:///host/` or `https:host/`, which no link check reads as a web address. + let names_host = base.split_once("://").is_some_and(|(_, rest)| !rest.starts_with(['/', '\\'])); + if hidden(reference) || hidden(base) || !names_host { + return None; + } + let base = url::Url::parse(base).ok()?; + if !matches!(base.scheme(), "http" | "https") || base.host().is_none() { + return None; + } + let resolved = base.join(reference).ok()?; + (resolved.scheme() == base.scheme() && resolved.host().is_some()).then(|| resolved.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE: &str = "https://example.com/base/"; + + #[test] + fn relative_references_resolve_against_the_base() { + let r = |raw: &str| resolve_uri(Some(BASE), raw); + assert_eq!(r("page.html").as_deref(), Some("https://example.com/base/page.html")); + assert_eq!(r(" page.html ").as_deref(), Some("https://example.com/base/page.html"), "trimmed like any link"); + assert_eq!(r("../up.html").as_deref(), Some("https://example.com/up.html")); + assert_eq!(r("/root.html").as_deref(), Some("https://example.com/root.html")); + assert_eq!(r("?q=1").as_deref(), Some("https://example.com/base/?q=1")); + assert_eq!(r("#part").as_deref(), Some("https://example.com/base/#part")); + assert_eq!(r("a/b:c").as_deref(), Some("https://example.com/base/a/b:c"), "a colon after a slash is no scheme"); + // A network-path reference names its own host; the confirmation shows that host. + assert_eq!(r("//other.example/x").as_deref(), Some("https://other.example/x")); + assert_eq!(resolve_uri(Some("http://example.com/dir/file.pdf"), "next.pdf").as_deref(), Some("http://example.com/dir/next.pdf")); + } + + #[test] + fn absolute_addresses_and_missing_bases_are_used_as_written() { + for raw in ["https://example.org/x", "HTTP://example.org", "mailto:a@example.org", "javascript:alert(1)", "file:///etc/passwd", "C:\\x.pdf"] { + assert_eq!(resolve_uri(Some(BASE), raw), None, "{raw}"); + } + assert_eq!(resolve_uri(None, "page.html"), None); + assert_eq!(resolve_uri(Some(BASE), ""), None); + assert_eq!(resolve_uri(Some(BASE), " "), None); + } + + #[test] + fn a_base_that_is_not_a_web_address_is_not_used() { + for base in [ + "file:///C:/docs/", + "javascript:alert(1)//", + "data:text/html,x/", + "mailto:a@example.org", + "smb://server/share/", + "relative/base/", + "", + "https://", + "https:///nohost/", + "https:nohost/", + "https:\\\\nohost/", + "https://exa\nmple.com/", + ] { + assert_eq!(resolve_uri(Some(base), "page.html"), None, "{base:?}"); + } + let long = format!("https://example.com/{}/", "a".repeat(MAX_URI_BASE_BYTES)); + assert_eq!(resolve_uri(Some(&long), "page.html"), None); + } + + #[test] + fn hidden_characters_never_make_a_scheme() { + // The URL rules drop tabs and newlines, which would read these as `javascript:`. + for raw in ["java\nscript:alert(1)", "java\tscript:alert(1)", "\u{0}javascript:alert(1)", "page\u{2028}.html"] { + assert_eq!(resolve_uri(Some(BASE), raw), None, "{raw:?}"); + } + } + + #[test] + fn quads_inside_the_rect_are_used() { + let rect = [10.0, 10.0, 200.0, 30.0]; + let q = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0].map(Some); + assert_eq!(link_quads(&q, rect), vec![[10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]]); + let two: Vec> = q.iter().chain(q.iter()).copied().collect(); + assert_eq!(link_quads(&two, rect).len(), 2); + } + + #[test] + fn quads_that_are_not_understood_fall_back_to_the_rect() { + let rect = [10.0, 10.0, 200.0, 30.0]; + let good = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]; + assert!(link_quads(&[], rect).is_empty()); + assert!(link_quads(&good[..7].iter().copied().map(Some).collect::>(), rect).is_empty(), "not a multiple of 8"); + let mut odd: Vec> = good.map(Some).to_vec(); + odd.push(Some(1.0)); + assert!(link_quads(&odd, rect).is_empty()); + let mut not_number = good.map(Some); + not_number[3] = None; + assert!(link_quads(¬_number, rect).is_empty()); + let mut nan = good.map(Some); + nan[0] = Some(f32::NAN); + assert!(link_quads(&nan, rect).is_empty()); + // A /Rect too large for f32 would hold an infinite corner. + let mut infinite = good.map(Some); + infinite[2] = Some(f32::INFINITY); + assert!(link_quads(&infinite, [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]).is_empty()); + let mut outside = good.map(Some); + outside[2] = Some(250.0); + assert!(link_quads(&outside, rect).is_empty(), "a corner outside /Rect"); + let mut below = good.map(Some); + below[1] = Some(9.0); + assert!(link_quads(&below, rect).is_empty(), "a corner below /Rect"); + let too_many: Vec> = std::iter::repeat_n(good.map(Some), MAX_LINK_QUADS + 1).flatten().collect(); + assert!(link_quads(&too_many, rect).is_empty()); + let most: Vec> = std::iter::repeat_n(good.map(Some), MAX_LINK_QUADS).flatten().collect(); + assert_eq!(link_quads(&most, rect).len(), MAX_LINK_QUADS); + } + + #[test] + fn quads_with_no_area_are_left_out() { + let rect = [10.0, 10.0, 200.0, 30.0]; + let good = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]; + // Corners on one line, all at one point, and a sliver thinner than the slack. + let line = [10.0, 20.0, 60.0, 20.0, 60.0, 20.0, 10.0, 20.0]; + let point = [30.0, 20.0, 30.0, 20.0, 30.0, 20.0, 30.0, 20.0]; + let sliver = [10.0, 20.0, 60.0, 20.0, 60.0, 20.005, 10.0, 20.005]; + for flat in [line, point, sliver] { + assert!(link_quads(&flat.map(Some), rect).is_empty(), "{flat:?} alone: active over /Rect"); + let mixed: Vec> = flat.iter().chain(good.iter()).map(|v| Some(*v)).collect(); + assert_eq!(link_quads(&mixed, rect), vec![good], "{flat:?} beside a usable quad"); + } + // Thicker than the slack is a region, however thin. + let thin = [10.0, 20.0, 60.0, 20.0, 60.0, 20.05, 10.0, 20.05]; + assert_eq!(link_quads(&thin.map(Some), rect), vec![thin]); + // A triangle (two corners shared) still encloses an area. + let triangle = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 60.0, 30.0]; + assert_eq!(link_quads(&triangle.map(Some), rect), vec![triangle]); + // The largest finite values neither overflow nor turn to NaN. + let all = [f32::MIN, f32::MIN, f32::MAX, f32::MAX]; + let huge = [f32::MIN, f32::MIN, f32::MAX, f32::MIN, f32::MAX, f32::MAX, f32::MIN, f32::MAX]; + assert_eq!(link_quads(&huge.map(Some), all), vec![huge]); + let huge_line = [f32::MIN, f32::MIN, f32::MAX, f32::MAX, f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + assert!(link_quads(&huge_line.map(Some), all).is_empty()); + } + + #[test] + fn a_quad_holds_its_area_in_any_corner_order() { + // Counterclockwise from the bottom left, as the specification writes them. + let ccw = [10.0, 10.0, 60.0, 10.0, 60.0, 30.0, 10.0, 30.0]; + // Top-left, top-right, bottom-left, bottom-right, as text-markup writers often do. + let z = [10.0, 30.0, 60.0, 30.0, 10.0, 10.0, 60.0, 10.0]; + for q in [ccw, z] { + assert!(quad_contains(&q, 30.0, 20.0)); + assert!(quad_contains(&q, 10.0, 10.0), "corners count"); + assert!(quad_contains(&q, 60.0, 20.0), "edges count"); + assert!(!quad_contains(&q, 100.0, 20.0), "right of the quad"); + assert!(!quad_contains(&q, 30.0, 31.0), "above the quad"); + assert!(!quad_contains(&q, 9.0, 20.0), "left of the quad"); + assert!(!quad_contains(&q, 30.0, 9.0), "below the quad"); + } + // A tilted quad: the corner of its bounding box is outside it. + let diamond = [50.0, 0.0, 100.0, 50.0, 50.0, 100.0, 0.0, 50.0]; + assert!(quad_contains(&diamond, 50.0, 50.0)); + assert!(!quad_contains(&diamond, 5.0, 5.0)); + // A quad with no area holds nothing. + let flat = [0.0, 0.0, 10.0, 0.0, 20.0, 0.0, 30.0, 0.0]; + assert!(!quad_contains(&flat, 5.0, 0.0)); + assert!(!quad_contains(&ccw, f32::NAN, 20.0), "a point that isn't a number is nowhere"); + } + + #[test] + fn a_quad_with_extreme_corners_holds_only_its_area() { + // In f32 the cross products overflow (inf, and inf - inf = NaN), which passed every sign + // test and put (1.5e20, 1.5e20), outside the triangle, inside it. + let q = [0.0, 0.0, 2e20, 0.0, 0.0, 2e20, 0.0, 0.0]; + assert_eq!(link_quads(&q.map(Some), [0.0, 0.0, 3e20, 3e20]), vec![q], "kept by the area check"); + assert!(!quad_contains(&q, 1.5e20, 1.5e20), "beyond the long edge"); + assert!(!quad_contains(&q, 3e20, 1.0), "right of the triangle"); + assert!(quad_contains(&q, 0.5e20, 0.5e20)); + assert!(quad_contains(&q, 1.0, 1.0)); + let max = [0.0, 0.0, f32::MAX, 0.0, 0.0, f32::MAX, 0.0, 0.0]; + assert!(!quad_contains(&max, f32::MAX, f32::MAX)); + assert!(quad_contains(&max, 1.0, 1.0)); + } +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 333464779f..1097da1997 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -83,6 +83,9 @@ pub struct RenderConfig { /// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an /// installed font is saved or printed (AGENTS.md §1.2, §1.4). pub system_fonts: bool, + /// The render is output for printing (print as image): the configuration's auto state for + /// the View event (ISO 32000-2 §8.11.4.4) is not applied, since printing is not viewing. + pub for_print: bool, /// Steps of JBIG2 decoding a render may take across the page's images, instead of the usual /// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't /// be decoded. @@ -113,6 +116,7 @@ impl RenderConfig { let system_fonts = self.system_fonts; InterpreterSettings { ocg_overrides: self.layers.clone(), + view_auto_state: !self.for_print, hide_comments: self.hide_comments, font_resolver: Arc::new(move |query| { japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query)) @@ -270,6 +274,13 @@ pub enum RenderWarning { /// A colour could not be computed (a colour space that cannot be read, or a Separation or /// DeviceN tint transform that cannot be evaluated) and was painted in a fallback colour. ColourFallback, + /// An image's `/Indexed` colour space is malformed (ISO 32000-2 §8.6.6.3), so the image may be + /// drawn wrongly or not at all. It is drawn recovered when it can be: indices past a lookup + /// table shorter than m × (hival + 1) bytes in black, a `hival` past 255 taken as 255, a real + /// one truncated, or (a `hival` below zero or not a number) the image in DeviceGray. With no + /// lookup table at all (missing, not a string or a stream, or a stream that does not decode) + /// it is not drawn. Reported once per page however many images it affects. + MalformedIndexedImage, /// A Type 3 font without the `/Widths` it requires (ISO 32000-2 Table 110) was used; its /// glyphs were spaced by the widths their glyph procedures give. Type3WidthsMissing, @@ -454,6 +465,8 @@ fn render_page<'a>( // Previews render at low detail (tiny images are skipped); finals always draw all. let mut preview_settings = settings.clone(); preview_settings.preview = req.preview; + // A print preview shows what prints, and printing is not the View event. + preview_settings.view_auto_state &= req.tag & PRINT_TAG == 0; render_into(page, cache, &preview_settings, &rs, pixels.bytes_mut()); Ok((w.into(), h.into(), pixels, None)) })); @@ -477,10 +490,12 @@ fn warning_settings(settings: &InterpreterSettings, warnings: Arc RenderWarning::ContentTruncated, InterpreterWarning::ColorFallback => RenderWarning::ColourFallback, + InterpreterWarning::MalformedIndexedImage => RenderWarning::MalformedIndexedImage, InterpreterWarning::Type3WidthsMissing => RenderWarning::Type3WidthsMissing, InterpreterWarning::UnsupportedFont | InterpreterWarning::ImageDecodeFailure => return, }; diff --git a/crates/render/src/raster/tests.rs b/crates/render/src/raster/tests.rs index 679e42cd5e..32a428c1e5 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -2091,6 +2091,125 @@ fn rounded_pixel_budget_invalid_requests_never_reach_allocator() { } } +/// #3962: FrameMaker files distilled with Distiller 3 embed TrueType subsets that are symbolic, +/// with no `/Encoding` and no `/ToUnicode`, whose codes are glyph positions in the Macintosh +/// order, 29 below ASCII; the font's own `cmap` maps them to the right glyphs. Read as WinAnsi +/// the text came out shifted (`Solid` as `6ROLG`), in the editor and in Word export alike. The +/// `post` table names the glyph each code selects, which gives the text (vendored +/// hayro-interpret patch 79). A font with an `/Encoding` still reads its codes through it. +#[test] +fn a_symbolic_truetype_font_without_an_encoding_reads_its_glyph_names() { + use skrifa::MetadataProvider; + use skrifa::raw::TableProvider; + const TEXT: &str = "Solid state"; + // Four more codes: 0x3D selects `W.cv07`, a variant whose suffix is dropped (`W`); 0x3E a + // glyph named `uniXXXX`, that code point; 0x3F `EMquad`, a name with no text, so the code + // reads as WinAnsi (`?`); 0x40 the ligature `less_equal.liga`, two characters (`<=`). + const SUFFIXED: u16 = 0x3D; + const UNI: u16 = 0x3E; + const UNNAMED: u16 = 0x3F; + const LIGATURE: u16 = 0x40; + // 0x41 selects a glyph whose name is spelt `uni01cd`, lowercase: the glyph list + // specification's hex digits are uppercase (ISO 32000-2 §9.10.2), so the name stands for no + // text and the code reads as WinAnsi (`A`), not as U+01CD. + const LOWERCASE: u16 = 0x41; + let font = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); + let face = skrifa::FontRef::new(font).expect("the test font parses"); + let charmap = face.charmap(); + let post = face.post().expect("the test font has a post table"); + let glyphs = face.maxp().expect("the test font has a maxp table").num_glyphs(); + let named = |name: &str| { + (1..glyphs).find(|&g| post.glyph_name(skrifa::raw::types::GlyphId16::new(g)) == Some(name)).expect("the test font has the glyph") + }; + let (uni, uni_char) = (1..glyphs) + .find_map(|g| { + let name = post.glyph_name(skrifa::raw::types::GlyphId16::new(g))?; + let c = char::from_u32(u32::from_str_radix(name.strip_prefix("uni")?, 16).ok()?)?; + (!c.is_control()).then_some((g, c)) + }) + .expect("the test font names a glyph uniXXXX"); + // Codes 29 below ASCII, mapped by a (3,0) symbol cmap at 0xF000 + code. + let mut map: Vec<(u16, u16)> = + TEXT.chars().map(|c| (0xF000 + c as u16 - 29, charmap.map(c).expect("the test font has the letter").to_u32() as u16)).collect(); + map.push((0xF000 + SUFFIXED, named("W.cv07"))); + map.push((0xF000 + UNI, uni)); + map.push((0xF000 + UNNAMED, named("EMquad"))); + map.push((0xF000 + LIGATURE, named("less_equal.liga"))); + let lowercase = named("uni01CD"); + assert_ne!(lowercase, uni, "the lowercased name is not the `uniXXXX` glyph read above"); + map.push((0xF000 + LOWERCASE, lowercase)); + map.sort_unstable(); + map.dedup(); + // A format 4 subtable: one segment per code, then the closing 0xFFFF segment. + let segments: Vec<(u16, u16)> = map.iter().copied().chain([(0xFFFF, 0)]).collect(); + let n = segments.len() as u16; + let mut sub = Vec::new(); + let words = |v: &mut Vec, w: &[u16]| w.iter().for_each(|w| v.extend_from_slice(&w.to_be_bytes())); + words(&mut sub, &[4, 16 + 8 * n, 0, 2 * n, 2, 0, 2 * n - 2]); + words(&mut sub, &segments.iter().map(|s| s.0).collect::>()); + words(&mut sub, &[0]); + words(&mut sub, &segments.iter().map(|s| s.0).collect::>()); + words(&mut sub, &segments.iter().map(|&(code, gid)| if code == 0xFFFF { 1 } else { gid.wrapping_sub(code) }).collect::>()); + words(&mut sub, &vec![0; segments.len()]); + let mut cmap = Vec::new(); + words(&mut cmap, &[0, 1, 3, 0]); + cmap.extend_from_slice(&12u32.to_be_bytes()); + cmap.extend(sub); + // The new cmap goes at the end; the table directory's `cmap` entry points at it. + let mut program = font.to_vec(); + while !program.len().is_multiple_of(4) { + program.push(0); + } + let at = program.len(); + program.extend(&cmap); + let tables = usize::from(u16::from_be_bytes([font[4], font[5]])); + let entry = (0..tables).map(|i| 12 + 16 * i).find(|&e| &font[e..e + 4] == b"cmap").expect("the test font has a cmap"); + program[entry + 8..entry + 12].copy_from_slice(&(at as u32).to_be_bytes()); + program[entry + 12..entry + 16].copy_from_slice(&(cmap.len() as u32).to_be_bytes()); + // The `post` table's own spelling of the name, a Pascal string, lowercased in place. + let spelt = b"\x07uni01CD"; + let spot = program.windows(spelt.len()).position(|w| w == spelt).expect("the post table spells the name"); + assert_eq!(program.windows(spelt.len()).filter(|w| w == spelt).count(), 1); + program[spot..spot + spelt.len()].copy_from_slice(b"\x07uni01cd"); + + let text_of = |encoding: &str| -> String { + let codes: String = TEXT.chars().map(|c| format!("{:02X}", c as u8 - 29)).collect::() + + &format!("{SUFFIXED:02X}{UNI:02X}{UNNAMED:02X}{LIGATURE:02X}{LOWERCASE:02X}"); + let content = format!("BT /F1 14 Tf 10 40 Td <{codes}> Tj ET"); + let objects: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 80] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + format!("<< /Type /Font /Subtype /TrueType /BaseFont /ABCDEF+JetBrainsMono-Regular /FirstChar 0 /LastChar 255 /Widths [{}] /FontDescriptor 6 0 R {encoding}>>", vec!["600"; 256].join(" ")).into_bytes(), + b"<< /Type /FontDescriptor /FontName /ABCDEF+JetBrainsMono-Regular /Flags 4 /FontBBox [-500 -300 1200 1000] /ItalicAngle 0 /Ascent 1020 /Descent -300 /CapHeight 730 /StemV 80 /FontFile2 7 0 R >>".to_vec(), + [format!("<< /Length {} >>\nstream\n", program.len()).into_bytes(), program.clone(), b"\nendstream".to_vec()].concat(), + ]; + let mut pdf = b"%PDF-1.3\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend(format!("{} 0 obj\n", i + 1).into_bytes()); + pdf.extend(object); + pdf.extend(b"\nendobj\n"); + } + let xref = pdf.len(); + pdf.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).into_bytes()); + for offset in offsets { + pdf.extend(format!("{offset:010} 00000 n \n").into_bytes()); + } + pdf.extend(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).into_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let page = r.filtered_text(0, crate::text::TextFilter::default()).expect("the page has text"); + page.glyphs.iter().map(|g| g.text.as_str()).collect() + }; + assert_eq!(text_of(""), format!("Solid stateW{uni_char}?<=A")); + // An encoding says what the codes are, so they read through it. + assert_eq!(text_of("/Encoding /WinAnsiEncoding "), "6ROLGVWDWH=>?@A"); + // `/Differences` still names its code: 0x36 reads `A`, not the glyph's `S`. + assert_eq!(text_of("/Encoding << /Differences [54 /A] >> "), format!("Aolid stateW{uni_char}?<=A")); +} + /// Issue #102: an A4 page (595.28×841.89 pt) rendered 595×841 at 72 dpi, dropping the last /// partial row and column. Like pdftoppm it is now 596×842, the edge pixels partly covered. #[test] @@ -2769,6 +2888,126 @@ trailer << /Root 1 0 R >> assert_eq!(px(off), vec![255, 255, 255, 255], "toggled-off layer must not draw"); } +/// A page whose red square is in group 5 0 R (`ocg` its dictionary), under the default +/// configuration `config`; `extra` adds objects from number 8. +fn auto_state_pdf(ocg: &str, config: &str, extra: &str) -> Vec { + format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [5 0 R] /D {config} >> >> 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 100 100] /Contents 4 0 R /Resources << /Properties << /L1 5 0 R >> >> >> endobj +4 0 obj << /Length 44 >> stream +/OC /L1 BDC 1 0 0 rg 0 0 100 100 re f EMC +endstream endobj +5 0 obj {ocg} endobj +{extra} +trailer << /Root 1 0 R >> +%%EOF" + ) + .into_bytes() +} + +/// Whether the red square in group 5 0 R draws. +fn auto_state_red(pdf: Vec, config: RenderConfig) -> bool { + let mut r = PageRenderer::new(Arc::new(pdf), config); + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }); + p.rgba.get(..4) == Some(&[255, 0, 0, 255][..]) +} + +const VIEW_OFF: &str = "<< /Type /OCG /Name (A) /Usage << /View << /ViewState /OFF >> >> >>"; +const VIEW_ON: &str = "<< /Type /OCG /Name (A) /Usage << /View << /ViewState /ON >> >> >>"; +const AS_VIEW: &str = "/AS [<< /Event /View /Category [/View] /OCGs [5 0 R] >>]"; + +/// ISO 32000-2 §8.11.4.4: the configuration's auto state (`/AS`) sets a group's state for +/// the View event from its usage, over `/BaseState`, `/ON` and `/OFF`. It was ignored, so a +/// group whose `/ViewState` is OFF still drew on screen. +#[test] +fn the_view_auto_state_sets_a_groups_state_on_screen() { + let shown = |ocg: &str, config: &str| auto_state_red(auto_state_pdf(ocg, config, ""), RenderConfig::default()); + assert!(!shown(VIEW_OFF, &format!("<< {AS_VIEW} >>")), "ViewState OFF, managed: hidden"); + assert!(shown(VIEW_OFF, "<< >>"), "no /AS: the usage alone changes nothing"); + assert!(shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/View] /OCGs [] >>] >>"), "not listed in /OCGs"); + assert!(shown(VIEW_ON, &format!("<< /OFF [5 0 R] {AS_VIEW} >>")), "ViewState ON wins over /OFF"); + assert!(shown(VIEW_ON, &format!("<< /BaseState /OFF {AS_VIEW} >>")), "and over /BaseState /OFF"); + assert!(!shown(VIEW_ON, "<< /OFF [5 0 R] >>"), "without /AS, /OFF decides"); + // The View event only, and the View category only. + assert!(shown(VIEW_OFF, "<< /AS [<< /Event /Print /Category [/View] /OCGs [5 0 R] >>] >>"), "Print event: not on screen"); + assert!(shown(VIEW_OFF, "<< /AS [<< /Event /Export /Category [/View] /OCGs [5 0 R] >>] >>"), "Export event: not on screen"); + assert!(shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/Print] /OCGs [5 0 R] >>] >>"), "a category without a View entry"); + // A category it cannot evaluate gives no recommendation; the View one still applies. + assert!(!shown(VIEW_OFF, "<< /AS [<< /Event /View /Category [/Zoom /View] /OCGs [5 0 R] >>] >>"), "Zoom alongside View"); + let zoomed = "<< /Type /OCG /Name (A) /Usage << /Zoom << /min 4 >> >> >>"; + assert!(shown(zoomed, "<< /AS [<< /Event /View /Category [/Zoom] /OCGs [5 0 R] >>] >>"), "Zoom alone: unchanged"); + // Indirect applications, arrays and usage dictionaries are followed. + let indirect = "<< /Type /OCG /Name (A) /Usage 10 0 R >>"; + let extra = "8 0 obj [9 0 R] endobj\n9 0 obj << /Event /View /Category [/View] /OCGs 11 0 R >> endobj\n10 0 obj << /View << /ViewState /OFF >> >> endobj\n11 0 obj [5 0 R] endobj"; + assert!(!auto_state_red(auto_state_pdf(indirect, "<< /AS 8 0 R >>", extra), RenderConfig::default()), "indirect"); +} + +/// The Layers panel's toggles still decide over the auto state, and a render for printing +/// does not apply it (printing is not the View event). +#[test] +fn layer_toggles_and_print_renders_are_not_the_view_auto_state() { + let pdf = auto_state_pdf(VIEW_OFF, &format!("<< {AS_VIEW} >>"), ""); + let on = RenderConfig { layers: Arc::new(vec![(5, 0, true)]), ..Default::default() }; + assert!(auto_state_red(pdf.clone(), on), "toggled on in the Layers panel"); + assert!(auto_state_red(pdf.clone(), RenderConfig { for_print: true, ..Default::default() }), "printed: its configured state"); + assert!(!auto_state_red(pdf, RenderConfig::default()), "on screen: hidden"); +} + +/// A print preview ([`PRINT_TAG`], with or without the canvas's scale and grayscale bits) +/// shows what prints, so the View auto state is not applied to it either. +#[test] +fn print_previews_are_not_the_view_auto_state() { + let pdf = Arc::new(auto_state_pdf(VIEW_OFF, &format!("<< {AS_VIEW} >>"), "")); + let mut r = PageRenderer::new(pdf, RenderConfig::default()); + for tag in [PRINT_TAG, PRINT_TAG | 1 << 59 | 7] { + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag, ..Default::default() }); + assert_eq!(p.rgba.get(..4), Some(&[255, 0, 0, 255][..]), "tag {tag:#x}: printed state"); + } + let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 7, ..Default::default() }); + assert_ne!(p.rgba.get(..4), Some(&[255, 0, 0, 255][..]), "on screen: hidden"); +} + +/// Malformed auto state is ignored without a panic, and the walk is bounded. +#[test] +fn malformed_view_auto_state_is_ignored() { + let shown = |config: &str, extra: &str| auto_state_red(auto_state_pdf(VIEW_OFF, config, extra), RenderConfig::default()); + for config in [ + "<< /AS 5 >>", + "<< /AS << /Event /View /Category [/View] /OCGs [5 0 R] >> >>", + "<< /AS [1 null /x (s) [5 0 R]] >>", + "<< /AS [<< /Event /View /Category [/View] >>] >>", + "<< /AS [<< /Event /View /OCGs [5 0 R] >>] >>", + "<< /AS [<< /Category [/View] /OCGs [5 0 R] >>] >>", + "<< /AS [<< /Event (View) /Category [/View] /OCGs [5 0 R] >>] >>", + "<< /AS [<< /Event /View /Category /View /OCGs [5 0 R] >>] >>", + "<< /AS [<< /Event /View /Category [/View] /OCGs 5 0 R >>] >>", + "<< /AS [<< /Event /View /Category [/View] /OCGs [(5) 5 /OCG] >>] >>", + ] { + assert!(shown(config, ""), "{config}: ignored"); + } + let bad_state = |s: &str| { + let ocg = format!("<< /Type /OCG /Name (A) /Usage << /View << /ViewState {s} >> >> >>"); + auto_state_red(auto_state_pdf(&ocg, &format!("<< {AS_VIEW} >>"), ""), RenderConfig::default()) + }; + for s in ["/Off", "(OFF)", "0", "null", "[/OFF]"] { + assert!(bad_state(s), "ViewState {s}: no recommendation"); + } + // Cyclic references: an /AS, an application and a usage that refer to themselves. + assert!(shown("<< /AS 8 0 R >>", "8 0 obj 8 0 R endobj"), "self-referencing /AS"); + assert!(shown("<< /AS [8 0 R] >>", "8 0 obj 8 0 R endobj"), "self-referencing application"); + let cyclic = "<< /Type /OCG /Name (A) /Usage 8 0 R >>"; + assert!(auto_state_red(auto_state_pdf(cyclic, &format!("<< {AS_VIEW} >>"), "8 0 obj 8 0 R endobj"), RenderConfig::default())); + // Huge arrays: the group listed after 4096 other entries is past the cap. + let many = format!("<< /AS [<< /Event /View /Category [/View] /OCGs [{}5 0 R] >>] >>", "9 0 R ".repeat(4096)); + assert!(shown(&many, "9 0 obj << /Type /OCG /Name (B) >> endobj"), "past the cap: unchanged"); + let within = format!("<< /AS [<< /Event /View /Category [/View] /OCGs [{}5 0 R] >>] >>", "9 0 R ".repeat(4095)); + assert!(!shown(&within, "9 0 obj << /Type /OCG /Name (B) >> endobj"), "within the cap: applied"); + let apps = format!("<< /AS [{}<< /Event /View /Category [/View] /OCGs [5 0 R] >>] >>", "<< /Event /Print >> ".repeat(256)); + assert!(shown(&apps, ""), "past the application cap: unchanged"); +} + /// #2986: an optional content membership dictionary with a visibility expression (`/VE`, /// ISO 32000-2 §8.11.2.2) shows its content only when the expression is true, and the /// expression takes precedence over `/OCGs` and `/P`. It was ignored, so content under it @@ -5778,6 +6017,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] @@ -9141,6 +9402,10 @@ fn an_averaged_down_soft_mask_follows_its_samples_and_its_drawn_size() { // ICC profiles whose stream dictionaries are the same (vendored hayro-interpret patch 48). mod icc_profile_cache; +// An Indexed resource named by many inline images reads its lookup once (vendored +// hayro-interpret patch 80). +mod indexed_lookup_cache; + // A Type 3 glyph shown again decodes its procedure once (vendored hayro-interpret patch 87). mod type3_proc_cache; @@ -10507,6 +10772,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 +11232,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 +11262,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/raster/tests/indexed_lookup_cache.rs b/crates/render/src/raster/tests/indexed_lookup_cache.rs new file mode 100644 index 0000000000..aab8029285 --- /dev/null +++ b/crates/render/src/raster/tests/indexed_lookup_cache.rs @@ -0,0 +1,75 @@ +//! An `/Indexed` colour space named by a resource is built once, and its lookup table read +//! once, however many inline images name it (vendored hayro-interpret patch 80). An earlier +//! version of the patch checked the table again before every inline image, decoding a lookup +//! stream once per image outside the colour-space cache. + +use super::image_cache_pdf; + +/// A page that draws `count` 2 × 1 inline images in the resource colour space `/P`, an Indexed +/// space over DeviceRGB with `hival` whose lookup is stream object 5 holding `table`. +fn page(count: usize, hival: u8, table: &[u8]) -> Vec { + let image = "q 20 0 0 20 0 0 cm BI /W 2 /H 1 /BPC 8 /CS /P /F /AHx ID 0001> EI Q"; + let content = vec![image; count].join("\n"); + let resources = format!("<< /ColorSpace << /P [/Indexed /DeviceRGB {hival} 5 0 R] >> >>"); + let dict = format!("<< /Length {} >>", table.len()); + image_cache_pdf(&content, &resources, &[(&dict, table)]) +} + +fn lookup_reads(pdf: Vec) -> [usize; 2] { + use hayro::hayro_interpret::font::Glyph; + use hayro::hayro_interpret::hayro_syntax::Pdf; + use hayro::hayro_interpret::{ + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, + TransformExt, interpret_page, + }; + use kurbo::{Affine, BezPath, Rect}; + use std::sync::Arc; + + /// Draws nothing: only the colour spaces the page reads matter here. + struct Nothing; + impl<'a> Device<'a> for Nothing { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'a>, _: &PathDrawMode) {} + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + + let pdf = Pdf::new(Arc::new(pdf)).expect("parses"); + let page = &pdf.pages()[0]; + let cache = InterpreterCache::new(); + [(); 2].map(|()| { + let initial = page.initial_transform(true).to_kurbo(); + let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 130.0, 70.0), &cache, page.xref(), InterpreterSettings::default()); + interpret_page(page, &mut ctx, &mut Nothing); + cache.indexed_lookup_reads() + }) +} + +/// Twenty inline images naming one resource read its lookup stream once, in the first render +/// and none in a second render through the same cache: for a full table, and for a short one +/// that is padded (whose images an earlier version of the patch checked, and skipped, one by +/// one). +#[test] +fn inline_images_naming_one_indexed_resource_read_its_lookup_once() { + let full = page(20, 1, b"\xFF\x00\x00\x00\xFF\x00"); + assert_eq!(lookup_reads(full), [1, 1], "a full table: read once, then cached"); + let short = page(20, 7, b"\xFF\x00\x00\x00\xFF\x00"); + assert_eq!(lookup_reads(short), [1, 1], "a short table: read once, then cached"); +} + +/// Two Indexed resources whose arrays differ (`/P` names object 5, `/Q` object 6) are two +/// spaces: each is built, and its table read, once, and neither stands in for the other. +#[test] +fn two_indexed_resources_are_cached_apart() { + let image = |name: &str| format!("q 20 0 0 20 0 0 cm BI /W 2 /H 1 /BPC 8 /CS /{name} /F /AHx ID 0001> EI Q"); + let content = [image("P"), image("Q"), image("P"), image("Q")].join("\n"); + let resources = "<< /ColorSpace << /P [/Indexed /DeviceRGB 1 5 0 R] /Q [/Indexed /DeviceRGB 1 6 0 R] >> >>"; + let (red_green, blue_white): (&[u8], &[u8]) = (b"\xFF\x00\x00\x00\xFF\x00", b"\x00\x00\xFF\xFF\xFF\xFF"); + let pdf = image_cache_pdf(&content, resources, &[("<< /Length 6 >>", red_green), ("<< /Length 6 >>", blue_white)]); + assert_eq!(lookup_reads(pdf), [2, 2], "each table read once"); +} diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index bc132466b1..9f3439e158 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 { @@ -133,7 +137,8 @@ impl PageText { /// Case-insensitive search. Returns glyph ranges of every match (words may span line breaks, /// which count as a single space, or as nothing where the line runs on between two Chinese or /// Japanese characters or ends in a soft hyphen: there a query with a space across the break - /// doesn't match, and one without does). + /// doesn't match, and one without does; a ligature matches its letters, and a word divided by a + /// hyphen at a line end matches whole as well as with the hyphen). pub fn find(&self, needle: &str) -> Vec> { self.find_opts(needle, false, false) } @@ -315,13 +320,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 +399,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..42df5c0a83 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -1,33 +1,133 @@ //! Literal search in normalized page text, retaining the original glyph owners. //! -//! Words and lines are separated by one space, except where a line of Chinese or Japanese runs on -//! to the next: a break between two such characters is no word break, so it reads as nothing and -//! "検知機構" is found when the line ends after "検知". +//! The page text is read as characters, each owned by the glyph it came from. Words and lines are +//! separated by one space, except where a line of Chinese or Japanese runs on to the next: a break +//! between two such characters is no word break, so it reads as nothing and "検知機構" is found +//! when the line ends after "検知". A Latin compatibility ligature (U+FB00–U+FB06) reads as its +//! letters in the page and in the query, so "office" finds "o\u{FB03}ce". A word divided by a +//! hyphen at the end of a line is also found whole: where a line ends in a hyphen between two +//! letters, the text is searched a second time without the hyphen and the line break ("example" +//! finds "exam-" + "ple") and a third time with the hyphen but without the break ("exam-ple"). A +//! hit in any reading counts; where hits from different readings overlap, the one that starts +//! first is kept (the longer on a tie). A soft hyphen needs no second reading: it draws nothing, +//! and a line that ends in one always runs on. +use super::device::ligature_letters; use super::{PageText, fold_case}; use std::ops::Range; +/// How a line ending in a hyphen between two letters is read. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Hyphen { + /// As written: the hyphen, then a line break (a space). + Written, + /// One word: neither the hyphen nor the break. + Joined, + /// A hyphenated compound: the hyphen, but no break. + Kept, +} + +/// A visible hyphen that can divide a word at a line end: HYPHEN-MINUS and HYPHEN. A SOFT HYPHEN +/// (ISO 32000-2 §14.8.2.3) is invisible instead: find skips it, and a line that ends in one runs on. +fn is_hyphen(c: char) -> bool { + matches!(c, '-' | '\u{2010}') +} + +/// Whether a line break separates glyph `i` from the glyph before it. +fn breaks_before(page: &PageText, i: usize) -> bool { + i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b) +} + pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: bool, limit: usize) -> Vec> { if limit == 0 { return Vec::new(); } - let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; + let fold = |s: &str| if sensitive { ligature_letters(s.to_owned()) } else { fold_case(&ligature_letters(s.to_owned())) }; let query: String = fold(query).chars().filter(|&c| !is_invisible(c)).collect(); let needle: Vec = query.split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() { return Vec::new(); } + let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); + let divided: Vec = (0..texts.len()).map(|i| divides(page, &texts, i)).collect(); + let readings: &[Hyphen] = if divided.contains(&true) { &[Hyphen::Written, Hyphen::Joined, Hyphen::Kept] } else { &[Hyphen::Written] }; + // KMP reuses a matched prefix instead of comparing a long query again at every position. + // The table is bounded by the page's already materialized character count. + let mut prefix = vec![0; needle.len()]; + let mut matched = 0; + for (i, &c) in needle.iter().enumerate().skip(1) { + matched = advance(&needle, &prefix, matched, c); + if let Some(slot) = prefix.get_mut(i) { + *slot = matched; + } + } + let mut hits: Vec<(Range, usize)> = Vec::new(); + for (r, &reading) in readings.iter().enumerate() { + let (chars, zero_width_space) = characters(page, &texts, ÷d, reading); + let found = search(&chars, &zero_width_space, &needle, &prefix, whole_words, limit); + hits.extend(found.into_iter().map(|hit| (hit, r))); + } + if readings.len() == 1 { + return hits.into_iter().map(|(hit, _)| hit).collect(); + } + // Each reading's hits are in order and apart in its characters, though two can share a glyph + // ("f" twice in an "ffi" glyph); they are all kept. Across readings, the earliest start wins + // (the longer on a tie): a hit is left out where it overlaps one kept from another reading. + // The sort is stable, so the earlier reading wins an exact tie. + hits.sort_by(|a, b| a.0.start.cmp(&b.0.start).then(b.0.end.cmp(&a.0.end))); + // The furthest end of the hits kept from each reading. Hits come in order of start, so one + // overlaps an earlier kept hit exactly when it starts before that hit's end. + let mut ends = [0usize; 3]; + let mut kept: Vec> = Vec::new(); + for (hit, r) in hits { + if ends.iter().enumerate().all(|(other, &end)| other == r || hit.start >= end) { + if let Some(end) = ends.get_mut(r) { + *end = (*end).max(hit.end); + } + kept.push(hit); + if kept.len() == limit { + break; + } + } + } + kept +} + +/// Whether glyph `i` ends its line with a hyphen that follows a letter of its word, and the next +/// line starts with a letter. `texts` are the glyphs' folded texts; the invisible characters find +/// skips are passed over on both sides. +fn divides(page: &PageText, texts: &[String], i: usize) -> bool { + let Some(text) = texts.get(i) else { return false }; + let mut back = text.chars().rev().filter(|&c| !is_invisible(c)); + if !back.next().is_some_and(is_hyphen) { + return false; + } + let before = match back.next() { + Some(c) => Some(c), + // A hyphen glyph of its own: the letter is the end of the glyph before, on its line with no word gap. + None if !page.space_before.get(i).copied().unwrap_or(false) && !breaks_before(page, i) => { + i.checked_sub(1).and_then(|p| texts.get(p)).and_then(|t| t.chars().rev().find(|&c| !is_invisible(c))) + } + None => None, + }; + let Some(next) = i.checked_add(1) else { return false }; + before.is_some_and(char::is_alphabetic) + && breaks_before(page, next) + && texts.get(next).and_then(|t| t.chars().find(|&c| !is_invisible(c))).is_some_and(char::is_alphabetic) +} + +/// The page's characters in one `reading`, each with the glyph it came from, and whether a +/// skipped ZERO WIDTH SPACE came right before each one: it draws nothing, but it is a word +/// boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words with +/// no visible gap, so whole-word search still sees two words there. +fn characters(page: &PageText, texts: &[String], divided: &[bool], reading: Hyphen) -> (Vec<(char, usize)>, Vec) { let mut chars: Vec<(char, usize)> = Vec::new(); - // Whether a skipped ZERO WIDTH SPACE came right before each character: it draws nothing, but - // it is a word boundary (UAX #29 breaks on both sides of it), which Thai and Khmer write between words - // with no visible gap, so whole-word search still sees two words there. let mut zero_width_space: Vec = Vec::new(); let mut pending_zero_width_space = false; // The last character pushed other than a variation selector, kept as characters are pushed so // a long run of selectors costs no rescan. let mut last_base: Option = None; - for (i, glyph) in page.glyphs.iter().enumerate() { - let text = fold(&glyph.text); - let line_break = i.checked_sub(1).and_then(|previous| page.line_of.get(previous)).zip(page.line_of.get(i)).is_some_and(|(a, b)| a != b); + for (i, text) in texts.iter().enumerate() { + let line_break = breaks_before(page, i); // A soft hyphen marks where a word may be divided at a line end (ISO 32000-2 §14.8.2.3), so // a line that ends in one runs on to the next with no word break between them. // Other invisible characters after the soft hyphen don't hide it. @@ -36,18 +136,29 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b // sides of the break are the visible ones: invisible characters are skipped below, and a // variation selector after the last one only picks its glyph. let joined = line_break && last_base.is_some_and(unspaced) && first_visible(page, i).is_some_and(unspaced) && wraps(page, i); - let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break)); + // And, in the readings that join it, a line that ends in a visible hyphen between letters. + let after_divide = reading != Hyphen::Written && i.checked_sub(1).and_then(|p| divided.get(p)).copied().unwrap_or(false); + let gap = page.space_before.get(i).copied().unwrap_or(false) || (line_break && !(joined || soft_break || after_divide)); if i > 0 && gap && chars.last().is_some_and(|c| c.0 != ' ') { chars.push((' ', i)); last_base = Some(' '); zero_width_space.push(false); pending_zero_width_space = false; } - for c in text.chars() { + // The divided glyph's hyphen is its last visible character, left out of the joined reading. + let dropped = if reading == Hyphen::Joined && divided.get(i).copied().unwrap_or(false) { + text.char_indices().rev().find(|&(_, c)| !is_invisible(c)).map(|(at, _)| at) + } else { + None + }; + for (at, c) in text.char_indices() { if is_invisible(c) { pending_zero_width_space |= c == '\u{200B}'; continue; } + if Some(at) == dropped { + continue; + } let c = if c.is_whitespace() { ' ' } else { c }; if c != ' ' || chars.last().is_none_or(|last| last.0 != ' ') { chars.push((c, i)); @@ -59,31 +170,38 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } } } + (chars, zero_width_space) +} + +/// Non-overlapping hits of `needle` (with its KMP `prefix` table) in `chars`, as glyph ranges. +/// `zero_width_space` says which characters come right after a skipped zero width space. +fn search( + chars: &[(char, usize)], + zero_width_space: &[bool], + needle: &[char], + prefix: &[usize], + whole_words: bool, + limit: usize, +) -> Vec> { if needle.len() > chars.len() { return Vec::new(); } - // KMP reuses a matched prefix instead of comparing a long query again at every position. - // The table is bounded by the page's already materialized character count. - let mut prefix = vec![0; needle.len()]; let mut matched = 0; - for (i, &c) in needle.iter().enumerate().skip(1) { - matched = advance(&needle, &prefix, matched, c); - if let Some(slot) = prefix.get_mut(i) { - *slot = matched; - } - } - 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))); + // A skipped zero width space before the first character or after the last is a boundary. + let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false); let mut hits = Vec::new(); for (i, &(c, owner)) in chars.iter().enumerate() { - matched = advance(&needle, &prefix, matched, c); + matched = advance(needle, prefix, matched, c); if matched != needle.len() { continue; } let end = i.saturating_add(1); let start = end.saturating_sub(needle.len()); - // A skipped zero width space before the first character or after the last is a boundary. - let after_break = |i: usize| zero_width_space.get(i).copied().unwrap_or(false); let bounded = !whole_words || ((after_break(start) || !word(start.checked_sub(1))) && (after_break(end) || !word(Some(end)))); if bounded { if let Some((_, first)) = chars.get(start) @@ -205,3 +323,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/euro_width.rs b/crates/render/tests/it/euro_width.rs new file mode 100644 index 0000000000..20833aae38 --- /dev/null +++ b/crates/render/tests/it/euro_width.rs @@ -0,0 +1,119 @@ +//! A standard 14 font named without `/Widths` draws each glyph within the advance its standard +//! metrics give (ISO 32000-2 §9.6.2.2), including the Euro at WinAnsiEncoding code 128 (Annex D, +//! Table D.2): the bundled stand-ins draw their € up to 722 units wide, and it overlapped the +//! glyph after it in Helvetica and Times. + +use pdfcraft_fonts::Std14; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest}; +use std::sync::Arc; + +/// 100 pt at scale 1 is 100 pixels to the em, so a width of 556 units is 55.6 pixels. +pub(crate) const SIZE: f64 = 100.0; +const EURO: u8 = 0x80; + +fn pdf(objects: &[String]) -> Vec { + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = bytes.len(); + bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + bytes +} + +/// The runs of columns holding dark pixels, as (first, last) column, of `text` (a PDF literal +/// string body) shown at 100 pt in the standard font `name` with WinAnsiEncoding and no +/// `/Widths`, starting 20 pt from the left edge. +fn ink_runs(name: &str, text: &str) -> Vec<(u32, u32)> { + ink_runs_in(name, Some("WinAnsiEncoding"), text) +} + +/// [`ink_runs`] in `encoding`, or with no `/Encoding` at all (the font's built-in one). +pub(crate) fn ink_runs_in(name: &str, encoding: Option<&str>, text: &str) -> Vec<(u32, u32)> { + let body = format!("BT /F1 {SIZE} Tf 20 40 Td ({text}) Tj ET"); + let bytes = pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 160] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{} >>", encoding.map(|e| format!(" /Encoding /{e}")).unwrap_or_default()), + ]); + let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default()); + let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(image.error.is_none(), "{:?}", image.error); + let dark = |x: u32| { + (0..image.height).any(|y| { + let at = usize::try_from((y * image.width + x) * 4).unwrap_or(usize::MAX); + image.rgba.get(at).is_some_and(|r| *r < 128) + }) + }; + let mut runs: Vec<(u32, u32)> = Vec::new(); + for x in 0..image.width { + if dark(x) { + match runs.last_mut() { + Some(run) if run.1 + 1 == x => run.1 = x, + _ => runs.push((x, x)), + } + } + } + runs +} + +/// `a€b`: the € keeps clear of both neighbours, its ink is no wider than its advance, and each +/// glyph sits where the advances the writer measures with (`Std14`) put it: 556 units for the +/// Helvetica family, 500 for Times and 600 for Courier. +#[test] +fn the_euro_fits_its_standard_advance() { + for (name, font, euro) in [ + ("Helvetica", Std14::Helvetica, 556), + ("Helvetica-Bold", Std14::HelveticaBold, 556), + ("Times-Roman", Std14::TimesRoman, 500), + ("Times-Bold", Std14::TimesBold, 500), + ("Courier", Std14::Courier, 600), + ("Courier-Bold", Std14::CourierBold, 600), + ] { + assert_eq!(font.width(EURO), f64::from(euro), "{name}: the writer's Euro width"); + let advance = font.advance(EURO, SIZE); + let a = font.advance(b'a', SIZE); + + let runs = ink_runs(name, "a\\200b"); + let [_, (euro_start, euro_end), (b_start, _)] = runs[..] else { panic!("{name}: a, € and b apart: {runs:?}") }; + let [(alone_euro, _)] = ink_runs(name, "\\200")[..] else { panic!("{name}: one €") }; + let [(alone_b, _)] = ink_runs(name, "b")[..] else { panic!("{name}: one b") }; + + assert!(f64::from(euro_end - euro_start + 1) <= advance + 1.0, "{name}: the € is no wider than {euro} units: {runs:?}"); + assert!((f64::from(euro_start) - f64::from(alone_euro) - a).abs() <= 1.0, "{name}: the € starts one `a` on: {runs:?}"); + assert!((f64::from(b_start) - f64::from(alone_b) - a - advance).abs() <= 1.0, "{name}: the b starts {euro} units after the €: {runs:?}"); + } +} + +/// `€€`: two Euros side by side stay two glyphs, the second one advance on. +#[test] +fn two_euros_stay_apart() { + for (name, font) in [("Helvetica", Std14::Helvetica), ("Times-Roman", Std14::TimesRoman), ("Times-Bold", Std14::TimesBold)] { + let runs = ink_runs(name, "\\200\\200"); + let [(first, _), (second, _)] = runs[..] else { panic!("{name}: two separate Euros: {runs:?}") }; + assert!((f64::from(second - first) - font.advance(EURO, SIZE)).abs() <= 1.0, "{name}: {runs:?}"); + } +} + +/// The slanted faces: a lone € is no wider than its advance either. (Their neighbours can share +/// a column with it through the slant alone, so the gaps of `a€b` aren't a measure there.) +#[test] +fn a_slanted_euro_fits_its_standard_advance() { + for (name, font) in [ + ("Helvetica-Oblique", Std14::HelveticaOblique), + ("Helvetica-BoldOblique", Std14::HelveticaBoldOblique), + ("Times-BoldItalic", Std14::TimesBoldItalic), + ] { + let runs = ink_runs(name, r"\200"); + let [(start, end)] = runs[..] else { panic!("{name}: one €: {runs:?}") }; + assert!(f64::from(end - start + 1) <= font.advance(EURO, SIZE) + 1.0, "{name}: {runs:?}"); + } +} diff --git a/crates/render/tests/it/indexed_lookup.rs b/crates/render/tests/it/indexed_lookup.rs new file mode 100644 index 0000000000..2a5d01673f --- /dev/null +++ b/crates/render/tests/it/indexed_lookup.rs @@ -0,0 +1,458 @@ +//! An `/Indexed` image whose colour space breaks ISO 32000-2 §8.6.6.3 (a lookup table shorter +//! than m × (hival + 1) bytes, or a `hival` past 255 or with a fraction) is drawn as most +//! readers draw it, and the render reports `RenderWarning::MalformedIndexedImage`: +//! - a short table keeps the entries it holds in full, and an index past them is drawn black, +//! whatever the base space; +//! - a `hival` past 255 is taken as 255; +//! - a real `hival` is truncated toward zero. +//! +//! An image whose lookup is missing, `null`, neither a string nor a stream, or a stream that +//! does not decode is not drawn, and is reported. A `hival` that is negative or not a number +//! keeps the upstream handling (the image falls back to DeviceGray), and is reported too. +//! Upstream fell back to DeviceGray for every one of these, painting the indices as near-black +//! grey levels, even those whose entries were present. +//! +//! The images are 8 × 1, samples 0 to 7, each column 25 px wide on a 200 × 200 page (the layout +//! of the seven cases A to G a reviewer compared across readers on #4393). + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderWarning, RenderedPage, RequestKind}; + +/// The eight entries the tests' tables hold, in order. +const TABLE: &str = "FF0000 00FF00 0000FF FFFF00 FF00FF 00FFFF 808080 000000"; +const COLOURS: [[u8; 3]; 8] = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0], [255, 0, 255], [0, 255, 255], [128, 128, 128], [0, 0, 0]]; +const BLACK: [u8; 3] = [0, 0, 0]; +const WHITE: [u8; 3] = [255, 255, 255]; +const DRAW_IMAGE: &str = "q 200 0 0 200 0 0 cm /Im Do Q"; +const MALFORMED: [RenderWarning; 1] = [RenderWarning::MalformedIndexedImage]; + +/// The first `n` hex entries of `TABLE`. +fn entries(n: usize) -> String { + TABLE.split(' ').take(n).collect::>().join(" ") +} + +/// A 200 × 200 page whose `/Im` image is eight samples wide (indices 0 to 7) and covers the +/// page, with `/P` naming `colour_space` as a resource. `extra` objects are numbered from 6. +fn pdf(colour_space: &str, content: &str, extra: &[&[u8]]) -> Vec { + pdf_with_resource("P", colour_space, content, extra) +} + +/// As `pdf`, with the colour space resource named `resource` rather than `/P`. +fn pdf_with_resource(resource: &str, colour_space: &str, content: &str, extra: &[&[u8]]) -> Vec { + pdf_with_resources(resource, colour_space, "", content, extra) +} + +/// As `pdf_with_resource`, with `more` entries in the page's resource dictionary. +fn pdf_with_resources(resource: &str, colour_space: &str, more: &str, content: &str, extra: &[&[u8]]) -> Vec { + let mut objs: Vec> = vec![ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R \ + /Resources << /XObject << /Im 5 0 R >> /ColorSpace << /{resource} {colour_space} >> {more} >> >>" + ) + .into_bytes(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()).into_bytes(), + format!( + "<< /Type /XObject /Subtype /Image /Width 8 /Height 1 /BitsPerComponent 8 \ + /ColorSpace {colour_space} /Filter /ASCIIHexDecode /Length 17 >>\nstream\n0001020304050607>\nendstream" + ) + .into_bytes(), + ]; + objs.extend(extra.iter().map(|o| o.to_vec())); + 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(); + let size = objs.len() + 1; + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").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 {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +fn render(bytes: Vec) -> RenderedPage { + let page = PageRenderer::new(Arc::new(bytes), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (200, 200)); + page +} + +/// The centre pixel of each of the eight columns (indices 0 to 7), RGB. +fn columns(page: &RenderedPage) -> [[u8; 3]; 8] { + std::array::from_fn(|i| { + let at = (100 * page.width as usize + 12 + 25 * i) * 4; + let px = page.rgba.get(at..at + 3).expect("pixel inside the page"); + [px[0], px[1], px[2]] + }) +} + +/// The first `n` of `COLOURS`, then black: a table of `n` entries. +fn padded(n: usize) -> [[u8; 3]; 8] { + std::array::from_fn(|i| if i < n { COLOURS[i] } else { BLACK }) +} + +/// Index `i` drawn as the grey level `i` / 255: the DeviceGray fallback. +fn grey_fallback() -> [[u8; 3]; 8] { + std::array::from_fn(|i| [i as u8; 3]) +} + +#[track_caller] +fn assert_page(colour_space: &str, extra: &[&[u8]], want: [[u8; 3]; 8], warned: bool) { + let page = render(pdf(colour_space, DRAW_IMAGE, extra)); + assert_eq!(columns(&page), want, "{colour_space}: the columns"); + let want_warnings: &[RenderWarning] = if warned { &MALFORMED } else { &[] }; + assert_eq!(page.warnings, want_warnings, "{colour_space}: the warnings"); +} + +/// Case A: a full table draws its eight colours, with no warning. +#[test] +fn a_full_table_draws_its_colours() { + assert_page(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &[], COLOURS, false); + assert_page(&format!("[/Indexed /DeviceRGB 7.0 <{TABLE}>]"), &[], COLOURS, false); +} + +/// Cases B and F: a short table keeps the entries it holds, and an index past them is black. +#[test] +fn a_short_table_draws_its_entries_and_black_past_them() { + // B: hival 7 needs 24 bytes; 6 are given. Indices 0 and 1 used to draw black. + assert_page(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)), &[], padded(2), true); + // F: one entry short. + assert_page(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(7)), &[], padded(7), true); + // One byte short: entry 1 is incomplete, so it is not held; indices past hival take it. + let mut want = [BLACK; 8]; + want[0] = COLOURS[0]; + assert_page("[/Indexed /DeviceRGB 1 ]", &[], want, true); +} + +/// The base space for each of the short-table tests below, with its two-entry table, and the +/// stream objects it needs (numbered from 6). +fn short_table_in(base: &str, table: &str) -> String { + format!("[/Indexed {base} 7 <{table}>]") +} + +/// A short table over `base`: the two entries it holds draw as they do with a full table (the +/// rest of which is white, never drawn black by a reader), and every index past them is black. +/// Zero bytes would be white in DeviceCMYK, Separation and DeviceN, and blue in Lab. +#[track_caller] +fn assert_black_past_the_table(base: &str, two: &str, white: &str, extra: &[&[u8]]) { + let full = render(pdf(&short_table_in(base, &format!("{two} {}", white.repeat(6))), DRAW_IMAGE, extra)); + assert!(full.warnings.is_empty(), "{base}: {:?}", full.warnings); + let full = columns(&full); + assert!(full[..2].iter().all(|c| *c != BLACK && *c != WHITE), "{base}: the two entries have colour: {full:?}"); + let page = render(pdf(&short_table_in(base, two), DRAW_IMAGE, extra)); + assert_eq!(page.warnings, MALFORMED, "{base}"); + let mut want = [BLACK; 8]; + want[..2].copy_from_slice(&full[..2]); + assert_eq!(columns(&page), want, "{base}: entries 2 to 7 are black"); +} + +/// DeviceGray: zero is black here, so a zero-padded table drew the same (a guard). +#[test] +fn indices_past_a_short_table_are_black_over_device_gray() { + assert_black_past_the_table("/DeviceGray", "40 80", "FF ", &[]); +} + +/// DeviceCMYK: a zero entry would be white (no ink). +#[test] +fn indices_past_a_short_table_are_black_over_device_cmyk() { + assert_black_past_the_table("/DeviceCMYK", "FF000000 00FF0000", "00000000 ", &[]); +} + +/// Lab: a zero entry would be L* 0 with a* and b* at the bottom of their ranges, blue. +#[test] +fn indices_past_a_short_table_are_black_over_lab() { + assert_black_past_the_table("[/Lab << /WhitePoint [0.9505 1 1.089] /Range [-128 127 -128 127] >>]", "80FF80 8080FF", "FF8080 ", &[]); +} + +/// Separation (a spot orange: tint 1 is orange in DeviceCMYK): a zero entry would be tint 0, +/// white. +#[test] +fn indices_past_a_short_table_are_black_over_separation() { + assert_black_past_the_table( + "[/Separation /Orange /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0.5 1 0] /N 1 >>]", + "FF 80", + "00 ", + &[], + ); +} + +/// DeviceN (cyan and magenta, mapped onto DeviceCMYK by a Type 4 function): a zero entry would +/// be white. +#[test] +fn indices_past_a_short_table_are_black_over_device_n() { + let tint: &[u8] = b"<< /FunctionType 4 /Domain [0 1 0 1] /Range [0 1 0 1 0 1 0 1] /Length 9 >>\nstream\n{ 0 0 }\nendstream"; + assert_black_past_the_table("[/DeviceN [/Cyan /Magenta] /DeviceCMYK 6 0 R]", "FF00 00FF", "0000 ", &[tint]); +} + +/// ICCBased with four components and no usable profile, drawn in its /Alternate DeviceCMYK: a +/// zero entry would be white. +#[test] +fn indices_past_a_short_table_are_black_over_icc_based_with_four_components() { + let profile: &[u8] = b"<< /N 4 /Alternate /DeviceCMYK /Length 4 >>\nstream\nJUNK\nendstream"; + assert_black_past_the_table("[/ICCBased 6 0 R]", "FF000000 00FF0000", "00000000 ", &[profile]); +} + +/// Case C: an index past `hival` takes the nearest entry (ISO 32000-2 §8.6.6.3: "shall be +/// adjusted to the nearest value within that range"); a table longer than needed is fine. +#[test] +fn an_index_past_hival_takes_the_last_entry() { + let mut want = COLOURS; + want[4..].fill(COLOURS[3]); + assert_page(&format!("[/Indexed /DeviceRGB 3 <{TABLE}>]"), &[], want, false); +} + +/// Case D: a `hival` past 255 is taken as 255, and the table padded to 256 entries. +#[test] +fn a_hival_past_255_is_clamped() { + assert_page(&format!("[/Indexed /DeviceRGB 300 <{TABLE}>]"), &[], COLOURS, true); + // With a table long enough for 301 entries, the clamp alone is reported. + let long = format!("<{TABLE}{}>", "00".repeat(3 * 293)); + assert_page(&format!("[/Indexed /DeviceRGB 300 {long}]"), &[], COLOURS, true); + // 256, one past. + assert_page(&format!("[/Indexed /DeviceRGB 256 <{TABLE}{}>]", "00".repeat(3 * 249)), &[], COLOURS, true); + // 255, the largest, with a full table draws without a warning. + assert_page(&format!("[/Indexed /DeviceRGB 255 <{TABLE}{}>]", "00".repeat(3 * 248)), &[], COLOURS, false); +} + +/// Case E: a real `hival` is truncated toward zero, as hayro read it before patch (80). +#[test] +fn a_real_hival_is_truncated() { + assert_page(&format!("[/Indexed /DeviceRGB 7.5 <{TABLE}>]"), &[], COLOURS, true); + // 6.9 is 6, not 7: index 7 takes entry 6. + let mut want = COLOURS; + want[7] = COLOURS[6]; + assert_page(&format!("[/Indexed /DeviceRGB 6.9 <{TABLE}>]"), &[], want, true); + // -0.5 is 0 (toward zero; a real above -1 truncates to 0): every index takes entry 0. + assert_page(&format!("[/Indexed /DeviceRGB -0.5 <{TABLE}>]"), &[], [COLOURS[0]; 8], true); + // 255.5 is 255; 255.0 written as a real is the integer 255. + let full = format!("<{TABLE}{}>", "00".repeat(3 * 248)); + assert_page(&format!("[/Indexed /DeviceRGB 255.5 {full}]"), &[], COLOURS, true); + assert_page(&format!("[/Indexed /DeviceRGB 255.0 {full}]"), &[], COLOURS, false); +} + +/// Case G: inline images, named through the resources or given in place, are recovered too, and +/// skipped when the space has no table. +#[test] +fn inline_images_with_a_short_table_are_recovered_too() { + let inline = |cs: &str| format!("q 200 0 0 200 0 0 cm BI /W 8 /H 1 /BPC 8 /CS {cs} /F /AHx ID 0001020304050607> EI Q"); + let short = format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)); + // Named through the page's resources. + let page = render(pdf_with_resource("CS0", &short, &inline("/CS0"), &[])); + assert_eq!(columns(&page), padded(2)); + assert_eq!(page.warnings, MALFORMED); + // Given in place, with the inline abbreviations. + let page = render(pdf("/DeviceRGB", &inline(&format!("[/I /RGB 7 <{}>]", entries(2))), &[])); + assert_eq!(columns(&page), padded(2)); + assert_eq!(page.warnings, MALFORMED); + // A `hival` with no reading falls back and is reported, named or in place. + let page = render(pdf(&format!("[/Indexed /DeviceRGB -1 <{TABLE}>]"), &inline("/P"), &[])); + assert_eq!((columns(&page), page.warnings), (grey_fallback(), MALFORMED.to_vec())); + let page = render(pdf("/DeviceRGB", &inline(&format!("[/I /RGB -1 <{TABLE}>]")), &[])); + assert_eq!((columns(&page), page.warnings), (grey_fallback(), MALFORMED.to_vec())); + // No table: not drawn, named or in place. + let page = render(pdf("[/Indexed /DeviceRGB 7 null]", &inline("/P"), &[])); + assert_eq!((columns(&page), page.warnings), ([WHITE; 8], MALFORMED.to_vec())); + let page = render(pdf("/DeviceRGB", &inline("[/I /RGB 7]"), &[])); + assert_eq!((columns(&page), page.warnings), ([WHITE; 8], MALFORMED.to_vec())); + // A well-formed one draws without a warning. + let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &inline("/P"), &[])); + assert_eq!(columns(&page), COLOURS); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); +} + +#[test] +fn an_inline_device_space_name_ignores_a_resource_of_the_same_name() { + // ISO 32000-2 §8.6.8 (the CS operator): the device colour space names never refer to + // resources in the ColorSpace subdictionary, so a malformed Indexed resource that shares the + // name (here the inline abbreviation `/RGB`, and `/DeviceRGB`) is not used. + let data: String = TABLE.split(' ').collect(); + for (resource, cs) in [("RGB", "/RGB"), ("DeviceRGB", "/DeviceRGB")] { + let content = format!("q 200 0 0 200 0 0 cm BI /W 8 /H 1 /BPC 8 /CS {cs} /F /AHx ID {data}> EI Q"); + let page = render(pdf_with_resource(resource, "[/Indexed /DeviceRGB 7 ]", &content, &[])); + assert_eq!(columns(&page), COLOURS, "{cs}: the device space draws"); + assert!(page.warnings.is_empty(), "{cs}: {:?}", page.warnings); + } +} + +#[test] +fn a_page_with_several_malformed_images_reports_the_warning_once() { + let content = format!("{DRAW_IMAGE} {DRAW_IMAGE} 0 0 1 rg 0 0 10 10 re f"); + let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)), &content, &[])); + assert_eq!(page.warnings, MALFORMED); + assert_eq!(columns(&page), padded(2)); + let at = (195 * page.width as usize + 5) * 4; + assert_eq!(page.rgba.get(at..at + 3), Some(&[0u8, 0, 255][..]), "content after the images still draws"); +} + +#[test] +fn a_lookup_string_given_by_reference_is_read() { + // A lookup is a byte string or a stream (ISO 32000-2 §8.6.6.3), and either may be indirect. + // A string by reference used to be missed: trying it as a stream consumed the reference. + let full = format!("<{TABLE}>"); + assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[full.as_bytes()], COLOURS, false); + let short = format!("<{}>", entries(2)); + assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[short.as_bytes()], padded(2), true); + // Something that is neither a string nor a stream is no table: the image is not drawn. + assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[b"42"], [WHITE; 8], true); +} + +#[test] +fn the_table_size_holds_at_the_bounds_of_hival_and_the_base() { + // Four components (DeviceCMYK): hival 1 needs 8 bytes. With 7, entry 1 is incomplete, so it + // is not held: index 1, and the indices past hival that take it, are black. + let full = render(pdf("[/Indexed /DeviceCMYK 1 ]", DRAW_IMAGE, &[])); + assert!(full.warnings.is_empty(), "{:?}", full.warnings); + assert_ne!(columns(&full)[0], WHITE, "the image is drawn"); + let short = render(pdf("[/Indexed /DeviceCMYK 1 ]", DRAW_IMAGE, &[])); + assert_eq!(short.warnings, MALFORMED); + let mut want = [BLACK; 8]; + want[0] = columns(&full)[0]; + assert_eq!(columns(&short), want); + // One component (DeviceGray): hival 0 needs one byte; an empty table holds no entry. + assert_page("[/Indexed /DeviceGray 0 <80>]", &[], [[128; 3]; 8], false); + assert_page("[/Indexed /DeviceGray 0 <>]", &[], [BLACK; 8], true); + // hival 255 over DeviceRGB needs 768 bytes: 767 is one short. + assert_page(&format!("[/Indexed /DeviceRGB 255 <{TABLE}{}>]", "00".repeat(3 * 248 - 1)), &[], COLOURS, true); +} + +#[test] +fn a_hival_that_would_overflow_a_size_is_clamped_without_panicking() { + // Values whose (hival + 1) × components would overflow a 32-bit or 64-bit size, or that do + // not fit an integer at all, are taken as 255. + for hival in ["4294967295", "4294967296", "18446744073709551615", "99999999999999999999999"] { + assert_page(&format!("[/Indexed /DeviceRGB {hival} <{}>]", entries(2)), &[], padded(2), true); + } +} + +/// A `hival` that is negative or not a number has no reading to recover: the image keeps the +/// upstream handling (it falls back to DeviceGray), and the warning is still reported. +#[test] +fn a_negative_or_non_numeric_hival_falls_back_with_a_warning() { + for hival in ["-1", "-1.5", "-2147483649", "/One", "null"] { + assert_page(&format!("[/Indexed /DeviceRGB {hival} <{TABLE}>]"), &[], grey_fallback(), true); + } +} + +/// An empty string is a table that holds no entry: every index is black. +#[test] +fn an_empty_table_draws_black() { + for lookup in ["<>", "()"] { + assert_page(&format!("[/Indexed /DeviceRGB 1 {lookup}]"), &[], padded(0), true); + } +} + +/// A lookup that is missing, `null` or a number is no table at all: the image is not drawn (the +/// page stays white), and the warning is reported. +#[test] +fn a_missing_lookup_skips_the_image() { + for lookup in ["", "null", "42"] { + assert_page(&format!("[/Indexed /DeviceRGB 7 {lookup}]"), &[], [WHITE; 8], true); + } +} + +#[test] +fn a_lookup_stream_is_measured_after_decoding() { + let short: &[u8] = b"<< /Length 14 /Filter /ASCIIHexDecode >>\nstream\nFF0000 00FF00>\nendstream"; + assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[short], padded(2), true); + let mut want = [COLOURS[1]; 8]; + want[0] = COLOURS[0]; + assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[short], want, false); + // An empty stream decodes to an empty table, which holds no entry. + let empty: &[u8] = b"<< /Length 0 >>\nstream\n\nendstream"; + assert_page("[/Indexed /DeviceRGB 1 6 0 R]", &[empty], padded(0), true); +} + +/// A stream that does not decode is no table: the image is not drawn, and is reported. +#[test] +fn a_lookup_stream_that_cannot_be_decoded_skips_the_image() { + for broken in [ + &b"<< /Length 12 /Filter /FlateDecode >>\nstream\nnot deflated\nendstream"[..], + b"<< /Length 6 /Filter /FlateDecode >>\nstream\nnot-zl\nendstream", + ] { + assert_page("[/Indexed /DeviceRGB 7 6 0 R]", &[broken], [WHITE; 8], true); + } +} + +/// An Indexed space whose base cannot be read keeps the upstream handling (DeviceGray), whatever +/// its `hival`; only a `hival` with no reading is reported. +#[test] +fn an_unreadable_base_keeps_its_fallback() { + for (hival, warned) in [("1", false), ("300", false), ("-1", true)] { + assert_page(&format!("[/Indexed /NoSuchSpace {hival} <{TABLE}>]"), &[], grey_fallback(), warned); + } +} + +/// A fill or stroke colour set in such a space (`cs`, `sc`, `CS`, `SC`) draws through the same +/// recovered table, and the recovery is reported as `ColourFallback`, as the upstream DeviceGray +/// fallback was. +#[test] +fn a_fill_or_stroke_colour_in_a_short_table_is_recovered_and_reported() { + let fill = |index: u8| format!("/P cs {index} sc 0 0 200 200 re f"); + let short = format!("[/Indexed /DeviceRGB 7 <{}>]", entries(2)); + let page = render(pdf(&short, &fill(1), &[])); + assert_eq!(columns(&page)[0], COLOURS[1], "entry 1 is green"); + assert_eq!(page.warnings, vec![RenderWarning::ColourFallback]); + let page = render(pdf(&short, &fill(5), &[])); + assert_eq!(columns(&page)[0], BLACK, "entry 5 is past the table"); + // Over DeviceCMYK too, where a zero entry would be white. + let cmyk = "[/Indexed /DeviceCMYK 7 ]"; + let page = render(pdf(cmyk, &fill(5), &[])); + assert_eq!(columns(&page)[0], BLACK, "DeviceCMYK entry 5 is past the table"); + // A stroke colour (`CS`, `SC`) the same way. + let page = render(pdf(&short, "/P CS 1 SC 20 w 0 100 m 200 100 l S", &[])); + assert_eq!(columns(&page)[0], COLOURS[1], "the stroke is green"); + assert_eq!(page.warnings, vec![RenderWarning::ColourFallback]); + let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 <{TABLE}>]"), &fill(1), &[])); + assert_eq!(columns(&page)[0], COLOURS[1]); + assert!(page.warnings.is_empty(), "{:?}", page.warnings); +} + +/// A fill colour in an Indexed space with no lookup table keeps the upstream fallback: DeviceGray, +/// reported as `ColourFallback` (tint 1 is white, over a red fill). +#[test] +fn a_fill_colour_in_a_space_with_no_table_keeps_the_fallback() { + for lookup in ["", "null"] { + let content = "1 0 0 rg 0 0 200 200 re f /P cs 1 sc 0 0 200 200 re f"; + let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 {lookup}]"), content, &[])); + assert_eq!(columns(&page)[0], WHITE, "{lookup:?}: DeviceGray 1"); + assert_eq!(page.warnings, vec![RenderWarning::ColourFallback], "{lookup:?}"); + // A stroke colour the same way. + let content = "1 0 0 rg 0 0 200 200 re f /P CS 1 SC 20 w 0 100 m 200 100 l S"; + let page = render(pdf(&format!("[/Indexed /DeviceRGB 7 {lookup}]"), content, &[])); + assert_eq!(columns(&page)[0], WHITE, "{lookup:?}: a DeviceGray 1 stroke"); + assert_eq!(page.warnings, vec![RenderWarning::ColourFallback], "{lookup:?}"); + } +} + +/// Only images and the colour operators handle a space with no table: anything else reads it as +/// upstream did, as a space that cannot be read. A shading in one is not drawn. +#[test] +fn a_shading_in_a_space_with_no_table_is_not_drawn() { + let shading = |cs: &str| { + format!( + "/Shading << /Sh << /ShadingType 2 /ColorSpace {cs} /Coords [0 0 200 0] \ + /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >> >>" + ) + }; + let page = render(pdf_with_resources("P", "/DeviceRGB", &shading("[/Indexed /DeviceRGB 1 null]"), "/Sh sh", &[])); + assert_eq!(columns(&page), [WHITE; 8]); + // The same shading with a table draws. + let page = render(pdf_with_resources("P", "/DeviceRGB", &shading("[/Indexed /DeviceRGB 1 ]"), "/Sh sh", &[])); + assert_ne!(columns(&page), [WHITE; 8]); +} diff --git a/crates/render/tests/it/main.rs b/crates/render/tests/it/main.rs index 1a50693fb2..1cd3e43910 100644 --- a/crates/render/tests/it/main.rs +++ b/crates/render/tests/it/main.rs @@ -2,7 +2,9 @@ mod cid_cff_unusable_font_dict; mod cid_fallback_query; +mod euro_width; mod fill_stroke_knockout; +mod indexed_lookup; mod inline_image_ei; mod jbig2_invariants; mod knockout_backdrop; @@ -15,9 +17,12 @@ mod search; mod separation_all; mod stray_q_in_a_form; mod stroke_degenerate_cubic; +mod stroke_geometry; mod stroke_zero_end_tangent; mod substitute_widths; +mod symbol_dingbats_width; mod symbol_mt_builtin_encoding; mod tiling_cell_placement; mod type3_text_clip; mod widgets_above_annotations; +mod zero_area_fill_clip; diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 5b2a1e4c46..20e419764a 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -6,69 +6,254 @@ 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 original straightforward search is an independent oracle for valid page metadata. +/// 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, extended +// with ligature letters and the three readings of a line-end hyphen between letters: as written, +// joined (no hyphen, no break) and kept (hyphen, no break). 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) }; + let letters = |s: &str| { + s.replace('\u{FB00}', "ff") + .replace('\u{FB01}', "fi") + .replace('\u{FB02}', "fl") + .replace('\u{FB03}', "ffi") + .replace('\u{FB04}', "ffl") + .replace(['\u{FB05}', '\u{FB06}'], "st") + }; + let fold = |s: &str| if sensitive { letters(s) } else { fold_case(&letters(s)) }; // Soft hyphens and zero width characters draw nothing and are skipped on both sides. - let visible = - |s: String| s.chars().filter(|c| !matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}')).collect::(); + let invisible = |c: char| matches!(c, '\u{AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{2060}' | '\u{FEFF}'); + let visible = |s: String| s.chars().filter(|&c| !invisible(c)).collect::(); let needle: Vec = visible(fold(query)).split_whitespace().collect::>().join(" ").chars().collect(); if needle.is_empty() || limit == 0 { return Vec::new(); } - // Each character, its glyph, and whether a skipped zero width space came right before it. - let mut chars: Vec<(char, usize, bool)> = Vec::new(); - let mut after_zwsp = false; - for (i, glyph) in page.glyphs.iter().enumerate() { - // A line that ends in a soft hyphen, past any empty or other invisible glyphs and - // characters after it on that line, runs on to the next. - let ends_in_soft_hyphen = |end: usize| { - (0..=end) - .rev() - .take_while(|&k| page.line_of[k] == page.line_of[end]) - .flat_map(|k| page.glyphs[k].text.chars().rev()) - .find(|&c| c == '\u{AD}' || !visible(c.to_string()).is_empty()) - == Some('\u{AD}') - }; - let line_break = i > 0 && page.line_of[i] != page.line_of[i - 1] && !ends_in_soft_hyphen(i - 1); - if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') { - chars.push((' ', i, false)); - after_zwsp = false; - } - for c in fold(&glyph.text).chars() { - after_zwsp |= c == '\u{200B}'; - if visible(c.to_string()).is_empty() { - continue; - } - let c = if c.is_whitespace() { ' ' } else { c }; - if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') { - chars.push((c, i, after_zwsp && c != ' ')); + let texts: Vec = page.glyphs.iter().map(|g| fold(&g.text)).collect(); + let n = texts.len(); + let new_line = |i: usize| i > 0 && page.line_of[i] != page.line_of[i - 1]; + // A line that ends in a soft hyphen, past any empty or other invisible glyphs and characters + // after it on that line, runs on to the next. + let ends_in_soft_hyphen = |end: usize| { + (0..=end) + .rev() + .take_while(|&k| page.line_of[k] == page.line_of[end]) + .flat_map(|k| page.glyphs[k].text.chars().rev()) + .find(|&c| c == '\u{AD}' || !invisible(c)) + == Some('\u{AD}') + }; + // A visible hyphen between letters at a line end. + let hyphen = |c: char| matches!(c, '-' | '\u{2010}'); + let divided: Vec = (0..n) + .map(|i| { + let chars: Vec = visible(texts[i].clone()).chars().collect(); + let Some(&last) = chars.last() else { return false }; + let before = if chars.len() >= 2 { + Some(chars[chars.len() - 2]) + } else if i > 0 && !page.space_before[i] && !new_line(i) { + visible(texts[i - 1].clone()).chars().last() + } else { + None + }; + hyphen(last) + && before.is_some_and(char::is_alphabetic) + && i + 1 < n + && new_line(i + 1) + && visible(texts[i + 1].clone()).chars().next().is_some_and(char::is_alphabetic) + }) + .collect(); + let mut all = Vec::new(); + let readings = if divided.contains(&true) { 3 } else { 1 }; + for reading in 0..readings { + // Each character, its glyph, and whether a skipped zero width space came right before it. + let mut chars: Vec<(char, usize, bool)> = Vec::new(); + let mut after_zwsp = false; + for i in 0..n { + let joined = reading > 0 && i > 0 && divided[i - 1]; + let line_break = new_line(i) && !ends_in_soft_hyphen(i - 1) && !joined; + if i > 0 && (page.space_before[i] || line_break) && chars.last().is_some_and(|&(c, ..)| c != ' ') { + chars.push((' ', i, false)); after_zwsp = false; } - } - } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); - // 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(); - let mut start = 0; - while start + needle.len() <= chars.len() { - let end = start + needle.len(); - let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end))); - if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) { - hits.push(chars[start].1..chars[end - 1].1 + 1); - if hits.len() == limit { - break; + let mut glyph: Vec = texts[i].chars().collect(); + if reading == 1 && divided[i] { + // The hyphen: the last visible character. + if let Some(at) = glyph.iter().rposition(|&c| !invisible(c)) { + glyph.remove(at); + } } - start = end; - } else { - start += 1; + for c in glyph { + after_zwsp |= c == '\u{200B}'; + if invisible(c) { + continue; + } + let c = if c.is_whitespace() { ' ' } else { c }; + if c != ' ' || chars.last().is_none_or(|&(last, ..)| last != ' ') { + chars.push((c, i, after_zwsp && c != ' ')); + after_zwsp = false; + } + } + } + // 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(); + let mut start = 0; + while start + needle.len() <= chars.len() { + let end = start + needle.len(); + let bounded = (edge(start) || !word(start.checked_sub(1))) && (edge(end) || !word(Some(end))); + if (!whole || bounded) && chars[start..end].iter().map(|&(c, ..)| c).eq(needle.iter().copied()) { + hits.push(chars[start].1..chars[end - 1].1 + 1); + if hits.len() == limit { + break; + } + start = end; + } else { + start += 1; + } + } + all.push(hits); + } + if readings == 1 { + return all.remove(0); + } + // A hit is left out where it overlaps one kept from another reading; one reading's hits are + // all kept, though two can share a glyph. + let mut hits: Vec<(Range, usize)> = all.into_iter().enumerate().flat_map(|(r, hits)| hits.into_iter().map(move |h| (h, r))).collect(); + hits.sort_by_key(|(h, _)| (h.start, std::cmp::Reverse(h.end))); + let mut out: Vec<(Range, usize)> = Vec::new(); + for (h, r) in hits { + let overlaps = out.iter().any(|(k, kr)| *kr != r && h.start < k.end && k.start < h.end); + if !overlaps && out.len() < limit { + out.push((h, r)); } } - hits + out.into_iter().map(|(h, _)| h).collect() +} + +/// One glyph per entry of `text`, each on the line given in `lines`. +fn lined(text: &[&str], lines: &[u32]) -> PageText { + let mut p = page(text); + p.line_of = lines.to_vec(); + p +} + +#[test] +fn a_ligature_matches_its_letters() { + let text = page(&["o", "\u{FB03}", "c", "e", " ", "o", "f", "\u{FB01}", "c", "e"]); + assert_eq!(text.find("office"), vec![0..4, 5..10]); + assert_eq!(text.find("OFFICE"), vec![0..4, 5..10]); + assert_eq!(text.find_opts("office", true, true), vec![0..4, 5..10], "case-sensitive and whole words too"); + assert_eq!(text.find("o\u{FB03}ce"), vec![0..4, 5..10], "a ligature in the query reads as its letters too"); + assert_eq!(text.find("fi"), vec![1..2, 7..8], "a match inside a ligature takes its glyph"); + assert!(text.find("o\u{FB13}").is_empty(), "no other compatibility folding"); +} + +#[test] +fn a_word_divided_by_a_line_end_hyphen_matches_whole_and_as_written() { + // "exam-" ends line 0, "ple" starts line 1; the hyphen is its own glyph. + let text = lined(&["e", "x", "a", "m", "-", "p", "l", "e"], &[0, 0, 0, 0, 0, 1, 1, 1]); + assert_eq!(text.find("example"), vec![0..8], "both parts, and the hyphen between them"); + assert_eq!(text.find_opts("example", false, true), vec![0..8], "a whole word"); + assert_eq!(text.find("exam-ple"), vec![0..8], "a query with the hyphen"); + assert_eq!(text.find("exam- ple"), vec![0..8], "and with the line break as a space"); + assert_eq!(text.find("exam"), vec![0..4]); + // The hyphen in the glyph of the last letter, a soft hyphen and U+2010. + for hyphen in ["m-", "m\u{AD}", "m\u{2010}"] { + let text = lined(&["e", "x", "a", hyphen, "p", "l", "e"], &[0, 0, 0, 0, 1, 1, 1]); + assert_eq!(text.find("example"), vec![0..7], "{hyphen:?}"); + } + // Not divided: a hyphen inside a line, a dash after a word gap, a hyphen between digits. + assert!(page(&["e", "x", "a", "m", "-", "p", "l", "e"]).find("example").is_empty(), "inside a line"); + let mut spaced = lined(&["e", "x", "a", "m", "-", "p", "l", "e"], &[0, 0, 0, 0, 0, 1, 1, 1]); + spaced.space_before[4] = true; + assert!(spaced.find("example").is_empty(), "a dash after a word gap"); + assert!(spaced.find("exam ple").is_empty(), "a dash after a word gap is never dropped"); + assert!(lined(&["1", "0", "-", "2", "0"], &[0, 0, 0, 1, 1]).find("1020").is_empty(), "between digits"); + assert!(lined(&["a", "-", "2", "0"], &[0, 0, 1, 1]).find("a20").is_empty(), "before a number"); + assert!(lined(&["1", "0", "-", "a", "b"], &[0, 0, 0, 1, 1]).find("10ab").is_empty(), "after a number"); + assert!(lined(&["a", "-"], &[0, 0]).find("a-").len() == 1, "at the end of the page"); } #[test] @@ -90,11 +275,32 @@ fn whole_word_rejection_keeps_overlapping_candidates() { // Accepted matches still do not overlap, even within one multi-character glyph. assert_eq!(page(&["aaaaa"]).find("aaa"), vec![0..1]); assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); + // So does a page with a word divided at a line end, which is searched in three readings. + let divided = lined(&["\u{FB03}", " ", "exam", "-", "ple"], &[0, 0, 0, 0, 1]); + assert_eq!(divided.find("f"), vec![0..1, 0..1]); + assert_eq!(divided.find("example"), vec![2..5]); +} + +/// 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}"]; + // Ligatures, digits and HYPHEN exercise the readings of a line-end hyphen. + let alphabet: Vec<&str> = alphabet.into_iter().chain(["\u{FB01}", "1", "\u{2010}"]).collect(); let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); diff --git a/crates/render/tests/it/stroke_geometry.rs b/crates/render/tests/it/stroke_geometry.rs new file mode 100644 index 0000000000..b60a6ee4e0 --- /dev/null +++ b/crates/render/tests/it/stroke_geometry.rs @@ -0,0 +1,295 @@ +//! Stroke geometry the renderer gets from the graphics state: degenerate subpaths and their line +//! caps (ISO 32000-2 §8.5.3.2), dash arrays that are all zero or otherwise invalid (§8.4.3.6) and +//! line widths below zero (§8.4.3.2). Vendored hayro-interpret patch 61. + +use std::sync::Arc; + +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, StrokeProps, + TransformExt, interpret_page, +}; +use kurbo::{Affine, BezPath, Rect}; +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind}; + +const WHITE: [u8; 4] = [255, 255, 255, 255]; +const BLUE: [u8; 4] = [0, 0, 255, 255]; + +/// Renders `content` on a 40 × 40 pt page at 1 px per pt, with `gs1` as the ExtGState `/GS1`. +fn render(content: &str, gs1: &str) -> RenderedPage { + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }); + assert!(page.error.is_none(), "{:?}", page.error); + assert_eq!((page.width, page.height), (40, 40)); + page +} + +/// The pixel at device (x, y), y down. +fn px(page: &RenderedPage, x: usize, y: usize) -> [u8; 4] { + let i = (y * 40 + x) * 4; + let mut out = [0; 4]; + out.copy_from_slice(&page.rgba[i..i + 4]); + out +} + +/// How many pixels are not white. +fn inked(page: &RenderedPage) -> usize { + page.rgba.as_chunks::<4>().0.iter().filter(|p| **p != WHITE).count() +} + +/// §8.5.3.2: a degenerate subpath (a single-point closed path, or points that all coincide) is +/// painted as a filled circle of the line width when the caps are round, and not at all with butt +/// or projecting square caps. User-space point (20, 20) is device pixel (20, 20); a 10 pt round +/// dot covers (20 ± 4, 20) and (20, 20 ± 4) but not the square's corner (24, 24). +#[test] +fn degenerate_subpaths_draw_a_round_dot_or_nothing() { + let round = [ + ("m l to the same point", "0 0 1 RG 10 w 1 J 20 20 m 20 20 l S"), + ("m h, a single-point closed path", "0 0 1 RG 10 w 1 J 20 20 m h S"), + ("m h by s", "0 0 1 RG 10 w 1 J 20 20 m s"), + ("m c with every point the same", "0 0 1 RG 10 w 1 J 20 20 m 20 20 20 20 20 20 c S"), + ("m l l h at one point", "0 0 1 RG 10 w 1 J 20 20 m 20 20 l 20 20 l h S"), + ("a 0 × 0 re", "0 0 1 RG 10 w 1 J 20 20 0 0 re S"), + ]; + for (what, content) in round { + let page = render(content, "<< >>"); + for (x, y) in [(20, 20), (16, 20), (23, 20), (20, 16), (20, 23)] { + assert_eq!(px(&page, x, y), BLUE, "{what}, round caps: ({x}, {y}) is inside the dot"); + } + assert_eq!(px(&page, 24, 24), WHITE, "{what}, round caps: the dot is round, not square"); + assert_eq!(px(&page, 27, 20), WHITE, "{what}, round caps: the dot is the line width across"); + } + for cap in ["0 J", "2 J"] { + for (what, content) in round { + let content = content.replace("1 J", cap); + assert_eq!(inked(&render(&content, "<< >>")), 0, "{what} with {cap}: nothing is painted"); + } + } + // A single-point open subpath (a trailing m) paints nothing whatever the cap. + assert_eq!(inked(&render("0 0 1 RG 10 w 1 J 20 20 m S", "<< >>")), 0, "a lone m"); +} + +/// Only the degenerate subpaths of a path change: the line drawn with them keeps its caps, and a +/// line whose ends differ by a hair is not degenerate. +#[test] +fn degenerate_subpaths_leave_the_rest_of_the_path_alone() { + // Projecting square caps: the line from x 10 to 30 at y 10 reaches x 7 and x 33; the + // degenerate subpath at (20, 30) is dropped. + let page = render("0 0 1 RG 6 w 2 J 10 30 m 30 30 l 20 10 m h S", "<< >>"); + for x in [8, 20, 32] { + assert_eq!(px(&page, x, 10), BLUE, "({x}, 10) on the line with its square caps"); + } + assert_eq!(px(&page, 20, 30), WHITE, "the degenerate subpath is dropped"); + // Round caps: the degenerate subpath becomes a dot beside the line. + let page = render("0 0 1 RG 6 w 1 J 10 30 m 30 30 l 20 10 m h S", "<< >>"); + assert_eq!(px(&page, 20, 10), BLUE, "the line"); + assert_eq!(px(&page, 20, 30), BLUE, "the dot"); + // A trailing open single-point subpath (`m` with nothing after it) paints nothing, and leaves + // the line before it as it is, with either cap. + let page = render("0 0 1 RG 6 w 2 J 10 30 m 30 30 l 20 10 m S", "<< >>"); + for x in [8, 20, 32] { + assert_eq!(px(&page, x, 10), BLUE, "({x}, 10) on the line before a trailing m"); + } + assert_eq!(px(&page, 20, 30), WHITE, "a trailing m with square caps paints nothing"); + let page = render("0 0 1 RG 6 w 1 J 10 30 m 30 30 l 20 10 m S", "<< >>"); + assert_eq!(px(&page, 20, 10), BLUE, "the line before a trailing m"); + assert_eq!(px(&page, 20, 30), WHITE, "a trailing m with round caps paints no dot"); + // `b` (close, fill and stroke) of a single point strokes the dot too. + let page = render("0 0 1 RG 1 0 0 rg 6 w 1 J 20 20 m b", "<< >>"); + assert_eq!(px(&page, 20, 20), BLUE, "b of one point strokes a round dot"); + // A curve back to its start through other control points is a loop, not a point. + let page = render("0 0 1 RG 2 w 2 J 20 10 m 35 35 5 35 20 10 c S", "<< >>"); + assert!(inked(&page) > 20, "a curve that returns to its start is stroked"); + // A hairline dot is still drawn, about a device pixel across. + let page = render("0 0 1 RG 0 w 1 J 20 20 m h S", "<< >>"); + assert!((1..=9).contains(&inked(&page)), "a 0 w round dot: {} pixels", inked(&page)); + // Not degenerate: the ends differ, so the square caps are drawn along the hairline segment. + let page = render("0 0 1 RG 10 w 2 J 20 20 m 20.01 20 l S", "<< >>"); + assert_eq!(px(&page, 20, 20), BLUE, "a segment a hundredth of a point long is stroked with its caps"); +} + +/// `h` "terminates the current subpath. Appending another segment to the current path shall begin +/// a new subpath, even if the new segment begins at the endpoint reached by the h operation" +/// (ISO 32000-2 §8.5.2.1, Table 58). So a segment after `h` back to the point `h` reached is a +/// degenerate subpath of its own: a round dot with round caps, nothing with the others. The closed +/// line from x 10 to x 30 turns back on itself at x 10, where the miter join falls back to a bevel +/// and paints nothing left of x 10; a 6 pt dot at (10, 20) reaches x 7. +#[test] +fn a_segment_after_h_begins_a_new_subpath() { + let page = render("0 0 1 RG 6 w 1 J 10 20 m 30 20 l h S", "<< >>"); + assert_eq!(px(&page, 20, 20), BLUE, "the closed line"); + assert_eq!(px(&page, 8, 20), WHITE, "a closed line has no cap at its start"); + for (what, after) in [("l", "10 20 l"), ("c", "10 20 10 20 10 20 c"), ("v", "10 20 10 20 v"), ("y", "10 20 10 20 y")] { + let page = render(&format!("0 0 1 RG 6 w 1 J 10 20 m 30 20 l h {after} S"), "<< >>"); + assert_eq!(px(&page, 20, 20), BLUE, "{what} after h: the closed line"); + assert_eq!(px(&page, 8, 20), BLUE, "{what} after h, round caps: a dot at the point h reached"); + for cap in ["0 J", "2 J"] { + let page = render(&format!("0 0 1 RG 6 w {cap} 10 20 m 30 20 l h {after} S"), "<< >>"); + assert_eq!(px(&page, 20, 20), BLUE, "{what} after h with {cap}: the closed line"); + assert_eq!(px(&page, 8, 20), WHITE, "{what} after h with {cap}: no dot"); + } + } + // A segment after h that goes somewhere is an open subpath with caps of its own: with square + // caps the line from (10, 20) down to (10, 35) reaches y 38 (device row 2). + let page = render("0 0 1 RG 6 w 2 J 10 20 m 30 20 l h 10 35 l S", "<< >>"); + assert_eq!(px(&page, 10, 3), BLUE, "the new subpath's projecting cap"); +} + +/// §8.4.3.6 asks for dash numbers that are nonnegative and not all zero, but not how to recover +/// from others. They draw a solid line, as an empty dash array does; before, `[0 0]` was cut into +/// hundredth-of-a-point dashes and came out a faint, half-covered line. +#[test] +fn all_zero_dash_arrays_draw_a_solid_line() { + for (what, content, gs1) in [ + ("[0 0]", "0 0 1 RG 4 w [0 0] 0 d 10 20 m 30 20 l S", "<< >>"), + ("[0]", "0 0 1 RG 4 w [0] 0 d 10 20 m 30 20 l S", "<< >>"), + ("[0 0 0] with a phase", "0 0 1 RG 4 w [0 0 0] 3 d 10 20 m 30 20 l S", "<< >>"), + ("[0 0] from an ExtGState", "0 0 1 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[0 0] 0] >>"), + ("[0 -1], negative", "0 0 1 RG 4 w [0 -1] 0 d 10 20 m 30 20 l S", "<< >>"), + ("[3 -1] from an ExtGState", "0 0 1 RG 4 w /GS1 gs 10 20 m 30 20 l S", "<< /D [[3 -1] 0] >>"), + ] { + let page = render(content, gs1); + for x in 11..30 { + assert_eq!(px(&page, x, 20), BLUE, "{what}: ({x}, 20) is solid and opaque"); + } + } +} + +/// Patterns that are valid keep their dashes: `[0 12]` with round caps is a row of dots, `[4 4]` +/// leaves gaps, an odd `[4]` repeats as `[4 4]`. +#[test] +fn valid_dash_patterns_still_dash() { + let page = render("0 0 1 RG 4 w 1 J [0 12] 0 d 4 20 m 36 20 l S", "<< >>"); + for x in [4, 16, 28] { + assert_eq!(px(&page, x, 20), BLUE, "[0 12] round caps: a dot at x {x}"); + } + for x in [10, 22, 34] { + assert_eq!(px(&page, x, 20), WHITE, "[0 12] round caps: a gap at x {x}"); + } + for (what, content) in [("[4 4]", "0 0 1 RG 4 w [4 4] 0 d 0 20 m 40 20 l S"), ("[4]", "0 0 1 RG 4 w [4] 0 d 0 20 m 40 20 l S")] { + let page = render(content, "<< >>"); + assert_eq!(px(&page, 2, 20), BLUE, "{what}: the first dash"); + assert_eq!(px(&page, 6, 20), WHITE, "{what}: the first gap"); + assert_eq!(px(&page, 10, 20), BLUE, "{what}: the second dash"); + } +} + +/// The stroke properties and paints every `draw_path` of `content` reaches a device with. +fn strokes(content: &str, gs1: &str) -> Vec<(StrokeProps, Paint<'static>)> { + #[derive(Default)] + struct Strokes(Vec<(StrokeProps, Paint<'static>)>); + impl<'a> Device<'a> for Strokes { + fn set_soft_mask(&mut self, _: Option>) {} + fn set_blend_mode(&mut self, _: BlendMode) {} + fn draw_path(&mut self, _: &BezPath, _: Affine, paint: &Paint<'a>, mode: &PathDrawMode) { + if let (PathDrawMode::Stroke(props), Paint::Color(colour)) = (mode, paint) { + self.0.push((props.clone(), Paint::Color(colour.clone()))); + } + } + fn push_clip_path(&mut self, _: &ClipPath) {} + fn push_transparency_group(&mut self, _: f32, _: Option>, _: BlendMode) {} + fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {} + fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {} + fn pop_clip_path(&mut self) {} + fn pop_transparency_group(&mut self) {} + } + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /ExtGState << /GS1 {gs1} >> >> >> endobj +4 0 obj << /Length {} >> stream +{content} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + content.len() + ); + let parsed = Pdf::new(Arc::new(pdf.into_bytes())).expect("parses"); + let page = &parsed.pages()[0]; + let cache = InterpreterCache::new(); + let mut ctx = + Context::new(page.initial_transform(true).to_kurbo(), Rect::new(0.0, 0.0, 40.0, 40.0), &cache, page.xref(), InterpreterSettings::default()); + let mut device = Strokes::default(); + interpret_page(page, &mut ctx, &mut device); + device.0 +} + +/// §8.4.3.2: a line width of 0 is the thinnest line the device can draw. A negative or +/// non-finite one is invalid and kept as 0, so no device sees a width below zero; the stroke +/// keeps the current stroke colour. +#[test] +fn negative_line_widths_reach_a_device_as_zero() { + for (what, content, gs1, width) in [ + ("-6 w", "0 0 1 RG -6 w 10 20 m 30 20 l S", "<< >>", 0.0), + ("/LW -6", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW -6 >>", 0.0), + ("/LW 1e40, beyond f32", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW 10000000000000000000000000000000000000000 >>", 0.0), + ("2.5 w", "0 0 1 RG 2.5 w 10 20 m 30 20 l S", "<< >>", 2.5), + ("0 w", "0 0 1 RG 0 w 10 20 m 30 20 l S", "<< >>", 0.0), + ] { + let seen = strokes(content, gs1); + assert_eq!(seen.len(), 1, "{what}: one stroke"); + let (props, paint) = &seen[0]; + assert_eq!(props.line_width, width, "{what}: line width"); + let Paint::Color(colour) = paint else { unreachable!() }; + assert_eq!(colour.to_rgba().to_rgba8(), [0, 0, 255, 255], "{what}: the stroke colour"); + } +} + +/// The dash array a device is given: `[]` (solid) for one that is all zero or has a negative or +/// non-finite entry, from `d` or from `/D`; a valid one keeps its entries, a zero entry turned +/// into a hundredth of a point, as before. +#[test] +fn invalid_dash_arrays_reach_a_device_as_solid() { + let line = "10 20 m 30 20 l S"; + for (what, setup, gs1, dashes) in [ + ("[0 0]", "[0 0] 0 d", "<< >>", &[][..]), + ("[0]", "[0] 0 d", "<< >>", &[]), + ("[0 0 0] with a phase", "[0 0 0] 4 d", "<< >>", &[]), + ("/D [[0 0] 0]", "/GS1 gs", "<< /D [[0 0] 0] >>", &[]), + ("[3 -1]", "[3 -1] 0 d", "<< >>", &[]), + ("/D [[0 -1] 0]", "/GS1 gs", "<< /D [[0 -1] 0] >>", &[]), + ("[3 1e40], beyond f32", "[3 10000000000000000000000000000000000000000] 0 d", "<< >>", &[]), + ("[]", "[] 0 d", "<< >>", &[]), + ("[0 12]", "[0 12] 0 d", "<< >>", &[0.01, 12.0]), + ("/D [[0 12] 0]", "/GS1 gs", "<< /D [[0 12] 0] >>", &[0.01, 12.0]), + ("[20 10]", "[20 10] 5 d", "<< >>", &[20.0, 10.0]), + ("[10]", "[10] 0 d", "<< >>", &[10.0]), + ] { + let seen = strokes(&format!("{setup} {line}"), gs1); + assert_eq!(seen.len(), 1, "{what}: one stroke"); + assert_eq!(seen[0].0.dash_array.as_slice(), dashes, "{what}"); + } +} + +/// The renderer already drew a negative width as its thinnest line (it widens anything under a +/// device pixel); that stays so, in the stroke colour. +#[test] +fn negative_line_widths_draw_the_thinnest_line_in_the_stroke_colour() { + for (what, content, gs1, reference) in [ + ("-6 w", "0 0 1 RG -6 w 10 20 m 30 20 l S", "<< >>", "0 0 1 RG 0 w 10 20 m 30 20 l S"), + ("/LW -6", "0 0 1 RG /GS1 gs 10 20 m 30 20 l S", "<< /LW -6 >>", "0 0 1 RG 0 w 10 20 m 30 20 l S"), + ("-6 w with round caps", "0 0 1 RG 1 J -6 w 10 20 m 30 20 l S", "<< >>", "0 0 1 RG 1 J 0 w 10 20 m 30 20 l S"), + ] { + let hairline = render(reference, "<< >>"); + let page = render(content, gs1); + assert!(page.rgba[..] == hairline.rgba[..], "{what}: drawn as 0 w"); + let ink: Vec<[u8; 4]> = page.rgba.as_chunks::<4>().0.iter().filter(|p| **p != WHITE).copied().collect(); + assert!(!ink.is_empty(), "{what}: the line is drawn"); + assert!(ink.iter().all(|p| p[2] == 255 && p[0] == p[1]), "{what}: every inked pixel is a tint of the blue stroke colour: {ink:?}"); + assert!(ink.len() <= 2 * 22, "{what}: about one pixel thick, {} pixels inked", ink.len()); + } +} diff --git a/crates/render/tests/it/symbol_dingbats_width.rs b/crates/render/tests/it/symbol_dingbats_width.rs new file mode 100644 index 0000000000..8b79dc3604 --- /dev/null +++ b/crates/render/tests/it/symbol_dingbats_width.rs @@ -0,0 +1,75 @@ +//! Symbol and ZapfDingbats named without `/Widths` or `/Encoding` draw each glyph within the +//! advance their standard metrics give (ISO 32000-2 §9.6.2.2, Annex D.5 and D.6), through their +//! bundled stand-ins as the text fonts do: the next glyph starts one standard advance on, and the +//! glyph's ink stays inside that advance. ZapfDingbats `4` (a20, the check mark) is what a check +//! box's `/MK /CA` caption draws. + +use crate::euro_width::{SIZE, ink_runs_in}; +use pdfcraft_fonts::Std14; + +/// The page's left margin, where the text starts, in pixels. +const ORIGIN: f64 = 20.0; + +/// `codes` as a PDF literal string body, every byte octal-escaped. +fn literal(codes: &[u8]) -> String { + codes.iter().map(|c| format!("\\{c:03o}")).collect() +} + +/// For each `(code, glyph)` of `font` (`name`): drawn alone, the glyph's ink lies within its +/// standard advance (one pixel of rounding either side, as for the Euro), and drawn before +/// `follower`, the follower's ink starts one standard advance later than when drawn alone. +fn fits(name: &str, font: Std14, follower: u8, glyphs: &[(u8, &str)]) { + let [(follower_alone, _), ..] = ink_runs_in(name, None, &literal(&[follower]))[..] else { panic!("{name}: the follower draws") }; + for &(code, glyph) in glyphs { + let advance = font.advance(code, SIZE); + assert!(advance > 0.0, "{name} {glyph}: a standard width"); + let alone = ink_runs_in(name, None, &literal(&[code])); + let (Some(&(start, _)), Some(&(_, end))) = (alone.first(), alone.last()) else { panic!("{name} {glyph}: no ink") }; + assert!(f64::from(start) >= ORIGIN - 1.0, "{name} {glyph}: ink starts at its origin or after: {alone:?}"); + assert!(f64::from(end) + 1.0 <= ORIGIN + advance + 1.0, "{name} {glyph}: ink ends within {advance} px: {alone:?}"); + // The glyph is fitted, not squeezed: its ink fills most of the advance, as each of these does. + assert!(f64::from(end - start + 1) >= 0.7 * advance, "{name} {glyph}: ink spans most of {advance} px: {alone:?}"); + + let pair = ink_runs_in(name, None, &literal(&[code, follower])); + let Some(&(next, _)) = pair.last() else { panic!("{name} {glyph}: no ink") }; + assert!(pair.len() > alone.len(), "{name} {glyph}: the follower stands apart: {pair:?}"); + assert!( + (f64::from(next) - f64::from(follower_alone) - advance).abs() <= 1.0, + "{name} {glyph}: the follower starts {advance} px on: {pair:?}" + ); + } +} + +/// Symbol's Greek letters and signs by their built-in codes (Annex D.5). +#[test] +fn symbol_glyphs_keep_their_standard_advances() { + let glyphs = [ + (0x61, "alpha"), + (0x62, "beta"), + (0x70, "pi"), + (0x53, "Sigma"), + (0x57, "Omega"), + (0x44, "Delta"), + (0xA5, "infinity"), + (0xB7, "bullet"), + (0x2B, "plus"), + ]; + fits("Symbol", Std14::Symbol, 0x53, &glyphs); +} + +/// ZapfDingbats' check marks, crosses, dot, square, diamond and star by their built-in codes +/// (Annex D.6), the check box caption `4` among them. +#[test] +fn zapf_dingbats_glyphs_keep_their_standard_advances() { + let glyphs = [ + (0x34, "a20 check"), + (0x33, "a19 light check"), + (0x37, "a23 ballot x"), + (0x38, "a24 heavy ballot x"), + (0x6C, "a71 dot"), + (0x6E, "a73 square"), + (0x75, "a77 diamond"), + (0x48, "a28 star"), + ]; + fits("ZapfDingbats", Std14::ZapfDingbats, 0x6E, &glyphs); +} diff --git a/crates/render/tests/it/zero_area_fill_clip.rs b/crates/render/tests/it/zero_area_fill_clip.rs new file mode 100644 index 0000000000..b3ca3aa322 --- /dev/null +++ b/crates/render/tests/it/zero_area_fill_clip.rs @@ -0,0 +1,301 @@ +//! Zero-area fills and clips mark the pixels they touch (ISO 32000-2 §10.7.4: "A zero-width or +//! zero-height rectangle paints a line 1 pixel wide", and "the clipping region consists of the +//! set of pixels that would be included by a fill operation"). +//! +//! Which row (or column) a line on a pixel boundary marks is not left open. §10.7.4, "Scan +//! conversion rules", in device space: +//! +//! > Pixel boundaries always fall on integer coordinates in device space. A pixel is a square +//! > region identified by the location of its corner with minimum horizontal and vertical +//! > coordinates. The region is half-open, meaning that it includes its lower but not its upper +//! > boundaries. More precisely, for any point whose real-number coordinates are (x, y), let +//! > i = floor(x) and j = floor(y). The pixel that contains this point is the one identified as +//! > (i, j). +//! +//! > Like pixels, shapes to be painted by filling (8.5.3.3, "Filling") and stroking (8.5.3.2, +//! > "Stroking") operations are also treated as half-open regions that include the boundaries +//! > along their "floor" sides, but not along their "ceiling" sides. +//! +//! > A shape shall be scan-converted by painting any pixel whose half-open square region +//! > intersects the shape, no matter how small the intersection is. +//! +//! So a line at device y = 150 lies in pixel row floor(150) = 150 and not in row 149, whose +//! region stops short of 150; a line from device x = 10 to x = 110 marks columns 10 to 109, as +//! its ceiling end at 110 is not part of it. Off a boundary, a line at y = 149.5 marks row +//! floor(149.5) = 149. The rule is stated in device space, whose y axis here points down the +//! page, so row 150 is the one just below the line as the page is seen. +//! +//! Every page is 200 × 200 pt rendered at scale 1, so user `(x, y)` lands on device +//! `(x, 200 - y)`: a line at user `y = 50` lies on the boundary between device rows 149 and +//! 150, and by the half-open rule marks row 150. + +use std::sync::Arc; + +use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind}; + +const SIZE: u32 = 200; + +fn build_pdf(content: &str) -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_owned(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {SIZE} {SIZE}] /Contents 4 0 R >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::with_capacity(objs.len()); + for (i, body) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + let size = objs.len() + 1; + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for off in offs { + out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + out +} + +/// The page drawn by `content`, as RGBA rows of `SIZE` pixels. +fn render(content: &str) -> Vec { + let pdf = Arc::new(build_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(), "render error: {:?}", page.error); + assert_eq!((page.width, page.height), (SIZE, SIZE)); + page.rgba.to_vec() +} + +fn px(rgba: &[u8], x: u32, y: u32) -> [u8; 3] { + let i = ((y * SIZE + x) * 4) as usize; + [rgba[i], rgba[i + 1], rgba[i + 2]] +} + +fn is_red(p: [u8; 3]) -> bool { + p[0] > 240 && p[1] < 15 && p[2] < 15 +} + +fn is_white(p: [u8; 3]) -> bool { + p.iter().all(|&c| c > 250) +} + +/// The red pixels of the page, as `(x, y)`. +fn red_pixels(rgba: &[u8]) -> Vec<(u32, u32)> { + let mut red = Vec::new(); + for y in 0..SIZE { + for x in 0..SIZE { + if is_red(px(rgba, x, y)) { + red.push((x, y)); + } + } + } + red +} + +/// The pixels that are neither white nor full red: partly covered ones. +fn tinted_pixels(rgba: &[u8]) -> usize { + let mut n = 0; + for y in 0..SIZE { + for x in 0..SIZE { + let p = px(rgba, x, y); + if !is_red(p) && !is_white(p) { + n += 1; + } + } + } + n +} + +/// Asserts the page is exactly a full-red run of device pixels `x0..x1` × `y0..y1` on white. +fn assert_only_red_run(rgba: &[u8], (x0, x1): (u32, u32), (y0, y1): (u32, u32), what: &str) { + let mut expected = Vec::new(); + for y in y0..y1 { + for x in x0..x1 { + expected.push((x, y)); + } + } + assert_eq!(red_pixels(rgba), expected, "{what}: the red pixels"); + assert_eq!(tinted_pixels(rgba), 0, "{what}: partly covered pixels"); +} + +const FLOOD: &str = "1 0 0 rg 0 0 200 200 re f"; +/// A fill that covers the whole page under any of the CTMs below. +const BIG_FLOOD: &str = "1 0 0 rg -400 -400 800 800 re f"; + +#[test] +fn zero_height_clip_shows_the_fill_as_one_device_row() { + // User y = 50 is device y = 150, a pixel boundary: row 150 and nothing else. + let rgba = render(&format!("q 10 50 100 0 re W n {FLOOD} Q")); + assert_only_red_run(&rgba, (10, 110), (150, 151), "clip on a pixel boundary"); + // Off the boundary (device y = 149.5) the line marks the row it runs through. + let rgba = render(&format!("q 10 50.5 100 0 re W n {FLOOD} Q")); + assert_only_red_run(&rgba, (10, 110), (149, 150), "clip inside a row"); + // Inside a translated CTM, as the line is usually drawn. + let rgba = render(&format!("q 1 0 0 1 20 30 cm 10 10 100 0 re W n {FLOOD} Q")); + assert_only_red_run(&rgba, (30, 130), (160, 161), "clip under a translation"); +} + +#[test] +fn zero_width_clip_shows_the_fill_as_one_device_column() { + let rgba = render(&format!("q 50 10 0 100 re W n {FLOOD} Q")); + assert_only_red_run(&rgba, (50, 51), (90, 190), "zero-width clip"); +} + +#[test] +fn zero_height_and_zero_width_fills_paint_one_pixel_lines() { + let rgba = render("1 0 0 rg 10 50 100 0 re f"); + assert_only_red_run(&rgba, (10, 110), (150, 151), "zero-height fill"); + let rgba = render("1 0 0 rg 50 10 0 100 re f"); + assert_only_red_run(&rgba, (50, 51), (90, 190), "zero-width fill"); + // The same line drawn as a fill and seen through a clip marks the same pixels. + let fill = render("1 0 0 rg 10 50.5 100 0 re f"); + let clip = render(&format!("q 10 50.5 100 0 re W n {FLOOD} Q")); + assert_eq!(red_pixels(&fill), red_pixels(&clip)); +} + +/// Each of the four cases (zero-height and zero-width, fill and clip) on a pixel boundary and +/// off one, by the half-open rule quoted above: a line on a boundary marks the row or column on +/// its floor side, one off it marks the row or column it runs through, and its ends mark every +/// pixel they reach into but not one they only meet at the ceiling side. +#[test] +fn zero_height_and_zero_width_fills_and_clips_on_and_off_a_pixel_boundary() { + let cases = [ + // (the line, its device columns, its device rows, what) + ("10 50 100 0 re", (10, 110), (150, 151), "zero-height on a boundary"), + ("10 50.5 100 0 re", (10, 110), (149, 150), "zero-height off a boundary"), + ("10.5 50.5 99 0 re", (10, 110), (149, 150), "zero-height, ends inside pixels"), + ("50 10 0 100 re", (50, 51), (90, 190), "zero-width on a boundary"), + ("50.5 10 0 100 re", (50, 51), (90, 190), "zero-width off a boundary"), + ("50.5 10.5 0 99 re", (50, 51), (90, 190), "zero-width, ends inside pixels"), + ]; + for (line, columns, rows, what) in cases { + let fill = render(&format!("1 0 0 rg {line} f")); + assert_only_red_run(&fill, columns, rows, &format!("{what}: fill")); + let clip = render(&format!("q {line} W n {FLOOD} Q")); + assert_only_red_run(&clip, columns, rows, &format!("{what}: clip")); + } +} + +#[test] +fn a_quarter_turn_maps_a_zero_height_clip_to_one_device_column() { + // `0 1 -1 0 100 20 cm` turns user x into device -y: the line runs up column 100. + let rgba = render(&format!("q 0 1 -1 0 100 20 cm 10 0 100 0 re W n {BIG_FLOOD} Q")); + assert_only_red_run(&rgba, (100, 101), (70, 170), "quarter-turned clip"); +} + +#[test] +fn a_rotated_zero_height_clip_and_fill_show_a_one_pixel_band() { + // 45 degrees: the line runs diagonally, so it shows as a band about one pixel wide. + let clip = render(&format!("q 0.70710678 0.70710678 -0.70710678 0.70710678 50 50 cm 0 0 100 0 re W n {BIG_FLOOD} Q")); + let fill = render("q 0.70710678 0.70710678 -0.70710678 0.70710678 50 50 cm 1 0 0 rg 0 0 100 0 re f Q"); + for (rgba, what) in [(&clip, "clip"), (&fill, "fill")] { + // Along the diagonal from device (50, 150) to (120.7, 79.3), the pixel the line crosses + // corner to corner in each column is mostly red, and the pixels well off it are white. + for step in 5..65 { + let (x, y) = (50 + step, 149 - step); + let p = px(rgba, x, y); + assert!(p[0] > 240 && p[1] < 160, "{what}: pixel ({x}, {y}) on the line is {p:?}"); + assert!(is_white(px(rgba, x + 3, y)), "{what}: pixel off the line"); + assert!(is_white(px(rgba, x - 3, y)), "{what}: pixel off the line"); + } + // The band is one pixel wide: its ink adds up to its length, 100 pixels. + let ink: f64 = rgba.as_chunks::<4>().0.iter().map(|p| 1.0 - f64::from(p[1]) / 255.0).sum(); + assert!((90.0..=110.0).contains(&ink), "{what}: ink {ink}"); + } +} + +#[test] +fn a_thin_rectangle_with_area_keeps_its_partial_coverage() { + // 0.3 pt high is not degenerate: it covers 30 % of device row 149 and is antialiased as + // before, not widened to a whole pixel. + let rgba = render("1 0 0 rg 10 50.2 100 0.3 re f"); + assert!(red_pixels(&rgba).is_empty(), "a 0.3 pt rectangle is not painted solid"); + for x in 10..110 { + let p = px(&rgba, x, 149); + assert!(p[0] > 240, "row 149 at {x}: {p:?}"); + assert!((150..=210).contains(&p[1]), "row 149 at {x} is 30 % covered: {p:?}"); + assert!(is_white(px(&rgba, x, 148)) && is_white(px(&rgba, x, 150))); + } + // The same rectangle as a clip keeps the same partial coverage. + let clip = render(&format!("q 10 50.2 100 0.3 re W n {FLOOD} Q")); + assert_eq!(clip, rgba); +} + +#[test] +fn a_zero_area_clip_with_nothing_painted_on_it_stays_blank() { + // The fill misses the line the clip keeps. + let rgba = render("q 10 50 100 0 re W n 1 0 0 rg 0 100 200 100 re f Q"); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| is_white([p[0], p[1], p[2]])), "page not blank"); + // Nothing painted at all. + let rgba = render("q 10 50 100 0 re W n Q"); + assert!(rgba.as_chunks::<4>().0.iter().all(|p| is_white([p[0], p[1], p[2]])), "page not blank"); + // A clip that is a point keeps nothing, as before. + let rgba = render(&format!("q 10 50 0 0 re W n {FLOOD} Q")); + assert!(red_pixels(&rgba).is_empty() && tinted_pixels(&rgba) == 0, "a point clip shows the fill"); + // So does a point a little off zero both ways. + let rgba = render(&format!("q 10 50 0.001 0.001 re W n {FLOOD} Q")); + assert!(red_pixels(&rgba).is_empty() && tinted_pixels(&rgba) == 0, "a tiny clip shows the fill"); +} + +#[test] +fn the_clip_is_restored_after_q() { + let rgba = render(&format!("q 10 50 100 0 re W n Q {FLOOD}")); + assert_eq!(red_pixels(&rgba).len(), (SIZE * SIZE) as usize); +} + +#[test] +fn a_thin_rectangle_scaled_up_to_whole_pixels_keeps_its_area() { + // 1/1024 pt high is under the rasteriser's "nearly zero" threshold, but the rectangle has + // area: scaled 4096 times vertically it covers device y 164..168, four whole rows, and is + // painted as the rectangle it is, not as a line across its bounding box. + let fill = render("q 1 0 0 4096 0 0 cm 1 0 0 rg 10 0.0078125 100 0.0009765625 re f Q"); + assert_only_red_run(&fill, (10, 110), (164, 168), "scaled-up thin fill"); + let clip = render(&format!("q 1 0 0 4096 0 0 cm 10 0.0078125 100 0.0009765625 re W n {FLOOD} Q")); + assert_only_red_run(&clip, (10, 110), (164, 168), "scaled-up thin clip"); +} + +#[test] +fn a_slanted_zero_height_line_crossing_a_row_marks_each_row_only_where_it_runs() { + // A slight skew lifts the line from device y 149.52 at x = 10 to 148.74 at x = 110: under a + // pixel high in all, but crossing the boundary between rows 148 and 149 near x = 75. Each + // row is marked where the line runs through it, not along the whole length. + let skew = "1 0.008 0 1 0 0 cm"; + let fill = render(&format!("q {skew} 1 0 0 rg 10 50.4 100 0 re f Q")); + let clip = render(&format!("q {skew} 10 50.4 100 0 re W n {FLOOD} Q")); + for (rgba, what) in [(&fill, "fill"), (&clip, "clip")] { + for x in [12, 15, 20] { + assert!(px(rgba, x, 149)[1] < 128, "{what}: ({x}, 149) on the line is {:?}", px(rgba, x, 149)); + assert!(px(rgba, x, 148)[1] > 128, "{what}: ({x}, 148) off the line is {:?}", px(rgba, x, 148)); + } + for x in [100, 105, 108] { + assert!(px(rgba, x, 148)[1] < 128, "{what}: ({x}, 148) on the line is {:?}", px(rgba, x, 148)); + assert!(px(rgba, x, 149)[1] > 128, "{what}: ({x}, 149) off the line is {:?}", px(rgba, x, 149)); + } + // One pixel wide: its ink adds up to its length, about 100 pixels, not two rows' worth. + let ink: f64 = rgba.as_chunks::<4>().0.iter().map(|p| 1.0 - f64::from(p[1]) / 255.0).sum(); + assert!((90.0..=110.0).contains(&ink), "{what}: ink {ink}"); + } +} + +#[test] +fn disconnected_subpaths_on_one_line_leave_the_gap_between_them_unpainted() { + // Two zero-height subpaths on user y = 50 (device row 150), x 10..30 and 60..90: the fill + // and the clip mark the pixels each subpath touches, not the gap between them. + let mut expected = Vec::new(); + for x in (10..30).chain(60..90) { + expected.push((x, 150)); + } + let fill = render("1 0 0 rg 10 50 m 30 50 l 60 50 m 90 50 l f"); + let clip = render(&format!("q 10 50 m 30 50 l 60 50 m 90 50 l W n {FLOOD} Q")); + for (rgba, what) in [(&fill, "fill"), (&clip, "clip")] { + assert_eq!(red_pixels(rgba), expected, "{what}: the red pixels"); + assert_eq!(tinted_pixels(rgba), 0, "{what}: partly covered pixels"); + } + // Overlapping subpaths drawn in opposite directions still paint their union under either rule. + let fill = render("1 0 0 rg 10 50 m 60 50 l 90 50 m 40 50 l f*"); + assert_only_red_run(&fill, (10, 90), (150, 151), "overlapping subpaths"); + let clip = render(&format!("q 10 50 m 60 50 l 90 50 m 40 50 l W* n {FLOOD} Q")); + assert_only_red_run(&clip, (10, 90), (150, 151), "overlapping subpaths: clip"); +} 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..37a11e210e 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, @@ -167,6 +168,7 @@ 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)); @@ -242,7 +244,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 { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 26d1bd4946..f23c359020 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}; @@ -41,6 +42,7 @@ mod xform; mod zoom; pub(super) mod zoom_snap; +pub(crate) use area::SignTab; pub(crate) use area::signature_badges; use area::{drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor}; #[cfg(test)] @@ -500,6 +502,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 +568,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 +601,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 +629,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)>, @@ -704,6 +715,24 @@ pub struct DocView { /// Redact tool: the box being drawn, and a mark to add (page, quads) for the app to style. pub redact_drag: crate::redact_ui::AreaDrag, pub pending_redaction: Option<(usize, Vec<[f64; 8]>)>, + /// How many Apply redactions steps are in effect in this view. While it is above 0, Save + /// proposes the name with `_Redacted` ([`pdfcraft_engine::redacted_file_name`]), and while it is + /// above `redactions_downloaded`, Save asks where. + /// Undo of an Apply redactions step takes one off and Redo adds it back. Reload and a desktop + /// save set it to 0, and Revert sets it to `redactions_downloaded`; after a desktop save the document has the name it was saved + /// under. A web download keeps it, so later downloads carry the `_Redacted` name. + pub redactions_applied: usize, + /// How many Apply redactions steps saved into the document's file are undone in this view. + /// While it is above 0, Save asks where, so the restored content is not written over the + /// redacted file. Redo takes one off. Revert, Reload and a desktop save set it to 0. + pub redactions_undone_after_save: usize, + /// Set when Apply redactions runs while saved Apply redactions steps are undone: Redo can no + /// longer bring those steps back, so Save asks where until a desktop save or Revert clears it. + pub saved_redactions_cut_off: bool, + /// `redactions_applied` at the last web download, which Revert restores. While + /// `redactions_applied` is below it, a delivered step is undone and Save asks where. A desktop + /// save leaves it at 0. + pub redactions_downloaded: usize, /// A crop rectangle being dragged (Crop tool). pub crop_drag: crate::crop::CropDrag, /// Enhance ▸ Camera image: the quadrilateral being placed. @@ -989,6 +1018,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 +1051,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 +1067,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 +1083,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, @@ -1115,6 +1148,10 @@ impl DocView { links: Default::default(), redact_drag: None, pending_redaction: None, + redactions_applied: 0, + redactions_undone_after_save: 0, + saved_redactions_cut_off: false, + redactions_downloaded: 0, crop_drag: None, camera: None, camera_done: None, @@ -1140,7 +1177,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 +1216,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 +1234,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 +1242,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 +1344,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 +1356,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 +1721,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. @@ -1654,6 +1739,7 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i area_tool: false, hover: None, released_over: false, + sign_tabs: Vec::new(), } } else { page_tool_input(ui, view, frame, &slot) @@ -2481,20 +2567,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 +2603,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); @@ -2968,7 +3049,8 @@ fn fits_height(r: Rect, view_height: f32, pixels_per_point: f32) -> bool { } /// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +/// scroll area with this frame's zoom, scrolling, tool input, overlays (form fields, comments, +/// signature badges and sign tabs) and render requests. pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let can_paste_key = egui::Id::unique("can-paste-image"); @@ -3154,6 +3236,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 +3278,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 +3290,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 +3307,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 { @@ -3637,5 +3724,7 @@ enum FieldMenu { Duplicate(String), } +#[cfg(test)] +mod sign_tab_tests; #[cfg(test)] mod tests; diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index 64cbd6e939..53b1edd328 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -1,4 +1,4 @@ -use super::{LayerOp, PdfCraftApp, Pos2, Rect}; +use super::{Color32, HashSet, LayerOp, PdfCraftApp, Pos2, Rect, Tokens, Vec2, icons, pos2, theme, vec2}; /// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. pub(crate) struct SignatureBadge { @@ -27,6 +27,121 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa .collect() } +/// The empty signature fields on one page that get a sign tab, as their names and widget +/// rectangles (user space). Each is unsigned and visible, on this page, with a rectangle that +/// passes the same screen-space guard as [`signature_badges`]. A click must also be able to sign +/// it, as in `forms_ui::page_input`: the document allows filling (`can_fill`), and the form field +/// of that name is a signature field that isn't read-only, with a shown widget at that place +/// that isn't read-only on its own. +pub(crate) fn sign_tabs<'a>( + signatures: &'a [pdfcraft_engine::SignatureInfo], + form: &[pdfcraft_engine::FormField], + page: usize, + can_fill: bool, +) -> Vec<(&'a str, [f64; 4])> { + let mut empty = signatures + .iter() + .filter(|s| can_fill && !s.signed && s.visible && s.page == Some(page)) + .filter_map(|s| s.rect.filter(|r| r.iter().all(|v| v.is_finite() && v.abs() < f32::MAX as f64)).map(|r| (s.field.as_str(), r))) + .peekable(); + if empty.peek().is_none() { + return Vec::new(); + } + // The widgets a click signs, by field name and exact rectangle, from one pass over the form: + // a file with thousands of fields costs no more per frame than the field hit test does. + let signable: HashSet<(&str, [u64; 4])> = form + .iter() + .filter(|f| f.kind == pdfcraft_engine::FormFieldKind::Signature && !f.read_only()) + .flat_map(|f| { + f.widgets.iter().filter(|w| w.page == Some(page) && !w.hidden && !w.read_only).map(move |w| (f.name.as_str(), w.rect.map(f64::to_bits))) + }) + .collect(); + empty.filter(|(name, r)| signable.contains(&(*name, r.map(f64::to_bits)))).collect() +} + +/// The largest sign tab's height, in points. Every other size is a share of the tab's height, +/// taken from this one: a 3 pt inset, a 12 pt icon, 5 pt padding, an 11 pt label and 4 pt corners. +pub(super) const SIGN_TAB_MAX: f32 = 18.0; +/// The smallest sign tab's height, in points: a field too small for this chip gets no tab. +const SIGN_TAB_MIN: f32 = 10.0; +/// The share of its field's height on screen that a sign tab takes, between the two sizes. +const SIGN_TAB_SHARE: f32 = 0.4; +/// The smallest sign tab that carries the label; below it the tab is the icon alone. +const SIGN_TAB_LABEL_MIN: f32 = 14.0; +/// The label's font size on the largest sign tab; it shrinks with the tab. +const SIGN_TAB_FONT: f32 = 11.0; + +/// Where one sign tab is drawn on screen: its chip, the icon's square and, when the label fits, +/// the point where the label starts (its left edge, on the chip's center line), with the +/// label's font size and the chip's corner radius at this tab's size. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SignTab { + pub rect: Rect, + pub icon: Rect, + pub label: Option, + pub font: f32, + pub radius: f32, +} + +/// Lays out a sign tab inside the top-left corner of `field` (the widget on screen), for a label +/// `label_width` points wide at [`SIGN_TAB_FONT`], its size on the largest tab. The tab is +/// [`SIGN_TAB_SHARE`] of the field's height, from [`SIGN_TAB_MIN`] to [`SIGN_TAB_MAX`], and its +/// inset, icon, padding, label and corners scale with it. It carries the label from +/// [`SIGN_TAB_LABEL_MIN`] up when the whole tab fits in half the field's width, and is a square +/// icon chip otherwise. It is left out when even that chip doesn't fit, or the field isn't finite. +pub(crate) fn sign_tab_layout(field: Rect, label_width: f32) -> Option { + if !field.is_finite() { + return None; + } + // Finite, so the clamp has a number to work on (and its bounds are constants in order). + let h = (field.height() * SIGN_TAB_SHARE).clamp(SIGN_TAB_MIN, SIGN_TAB_MAX); + let scale = h / SIGN_TAB_MAX; + let (inset, icon_side, pad) = (3.0 * scale, 12.0 * scale, 5.0 * scale); + if field.height() < h + 2.0 * inset || h > field.width() / 2.0 { + return None; + } + let full = pad + icon_side + pad + label_width * scale + pad; + let with_label = h >= SIGN_TAB_LABEL_MIN && label_width.is_finite() && label_width > 0.0 && full <= field.width() / 2.0; + let width = if with_label { full } else { h }; + let rect = Rect::from_min_size(field.min + Vec2::splat(inset), vec2(width, h)); + let icon_x = if with_label { rect.left() + pad } else { rect.center().x - icon_side / 2.0 }; + let icon = Rect::from_min_size(pos2(icon_x, rect.center().y - icon_side / 2.0), Vec2::splat(icon_side)); + let label = with_label.then(|| pos2(icon.right() + pad, rect.center().y)); + Some(SignTab { rect, icon, label, font: SIGN_TAB_FONT * scale, radius: 4.0 * scale }) +} + +/// The sign tab's label, "Sign", laid out at `size` points. +fn sign_tab_label(ui: &egui::Ui, size: f32) -> std::sync::Arc { + ui.fonts_mut(|f| f.layout_no_wrap(tl!("Sign").to_string(), theme::semibold(size), Color32::WHITE)) +} + +/// The sign tab of an empty signature field whose widget is at `field` on screen, on the page at +/// `page`, laid out for its label measured at its largest size: where it is painted and where a +/// click on it signs. +pub(super) fn sign_tab_at(ui: &egui::Ui, field: Rect, page: Rect) -> Option { + sign_tab_on_page(field, page, sign_tab_label(ui, SIGN_TAB_FONT).size().x) +} + +/// [`sign_tab_layout`] in the part of `field` that is on `page`: form fields take clicks on the +/// page only, so a widget that straddles the page's edge gets its tab where a click reaches it, +/// or none when that part has no room for one. +pub(crate) fn sign_tab_on_page(field: Rect, page: Rect, label_width: f32) -> Option { + sign_tab_layout(field.intersect(page), label_width) +} + +/// Paints a sign tab laid out by [`sign_tab_at`]: an accent chip with the signature icon and +/// "Sign" in white, as the app's primary buttons are. The label is drawn at the tab's size; text +/// scales with its size, and the padding takes up any rounding. Screen-only chrome, like the +/// validation badges. +pub(super) fn paint_sign_tab(ui: &egui::Ui, painter: &egui::Painter, tab: &SignTab, t: &Tokens) { + painter.rect_filled(tab.rect, egui::epaint::CornerRadiusF32::same(tab.radius), t.accent); + icons::paint(ui, tab.icon, "signature", tab.icon.width(), Color32::WHITE); + if let Some(at) = tab.label { + let galley = sign_tab_label(ui, tab.font); + painter.galley(at - vec2(0.0, galley.size().y / 2.0), galley, Color32::WHITE); + } +} + // Keep the viewer's scroll identity tied to the document instead of its parent UI. Without // `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only. pub(super) fn page_scroll( 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..f0c5b46a3a 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); @@ -362,6 +363,9 @@ fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) } } +/// The document view's keyboard shortcuts: find, rotation, zoom (or the page grid's size), +/// history, Escape, and the paging and scrolling keys. An open form field keeps the paging +/// and scrolling keys for itself. pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { use egui::{Key, KeyboardShortcut, Modifiers}; if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { @@ -406,8 +410,13 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.view_history(true); } let key = |k| (arrows || !matches!(k, Key::ArrowLeft | Key::ArrowRight | Key::ArrowUp | Key::ArrowDown)) && ctx.input(|i| i.key_pressed(k)); + // An open form field (a drop-down's or a list box's items) takes the arrows, Page Up / Page + // Down, Home, End and Escape. A focused check box or radio button uses none of them, so the + // page keys still work there. + let field_open = view.forms.focus.is_some() && form_box.is_none(); // An open menu takes Escape first and closes; the selection stays for the next one (#3535). - if key(Key::Escape) && !egui::Popup::is_any_open(ctx) { + // So does an open field, whose list closes. + if key(Key::Escape) && !egui::Popup::is_any_open(ctx) && !field_open { if view.camera.is_some() || view.camera_done.is_some() { view.pending_action = Some(ViewAction::CancelCamera); } else if view.selection.is_some() || view.all_text { @@ -417,6 +426,10 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s view.find = None; } } + // The open field's keys leave the page under it where it is. + if field_open { + return; + } // Home and End, and the keys listed with them for Go to first / last page: Shift+Ctrl // (Shift+⌘ on macOS) with ↑ / ↓ or Page Up / Page Down (#6434). let shift_cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k); diff --git a/crates/ui-egui/src/canvas/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e253fd6b59..48a94806a0 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,8 +2,9 @@ //! 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::area::{SignTab, paint_sign_tab, sign_tab_at, sign_tabs}; use super::{ DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor, signature_badges, verdict_mark_rect, @@ -30,6 +31,8 @@ pub(super) struct PageInput { /// The press's click came up over what the press began on: a link's or a field's click counts /// only then, as a button's does. pub(super) released_over: bool, + /// The sign tabs on empty fields a click would sign, painted over the page. + pub(super) sign_tabs: Vec, } /// Common geometry and pointer state for a tool acting on one page. @@ -67,6 +70,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; @@ -82,6 +86,10 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut }; // While a table is being set up, nothing else is drawn or moved. let prepare_allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + // An empty field a click would sign carries a small tab saying so, shown only while + // a click on the field signs it (the Select tool, not while preparing a form). The tab + // is drawn over every field, so a click on it signs its field (`forms_ui::hit`). + let tabs = page_sign_tabs(ui, doc, form, info, slot, can_fill, tool == QuickTool::Select && !preparing); let hits = PageHits { page: i, xf: &xf, @@ -100,6 +108,8 @@ 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, + tabs: &tabs, }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it @@ -252,7 +262,39 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut let on_comment = comments::page_input(ui, resp, &pcx, press, taken, view); let consumed = taken || on_comment; let hover = pointer.and_then(|p| claim_under(&hits, view, p, frame.env.unobstructed)); - PageInput { press, text_drag: !view.router.keeps_drag(), consumed, area_tool, hover, released_over } + PageInput { + press, + text_drag: !view.router.keeps_drag(), + consumed, + area_tool, + hover, + released_over, + sign_tabs: tabs.into_iter().map(|(_, tab)| tab).collect(), + } +} + +/// The sign tabs on the slot's page, on empty fields a click would sign, each with the field it +/// signs; none unless `shown`. +fn page_sign_tabs<'a>( + ui: &egui::Ui, + doc: &'a pdfcraft_engine::Document, + form: &[pdfcraft_engine::FormField], + info: &DocInfo, + slot: &PageSlot, + can_fill: bool, + shown: bool, +) -> Vec<(&'a str, SignTab)> { + let PageSlot { i, xf, .. } = slot; + let i = *i; + if !shown { + return Vec::new(); + } + sign_tabs(&doc.signatures, form, i, can_fill) + .into_iter() + .filter_map(|(name, [x0, y0, x1, y1])| { + sign_tab_at(ui, xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]), xf.rect).map(|tab| (name, tab)) + }) + .collect() } /// What lies under the pointer at `p`, after the tools act: its top, what a press there would take, @@ -453,6 +495,11 @@ 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, + /// The sign tabs on empty signature fields, with the field each one signs: drawn over every + /// field, so the pointer on one is on its own field. + tabs: &'a [(&'a str, SignTab)], } impl PageHits<'_> { @@ -534,6 +581,8 @@ impl PageHits<'_> { lines, images, clip, + text_links, + tabs, } = *self; // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { @@ -556,7 +605,7 @@ impl PageHits<'_> { let fields = if preparing { crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p) } else if tool == QuickTool::Select { - crate::forms_ui::hit(xf, info, page, form, can_fill, p) + crate::forms_ui::hit(xf, info, page, form, tabs, can_fill, p) } else { None }; @@ -589,11 +638,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 +670,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,9 +884,11 @@ 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, + t, table_preview, tool, unobstructed, @@ -871,7 +938,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 +972,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 { @@ -934,6 +1014,11 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P view.flash = None; } } + // The sign tabs (above). Like the validation badges, they never enter the file, + // exports or printing. + for tab in &input.sign_tabs { + paint_sign_tab(ui, painter, tab, t); + } // Validation badges are viewer chrome over the widget. A recognised named-layer appearance may // show the verdict in the display copy too; saves retain its original streams. Badges never // enter document streams, exports or either print path. Their tips show where the comments' diff --git a/crates/ui-egui/src/canvas/page_pointer/tests.rs b/crates/ui-egui/src/canvas/page_pointer/tests.rs index 21698e7f95..9246bb83f5 100644 --- a/crates/ui-egui/src/canvas/page_pointer/tests.rs +++ b/crates/ui-egui/src/canvas/page_pointer/tests.rs @@ -241,7 +241,7 @@ const RAW_READS: [&str; 21] = [ /// Functions in the page modules that are not page input and read egui's pointer state /// themselves: (file, function, why). -const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ +const NOT_PAGE_INPUT: [(&str, &str, &str); 12] = [ ("canvas/area.rs", "zoom_anchor", "a pinch zooms the whole view, and is read before the pages' widget exists"), ("canvas/area.rs", "drawing_edge_pointer", "scrolls the view at its edges, read before the pages' widget exists"), ("canvas/area.rs", "glide_interrupted", "a press anywhere stops the touchpad's glide"), @@ -249,6 +249,8 @@ const NOT_PAGE_INPUT: [(&str, &str, &str); 10] = [ ("canvas/bars.rs", "page_size_hint", "the page size hint sits in the view's corner, not on a page"), ("canvas/organize.rs", "organize_grid", "Organize Pages shows thumbnails, not pages"), ("tools/forms_ui.rs", "overlay", "the field editor is an Area of its own over the page"), + ("tools/forms_ui.rs", "choice_list", "a drop-down's open list is an Area of its own over the page"), + ("tools/forms_ui.rs", "scroll_bar", "the scroll bar of a drop-down's open list, in its Area over the page"), ("tools/content_ui.rs", "editor", "the added-text editor is an Area of its own over the page"), ("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page"), ("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"), 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..f50bbcfa70 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -76,9 +76,14 @@ impl DocView { /// Screen position of the centre of glyph `glyph` on `page`, if that page is on screen and its /// text layer is loaded (used by UI tests and automation to aim pointer input). pub fn glyph_screen_pos(&self, page: usize, glyph: usize) -> Option { + self.glyph_screen_rect(page, glyph).map(|r| r.center()) + } + + /// The screen rectangle of glyph `glyph` on `page`, when the page is on screen. + pub fn glyph_screen_rect(&self, page: usize, glyph: usize) -> Option { let (_, xf) = self.screen_xforms.iter().find(|(p, _)| *p == page)?; let g = self.texts.get(&page)?.glyphs.get(glyph)?; - Some(xf.view_rect(g.rect).center()) + Some(xf.view_rect(g.rect)) } /// Selected text, if any (⌘C). @@ -172,6 +177,9 @@ enum SelectionAction { Copy, } +/// The selection bar's width before it has been laid out once (English labels). +const SELECTION_BAR_GUESS: f32 = 520.0; + /// Actions on an existing text selection, next to the selected words. pub(super) fn selection_actions( ui: &mut egui::Ui, @@ -203,32 +211,33 @@ pub(super) fn selection_actions( if !viewport.intersects(rect) { return false; } - let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); + let id = egui::Id::unique(("text-selection-actions", view.view_key())); + // The bar stays inside the pages' area, by the width it had last frame (its labels make it + // wider in some languages), so it never covers a side panel; a first guess before it has one. + let width = egui::AreaState::load(ui.ctx(), id).and_then(|a| a.size).map_or(SELECTION_BAR_GUESS, |s| s.x); + let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - width - 8.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( - ui.ctx(), - |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.horizontal(|ui| { - for (label, action) in [ - (tl!("Add comment"), SelectionAction::Note), - (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), - (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), - (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), - (tl!("Replace text"), SelectionAction::Replace), - ] { - if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { - chosen = Some(action); - } + egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + for (label, action) in [ + (tl!("Add comment"), SelectionAction::Note), + (tl!("Highlight"), SelectionAction::Markup(comments::CommentTool::Highlight)), + (tl!("Underline"), SelectionAction::Markup(comments::CommentTool::Underline)), + (tl!("Strikethrough"), SelectionAction::Markup(comments::CommentTool::StrikeOut)), + (tl!("Replace text"), SelectionAction::Replace), + ] { + if ui.add_enabled(can_comment, egui::Button::new(label)).clicked() { + chosen = Some(action); } - if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { - chosen = Some(SelectionAction::Copy); - } - }); + } + if ui.add_enabled(can_copy, egui::Button::new(tl!("Copy"))).clicked() { + chosen = Some(SelectionAction::Copy); + } }); - }, - ); + }); + }); let Some(action) = chosen else { return false }; if let SelectionAction::Copy = action { // Copying closes the bar but keeps the words selected, for ⌘C or a second action. diff --git a/crates/ui-egui/src/canvas/sign_tab_tests.rs b/crates/ui-egui/src/canvas/sign_tab_tests.rs new file mode 100644 index 0000000000..60a57f8137 --- /dev/null +++ b/crates/ui-egui/src/canvas/sign_tab_tests.rs @@ -0,0 +1,377 @@ +//! Unit tests for the sign tabs on empty signature fields (`area.rs`). + +use super::area::{SIGN_TAB_MAX, sign_tab_layout, sign_tab_on_page, sign_tabs}; +use super::*; +use pdfcraft_engine::{FormField, FormFieldKind, FormWidget, SignatureInfo}; + +/// The widget rectangle the unit tests use (user space). +const RECT: [f64; 4] = [20.0, 110.0, 140.0, 180.0]; + +/// An unsigned, visible signature listing for field `name` with its widget on `page` at +/// `rect`. Each test changes the one thing it is about. +fn listing(name: &str, page: usize, rect: [f64; 4]) -> SignatureInfo { + SignatureInfo { + field: name.into(), + signed: false, + page: Some(page), + rect: Some(rect), + visible: true, + signer: None, + certificate: None, + chain: Vec::new(), + date: None, + signing_time: None, + reason: None, + location: None, + contact: None, + sub_filter: None, + doc_timestamp: false, + certify: None, + revision: 1, + signed_len: 0, + digest: None, + algorithm: None, + timestamp: false, + timestamp_time: None, + status: pdfcraft_engine::SignatureStatus::Unknown, + modification: pdfcraft_engine::sign::Modification::None, + details: Vec::new(), + verdict_shown: false, + } +} + +/// The signature form field `name` with one shown widget on `page` at `rect`. +fn field(name: &str, page: usize, rect: [f64; 4]) -> FormField { + FormField { + name: name.into(), + obj: pdfcraft_cos::ObjRef::new(4, 0), + kind: FormFieldKind::Signature, + value: Vec::new(), + default: Vec::new(), + flags: 0, + max_len: None, + options: Vec::new(), + selected: Vec::new(), + single_value_is_array: false, + da: String::new(), + quadding: 0, + tooltip: None, + widgets: vec![FormWidget { + obj: pdfcraft_cos::ObjRef::new(4, 0), + page: Some(page), + rect, + on_state: None, + state: None, + tab: 0, + locked: false, + hidden: false, + read_only: false, + rotation: 0, + caption: None, + list: None, + round: false, + }], + actions: Default::default(), + button: None, + } +} + +/// The cases (by name) whose single listing and form still got a tab on page 0. +fn tabbed<'a>(cases: &[(&'a str, SignatureInfo, Vec, bool)]) -> Vec<&'a str> { + cases.iter().filter(|(_, s, form, can_fill)| !sign_tabs(std::slice::from_ref(s), form, 0, *can_fill).is_empty()).map(|c| c.0).collect() +} + +/// An empty, visible signature field that a click signs gets one tab, at its widget, on its +/// own page only. +#[test] +fn sign_tabs_mark_an_empty_field_that_a_click_signs() { + let approver = [listing("Approver", 0, RECT)]; + assert_eq!(sign_tabs(&approver, &[field("Approver", 0, RECT)], 0, true), vec![("Approver", RECT)]); + let later = [listing("Witness", 1, RECT)]; + assert_eq!(sign_tabs(&later, &[field("Witness", 1, RECT)], 1, true), vec![("Witness", RECT)]); + assert!(sign_tabs(&later, &[field("Witness", 1, RECT)], 0, true).is_empty(), "not on the page before"); +} + +/// Signed, invisible, other-page and unplaced fields get no tab. Each case is checked on its +/// own, so a failure names every case that wrongly got one. +#[test] +fn sign_tabs_skip_signed_invisible_other_page_and_unplaced_fields() { + let form = || vec![field("Approver", 0, RECT)]; + let cases = [ + ("signed", SignatureInfo { signed: true, ..listing("Approver", 0, RECT) }, form(), true), + ("invisible", SignatureInfo { visible: false, ..listing("Approver", 0, RECT) }, form(), true), + ("on another page", listing("Approver", 1, RECT), vec![field("Approver", 1, RECT)], true), + ("on no page", SignatureInfo { page: None, ..listing("Approver", 0, RECT) }, form(), true), + ("without a rectangle", SignatureInfo { rect: None, ..listing("Approver", 0, RECT) }, form(), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// NaN, infinite and gigantic rectangles never reach the painter, even when the form's +/// widget carries the same numbers. +#[test] +fn sign_tabs_skip_rectangles_that_cannot_reach_the_screen() { + let hostile = |name: &'static str, rect: [f64; 4]| (name, listing("Approver", 0, rect), vec![field("Approver", 0, rect)], true); + let cases = [ + hostile("NaN", [f64::NAN, 110.0, 140.0, 180.0]), + hostile("infinite", [20.0, f64::INFINITY, 140.0, 180.0]), + hostile("minus infinite", [20.0, 110.0, f64::NEG_INFINITY, 180.0]), + hostile("huge", [20.0, 110.0, 1e300, 180.0]), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// Fields a click would not sign get no tab: a read-only field, a document that refuses +/// filling, a listing with no form field of its name, a field of another kind of that name, +/// and a widget that is hidden, read-only on its own or elsewhere. +#[test] +fn sign_tabs_skip_fields_that_a_click_cannot_sign() { + let empty = || listing("Approver", 0, RECT); + let with = |change: fn(&mut FormField)| { + let mut f = field("Approver", 0, RECT); + change(&mut f); + vec![f] + }; + let cases = [ + ("read-only", empty(), with(|f| f.flags = pdfcraft_engine::field_flags::READ_ONLY), true), + ("filling refused", empty(), with(|_| {}), false), + ("no form field", empty(), Vec::new(), true), + ("another kind", empty(), with(|f| f.kind = FormFieldKind::Text), true), + ("hidden widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.hidden = true)), true), + ("read-only widget", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.read_only = true)), true), + ("widget elsewhere", empty(), with(|f| f.widgets.iter_mut().for_each(|w| w.rect = [160.0, 110.0, 280.0, 180.0])), true), + ]; + assert_eq!(tabbed(&cases), Vec::<&str>::new()); +} + +/// Whether two layout sizes match, to a hundredth of a point. +fn near(a: f32, b: f32) -> bool { + (a - b).abs() < 0.01 +} + +/// What a field `width` by `height` points on screen shows, for a label 30 pt wide on the +/// largest tab: "label" (the full tab), "icon" (the square chip) or "none". +fn shows(width: f32, height: f32) -> &'static str { + match sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(width, height)), 30.0) { + Some(tab) if tab.label.is_some() => "label", + Some(_) => "icon", + None => "none", + } +} + +/// The tab is 0.4 of the field's height on screen, at most 18 pt: a tall, wide field gets the +/// 18 pt tab with its label 3 pt inside the corner, and a 29 pt field (a 22 pt box at fit +/// width) an 11.6 pt icon chip, too small for the label, with the inset and icon scaled to it. +#[test] +fn sign_tab_layout_scales_the_chip_with_the_field_height() { + let large = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 60.0)); + let tab = sign_tab_layout(large, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 18.0) && tab.rect.min == large.min + vec2(3.0, 3.0), "{tab:?}"); + assert!(near(tab.icon.width(), 12.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + let at = tab.label.expect("the label fits"); + assert!(at.x >= tab.icon.right() && at.x + 30.0 <= tab.rect.right() && at.y == tab.rect.center().y, "{tab:?}"); + + let short = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 29.0)); + let tab = sign_tab_layout(short, 30.0).expect("fits"); + assert!(near(tab.rect.height(), 11.6) && near(tab.rect.width(), 11.6) && tab.label.is_none(), "{tab:?}"); + let inset = tab.rect.min - short.min; + assert!(near(inset.x, 11.6 / 6.0) && near(inset.y, 11.6 / 6.0) && short.contains_rect(tab.rect), "{tab:?}"); + assert!(near(tab.icon.width(), 11.6 * 2.0 / 3.0) && tab.rect.contains_rect(tab.icon), "{tab:?}"); +} + +/// A 19 pt field (a 14 pt box at fit width) still gets the smallest chip, 10 pt with the icon +/// alone. A 12 pt field is too short for even that, and a rectangle that isn't finite gets +/// nothing. A label that can't be measured is left out instead of drawn at a NaN position. +#[test] +fn sign_tab_layout_keeps_a_small_icon_on_short_fields_and_none_below() { + let field = Rect::from_min_size(pos2(100.0, 200.0), vec2(400.0, 19.0)); + let tab = sign_tab_layout(field, 30.0).expect("the smallest chip fits"); + assert!(near(tab.rect.height(), 10.0) && near(tab.rect.width(), 10.0) && tab.label.is_none(), "{tab:?}"); + assert!(field.contains_rect(tab.rect) && tab.rect.contains_rect(tab.icon), "{tab:?}"); + let cases = [ + ("12 pt tall", Rect::from_min_size(Pos2::ZERO, vec2(400.0, 12.0))), + ("infinite", Rect::from_min_max(Pos2::ZERO, pos2(f32::INFINITY, 40.0))), + ("NaN", Rect::from_min_max(pos2(f32::NAN, 0.0), pos2(400.0, 40.0))), + ]; + let laid_out: Vec<&str> = cases.iter().filter(|(_, r)| sign_tab_layout(*r, 30.0).is_some()).map(|c| c.0).collect(); + assert_eq!(laid_out, Vec::<&str>::new()); + let roomy = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)); + assert_eq!(sign_tab_layout(roomy, f32::NAN).map(|t| t.label), Some(None)); +} + +/// The label's font and the chip's corners shrink with the tab: 11 pt and 4 pt on the largest +/// tab, 8.8 pt and 3.2 pt on the 14.4 pt tab of a 36 pt field. +#[test] +fn sign_tab_layout_scales_the_label_font_and_corners_with_the_chip() { + let large = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 60.0)), 30.0).expect("fits"); + assert!(near(large.font, 11.0) && near(large.radius, 4.0), "{large:?}"); + let small = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(400.0, 36.0)), 30.0).expect("fits"); + assert!(near(small.font, 8.8) && near(small.radius, 3.2) && small.label.is_some(), "{small:?}"); +} + +/// As a 36 pt field (a 14.4 pt tab, its label 24 pt wide at that size) narrows, the tab keeps +/// its label while the whole tab fits in half the width (91.2 pt), then shows the icon alone +/// while the chip does (28.8 pt), then nothing. +#[test] +fn sign_tab_layout_drops_the_label_then_the_tab_as_the_field_narrows() { + let widths = [400.0, 92.0, 90.0, 29.0, 28.0]; + assert_eq!(widths.map(|w| shows(w, 36.0)), ["label", "label", "icon", "icon", "none"]); + let tab = sign_tab_layout(Rect::from_min_size(Pos2::ZERO, vec2(92.0, 36.0)), 30.0).expect("fits"); + assert!(near(tab.rect.height(), 14.4) && tab.rect.width() <= 46.0, "{tab:?}"); +} + +/// A field that straddles the page's edge gets its tab in the part on the page, where clicks +/// reach it, or none when that part has no room for one. +#[test] +fn a_sign_tab_stays_on_the_page() { + let page = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 800.0)); + let straddling = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 90.0)); + let tab = sign_tab_on_page(straddling, page, 30.0).expect("the part on the page has room"); + assert!(page.contains_rect(tab.rect), "{tab:?} on {page:?}"); + assert_eq!(tab.rect.height(), SIGN_TAB_MAX, "sized to the 60 px on the page"); + let sliver = Rect::from_min_size(pos2(60.0, 20.0), vec2(300.0, 36.0)); + assert_eq!(sign_tab_on_page(sliver, page, 30.0), None, "6 px on the page"); + let off = Rect::from_min_size(pos2(60.0, 900.0), vec2(300.0, 90.0)); + assert_eq!(sign_tab_on_page(off, page, 30.0), None, "wholly off the page"); +} + +/// Where two sign tabs overlap, a click takes the one painted last, which is on top. +#[test] +fn the_sign_tab_painted_last_takes_the_click() { + let tab = |x: f32| sign_tab_layout(Rect::from_min_size(pos2(x, 0.0), vec2(300.0, 60.0)), 30.0).expect("fits"); + let tabs = [("Approver", tab(0.0)), ("Witness", tab(10.0))]; + let both = tabs[1].1.rect.left_center() + vec2(2.0, 0.0); + assert!(tabs[0].1.rect.contains(both), "the tabs overlap there"); + assert_eq!(crate::forms_ui::tab_under(&tabs, both), Some("Witness")); + assert_eq!(crate::forms_ui::tab_under(&tabs, tabs[0].1.rect.left_center() + vec2(2.0, 0.0)), Some("Approver")); + assert_eq!(crate::forms_ui::tab_under(&tabs, pos2(-5.0, -5.0)), None); +} + +/// One page with three signature fields: Approver is empty, Witness is signed (a signature +/// dictionary as its value is enough to list it as signed) and Locked is empty but read-only. +const SIGN_FIELDS: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R 6 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 5 0 R 6 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Witness) /V << /Type /Sig >> /Rect [160 110 280 180] /P 3 0 R /F 4 >> endobj +6 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Locked) /Ff 1 /Rect [20 20 140 90] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// Every shape painted in the last frame, with nested lists flattened. +fn painted(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { + let mut stack: Vec = h.output().shapes.iter().map(|c| c.shape.clone()).collect(); + let mut out = Vec::new(); + while let Some(shape) = stack.pop() { + match shape { + egui::Shape::Vec(inner) => stack.extend(inner), + shape => out.push(shape), + } + } + out +} + +/// Where the named field's first widget is on screen. +fn field_on_screen(h: &egui_kittest::Harness<'static, PdfCraftApp>, name: &str) -> Rect { + let app = h.state(); + let view = &app.views[0]; + let doc = app.session.get(view.id).expect("open"); + let f = doc.form.iter().find(|f| f.name == name).expect("field"); + crate::forms_ui::field_screen_rect(view, &doc.info, f, 0).expect("on screen") +} + +/// One page where the text field Name, first in the form's order, covers the top-left corner +/// of the empty signature field Approver, where its sign tab is drawn. +const SIGN_UNDER_TEXT: &[u8] = b"%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 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 300 200] /Annots [4 0 R 5 0 R] >> endobj +4 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /Rect [10 120 130 190] /P 3 0 R /F 4 >> endobj +5 0 obj << /Type /Annot /Subtype /Widget /FT /Sig /T (Approver) /Rect [20 110 140 180] /P 3 0 R /F 4 >> endobj +trailer << /Root 1 0 R >> +%%EOF"; + +/// The real shell with `bytes` open, the side panes closed, after the first frames. +fn shell(bytes: &'static [u8]) -> egui_kittest::Harness<'static, PdfCraftApp> { + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("sign-fields.pdf", None, bytes.to_vec()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app + }); + h.run_steps(6); + h +} + +/// The accent chips painted in the last frame: the sign tabs. +fn chips(h: &egui_kittest::Harness<'static, PdfCraftApp>) -> Vec { + let accent = Tokens::get(&h.ctx).accent; + painted(h) + .iter() + .filter_map(|s| match s { + egui::Shape::Rect(r) if r.fill == accent => Some(r.rect), + _ => None, + }) + .collect() +} + +/// Clicks at `at` and lets the shell answer. +fn click_at(h: &mut egui_kittest::Harness<'static, PdfCraftApp>, at: Pos2) { + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); +} + +/// In the real shell, an empty signature field shows its accent tab with "Sign" in its +/// top-left corner, and the signed and read-only fields show none. The tab is part of the +/// field: hovering it shows the pointing hand, and clicking it starts signing that field. +#[test] +fn empty_signature_fields_show_a_sign_tab_that_signs_them() { + let mut h = shell(SIGN_FIELDS); + let (shapes, chips) = (painted(&h), chips(&h)); + let (approver, witness, locked) = (field_on_screen(&h, "Approver"), field_on_screen(&h, "Witness"), field_on_screen(&h, "Locked")); + let tab = chips.iter().copied().find(|c| approver.contains_rect(*c)).unwrap_or_else(|| panic!("no tab in {approver:?}: {chips:?}")); + assert!(tab.min.x - approver.min.x < 8.0 && tab.min.y - approver.min.y < 8.0, "in the top-left corner: {tab:?} in {approver:?}"); + let label = shapes.iter().any(|s| matches!(s, egui::Shape::Text(t) if t.galley.text() == "Sign" && tab.contains(t.pos))); + assert!(label, "the tab says Sign"); + assert!(!chips.iter().any(|c| witness.intersects(*c)), "a signed field has no tab: {chips:?}"); + assert!(!chips.iter().any(|c| locked.intersects(*c)), "a read-only field has no tab: {chips:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + click_at(&mut h, tab.center()); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); +} + +/// A sign tab is drawn over every field, so a click on it signs its field even where a field +/// earlier in the form's order overlaps it, which would otherwise take the click. Beside the +/// tab, the overlapping field still takes clicks as before. +#[test] +fn a_sign_tab_signs_its_field_where_an_earlier_field_overlaps_it() { + let mut h = shell(SIGN_UNDER_TEXT); + let (name, approver) = (field_on_screen(&h, "Name"), field_on_screen(&h, "Approver")); + let tab = chips(&h).into_iter().find(|c| approver.contains_rect(*c)).expect("Approver has a tab"); + assert!(name.contains_rect(tab), "the text field covers the tab: {tab:?} in {name:?}"); + + h.hover_at(tab.center()); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand); + click_at(&mut h, tab.center()); + assert_eq!(h.state().dialog, Some(crate::Dialog::Sign)); + assert_eq!(h.state().sign_draft.as_ref().and_then(|d| d.field.clone()).as_deref(), Some("Approver")); + assert!(h.state().views[0].forms.focus.is_none(), "the text field didn't open"); + + let beside = pos2(tab.right() + 6.0, tab.center().y); + assert!(name.contains(beside) && approver.contains(beside), "beside the tab, both fields are under the pointer"); + h.state_mut().dialog = None; + h.state_mut().sign_draft = None; + h.run_steps(1); + click_at(&mut h, beside); + assert_eq!(h.state().views[0].forms.focus.as_ref().map(|f| f.name.as_str()), Some("Name"), "the earlier field takes the click"); +} 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/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 0f6249c8a8..6d51c2f1c0 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -128,6 +128,19 @@ impl PageXform { Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1)) } + /// Whether the screen point `p` activates link `l` on `page`: inside one of its + /// `/QuadPoints` quadrilaterals, or its rectangle when it has none (ISO 32000-2 Table 176). + pub fn link_contains(&self, info: &DocInfo, page: usize, l: &pdfcraft_render::Link, p: Pos2) -> bool { + if l.quads.is_empty() { + return self.user_rect(info, page, l.rect).contains(p); + } + // The inverse of `user_rect`: screen → the displayed page's fraction → user space. + let Some(pg) = info.pages.get(page) else { return false }; + let (u, v) = self.screen_to_norm(p); + let [x, y] = pg.view_to_user(u * pg.width, v * pg.height); + l.contains(x, y) + } + pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect { self.user_rect(info, page, r.map(|v| v as f32)) } 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..e36a3a59ed 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; @@ -1877,6 +1885,15 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { Some(Dialog::RedactApply) => { let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); if app.apply_edit(Edit::ApplyRedactions { pages: None }) { + if let Some(view) = app.active.and_then(|i| app.views.get_mut(i)) { + let delivered_undone = view.redactions_applied < view.redactions_downloaded; + view.redactions_applied = view.redactions_applied.saturating_add(1); + // A new step clears the redo history, so the undone saved steps can't come back. + if view.redactions_undone_after_save > 0 || delivered_undone { + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = true; + } + } if let Some((_, id)) = app.active_ids() { app.renew_recovery(id); } @@ -1977,6 +1994,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..40e5df96b9 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -20,7 +20,7 @@ pub use save::{SaveTarget, write_atomically}; #[cfg(test)] pub(crate) use save::{is_mail_temp_copy, save_dialog_folder}; #[cfg(test)] -use save::{write_atomically_syncing, write_atomically_with}; +use save::{unredacted_file_name, write_atomically_syncing, write_atomically_with}; /// Text typed into a new text box that isn't added to the page yet. fn new_text_typed(v: &crate::DocView) -> bool { @@ -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() { @@ -380,6 +380,17 @@ impl PdfCraftApp { view.comments.selected = None; view.comments.history_stepped(); } + if label == (Edit::ApplyRedactions { pages: None }).label() { + let view = &mut self.views[i]; + let (applied, undone) = (view.redactions_applied, view.redactions_undone_after_save); + // Undo past the last save counts as undone; Redo first takes those back. + match (undo, applied, undone) { + (true, 0, _) => view.redactions_undone_after_save = undone.saturating_add(1), + (true, _, _) => view.redactions_applied = applied.saturating_sub(1), + (false, _, 0) => view.redactions_applied = applied.saturating_add(1), + (false, _, _) => view.redactions_undone_after_save = undone.saturating_sub(1), + } + } let label = crate::i18n::edit_label(&label); if undo { self.notify_fmt("Undid {label}", &[("label", &label)]); @@ -432,7 +443,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 +453,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 +474,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 +491,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 +535,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 +815,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 +864,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 +880,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 +893,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 +930,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 +940,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()) @@ -1124,9 +1212,11 @@ mod revert_draft_tests { use crate::forms_ui::Focus; use pdfcraft_engine::FieldValue; + /// An open editor on the first widget of the field "name", holding `text` as typed. fn focus(text: &str) -> Focus { Focus { name: "name".into(), + obj: None, widget: 0, text: text.into(), picked: Vec::new(), @@ -1136,6 +1226,7 @@ mod revert_draft_tests { calendar: None, calendar_rect: None, calendar_text: None, + list: Default::default(), had_focus: false, } } diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index a6803e9dd4..d891d4474e 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); @@ -89,6 +94,11 @@ impl PdfCraftApp { view.forms.committed = None; view.forms.unbaked.clear(); view.pending_edit = None; + // The redactions applied since the last save are discarded too. Those in the + // last web download stay, so later downloads keep the `_Redacted` name. + view.redactions_applied = view.redactions_downloaded; + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = false; } self.notify_tr("Reverted to the last saved version"); } @@ -107,7 +117,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 +152,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 +160,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..fe9e0f8b81 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -50,6 +50,21 @@ impl PdfCraftApp { let Some(id) = self.views.get(index).map(|v| v.id) else { return false }; let Some(doc) = self.session.get(id) else { return false }; let (name, path) = (doc.name.clone(), doc.path.clone()); + // After Apply redactions, Save asks where and proposes the name with `_Redacted`. + // In the web build a delivered step stays counted, so only the steps applied since the + // last delivery make Save ask where. + let redacted = self.views.get(index).is_some_and(|v| v.redactions_applied > 0); + let not_delivered = self.views.get(index).is_some_and(|v| v.redactions_applied > v.redactions_downloaded); + let name = if redacted { pdfcraft_engine::redacted_file_name(&name) } else { name }; + // Undo past a saved Apply redactions brings back content that the file on disk has + // redacted: ask where instead of writing it over that file. In the web build a delivered + // step stays counted in `redactions_applied`, so fewer steps than were delivered means one + // was undone. + let unredacted = self.views.get(index).is_some_and(restores_saved_redactions); + // The restored content is not a redacted copy: propose the name without `_Redacted`, so + // the dialog's default is not the redacted file. + let name = if unredacted && !redacted { unredacted_file_name(&name) } else { name }; + let target = if not_delivered || unredacted { SaveTarget::As } else { target }; #[cfg(not(target_arch = "wasm32"))] { let doc_path = path.clone(); @@ -57,13 +72,23 @@ 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); let dialog = in_document_folder(dialog, doc_path.as_deref()); // The bytes are taken once the user has chosen, so edits made meanwhile are saved too. self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| { + // An Apply redactions or an Undo of one while the dialog was open makes the + // name it proposed wrong: ask again with the name for the current content. + let asked = (redacted, unredacted); + let changed = |v: &crate::DocView| v.id == id && (v.redactions_applied > 0, restores_saved_redactions(v)) != asked; + if let Some(index) = app.views.iter().position(changed) { + app.save_then(index, SaveTarget::As, after); + return; + } app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after)); }); return false; @@ -98,6 +123,9 @@ impl PdfCraftApp { && let Some(view) = self.views.get_mut(index) { view.document_changed(&doc.info); + // `redactions_applied` stays set: the document keeps its name here, so + // later downloads are named after the redacted copy too. + view.redactions_downloaded = view.redactions_applied; } // The host reports where the file went (and any failure) itself. if !to_host { @@ -164,11 +192,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,12 +223,18 @@ 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; + view.redactions_applied = 0; + view.redactions_undone_after_save = 0; + view.saved_redactions_cut_off = false; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); #[cfg(target_os = "macos")] @@ -238,6 +273,27 @@ pub(crate) fn is_mail_temp_copy(path: &str) -> bool { || p.contains("/library/containers/com.apple.mail/data/library/mail downloads/") } +/// Whether `view` holds content that a saved or delivered Apply redactions removed: a saved step +/// is undone, cannot be redone, or (in the web build) fewer steps are in effect than were +/// delivered. +fn restores_saved_redactions(view: &crate::DocView) -> bool { + view.redactions_undone_after_save > 0 || view.saved_redactions_cut_off || view.redactions_applied < view.redactions_downloaded +} + +/// `name` without the `_Redacted` that [`pdfcraft_engine::redacted_file_name`] adds before the +/// extension ("contract_Redacted.pdf" gives "contract.pdf"). Any other name is returned as it is. +pub(crate) fn unredacted_file_name(name: &str) -> String { + let cut = name.len().saturating_sub(4); + let (stem, ext) = match (name.get(..cut), name.get(cut..)) { + (Some(stem), Some(ext)) if ext.eq_ignore_ascii_case(".pdf") => (stem, ext), + _ => (name, ""), + }; + match stem.strip_suffix("_Redacted") { + Some(base) if !base.is_empty() => format!("{base}{ext}"), + _ => name.to_string(), + } +} + /// The folder a Save As style dialog should open in for a document stored at `path`: the /// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks. /// `None` (keep the system's choice) for a new document, a path with no parent, or an email diff --git a/crates/ui-egui/src/editing/tests.rs b/crates/ui-egui/src/editing/tests.rs index c0e35329c0..b53ac49305 100644 --- a/crates/ui-egui/src/editing/tests.rs +++ b/crates/ui-egui/src/editing/tests.rs @@ -1,4 +1,15 @@ -use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with}; +use super::{ + SaveTarget, is_mail_temp_copy, save_dialog_folder, unredacted_file_name, write_atomically, write_atomically_syncing, write_atomically_with, +}; + +#[test] +fn restored_content_is_not_proposed_under_the_redacted_name() { + assert_eq!(unredacted_file_name("contract_Redacted.pdf"), "contract.pdf"); + assert_eq!(unredacted_file_name("SCAN_Redacted.PDF"), "SCAN.PDF"); + assert_eq!(unredacted_file_name("Untitled_Redacted"), "Untitled"); + assert_eq!(unredacted_file_name("contract.pdf"), "contract.pdf"); + assert_eq!(unredacted_file_name("_Redacted.pdf"), "_Redacted.pdf"); +} #[test] fn save_dialogs_open_in_the_documents_folder() { 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..227136c003 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; @@ -281,6 +283,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 +424,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 +679,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 +718,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/right.rs b/crates/ui-egui/src/panels/right.rs index 790c0ce2d3..349a837b3e 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -49,7 +49,9 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Bookmarks => ("Bookmarks", None), RightPanel::Pages => ("Pages", Some(info.pages.len())), RightPanel::Fields => ("Fields", Some(info.fields.len())), - RightPanel::Layers => ("Layers", Some(info.layers.len())), + RightPanel::Layers => { + ("Layers", Some(info.layer_order.iter().filter(|r| matches!(r.item, pdfcraft_render::LayerItem::Layer(_))).count())) + } RightPanel::Attachments => ("Attachments", Some(info.attachments.len())), RightPanel::Signatures => ("Signatures", Some(doc.signatures.iter().filter(|s| s.signed).count())), RightPanel::Accessibility => ("Accessibility Checker", None), @@ -234,8 +236,31 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { RightPanel::Layers => { if info.layers.is_empty() { empty(ui, &t, "layers", "This document has no layers."); + } else if info.layer_order.is_empty() { + // ISO 32000-2 Table 99: only the groups /Order lists are presented. + empty(ui, &t, "layers", "This document lists no layers to show."); } - for (li, l) in info.layers.iter().enumerate() { + // The default configuration's /Order, as a tree: a level is indented + // 16 points, up to eight levels; a label is a heading, not a layer. + for row in &info.layer_order { + let indent = 16.0 * row.depth.min(8) as f32; + let li = match &row.item { + pdfcraft_render::LayerItem::Layer(li) => *li, + pdfcraft_render::LayerItem::Label(label) => { + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, label)); + crate::widgets::paint_left( + ui, + rect.left_center() + vec2(8.0 + indent, 0.0), + label, + theme::medium(12.0), + t.text_faint, + (rect.width() - 12.0 - indent).max(0.0), + ); + continue; + } + }; + let Some(l) = info.layers.get(li) else { continue }; let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, l.visible, &l.name)); let press = theme::Press::track(ui, &resp); @@ -243,7 +268,7 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let body = rect.translate(press.offset()); icons::paint( ui, - Rect::from_min_size(body.min + vec2(4.0, 7.0), vec2(18.0, 18.0)), + Rect::from_min_size(body.min + vec2(4.0 + indent, 7.0), vec2(18.0, 18.0)), if l.visible { "eye" } else { "eye-off" }, 16.0, t.icon, @@ -251,11 +276,11 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { let fg = if l.visible { t.text } else { t.text_faint }; crate::widgets::paint_left( ui, - body.left_center() + vec2(32.0, 0.0), + body.left_center() + vec2(32.0 + indent, 0.0), &l.name, theme::regular(13.0), fg, - (rect.width() - 40.0).max(0.0), + (rect.width() - 40.0 - indent).max(0.0), ); if theme::hand(resp).on_hover_text(if l.visible { tl!("Hide layer") } else { tl!("Show layer") }).clicked() { toggle_layer = Some((li, !l.visible)); 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 4a5df450a8..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; } } } @@ -122,3 +139,16 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { #[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..2e87bd5ead 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -10,13 +10,13 @@ use std::collections::HashSet; use egui::{Color32, CornerRadius, Pos2, Rect, Stroke, vec2}; +use pdfcraft_cos::ObjRef; use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, field_flags, form_appearance}; use pdfcraft_render::DocInfo; -use crate::canvas::{DocView, PageToolContext, PageXform}; +use crate::canvas::{DocView, PageToolContext, PageXform, SignTab}; use crate::page_cursor::Layer; use crate::page_router::{Hit, Owner, PagePress}; -use crate::theme::Tokens; const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6); /// Background of the in-place text field editor, the same in every theme. @@ -26,6 +26,10 @@ const FIELD_FILL: Color32 = Color32::from_rgb(0xFF, 0xFF, 0xF4); #[derive(Clone, Debug, PartialEq)] pub struct Focus { pub name: String, + /// The focused field's object. Same-name twins are one field (ISO 32000-2 §12.7.3.1) but come + /// as separate fields here, each with its own widgets and option order, and this says which + /// one has the focus. `None` goes by the name: the first field with it. + pub obj: Option, /// Which of the field's widgets (a field can appear on several pages). pub widget: usize, /// Text being typed (text fields). @@ -44,11 +48,30 @@ pub struct Focus { /// The text the calendar last saw, so it moves to a typed date once, when the typing becomes /// a whole date, and otherwise keeps the month its arrows turned to. pub calendar_text: Option, + /// Drop-downs: where the open list is scrolled to and which item is current. + pub list: ChoiceList, /// Whether the text box had the keyboard at the end of the editor's last frame /// ([`crate::edit_text_ui::lost_focus_once`]). pub(crate) had_focus: bool, } +/// A drop-down's open list: the first item shown, the item the keys or the pointer are on, and +/// where on the scroll bar's thumb a drag took hold. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct ChoiceList { + pub first: usize, + pub current: Option, + pub grab: Option, + /// Wheel movement not yet a whole row. + pub wheel: f32, + /// Where the pointer was last frame: only a pointer that moves makes the item under it + /// current, so a resting one keeps no claim on the highlight when the keys scroll the list + /// beneath it. + pub pointer_at: Option, + /// Set on the first frame, from the field's value. + pub placed: bool, +} + #[derive(Clone, Debug, Default)] pub struct FormView { pub focus: Option, @@ -73,6 +96,10 @@ pub struct FormView { /// The widget holding egui's keyboard focus for the focused check box or radio button, and /// whether it takes the arrows (radio buttons). The page keys still work around it. pub(crate) keys: Option<(egui::Id, bool)>, + /// The list box row clicked last, without Shift: (its field name, the row). A Shift-click picks + /// the rows from it to the one clicked; the field's value can't say which row came last when + /// rows share an export value, and `/I` lists them in ascending order. + pub(crate) list_anchor: Option<(String, usize)>, } /// A form message for the app to show, with document data kept separate so the visible @@ -95,6 +122,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 { @@ -113,9 +172,40 @@ pub(crate) fn draft_text(f: &FormField) -> String { value } +impl Focus { + /// Whether this is the focus of widget `widget` of `f` (of that twin, for same-name fields). + fn is_on(&self, f: &FormField, widget: usize) -> bool { + self.name == f.name && self.widget == widget && self.obj.is_none_or(|o| o == f.obj) + } +} + +/// The field `focus` is on: the twin with its field object, or else the first field with its name. +pub(crate) fn focused_field<'a>(focus: &Focus, form: &'a [FormField]) -> Option<&'a FormField> { + focus.obj.and_then(|o| form.iter().find(|f| f.obj == o && f.name == focus.name)).or_else(|| form.iter().find(|f| f.name == focus.name)) +} + +/// The edit picking the options at `picked` (positions in `f`'s options) in the list box `f`. +/// The edit names the field, and positions are read in the first field of that name: a same-name +/// twin listing its options in another order gives its picks as positions there. One option +/// missing there goes by export value. +fn list_pick_edit(form: &[FormField], f: &FormField, picked: Vec) -> Edit { + let first = form.iter().find(|x| x.name == f.name).filter(|x| x.obj != f.obj); + let value = match first { + None => FieldValue::ChoiceIndices(picked), + Some(first) => match f.positions_in(first, &picked) { + Some(there) => FieldValue::ChoiceIndices(there), + None => FieldValue::Choice(picked.iter().filter_map(|&i| f.options.get(i)).map(|(e, _)| e.clone()).collect()), + }, + }; + Edit::SetFieldValue { name: f.name.clone(), value } +} + +/// Editor state for opening widget `widget` of `f`: it starts from the field's value, asks +/// for keyboard focus, and gives a drop-down a fresh list that places itself from that value. fn open_focus(f: &FormField, widget: usize) -> Focus { Focus { name: f.name.clone(), + obj: Some(f.obj), widget, text: draft_text(f), picked: f.value.clone(), @@ -125,33 +215,58 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { calendar: None, calendar_rect: None, calendar_text: None, + list: ChoiceList::default(), had_focus: false, } } -/// 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 w = f.widgets.get(wi)?; 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() { + // A check box's kids can have different on states (ISO 32000-2 §12.7.5.2.3, Table 230): + // clicking one that is off turns on its state (it, and any kid sharing that state, on; + // the others off); clicking one that is on turns the field off. + FormFieldKind::CheckBox => match &w.on_state { + Some(s) if !f.widget_on(wi) => FieldValue::Text(s.clone()), + Some(_) => FieldValue::Check(false), + None => FieldValue::Check(f.value.is_empty()), + }, + // 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 }) } +/// The first shown widget of `f` on `page` under the pointer at `p`, with its index. +fn widget_at<'a>(xf: &PageXform, info: &DocInfo, page: usize, f: &'a FormField, p: egui::Pos2) -> Option<(usize, &'a FormWidget)> { + f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) +} + +/// The field whose sign tab is under `p`, of `tabs` in the order they are painted: where tabs +/// overlap, the one painted last is on top. +pub(crate) fn tab_under<'a>(tabs: &[(&'a str, SignTab)], p: egui::Pos2) -> Option<&'a str> { + tabs.iter().rev().find(|(_, tab)| tab.rect.contains(p)).map(|(name, _)| *name) +} + /// Whether `w`, a widget of `f`, takes a press: fillable, a push button with an action, or an /// empty signature field (signed by clicking it, Use a certificate). A radio button of a group can /// be read-only on its own. @@ -161,19 +276,22 @@ fn usable(f: &FormField, w: &FormWidget, allowed: bool) -> bool { } /// The form widget under `p` on `page`: the one hit test of the fields, for the cursor and the -/// press. Over the page's text too: a check box drawn with a glyph, or set on printed text, is a -/// button, not text to select. -pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], allowed: bool, p: Pos2) -> Option { +/// press. It is the first widget under the pointer in the form's order, except on a sign tab +/// (`tabs`, with the signature field each one signs), which is drawn over every field and so is +/// on its own field. Over the page's text too: a check box drawn with a glyph, or set on printed +/// text, is a button, not text to select. +pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, form: &[FormField], tabs: &[(&str, SignTab)], allowed: bool, p: Pos2) -> Option { if !xf.rect.contains(p) { return None; } - let (field, f, widget, w) = form.iter().enumerate().find_map(|(fi, f)| { - f.widgets - .iter() + let under = |only: Option<&str>| { + form.iter() .enumerate() - .find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p)) - .map(|(wi, w)| (fi, f, wi, w)) - })?; + .filter(|(_, f)| only.is_none_or(|name| f.name == name && f.kind == FormFieldKind::Signature)) + .find_map(|(fi, f)| widget_at(xf, info, page, f, p).map(|(wi, w)| (fi, f, wi, w))) + }; + // A tab lies inside its field's widget, so that widget is under the pointer too. + let (field, f, widget, w) = tab_under(tabs, p).and_then(|name| under(Some(name))).or_else(|| under(None))?; let icon = match (usable(f, w, allowed), f.kind) { (false, _) => egui::CursorIcon::NotAllowed, (true, FormFieldKind::Text) => egui::CursorIcon::Text, @@ -213,7 +331,7 @@ pub(crate) fn page_input( if f.kind == FormFieldKind::Text && usable && let Some(at) = ptr.pressed() - && view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) + && view.forms.focus.as_ref().is_none_or(|x| !x.is_on(f, wi)) { commit(view, form); view.forms.focus = Some(Focus { press: Some(at), ..open_focus(f, wi) }); @@ -238,7 +356,7 @@ pub(crate) fn page_input( FormFieldKind::CheckBox | FormFieldKind::Radio => { // The box takes the focus, as in Acrobat, so Tab goes on from it (#2223). The overlay // toggles it, after the edit for any text this commits. - if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { + if view.forms.focus.as_ref().is_none_or(|x| !x.is_on(f, wi)) { commit(view, form); view.forms.focus = Some(open_focus(f, wi)); } @@ -250,12 +368,12 @@ pub(crate) fn page_input( FormFieldKind::Text if ptr.pressed().is_none() => { // An editor opened by the press cannot be hit until a frame has laid it out: the // release lands on the page and would take its focus away. - if let Some(x) = view.forms.focus.as_mut().filter(|x| x.name == f.name && x.widget == wi) { + if let Some(x) = view.forms.focus.as_mut().filter(|x| x.is_on(f, wi)) { x.request_focus = true; } } FormFieldKind::Text | FormFieldKind::Combo => { - if view.forms.focus.as_ref().is_none_or(|x| x.name != f.name || x.widget != wi) { + if view.forms.focus.as_ref().is_none_or(|x| !x.is_on(f, wi)) { commit(view, form); view.forms.focus = Some(open_focus(f, wi)); } @@ -269,31 +387,39 @@ pub(crate) fn page_input( let zoom = (r.height() as f64) / height_pt; let y_pt = (p.y - r.min.y) as f64 / zoom; let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); - let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); - let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + let top = form_appearance::ListRows::top_showing(rows.top, &f.selected, rows.visible(height_pt), f.options.len()); if let Some(opt_idx) = rows.option_at(top, y_pt).filter(|&i| i < f.options.len()) { - let export = &f.options[opt_idx].0; let ctrl = ui.input(|i| i.modifiers.command || i.modifiers.ctrl); let shift = ui.input(|i| i.modifiers.shift); let multi = f.has(field_flags::MULTI_SELECT); - let new_value = if multi && ctrl { - let mut picked = f.value.clone(); - if let Some(pos) = picked.iter().position(|x| x == export) { + // Options by position: two options can share an export value (ISO 32000-2 + // Table 234, `/I`), and the click picks the row, not the first option with its value. + let in_order = f.picked_in_order(); + let picked: Vec = if multi && ctrl { + let mut picked = in_order.clone(); + if let Some(pos) = picked.iter().position(|&x| x == opt_idx) { picked.remove(pos); } else { - picked.push(export.clone()); + picked.push(opt_idx); } - FieldValue::Choice(picked) - } else if multi && shift && !f.value.is_empty() { - let anchor = f.value.last().and_then(|last| f.options.iter().position(|(e, _)| e == last)).unwrap_or(opt_idx); + picked + } else if multi + && shift + && let Some(anchor) = + view.forms.list_anchor.as_ref().filter(|(name, _)| *name == f.name).map(|(_, row)| *row).or_else(|| in_order.last().copied()) + { let (min, max) = (anchor.min(opt_idx), anchor.max(opt_idx)); - let picked = f.options[min..=max].iter().map(|(e, _)| e.clone()).collect(); - FieldValue::Choice(picked) + (min..=max).collect() } else { - FieldValue::Choice(vec![export.clone()]) + vec![opt_idx] }; - if new_value != FieldValue::Choice(f.value.clone()) { - view.pending_edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: new_value }); + if !(multi && shift) { + view.forms.list_anchor = Some((f.name.clone(), opt_idx)); + } + // A single-select list holding an array is written as one value even when its shown + // row is clicked again. + if picked != in_order || f.single_value_is_array { + view.pending_edit = Some(list_pick_edit(form, f, picked)); } } } @@ -303,7 +429,7 @@ pub(crate) fn page_input( /// The edit the focused field's draft makes, if it differs from the field's value. pub(crate) fn draft_edit(focus: &Focus, form: &[FormField]) -> Option { - let f = form.iter().find(|f| f.name == focus.name)?; + let f = focused_field(focus, form)?; let value = match f.kind { FormFieldKind::Text if f.value.first().map(String::as_str).unwrap_or("") != focus.text => FieldValue::Text(focus.text.clone()), // Typed into an editable combo box: a shown text picks its option, anything else is the @@ -340,7 +466,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 +485,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 +527,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 +563,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()); } @@ -517,19 +644,36 @@ pub(crate) fn paint_page( for (fi, f) in form.iter().enumerate() { for (wi, w) in f.widgets.iter().enumerate().filter(|(_, w)| w.page == Some(page) && !w.hidden) { let r = widget_rect(xf, info, page, w.rect); - let focused = view.forms.focus.as_ref().is_some_and(|x| x.name == f.name && x.widget == wi); + let focused = view.forms.focus.as_ref().is_some_and(|x| x.is_on(f, wi)); if focused { 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); } + if f.kind == FormFieldKind::Combo && fillable(f) { + paint_drop_down_arrow(painter, r); + } } } } +/// A drop-down's arrow at the right of its box, which says the field opens a list (its page +/// appearance shows only the value). Drawn on screen only, not into the file. +fn paint_drop_down_arrow(painter: &egui::Painter, r: Rect) { + let width = (r.height() * 0.45).clamp(5.0, 12.0); + let inset = (r.height() * 0.25).clamp(2.0, 6.0); + // Too narrow a box keeps its value visible instead. + if r.width() < width * 3.0 { + return; + } + let (x, y, half) = (r.max.x - inset - width / 2.0, r.center().y, width / 2.0); + let points = vec![egui::pos2(x - half, y - half / 2.0), egui::pos2(x + half, y - half / 2.0), egui::pos2(x, y + half / 2.0)]; + painter.add(egui::Shape::convex_polygon(points, Color32::BLACK, Stroke::NONE)); +} + /// The size, in points, a text field shows `text` at on screen: its `/DA` size, or for auto size /// (`0 Tf`) the page appearance's fit, measured in the font the screen draws with so the text /// stays inside the box (the interface font runs wider than the page's Helvetica). @@ -581,7 +725,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(""); @@ -612,19 +756,19 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, let zoom = (xf.rect.width() / unrotated_w.max(1.0)) as f64; let rows = w.list.unwrap_or(form_appearance::ListRows { top: 0, inset: 2.0, row: 12.0 * 1.15, size: 12.0 }); let height_pt = if matches!(w.rotation, 90 | 270) { (w.rect[2] - w.rect[0]).max(1.0) } else { (w.rect[3] - w.rect[1]).max(1.0) }; - let picked_indices: Vec = f.value.iter().filter_map(|v| f.options.iter().position(|(e, _)| e == v)).collect(); - let top = form_appearance::ListRows::top_showing(rows.top, &picked_indices, rows.visible(height_pt), f.options.len()); + let top = form_appearance::ListRows::top_showing(rows.top, &f.selected, rows.visible(height_pt), f.options.len()); let font_size = (rows.size * zoom).clamp(6.0, 64.0) as f32; let row_h = (rows.row * zoom) as f32; let inset = (rows.inset * zoom) as f32; let p = painter.with_clip_rect(r); let mut y = r.min.y + inset; - for (export, display) in f.options.iter().skip(top) { + for (i, (_, display)) in f.options.iter().enumerate().skip(top) { if y + row_h > r.max.y { break; } let row_rect = Rect::from_min_size(egui::pos2(r.min.x + 1.0, y), egui::vec2(r.width() - 2.0, row_h)); - if f.value.contains(export) { + // By position: options can share an export value. + if f.selected.contains(&i) { p.rect_filled(row_rect, CornerRadius::ZERO, Color32::from_rgb(0x99, 0xC0, 0xDC)); } p.text( @@ -640,7 +784,7 @@ fn paint_unbaked_widget(painter: &egui::Painter, xf: &PageXform, f: &FormField, FormFieldKind::CheckBox => { 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 checked = f.value.first().is_some_and(|v| v != "Off" && !v.is_empty()); + let checked = if w.on_state.is_some() { f.widget_on(wi) } else { f.value.first().is_some_and(|v| v != "Off" && !v.is_empty()) }; if checked { let sz = (r.height() * 0.75).clamp(8.0, 32.0); painter.text(r.center(), egui::Align2::CENTER_CENTER, "✔", egui::FontId::proportional(sz), Color32::BLACK); @@ -649,7 +793,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 +862,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.obj == f.obj && *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 +925,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() { @@ -782,7 +955,7 @@ pub(crate) fn overlay( let toggle = std::mem::take(&mut view.forms.toggle); view.forms.keys = None; let focus = view.forms.focus.clone()?; - let Some(f) = form.iter().find(|f| f.name == focus.name) else { + let Some(f) = focused_field(&focus, form) else { view.forms.focus = None; return None; }; @@ -803,7 +976,6 @@ pub(crate) fn overlay( // winit reports Windows pen input as touch), which clears `hover_pos`. let clicked_outside = || ctx.input(|i| i.pointer.any_click() && !i.pointer.interact_pos().is_some_and(|p| rect.contains(p))); let zoom = xf.zoom(); - let t = Tokens::get(ctx); let mut edit = None; let mut close = false; let mut next: Option = None; // Tab / Shift+Tab @@ -814,7 +986,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 +1011,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); } @@ -890,28 +1062,21 @@ pub(crate) fn overlay( } FormFieldKind::Combo => { // An editable combo box has a text box over the field as well as its list (ISO - // 32000-2 Table 233, Edit). + // 32000-2 Table 233, Edit); the keys are the text box's then, and the list takes + // the pointer. 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))) - .order(egui::Order::Foreground) - .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) - .show(ctx, |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(rect.width().max(160.0)); - ui.label(egui::RichText::new(&f.name).small().color(t.text_faint)); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for (export, display) in &f.options { - let on = f.value.first() == Some(export); - if ui.selectable_label(on, display).clicked() { - edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); - close = true; - } - } - }); - }); - }); + let mut st = view.forms.focus.as_ref().map(|x| x.list).unwrap_or_default(); + let list = DropDownList { view: view.view_key(), field: f, widget: w, rect, zoom, keys: !editable_combo(f) }; + let (chosen, resp) = choice_list(ctx, list, &mut st); + if let Some(fx) = view.forms.focus.as_mut() { + fx.list = st; + } + if let Some((export, _)) = chosen.and_then(|i| f.options.get(i)) { + edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export.clone()]) }); + close = true; + } let clicked_away = !resp.response.contains_pointer() && clicked_outside(); let (tab, shift) = ctx.input(|i| (i.key_pressed(egui::Key::Tab), i.modifiers.shift)); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { @@ -939,11 +1104,12 @@ pub(crate) fn overlay( } else if tab { next = Some(!shift); } else if (up || down) && !f.options.is_empty() { - let current_idx = f.value.last().and_then(|v| f.options.iter().position(|(e, _)| e == v)).unwrap_or(0); - let next_idx = if up { current_idx.saturating_sub(1) } else { (current_idx + 1).min(f.options.len().saturating_sub(1)) }; + let current_idx = f.picked_in_order().last().copied().unwrap_or(0); + let next_idx = if up { current_idx.saturating_sub(1) } else { current_idx.saturating_add(1).min(f.options.len().saturating_sub(1)) }; if next_idx != current_idx && next_idx < f.options.len() { - let export = f.options[next_idx].0.clone(); - edit = Some(Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Choice(vec![export]) }); + // By position, so the arrows step through options that share an export value, + // in the focused twin's own order. + edit = Some(list_pick_edit(form, f, vec![next_idx])); } } if clicked_outside() { @@ -953,10 +1119,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 +1156,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,11 +1171,207 @@ 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()) } +/// Where a drop-down's open list ([`choice_list`]) goes and what it lists. +struct DropDownList<'a> { + /// The document view's pane key, which the list's own ids are made from. + view: u64, + field: &'a FormField, + widget: &'a FormWidget, + /// The field's rectangle on screen; the list opens under it. + rect: Rect, + zoom: f32, + /// Whether the list takes the arrow keys and Enter: not for an editable combo box, whose + /// text box has them. + keys: bool, +} + +/// Items a drop-down's list shows before it scrolls. +const LIST_ROWS: usize = 9; + +/// A drop-down's open list, under the field and drawn like it, as other viewers draw theirs: as +/// wide as the box, its items in the field's text size and alignment, black on white with a blue +/// highlight that follows the pointer and the arrow keys, and a scroll bar with step buttons when +/// there are more than nine items. Returns the item chosen (a click, or Enter on the current one) +/// and the list's response. With `keys` off (an editable combo box, whose text box has the keys) +/// only the pointer moves through and picks from it. +fn choice_list(ctx: &egui::Context, list: DropDownList<'_>, st: &mut ChoiceList) -> (Option, egui::InnerResponse<()>) { + let DropDownList { view, field: f, widget: w, rect, zoom, keys } = list; + let n = f.options.len(); + let shown = n.min(LIST_ROWS); + let max_first = n.saturating_sub(shown); + // Auto-sized text is fitted to what the field shows (its chosen item's shown name), so the + // list's items come out at the size the field's value is drawn at. + let value_text = f.value.first().map_or("", |v| f.options.iter().find(|(e, _)| e == v).map_or(v.as_str(), |(_, d)| d.as_str())); + let size = (field_text_size(&ctx.layer_painter(egui::LayerId::background()), f, w, value_text) * zoom).clamp(6.0, 48.0); + let row = (size * 1.45).round().max(8.0); + let bar = if n > shown { (row * 0.7).round().clamp(12.0, 22.0) } else { 0.0 }; + let width = rect.width().max(24.0 + bar); + if !st.placed { + st.current = f.value.first().and_then(|v| f.options.iter().position(|(e, _)| e == v)); + st.first = st.current.map_or(0, |c| c.min(max_first)); + st.placed = true; + } + // Consumed, so the keys move through the list rather than the page. + let key = |k| keys && ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, k)); + let moved = if n == 0 { + None + } else if key(egui::Key::ArrowUp) { + Some(st.current.map_or(0, |c| c.saturating_sub(1))) + } else if key(egui::Key::ArrowDown) { + Some(st.current.map_or(0, |c| (c + 1).min(n - 1))) + } else if key(egui::Key::PageUp) { + Some(st.current.map_or(0, |c| c.saturating_sub(shown))) + } else if key(egui::Key::PageDown) { + Some(st.current.map_or(0, |c| (c + shown).min(n - 1))) + } else if key(egui::Key::Home) { + Some(0) + } else if key(egui::Key::End) { + Some(n - 1) + } else { + None + }; + if let Some(m) = moved { + st.current = Some(m); + st.first = if m < st.first { + m + } else if m >= st.first + shown { + m + 1 - shown + } else { + st.first + }; + } + let mut chosen = key(egui::Key::Enter).then_some(st.current).flatten(); + let align = match f.quadding { + 1 => egui::Align::Center, + 2 => egui::Align::Max, + _ => egui::Align::Min, + }; + let resp = + egui::Area::new(egui::Id::unique(("form-choices", view))).order(egui::Order::Foreground).fixed_pos(rect.left_bottom()).show(ctx, |ui| { + let (outer, area) = ui.allocate_exact_size(vec2(width, shown as f32 * row + 2.0), egui::Sense::hover()); + let p = ui.painter(); + // The list is drawn as the form's own, a white list in the field's font with a blue + // highlight, in light and dark themes alike, so its colors are fixed, not the app's tokens. + p.rect_filled(outer, CornerRadius::ZERO, Color32::WHITE); + // The wheel scrolls the list, a row at a time, and not the page under it. + if area.contains_pointer() && n > shown { + st.wheel += ctx.input(|i| i.smooth_scroll_delta.y); + ctx.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let steps = (st.wheel / row).trunc(); + if steps != 0.0 { + st.wheel -= steps * row; + st.first = (st.first as f32 - steps).clamp(0.0, max_first as f32) as usize; + } + } + let pointer = ctx.input(|i| i.pointer.hover_pos()); + let pointer_moved = pointer != st.pointer_at; + let font = egui::FontId::proportional(size); + for k in 0..shown { + let i = st.first + k; + let Some((_, display)) = f.options.get(i) else { break }; + let r = Rect::from_min_size(outer.min + vec2(1.0, 1.0 + k as f32 * row), vec2(width - 2.0 - bar, row)); + let item = ui.interact(r, ui.scope_id().with(("choice", i)), egui::Sense::click()); + item.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, st.current == Some(i), display)); + if item.hovered() && pointer_moved { + st.current = Some(i); + } + if item.clicked() { + chosen = Some(i); + } + let on = st.current == Some(i); + if on { + p.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0x1F, 0x65, 0xD6)); + } + let galley = p.layout_no_wrap(display.clone(), font.clone(), if on { Color32::WHITE } else { Color32::BLACK }); + let inner = r.shrink2(vec2(3.0, 0.0)); + let x = match align { + egui::Align::Center => inner.center().x - galley.size().x / 2.0, + egui::Align::Max => inner.max.x - galley.size().x, + egui::Align::Min => inner.min.x, + }; + p.with_clip_rect(r).galley(egui::pos2(x, r.center().y - galley.size().y / 2.0), galley, Color32::BLACK); + } + st.pointer_at = pointer; + if bar > 0.0 { + scroll_bar(ui, Rect::from_min_max(egui::pos2(outer.max.x - 1.0 - bar, outer.min.y + 1.0), outer.max - vec2(1.0, 1.0)), shown, n, st); + } + p.rect_stroke(outer, CornerRadius::ZERO, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Inside); + }); + (chosen, resp) +} + +/// A list's classic scroll bar: step buttons at each end, a thumb sized to the share of the +/// items shown that can be dragged, and a track that pages when clicked. +fn scroll_bar(ui: &egui::Ui, track: Rect, shown: usize, n: usize, st: &mut ChoiceList) { + let max_first = n.saturating_sub(shown); + if max_first == 0 { + return; + } + let side = track.width(); + let up = Rect::from_min_size(track.min, vec2(side, side)); + let down = Rect::from_min_max(egui::pos2(track.min.x, track.max.y - side), track.max); + let trough = Rect::from_min_max(egui::pos2(track.min.x, up.max.y), egui::pos2(track.max.x, down.min.y)); + let thumb_len = (trough.height() * shown as f32 / n as f32).max(side * 0.75).min(trough.height()); + let travel = (trough.height() - thumb_len).max(1.0); + let thumb_at = |first: usize| trough.min.y + travel * first as f32 / max_first as f32; + let id = ui.scope_id().with("choice-bar"); + let p = ui.painter(); + p.rect_filled(track, CornerRadius::ZERO, Color32::from_gray(240)); + for (r, step, pointing_up) in [(up, -1i64, true), (down, 1, false)] { + let b = ui.interact(r, id.with(pointing_up), egui::Sense::click()); + if b.clicked() { + st.first = (st.first as i64 + step).clamp(0, max_first as i64) as usize; + } + p.rect_filled(r.shrink(1.0), CornerRadius::ZERO, if b.hovered() { Color32::from_gray(205) } else { Color32::from_gray(222) }); + p.rect_stroke(r.shrink(1.0), CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(150)), egui::StrokeKind::Inside); + let (c, h) = (r.center(), (side * 0.18).max(2.0)); + let tip = if pointing_up { -h } else { h }; + let points = vec![egui::pos2(c.x - h * 1.4, c.y - tip / 2.0), egui::pos2(c.x + h * 1.4, c.y - tip / 2.0), egui::pos2(c.x, c.y + tip / 2.0)]; + p.add(egui::Shape::convex_polygon(points, Color32::from_gray(40), Stroke::NONE)); + } + let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)); + let t = ui.interact(thumb, id.with("thumb"), egui::Sense::drag()); + let pointer = ui.input(|i| i.pointer.interact_pos()); + // The primary button only: a right-button drag moves nothing (AGENTS.md "Page presses"). + if t.drag_started_by(egui::PointerButton::Primary) { + st.grab = pointer.map(|q| q.y - thumb.min.y); + } + if t.dragged() + && let (Some(q), Some(grab)) = (pointer, st.grab) + { + let share = ((q.y - grab - trough.min.y) / travel).clamp(0.0, 1.0); + st.first = (share * max_first as f32).round() as usize; + } + if t.drag_stopped() { + st.grab = None; + } + let page = ui.interact(trough, id.with("trough"), egui::Sense::click()); + if page.clicked() + && let Some(q) = pointer + && !thumb.contains(q) + { + st.first = if q.y < thumb.min.y { st.first.saturating_sub(shown) } else { (st.first + shown).min(max_first) }; + } + let thumb = Rect::from_min_size(egui::pos2(trough.min.x, thumb_at(st.first)), vec2(side, thumb_len)).shrink2(vec2(1.0, 0.5)); + p.rect_filled(thumb, CornerRadius::ZERO, if t.dragged() || t.hovered() { Color32::from_gray(176) } else { Color32::from_gray(196) }); + p.rect_stroke(thumb, CornerRadius::ZERO, Stroke::new(1.0, Color32::from_gray(140)), egui::StrokeKind::Inside); + // Grip lines across the middle of the thumb. + if thumb.height() > side { + for k in -1..=1 { + let y = thumb.center().y + k as f32 * 3.0; + p.line_segment( + [egui::pos2(thumb.min.x + side * 0.3, y), egui::pos2(thumb.max.x - side * 0.3, y)], + Stroke::new(1.0, Color32::from_gray(120)), + ); + } + } +} + /// Where a field's widget is on screen (tests and automation). pub fn field_screen_rect(view: &DocView, info: &DocInfo, f: &FormField, widget: usize) -> Option { let w = f.widgets.get(widget)?; diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index 6249288c4d..2e02b89239 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -117,7 +117,7 @@ pub(crate) fn hit(xf: &PageXform, info: &DocInfo, page: usize, links: &[LinkItem let index = links.iter().rfind(|l| l.page == page && screen_rect(xf, info, page, l.rect).expand(LINK_TOOL_MARGIN).contains(p))?.index; (index, info.links.iter().find(|l| l.page == page && l.index == index)) } else { - let link = info.links.iter().rfind(|l| l.page == page && xf.user_rect(info, page, l.rect).contains(p))?; + let link = info.links.iter().rfind(|l| l.page == page && xf.link_contains(info, page, l, p))?; (link.index, Some(link)) }; let tip = link.map(|l| target_label(info, &l.target)); diff --git a/crates/ui-egui/src/tools/prepare.rs b/crates/ui-egui/src/tools/prepare.rs index 128e033dc2..8297eda94d 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(None), 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/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/tests/it/comments.rs b/crates/ui-egui/tests/it/comments.rs index c9ab522ecd..502c1cc34e 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); } @@ -3471,6 +3556,28 @@ fn an_upright_comment_on_a_turned_page_is_drawn_where_it_goes_before_release() { h.run_steps(3); } +/// The selection bar stays inside the pages' area: its place was worked out for a 520 pt bar, +/// so longer labels (German here) pushed it over the side panel next to a selection on the right. +#[test] +fn the_selection_bar_stays_inside_the_pages_area() { + let mut h = harness(|app| app.set_option("language", "de").unwrap()); + // Narrower than the harness, as with a side panel open. + h.set_size(egui::vec2(1100.0, 900.0)); + h.run_steps(4); + // The rightmost glyph of "The quick brown fox": the bar opens at its left. + let last = (0..64).filter_map(|i| h.state().views[0].glyph_screen_pos(0, i).map(|p| (i, p.x))).max_by(|a, b| a.1.total_cmp(&b.1)).unwrap().0; + h.state_mut().views[0].select_text(0, last, last); + h.run_steps(4); + let viewport = h.state().views[0].viewport_rect(); + // The bar's whole area, frame included, not only its first and last buttons. + let id = egui::Id::unique(("text-selection-actions", h.state().views[0].view_key())); + let bar = h.ctx.memory(|m| m.area_rect(id)).expect("the selection bar is shown"); + // The bar opens at the selection's left edge: past the area's right edge, without the clamp. + let anchor = h.state().views[0].glyph_screen_rect(0, last).unwrap().left(); + assert!(anchor + bar.width() > viewport.right(), "selection at {anchor}, bar {} wide, area {viewport:?}", bar.width()); + assert!(viewport.contains_rect(bar), "bar {bar:?} inside {viewport:?}"); +} + /// `n` pages with one comment each, "Card 1" to "Card n". fn one_comment_per_page(n: usize) -> Vec { let mut objs = vec!["<< /Type /Catalog /Pages 2 0 R >>".to_string()]; 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..ec82830f53 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -912,3 +912,187 @@ 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"); +} + +/// One 300×400 page with two links (ISO 32000-2 §12.5.6.5, §12.6.4.8): at y 300..350 a relative +/// address the catalog's `/URI /Base` resolves, and at y 200..250 a link whose `/Rect` spans the +/// page but whose `/QuadPoints` cover only x 50..120. +fn base_and_quads_fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /URI << /Base (https://example.org/base/) >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>", + "<< /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /A << /S /URI /URI (page.html) >> >>", + "<< /Type /Annot /Subtype /Link /Rect [50 200 250 250] /QuadPoints [50 200 120 200 120 250 50 250] /Border [0 0 0] /A << /S /URI /URI (https://example.org/first-word) >> >>", + ]; + 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 +} + +/// A relative link opens at the address the document's base gives it, through the usual +/// confirmation; a link is active only in its `/QuadPoints`, not over the rest of its `/Rect`. +#[test] +fn relative_links_use_the_base_and_quad_points_limit_where_a_link_is_active() { + 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("base.pdf", None, base_and_quads_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); + click_link(&mut h); + assert_eq!(h.state().pending_link, pending("https://example.org/base/page.html", LinkOrigin::Link)); + h.get_by_label("Cancel").click(); + h.run_steps(4); + let point = |h: &Harness<'static, PdfCraftApp>, x: f32, y: f32| { + let r = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + egui::pos2(r.min.x + r.width() * (x / 300.0), r.min.y + r.height() * (1.0 - y / 400.0)) + }; + // Inside /Rect, outside the quadrilateral: nothing. + let away = point(&h, 200.0, 225.0); + click_at(&mut h, away); + assert_eq!(h.state().pending_link, None, "the rest of /Rect is not the link"); + let on = point(&h, 80.0, 225.0); + click_at(&mut h, on); + assert_eq!(h.state().pending_link, pending("https://example.org/first-word", LinkOrigin::Link)); +} 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..5a45b4f3e5 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)); @@ -1297,6 +1422,145 @@ fn list_box_clicks_select_directly_and_ctrl_click_toggles_multi() { assert_eq!(value(&h, "ListMulti"), ["Tue", "Thu"], "Ctrl-clicking Thu toggled it on, keeping Tue"); } +/// The selected option positions of `name` (`/I`, or `/V` without a usable one). +fn selected(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().selected.clone() +} + +/// ISO 32000-2 Table 234: `/I` tells apart options that share an export value. A list box whose +/// One-A and One-B both export `1` shows the one `/I` names, and a click picks the row clicked: +/// `/V (1)` with `/I` naming it, so the pick survives a save and reopen. +#[test] +fn a_list_box_click_picks_one_of_two_options_with_the_same_export_value() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /V (1) /I [1] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + 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("dup.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert_eq!(selected(&h, "dup"), [1], "One-B is selected, not One-A too"); + + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "dup").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom = r.height() / 70.0; + // Row 0 (One-A), then row 1 (One-B): each click changes the selection though /V stays (1). + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + assert_eq!(value(&h, "dup"), ["1"]); + assert_eq!(selected(&h, "dup"), [0], "clicking One-A picks One-A"); + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom)); + assert_eq!(selected(&h, "dup"), [1], "clicking One-B picks One-B again"); + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + assert_eq!(selected(&h, "dup"), [0]); + + let dir = scratch("duplicate-exports"); + let out = dir.join("saved.pdf"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::S); + wait_for_file(&mut h, &out); + let mut app = PdfCraftApp::new(); + app.open_bytes("saved.pdf", None, std::fs::read(&out).unwrap()).unwrap(); + let reopened = app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "dup").unwrap().clone(); + assert_eq!(reopened.value, ["1"], "/V is the one export value"); + assert_eq!(reopened.selected, [0], "/I keeps One-A through the save"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Same-name list boxes are one field (ISO 32000-2 §12.7.3.1), and a twin can list the options +/// sharing an export value in another order: clicking One-B in the second gives both One-B, not +/// the option at the same position in the first. +#[test] +fn a_click_in_a_twin_list_box_with_options_in_another_order_picks_the_row_clicked() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 6 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R 6 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-A)] [(1) (One-B)] [(2) (Two)]] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (dup) /Opt [[(1) (One-B)] [(1) (One-A)] [(2) (Two)]] /Rect [50 100 200 170] /P 3 0 R /F 4 >>", + ]); + 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("twins.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + let twins = |h: &Harness<'static, PdfCraftApp>| -> Vec> { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().filter(|f| f.name == "dup").map(|f| f.selected.clone()).collect() + }; + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().filter(|f| f.name == "dup").nth(1).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom = r.height() / 70.0; + // Row 0 of the second list box: One-B. + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + assert_eq!(value(&h, "dup"), ["1"]); + assert_eq!(twins(&h), [vec![1], vec![0]], "One-B in both: second in the first, first in the twin"); + // The arrows step through the focused twin's own rows: ↓ from One-B there is One-A, which is + // first in the first field, not Two (the row after One-B in the first field). + h.key_press(egui::Key::ArrowDown); + h.run_steps(3); + assert_eq!(twins(&h), [vec![0], vec![1]], "One-A in both after ↓ in the twin"); + h.key_press(egui::Key::ArrowUp); + h.run_steps(3); + assert_eq!(twins(&h), [vec![1], vec![0]], "back to One-B after ↑ in the twin"); +} + +/// ISO 32000-2 Table 233: a list box without MultiSelect selects "at most one item". Given +/// `/V [(TW) (JP)]` it shows Taiwan alone, and a click writes the one option clicked. +#[test] +fn a_single_select_list_box_given_two_values_shows_and_takes_one() { + let bytes = pdf_from_objects(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ]); + 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("pair.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["TW".to_string()], vec![0]), "Taiwan alone"); + + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == "pair").unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + let zoom = r.height() / 70.0; + // Row 1 (Japan), which /V also named: clicking it makes it the one value. + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 19.0 * zoom)); + assert_eq!((value(&h, "pair"), selected(&h, "pair")), (vec!["JP".to_string()], vec![1])); +} + /// Auto-size (`0 Tf`, inherited from the AcroForm) single-line and multiline fields, and a fixed 10 pt one. fn auto_size_form() -> Vec { pdf_from_objects(&[ @@ -1579,3 +1843,599 @@ 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"); +} + +/// Check box fields of two kids each: `pair` with the on states A and B, `same` with Yes twice. +fn check_box_kids() -> Harness<'static, PdfCraftApp> { + let kid = |parent: u32, y: u32, x: u32, state: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} {y} {} {}] /P 3 0 R /F 4 /AP << /N << /{state} 5 0 R /Off 5 0 R >> >> >>", + x + 16, + y + 16 + ) + }; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 9 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 10 0 R 11 0 R] >>".into(), + "<< >>".into(), + "<< /Type /XObject /Subtype /Form /BBox [0 0 16 16] /Length 0 >>\nstream\n\nendstream".into(), + "<< /FT /Btn /T (pair) /V /Off /Kids [7 0 R 8 0 R] >>".into(), + kid(6, 350, 20, "A"), + kid(6, 350, 60, "B"), + "<< /FT /Btn /T (same) /V /Off /Kids [10 0 R 11 0 R] >>".into(), + kid(9, 300, 20, "Yes"), + kid(9, 300, 60, "Yes"), + ]; + 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("kids.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 §12.7.5.2.3: a check box field's value is the on state of the kid that is on, and +/// its kids can have different on states (Table 230). Clicking B after A ticks B and clears A; +/// clicking B again clears the field; kids sharing an on state tick together (EXAMPLE 2). +#[test] +fn clicking_check_box_kids_with_different_on_states() { + let mut h = check_box_kids(); + click_field(&mut h, "pair", 0); + assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()])); + click_field(&mut h, "pair", 1); + assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![false, true], vec!["B".to_string()]), "B ticks and A clears"); + click_field(&mut h, "pair", 1); + assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![false, false], vec![]), "clicking B again clears the field"); + click_field(&mut h, "pair", 1); + click_field(&mut h, "pair", 0); + assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]), "the tick moves back to A"); + click_field(&mut h, "same", 0); + assert_eq!(on_widgets(&h, "same"), [true, true], "kids sharing Yes tick together"); + click_field(&mut h, "same", 1); + assert_eq!(on_widgets(&h, "same"), [false, false], "and untick together"); + // Space on the focused kid toggles that kid too. + click_field(&mut h, "pair", 1); + assert_eq!(value(&h, "pair"), ["B"]); + h.key_press(egui::Key::Space); + h.run_steps(3); + assert!(value(&h, "pair").is_empty(), "Space clears the ticked kid"); +} + +/// Click at `p` with `modifiers` held. +fn click_with(h: &mut Harness<'static, PdfCraftApp>, p: egui::Pos2, modifiers: egui::Modifiers) { + h.event(egui::Event::ModifiersChanged(modifiers)); + h.hover_at(p); + h.run_steps(1); + for pressed in [true, false] { + h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers }); + h.run_steps(1); + } + h.event(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + h.run_steps(3); +} + +/// A list box opened at 100% zoom on its own page, and its rectangle on screen. +fn list_box(objects: &[&str], name: &str) -> (Harness<'static, PdfCraftApp>, egui::Rect) { + let bytes = pdf_from_objects(objects); + 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("list.pdf", None, bytes).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("zoom", "100").unwrap(); + app + }); + h.run_steps(4); + let r = { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") + }; + (h, r) +} + +/// A Shift-click picks the rows from the one clicked last: after Ctrl-clicking row 3 and then +/// row 1, Shift on row 4 picks rows 1 to 4, not 3 to 4. The rows share one export value, so the +/// value can't say which came last, and `/I` lists them in ascending order. +#[test] +fn a_shift_click_in_a_list_box_extends_from_the_row_clicked_last() { + let (mut h, r) = list_box( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (days) /Ff 2097152 /Opt [[(x) (Mon)] [(x) (Tue)] [(x) (Wed)] [(x) (Thu)] [(x) (Fri)] [(x) (Sat)]] /Rect [50 220 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ], + "days", + ); + let zoom = r.height() / 100.0; + let row = |k: f32| egui::pos2(r.center().x, r.min.y + (8.0 + 11.5 * k) * zoom); + click_with(&mut h, row(3.0), egui::Modifiers::COMMAND); + click_with(&mut h, row(1.0), egui::Modifiers::COMMAND); + assert_eq!(selected(&h, "days"), [1, 3]); + click_with(&mut h, row(4.0), egui::Modifiers::SHIFT); + assert_eq!(selected(&h, "days"), [1, 2, 3, 4], "from row 1, the one clicked last"); +} + +/// A single-select list box holding an array `/V` is written as one value when its shown row is +/// clicked again, not left as an array because the row was already selected. +#[test] +fn clicking_the_shown_row_of_a_single_select_list_holding_an_array_writes_one_value() { + let (mut h, r) = list_box( + &[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R] /NeedAppearances true /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 400] >>", + "<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>", + "<< /Type /Annot /Subtype /Widget /FT /Ch /T (pair) /Opt [[(TW) (Taiwan)] [(JP) (Japan)] [(US) (United States)]] /V [(TW) (JP)] /Rect [50 250 200 320] /P 3 0 R /F 4 >>", + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", + ], + "pair", + ); + let field = |h: &Harness<'static, PdfCraftApp>| { + let s = h.state(); + s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "pair").unwrap().clone() + }; + assert!(field(&h).single_value_is_array, "loaded from an array"); + let zoom = r.height() / 70.0; + click_at(&mut h, egui::pos2(r.center().x, r.min.y + 8.0 * zoom)); + let f = field(&h); + assert_eq!((f.value, f.selected, f.single_value_is_array), (vec!["TW".to_string()], vec![0], false), "Taiwan, now one value"); +} + +/// Where the first widget of field `name` is on screen in the first view. +fn field_rect(h: &Harness<'static, PdfCraftApp>, name: &str) -> egui::Rect { + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + let f = doc.form.iter().find(|f| f.name == name).unwrap(); + pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 0).expect("on screen") +} + +/// Filled triangles painted inside `within`, at any depth of the frame's shapes. +fn triangles_in(h: &Harness<'static, PdfCraftApp>, within: egui::Rect) -> Vec> { + /// Collects the filled three-point paths of `shape` that lie inside `within`, descending into + /// nested shape lists. + fn walk(shape: &egui::Shape, within: egui::Rect, out: &mut Vec>) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| walk(s, within, out)), + egui::Shape::Path(p) if p.points.len() == 3 && p.fill != egui::Color32::TRANSPARENT && p.points.iter().all(|q| within.contains(*q)) => { + out.push(p.points.clone()) + } + _ => {} + } + } + let mut out = Vec::new(); + for c in &h.output().shapes { + walk(&c.shape, within, &mut out); + } + out +} + +/// A drop-down draws one filled arrow at the right of its box, and a text field draws none. +#[test] +fn a_drop_down_shows_its_arrow_and_text_fields_do_not() { + let mut h = harness(); + h.run_steps(2); + let country = field_rect(&h, "country"); + let arrows = triangles_in(&h, country); + assert_eq!(arrows.len(), 1, "one arrow in the drop-down: {arrows:?}"); + let tip = arrows[0].iter().fold(egui::Pos2::ZERO, |a, p| egui::pos2(a.x.max(p.x), a.y.max(p.y))); + assert!(tip.x > country.center().x, "at the right of the box"); + assert!(triangles_in(&h, field_rect(&h, "city")).is_empty(), "a text field has none"); +} + +/// A drop-down's list opens on the field's left edge and no wider than the field, without +/// showing the field's name, and clicking an item chooses it. +#[test] +fn a_drop_downs_list_opens_as_wide_as_the_field_without_its_internal_name() { + let mut h = harness(); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "country").is_none(), "the field's name isn't shown"); + let item = egui_kittest::kittest::Queryable::get_by_label(&h, "France").rect(); + assert!((item.left() - country.left()).abs() <= 2.0 && item.right() <= country.right() + 1.0, "{item:?} in {country:?}"); + egui_kittest::kittest::Queryable::get_by_label(&h, "France").click(); + h.run_steps(4); + assert_eq!(value(&h, "country"), ["fr"]); +} + +/// A drop-down with 30 options keeps its scroll bar in its own room, so the items stop short +/// of the field's right edge. +#[test] +fn a_long_drop_down_list_keeps_its_scroll_bar_beside_the_items() { + let mut h = harness(); + let options: Vec = (0..30).map(|i| format!("{i:02}00")).collect(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }), + })); + h.run_steps(3); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + // A scroll bar that stays shown takes its own room, so people see there is more below. + assert!(first.right() <= country.right() - 6.0, "{first:?} leaves room for the bar in {country:?}"); +} + +/// Where `text` is drawn in the last frame, if anywhere. +fn text_rect(h: &Harness<'static, PdfCraftApp>, text: &str) -> Option { + /// The rectangle of the text shape in `shape` that reads exactly `text`, searching nested + /// shape lists. + fn walk(shape: &egui::Shape, text: &str) -> Option { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().find_map(|s| walk(s, text)), + egui::Shape::Text(t) if t.galley.text() == text => Some(egui::Rect::from_min_size(t.pos, t.galley.size())), + _ => None, + } + } + h.output().shapes.iter().find_map(|c| walk(&c.shape, text)) +} + +/// Gives the "country" drop-down 30 options, "0000" to "2900", more than its list shows at once. +fn long_country(h: &mut Harness<'static, PdfCraftApp>) { + let options: Vec = (0..30).map(|i| format!("{i:02}00")).collect(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(options), ..Default::default() }), + })); + h.run_steps(3); +} + +/// A centered drop-down (quadding 1) centers the items of its open list too. +#[test] +fn a_drop_downs_list_aligns_its_items_as_the_field_does() { + let mut h = harness(); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { quadding: Some(1), ..Default::default() }), + })); + h.run_steps(3); + click_field(&mut h, "country", 0); + let country = field_rect(&h, "country"); + let france = text_rect(&h, "France").expect("drawn"); + assert!((france.center().x - country.center().x).abs() <= 2.0, "centered as the field is: {france:?} in {country:?}"); +} + +/// The arrow keys move through an open drop-down's list without changing the value, Enter +/// chooses the current item and closes the list, and a reopened list starts from the value. +#[test] +fn the_arrow_keys_move_through_a_drop_downs_list_and_enter_chooses() { + let mut h = harness(); + click_field(&mut h, "country", 0); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + h.key_press(egui::Key::ArrowDown); + h.run_steps(1); + assert!(value(&h, "country").is_empty(), "the arrows only move through the list"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["fr"]); + assert!(h.state().views[0].forms.focus.is_none(), "Enter closes the list"); + // Reopened, the list starts from the field's value. + click_field(&mut h, "country", 0); + h.key_press(egui::Key::ArrowUp); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["ca"]); +} + +/// Pressing the scroll bar's down button moves a long drop-down list on one row, and End goes +/// to the last item, which Enter then chooses. +#[test] +fn a_long_drop_down_list_scrolls_by_its_arrow_button_and_to_its_end() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_none(), "nine rows show"); + let country = field_rect(&h, "country"); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + // The bar's down button, at the bottom of the bar beside the ninth row. + let bar = country.right() - first.right(); + let down = egui::pos2(country.right() - bar / 2.0, first.top() + first.height() * 9.0 - bar / 2.0); + h.hover_at(down); + h.run_steps(1); + h.drag_at(down); + h.run_steps(1); + h.drop_at(down); + h.run_steps(2); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0900").is_some(), "one row on"); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none()); + h.key_press(egui::Key::End); + h.run_steps(2); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "2900").is_some(), "End goes to the last item"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["2900"]); +} + +/// The mouse wheel over an open long drop-down scrolls its list and leaves the page where it was. +#[test] +fn the_mouse_wheel_scrolls_a_long_drop_down_list_and_not_the_page() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + let page = h.state().views[0].page_screen_rect(0); + h.hover_at(first.center() + egui::vec2(0.0, first.height() * 4.0)); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, + delta: egui::vec2(0.0, -1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(2); + } + h.run_steps(6); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down"); + assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); +} + +/// Escape in an open drop-down closes its list and nothing else: the find bar, which Escape +/// closes when no field is open, stays open. +#[test] +fn escape_closes_an_open_drop_downs_list_and_leaves_the_page_alone() { + let mut h = harness(); + long_country(&mut h); + h.state_mut().views[0].open_find(); + h.run_steps(3); + assert!(h.state().views[0].find.is_some()); + click_field(&mut h, "country", 0); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_some(), "the list is open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "Escape closed the list"); + assert!(h.state().views[0].forms.focus.is_none(), "and the field"); + assert!(h.state().views[0].find.is_some(), "the find bar stays open"); +} + +/// An auto-sized drop-down's list draws its items at the size the field shows its value at, so a +/// long chosen item fits the list as it fits the field instead of growing past it. +#[test] +fn an_auto_sized_drop_downs_list_shows_its_items_at_the_fields_text_size() { + let mut h = harness(); + let long = "The Commonwealth of the Northern Mariana Islands"; + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldProps { + name: "country".into(), + props: Box::new(pdfcraft_engine::FieldProps { options: Some(vec!["Canada".into(), long.into()]), ..Default::default() }), + })); + assert!( + h.state_mut().apply_edit(pdfcraft_engine::Edit::SetFieldValue { + name: "country".into(), + value: pdfcraft_engine::FieldValue::Choice(vec![long.into()]) + }) + ); + h.run_steps(3); + click_field(&mut h, "country", 0); + let field = field_rect(&h, "country"); + let item = text_rect(&h, long).expect("the long item in the open list"); + assert!(item.width() <= field.width(), "the item fits as the field's value does: {} wide in a {} field", item.width(), field.width()); +} + +/// In single-page view, where the wheel turns pages, the wheel over an open drop-down's list +/// scrolls the list and leaves the page as it is; over the page itself it still turns it. +#[test] +fn in_single_page_view_the_wheel_over_an_open_list_scrolls_the_list_not_the_page() { + let mut h = harness(); + long_country(&mut h); + assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 300.0, height: 400.0 })); + h.state_mut().set_option("continuous", "off").unwrap(); + h.key_press(egui::Key::Home); + for _ in 0..60 { + h.run_steps(2); + if !h.state().render_pending() && h.state().views[0].page_screen_rect(0).is_some() { + break; + } + } + let wheel = |h: &mut Harness<'static, PdfCraftApp>, at: egui::Pos2| { + h.hover_at(at); + h.run_steps(1); + for _ in 0..3 { + h.event(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + source: egui::MouseWheelSource::Unknown, + delta: egui::vec2(0.0, -1.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.run_steps(2); + } + h.run_steps(6); + }; + // The control: over the page, the wheel turns it. + let page = h.state().views[0].page_screen_rect(0).expect("page 1 on screen"); + wheel(&mut h, page.center()); + assert_eq!(h.state().views[0].current, 1, "over the page, the wheel turns it"); + h.key_press(egui::Key::Home); + h.run_steps(6); + assert_eq!(h.state().views[0].current, 0); + // Over the open list, it scrolls the list instead. + click_field(&mut h, "country", 0); + let first = egui_kittest::kittest::Queryable::get_by_label(&h, "0000").rect(); + wheel(&mut h, first.center() + egui::vec2(0.0, first.height() * 4.0)); + assert!(egui_kittest::kittest::Queryable::query_by_label(&h, "0000").is_none(), "the list scrolled down"); + assert_eq!(h.state().views[0].current, 0, "the page didn't turn"); +} + +/// With a page taller than the window and the pointer resting on an open drop-down's list, +/// Home and the down arrow move through the list and leave the page where it was. +#[test] +fn an_open_drop_down_keeps_the_arrow_keys_and_home_from_moving_the_page() { + let mut h = harness(); + long_country(&mut h); + // A page taller than the window, so the keys would scroll it. + h.state_mut().set_option("zoom", "300").unwrap(); + for _ in 0..30 { + h.run_steps(2); + if !h.state().render_pending() { + break; + } + } + click_field(&mut h, "country", 0); + // The pointer rests on the list, as it does after reaching for it. + let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect(); + h.hover_at(row.center()); + h.run_steps(2); + let page = h.state().views[0].page_screen_rect(0); + h.key_press(egui::Key::Home); + h.run_steps(2); + h.key_press(egui::Key::ArrowDown); + h.run_steps(4); + assert_eq!(h.state().views[0].page_screen_rect(0), page, "the page stayed where it was"); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["0100"], "one item down from the first"); +} + +/// When End scrolls a drop-down's list under a resting pointer, Enter chooses the item End +/// went to, not the one that came under the pointer. +#[test] +fn a_pointer_resting_on_a_list_the_keys_scroll_keeps_the_highlight_on_the_keys() { + let mut h = harness(); + long_country(&mut h); + click_field(&mut h, "country", 0); + let row = egui_kittest::kittest::Queryable::get_by_label(&h, "0300").rect(); + h.hover_at(row.center()); + h.run_steps(2); + // End scrolls the list, so another item comes under the resting pointer. + h.key_press(egui::Key::End); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(3); + assert_eq!(value(&h, "country"), ["2900"], "the item End went to, not the one under the pointer"); +} diff --git a/crates/ui-egui/tests/it/layer_actions.rs b/crates/ui-egui/tests/it/layer_actions.rs index 4878343938..12c6868f02 100644 --- a/crates/ui-egui/tests/it/layer_actions.rs +++ b/crates/ui-egui/tests/it/layer_actions.rs @@ -9,9 +9,14 @@ use pdfcraft_ui_egui::PdfCraftApp; /// One page with layers Red (on) and Green (off), a radio-button group, each drawing a band; /// a "show-red" push button turns Red on and a link toggles Green. fn fixture() -> Vec { + fixture_with("") +} + +/// [`fixture`] with `order` added to the default configuration. +fn fixture_with(order: &str) -> Vec { let content = "/OC /R BDC 1 0 0 rg 20 300 260 40 re f EMC /OC /G BDC 0 1 0 rg 20 240 260 40 re f EMC"; let objs = [ - "<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] >> >> >>".to_string(), + format!("<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [7 0 R] >> /OCProperties << /OCGs [5 0 R 6 0 R] /D << /OFF [6 0 R] /RBGroups [[5 0 R 6 0 R]] {order} >> >> >>"), "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 400] /Contents 4 0 R /Resources << /Properties << /R 5 0 R /G 6 0 R >> >> /Annots [7 0 R 8 0 R] >>".into(), format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), @@ -36,10 +41,14 @@ fn fixture() -> Vec { } fn harness(setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harness<'static, PdfCraftApp> { + harness_with(fixture(), setup) +} + +fn harness_with(pdf: Vec, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> 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("layers.pdf", None, fixture()).unwrap(); + app.open_bytes("layers.pdf", None, pdf).unwrap(); app.set_option("zoom", "150").unwrap(); setup(&mut app); app @@ -124,3 +133,28 @@ fn layer_options_reset_to_initial_visibility() { assert!(!doc.dirty, "showing and hiding layers doesn't change the document"); assert_eq!(doc.can_undo(), None, "nor is it an edit to undo"); } + +/// ISO 32000-2 Table 99 `/Order`: the Layers panel shows the tree it gives, a leading text +/// string as a label, and not the groups it leaves out, which still draw in their state. Without +/// `/Order` it presents none. +#[test] +fn the_layers_panel_shows_the_order_tree() { + let mut h = harness_with(fixture_with("/Order [[(Colours) 5 0 R]]"), |app| app.set_option("panel", "layers").unwrap()); + h.run_steps(2); + let label = h.get_by_label("Colours").rect(); + let red = h.get_by_label("Red").rect(); + assert!(red.min.y > label.max.y - 1.0, "Red is listed under its label"); + assert!(h.query_by_label("Green").is_none(), "Green isn't in /Order, so it isn't presented"); + assert_eq!(shown(&h), layers(true, false), "but it is still a layer, in its state"); + // Clicking the label changes nothing; clicking Red hides it. + h.get_by_label("Colours").click(); + h.run_steps(2); + assert_eq!(shown(&h), layers(true, false)); + h.get_by_label("Red").click(); + h.run_steps(3); + assert_eq!(shown(&h), layers(false, false)); + + let h = harness(|app| app.set_option("panel", "layers").unwrap()); + assert!(h.query_by_label("Red").is_none() && h.query_by_label("Green").is_none(), "no /Order: none presented"); + h.get_by_label("This document lists no layers to show."); +} diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 972bc78b82..1d848fc8e6 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -77,6 +77,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/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/redact.rs b/crates/ui-egui/tests/it/redact.rs index 3e11861cbc..ccb3a267ef 100644 --- a/crates/ui-egui/tests/it/redact.rs +++ b/crates/ui-egui/tests/it/redact.rs @@ -4,7 +4,7 @@ use egui::Pos2; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use pdfcraft_ui_egui::{Dialog, PdfCraftApp}; +use pdfcraft_ui_egui::{Dialog, PdfCraftApp, SaveTarget}; fn harness() -> Harness<'static, PdfCraftApp> { let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { @@ -74,6 +74,417 @@ fn marking_applying_and_clearing() { assert!(doc.form.is_empty(), "the whole page was redacted, fields included"); } +/// After Apply redactions, Save asks where instead of writing over the opened file, as Acrobat +/// shows Save As with `_Redacted` appended to the name. Once that copy is written, Save writes +/// it in place again. +#[test] +fn saving_after_applying_redactions_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + let source = include_bytes!("../data/form.pdf").to_vec(); + std::fs::write(&original, &source).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(marks(&h), 1); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(marks(&h), 0); + assert_eq!(h.state().views[0].redactions_applied, 1); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where first"); + h.run_steps(3); + assert_eq!(std::fs::read(&original).unwrap(), source, "the opened file is left as it was"); + assert!(copy.exists(), "the redacted copy is written where the user chose"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract_Redacted.pdf"); + assert_eq!(s.views[0].redactions_applied, 0); + h.state_mut().pick_override = None; + assert!(h.state_mut().save_active(SaveTarget::InPlace), "the next Save writes the copy in place"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo past an Apply redactions that is already saved in the `_Redacted` copy makes Save ask +/// where, so the restored content is not written over the copy. Redo makes Save write in place. +#[test] +fn undoing_saved_redactions_does_not_save_over_the_redacted_copy() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-saved-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + h.state_mut().pick_override = None; + assert!(copy.exists()); + // Undo and Redo leave the saved redactions in effect: Save writes the copy in place. + h.state_mut().execute("edit.undo"); + h.run_steps(3); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!((h.state().views[0].redactions_applied, h.state().views[0].redactions_undone_after_save), (0, 0)); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions back, Save writes the copy in place"); + let saved = std::fs::read(©).unwrap(); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_undone_after_save, 1); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(elsewhere.exists(), "the restored version is written where the user chose"); + assert_eq!(h.state().views[0].redactions_undone_after_save, 0); + // The restored version is now the saved one: Redo applies the redactions to it again. + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A new Apply redactions after Undo past a saved one clears Redo, so Undo and Redo of the new +/// step do not bring Save in place back over the `_Redacted` copy. +#[test] +fn redoing_a_new_apply_after_undoing_saved_redactions_still_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-branch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + h.state_mut().pick_override = None; + let saved = std::fs::read(©).unwrap(); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + // Apply the marks again as a new step, then Undo and Redo it. + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + let view = &h.state().views[0]; + assert_eq!((view.redactions_applied, view.redactions_undone_after_save, view.saved_redactions_cut_off), (0, 0, true)); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + let elsewhere = dir.join("contract_again.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where instead of writing over the copy"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(!h.state().views[0].saved_redactions_cut_off); + let _ = std::fs::remove_dir_all(&dir); +} + +/// In the web build a delivered save keeps `redactions_applied` and records it in +/// `redactions_downloaded`. Undo past a delivered Apply redactions, or a new Apply after it, makes +/// Save ask where, so a host does not write the restored content over the redacted copy. +#[test] +fn undoing_delivered_redactions_asks_where() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + // The state a web save leaves: the step is delivered and stays counted. + h.state_mut().views[0].redactions_downloaded = 1; + h.state_mut().execute("edit.undo"); + h.run_steps(3); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace), "Save asks where after Undo past a delivered step"); + h.run_steps(3); + h.state_mut().pick_override = None; + assert!(elsewhere.exists()); + // A new Apply after Undo past a delivered step cannot be redone back to the delivered one. + h.state_mut().views[0].redactions_downloaded = 1; + h.state_mut().views[0].redactions_applied = 0; + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert!(h.state().views[0].saved_redactions_cut_off); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Once every Apply redactions step is delivered, Save writes in place again even though the web +/// build keeps the steps counted for the `_Redacted` name. +#[test] +fn delivered_redactions_save_in_place() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-delivered-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + // The state a web save leaves: the step is delivered and stays counted. + h.state_mut().views[0].redactions_downloaded = 1; + assert!(h.state_mut().save_active(SaveTarget::InPlace), "Save writes in place after the step is delivered"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo past a saved Apply redactions while the Save dialog is open makes Save ask again, so the +/// restored content is not written to the redacted copy the dialog proposed. +#[test] +fn undoing_saved_redactions_while_the_save_dialog_is_open_asks_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + let apply = |h: &mut Harness<'static, PdfCraftApp>| { + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + }; + apply(&mut h); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.run_steps(3); + let saved = std::fs::read(©).unwrap(); + // A second Apply redactions makes Save ask where. Before the answer, Undo takes back the + // second Apply, its marks and the saved Apply. + apply(&mut h); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + for _ in 0..3 { + h.state_mut().execute("edit.undo"); + } + assert_eq!(h.state().views[0].redactions_undone_after_save, 1); + let elsewhere = dir.join("contract_restored.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + h.run_steps(4); + h.state_mut().pick_override = None; + assert_eq!(std::fs::read(©).unwrap(), saved, "the redacted copy is left as it was"); + assert!(elsewhere.exists(), "Save asked again and wrote where the user chose"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo of Apply redactions while the Save dialog that proposed the `_Redacted` name is open +/// makes Save ask again, so the unredacted content is not saved under that name. +#[test] +fn undoing_apply_redactions_while_the_save_dialog_is_open_asks_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-dialog-unapply-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + let copy = dir.join("contract_Redacted.pdf"); + h.state_mut().pick_override = Some(vec![copy.to_string_lossy().into_owned()]); + assert!(!h.state_mut().save_active(SaveTarget::InPlace)); + h.state_mut().execute("edit.undo"); + assert_eq!(h.state().views[0].redactions_applied, 0); + let elsewhere = dir.join("contract_copy.pdf"); + h.state_mut().pick_override = Some(vec![elsewhere.to_string_lossy().into_owned()]); + h.run_steps(4); + h.state_mut().pick_override = None; + assert!(!copy.exists(), "the unredacted content is not saved under the _Redacted name"); + assert!(elsewhere.exists(), "Save asked again and wrote where the user chose"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Undo of Apply redactions brings back Save in place; Redo makes Save ask where again. +#[test] +fn undoing_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-undo-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + for _ in 0..2 { + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + } + assert_eq!(h.state().views[0].redactions_applied, 2); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1, "the first Apply redactions is still in effect"); + // Undoing the second mark leaves the first Apply redactions in effect. + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + h.state_mut().execute("edit.redo"); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 1); + h.state_mut().execute("edit.undo"); + h.run_steps(3); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "with the redactions undone, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// File ▸ Revert discards the applied redactions, so Save writes the opened file in place again. +#[test] +fn reverting_after_apply_redactions_saves_in_place_again() { + let dir = std::env::temp_dir().join(format!("pdfcraft-redacted-revert-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("contract.pdf"); + std::fs::write(&original, include_bytes!("../data/form.pdf")).unwrap(); + let path = original.to_string_lossy().into_owned(); + 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_path(&path); + app + }); + crate::support::settle(&mut h); + h.state_mut().execute("redact.pages"); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(3); + assert!(h.state_mut().execute("redact.apply")); + h.run_steps(2); + h.get_by_label("Apply").click(); + h.run_steps(4); + assert_eq!(h.state().views[0].redactions_applied, 1); + assert!(h.state_mut().execute("file.revert")); + h.run_steps(2); + assert_eq!(h.state().dialog, Some(Dialog::Revert)); + h.get_all_by_label("Revert").last().expect("the button").click(); + h.run_steps(3); + assert_eq!(h.state().views[0].redactions_applied, 0); + assert!(h.state_mut().save_active(SaveTarget::InPlace), "after Revert, Save writes in place"); + let s = h.state(); + assert_eq!(s.session.get(s.views[0].id).unwrap().name, "contract.pdf"); + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn redaction_codes_become_the_overlay_text() { let mut h = harness(); 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 d0d2d066a7..dc4603ace9 100644 --- a/crates/xfa/README.md +++ b/crates/xfa/README.md @@ -94,6 +94,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 ed61ee6904..e98ca1f2df 100644 --- a/crates/xfa/src/layout.rs +++ b/crates/xfa/src/layout.rs @@ -125,6 +125,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 { @@ -136,6 +140,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>, @@ -163,6 +176,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, @@ -211,6 +228,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() }; @@ -225,6 +244,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 }) } @@ -244,3 +265,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 b65e72477a..7fadabf53b 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,20 +1,37 @@ -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_MEASURES, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, 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. @@ -32,7 +49,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 @@ -68,12 +137,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 @@ -84,7 +160,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. @@ -93,8 +186,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 { @@ -109,7 +206,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) @@ -128,10 +246,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; @@ -142,9 +278,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; } } @@ -187,7 +337,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 a9cf4200a7..919b053606 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 b7725f0f13..35a25077f0 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() { @@ -4221,6 +4236,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"); } @@ -4508,6 +4525,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 { @@ -4894,6 +4972,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..11d4b8a5dc 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] { @@ -2232,6 +2269,48 @@ fn an_fdf_comment_popup_without_a_rect_is_dropped() { assert_eq!(popups, 0); } +/// A check box field whose two kids have the on states `A` and `B`. +fn check_box_kids() -> Document { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>", + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>", + "<< /Fields [5 0 R] >>", + "<< /FT /Btn /T (pair) /V /Off /Kids [6 0 R 7 0 R] >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [50 700 64 714] /P 3 0 R /AP << /N << /A 8 0 R /Off 8 0 R >> >> >>", + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [80 700 94 714] /P 3 0 R /AP << /N << /B 8 0 R /Off 8 0 R >> >> >>", + "<< /Length 0 >>\nstream\n\nendstream", + ]; + 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()); + Document::open(Arc::new(out)).unwrap() +} + +/// Imported data that names a check box kid's on state ticks that kid (ISO 32000-2 §12.7.5.2.3: +/// the field's value is the on state of the kid that is on), not the first kid. +#[test] +fn imported_values_tick_the_check_box_kid_they_name() { + let mut src = check_box_kids(); + set_value(&mut src, "pair", &pdfcraft_forms::FieldValue::Text("B".into())).unwrap(); + let xfdf = export_xfdf(&src, false, true, "form.pdf"); + let mut dst = check_box_kids(); + import(&mut dst, xfdf.as_bytes()).unwrap(); + let all = fields(&dst); + let pair = all.iter().find(|f| f.name == "pair").unwrap(); + assert_eq!(pair.value, ["B"], "{xfdf}"); + assert_eq!((pair.widget_on(0), pair.widget_on(1)), (false, true)); +} + /// Copy: the chosen comments travel with their replies and nothing else. #[test] fn exporting_selected_comments_takes_them_and_their_replies() { diff --git a/crates/xfdf/src/xfdf.rs b/crates/xfdf/src/xfdf.rs index c5ec3d642d..efbf5033f0 100644 --- a/crates/xfdf/src/xfdf.rs +++ b/crates/xfdf/src/xfdf.rs @@ -837,7 +837,7 @@ pub(crate) fn apply_values(doc: &mut Document, values: &[(String, Vec)], let first = vals.first().cloned().unwrap_or_default(); let v = match f.kind { FieldKind::Text => FieldValue::Text(first), - FieldKind::CheckBox => FieldValue::Check(!first.is_empty() && first != "Off"), + FieldKind::CheckBox => f.check_box_value(Some(first.as_str())), FieldKind::Radio => FieldValue::Radio((!first.is_empty() && first != "Off").then_some(first)), // XML, CSV and text hold a multi-select list's values in one cell, joined by ", " // (as export_data writes them); split it unless the whole cell is one option. 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