diff --git a/Cargo.lock b/Cargo.lock index a08f650e80..f9e243ba0b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1289,7 +1289,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" +source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d" dependencies = [ "chrono", "dunce", @@ -4767,6 +4767,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "flate2", "fontconfig-parser", "getrandom 0.4.3", "image", @@ -8766,7 +8767,6 @@ dependencies = [ "nix", "typed-path", "windows-sys 0.61.2", - "zopfli", ] [[package]] @@ -8781,18 +8781,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" -[[package]] -name = "zopfli" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index d919a90383..b0f3411624 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1. # Whether a path stored in a document may be opened (GoToR link targets). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" } # craft-update: app-specific pretend versions apply to startup and manual checks. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.18.1" } pdfcraft-filters = { path = "crates/filters", version = "0.18.1" } diff --git a/README.md b/README.md index 7ca789c6e0..74288bff8f 100644 --- a/README.md +++ b/README.md @@ -476,7 +476,8 @@ logs and crash recovery in a `PdfCraftData` folder next to `pdfcraft.exe`, so no 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 +489,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..85f06fcd1e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -442,6 +442,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 +453,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 +525,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/src/main.rs b/apps/pdfcraft/src/main.rs index 4de423db7f..58ce6ba8ec 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")] @@ -368,6 +371,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, @@ -392,7 +397,7 @@ fn app_creator<'a>( 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(); @@ -466,6 +471,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). 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/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_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/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/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/tests.rs b/crates/annot/src/tests.rs index b0220b1023..526cd02770 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -1314,6 +1314,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(); diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs index 8fb1e0fd52..f47e8a2527 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,8 +552,9 @@ 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)?; - self.accessibility_tags(a) + 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_move_tag(&mut self, a: &Args) -> Result { @@ -573,9 +575,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/dispatch.rs b/crates/automation/src/dispatch.rs index 642c4b5b11..276642b930 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)?; 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 c9f758de31..1d59e61b82 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -213,6 +213,16 @@ fn value_for(f: &FormField, v: &Value) -> Result { let wrong = |what: &str| ToolError::InvalidArgs(format!("{}: {what}", f.name)); Ok(match (f.kind, v) { (FormFieldKind::CheckBox, Value::Bool(b)) => FieldValue::Check(*b), + // {"widget": n} turns on button n (1-based, as form_fields numbers them) and never turns it + // off: radio buttons that share an on state stay exclusive (ISO 32000-2 Table 229). + (FormFieldKind::CheckBox | FormFieldKind::Radio, Value::Object(o)) => { + let n = o.get("widget").and_then(Value::as_u64).filter(|&n| n >= 1).ok_or_else(|| wrong("use {\"widget\": n} with n from 1"))?; + let i = usize::try_from(n - 1).map_err(|_| wrong("there is no such widget"))?; + if f.widgets.get(i).and_then(|w| w.on_state.as_ref()).is_none() { + return Err(wrong(&format!("there is no button {n} with an on state (the field has {})", f.widgets.len()))); + } + FieldValue::Widget(i) + } (FormFieldKind::Radio, Value::Null) => FieldValue::Radio(None), (FormFieldKind::Radio, Value::String(s)) => FieldValue::Radio(Some(s.clone())), (FormFieldKind::Combo | FormFieldKind::List, Value::Array(a)) => FieldValue::Choice( @@ -294,6 +304,11 @@ impl Automation { } FormFieldKind::Radio => { o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); + // Buttons sharing an on state have the same value: which is on (1-based). + o.insert( + "on_widgets".into(), + json!((0..f.widgets.len()).filter(|&i| f.widget_on(i)).map(|i| i.saturating_add(1)).collect::>()), + ); } // 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 diff --git a/crates/automation/src/hairlines.rs b/crates/automation/src/hairlines.rs new file mode 100644 index 0000000000..60e9dfe475 --- /dev/null +++ b/crates/automation/src/hairlines.rs @@ -0,0 +1,40 @@ +//! Print production ▸ Fix hairlines as a tool: strokes narrower than a threshold on the page get a +//! width (`doc_fix_hairlines`). + +use pdfcraft_engine::{Edit, Hairlines}; +use serde_json::{Value, json}; + +use crate::{Args, Automation, Result, ToolError}; + +impl Automation { + /// Strokes thinner than `narrower_than` points on the page (after every transformation; + /// zero-width lines always) are given `width` points; Type 3 glyphs and tiling patterns on + /// request. Nothing to fix is not an error: `fixed` is 0 and no edit is made. `elsewhere` + /// counts the glyphs and patterns changed that something outside `pages` also uses. + pub(crate) fn fix_hairlines(&mut self, a: &Args) -> Result { + let n = self.doc(a)?.info.pages.len(); + let pages = match a.opt_ints("pages")? { + Some(_) => self.pages(a, "pages")?, + None => (0..n).collect(), + }; + let settings = Hairlines { + narrower_than: a.opt_num("narrower_than")?.unwrap_or(0.003 * 72.0), + width: a.opt_num("width")?.unwrap_or(0.01 * 72.0), + type3: a.opt_bool("type3")?.unwrap_or(false), + patterns: a.opt_bool("patterns")?.unwrap_or(false), + }; + if !settings.in_range() { + return Err(ToolError::InvalidArgs(format!( + "narrower_than must be 0 or more and width above 0, both at most {} (points)", + Hairlines::MAX_POINTS + ))); + } + let id = self.doc(a)?.id; + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + return Ok(json!({ "fixed": 0, "pages": 0, "elsewhere": 0 })); + } + let r = self.apply(a, Edit::FixHairlines { pages, settings })?; + Ok(json!({ "fixed": found.lines, "pages": found.pages, "elsewhere": found.elsewhere, "undo": r["undo"] })) + } +} diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 594a129d8b..7fa63d1a1d 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -22,6 +22,7 @@ mod content; mod conventions; pub mod file_access; mod forms; +mod hairlines; mod layers; mod links; #[cfg(feature = "mcp")] diff --git a/crates/automation/src/links.rs b/crates/automation/src/links.rs index a3bf9cd28b..546413f6fc 100644 --- a/crates/automation/src/links.rs +++ b/crates/automation/src/links.rs @@ -113,7 +113,41 @@ impl Automation { v }) .collect(); - Ok(json!({ "count": items.len(), "links": items })) + let mut out = json!({ "count": items.len(), "links": items }); + // Addresses written as plain page text, which the viewer makes clickable (#4207). Reading + // the pages' text costs more than the list above, so only on request. + if a.opt_bool("text_links")?.unwrap_or(false) { + let id = doc.id; + // One share of the pages at a time (`MAX_TEXT_LINK_PAGES_PER_SCAN`), from the 1-based + // `text_links_from`; `text_links_next_page` says where the next share starts. + let pages = doc.info.pages.len().max(1); + let from = match a.opt_int("text_links_from")? { + None => 0, + Some(p) => usize::try_from(p) + .ok() + .filter(|p| (1..=pages).contains(p)) + .map(|p| p - 1) + .ok_or_else(|| bad(format!("text_links_from must be a page from 1 to {pages}")))?, + }; + let scan = self.session.text_links(id, from); + if let Some(next) = scan.next_page { + out["text_links_next_page"] = json!(next.saturating_add(1)); + } + let found: Vec = scan + .links + .into_iter() + .map(|(page, l)| { + let mut v = json!({ "page": page + 1, "rects": l.rects, "url": l.uri }); + // What the viewer does on a click: the same check every document link gets. + if let Err(e) = pdfcraft_engine::links::document_url(&l.uri) { + v["blocked"] = json!(e.to_string()); + } + v + }) + .collect(); + out["text_links"] = json!(found); + } + Ok(out) } pub(crate) fn link_open(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 04ab8dace4..f9ddec40a3 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -235,6 +235,10 @@ impl Automation { let (mut html_left, mut spent, mut cut) = (html_budget, false, false); for (&p, t) in pages.iter().zip(texts) { let mut page = json!({ "page": p + 1, "text": text(&t) }); + // Content cut at the renderer's limits (#623): the text is incomplete, and said so. + if t.truncated { + page["truncated"] = json!(true); + } if html { // Copy with formatting: the same glyphs as an HTML fragment, within one budget for // the whole call; a page past it gets none and the call says so. diff --git a/crates/automation/src/printing.rs b/crates/automation/src/printing.rs index 2f1cf5e136..8a6f825e6a 100644 --- a/crates/automation/src/printing.rs +++ b/crates/automation/src/printing.rs @@ -38,8 +38,14 @@ fn bad(m: impl Into) -> ToolError { } /// `doc_print`'s `paper`, by its name or a short form ("Letter", "ARCH D"), as its `PAPERS` -/// index and size; US Letter when none is given. +/// index and size (or a size such as "8.5x14in", whose index is past the list); US Letter when +/// none is given. fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { + // A size of its own: "8.5x14in", "210x297mm", "10x15cm", "612x792pt". It has no place in + // `PAPERS` (index `PAPERS.len()`), so no `media` option is sent for it. + if let Some(size) = name.and_then(paper_size) { + return Ok((PAPERS.len(), size)); + } let mut papers = PAPERS.iter().enumerate(); let found = match name { None => papers.next(), @@ -51,7 +57,7 @@ fn paper_named(name: Option<&str>) -> Result<(usize, (f64, f64))> { }; found.map(|(index, (_, size))| (index, *size)).ok_or_else(|| { bad(format!( - "unknown paper {:?}; choose one of {}", + "unknown paper {:?}; choose one of {}, or a size such as 8.5x14in or 210x297mm (1 to 200 inches a side)", name.unwrap_or_default(), PAPERS.iter().map(|(name, _)| *name).collect::>().join(", ") )) @@ -237,6 +243,19 @@ impl Automation { } } +/// "WxH" with a unit (in, mm, cm or pt) as a portrait paper size in points; each side 1 to 200 +/// inches (PDF's own page limit). +fn paper_size(spec: &str) -> Option<(f64, f64)> { + let s = spec.trim().to_ascii_lowercase(); + let (body, per_point) = [("in", 1.0 / 72.0), ("mm", 25.4 / 72.0), ("cm", 2.54 / 72.0), ("pt", 1.0)] + .into_iter() + .find_map(|(unit, per_point)| s.strip_suffix(unit).map(|b| (b, per_point)))?; + let (w, h) = body.split_once('x')?; + let side = |t: &str| t.trim().parse::().ok().map(|v| v / per_point).filter(|v| (72.0..=14_400.0).contains(v)); + let (w, h) = (side(w)?, side(h)?); + Some((w.min(h), w.max(h))) +} + #[cfg(test)] mod tests { use super::*; @@ -289,7 +308,19 @@ mod tests { assert_eq!(job(json!({}), Some("letter")).options, [media("Letter")]); assert_eq!(job(json!({}), Some("ARCH D")).options, [media("na_arch-d_24x36in")]); assert!(matches!(paper_named(Some("Quarto")), Err(ToolError::InvalidArgs(m)) if m.contains("US Letter"))); + // A size of its own is laid out as asked and names no media. + assert_eq!(paper_named(Some("8.5x14in")).ok(), Some((PAPERS.len(), (612.0, 1008.0)))); + assert!(job(json!({}), Some("8.5x14in")).options.is_empty()); assert_eq!(spool_job(&Args(&json!({})), "default", String::new(), None, 0).ok().map(|j| j.printer), Some(None)); assert!(spool_job(&Args(&json!({ "options": { "InputSlot": 3 } })), "Office", String::new(), None, 0).is_err()); } + + #[test] + fn a_paper_size_reads_its_numbers_and_needs_a_unit() { + assert_eq!(paper_size("8.5 x 14 in"), Some((612.0, 1008.0))); + assert_eq!(paper_size("1e3x1e3pt"), Some((1000.0, 1000.0))); + for spec in ["0x0mm", "-5x10cm", "5x-10cm", "10x15"] { + assert_eq!(paper_size(spec), None, "{spec}"); + } + } } diff --git a/crates/automation/src/redact.rs b/crates/automation/src/redact.rs index 6ec66ce680..a793487d9f 100644 --- a/crates/automation/src/redact.rs +++ b/crates/automation/src/redact.rs @@ -2,7 +2,7 @@ //! (removing what they cover for good) or clear them. use pdfcraft_engine::{ - Edit, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, + Edit, HIDDEN, Hidden, NewAnnotation, REDACT_MAX_WORDS, REDACT_VALIDATORS, REDACTION_CODE_SETS, RedactPattern, RedactRegex, RedactValidator, RedactionCodeSet, Shape, Style, find_pattern, rect_quad, redact_word_list, }; use serde_json::{Value, json}; @@ -235,6 +235,9 @@ impl Automation { Ok(json!({ "findings": items, "count": items.len() })) } + /// `doc_remove_hidden`: remove the listed categories of hidden information, or all of them + /// (Sanitize Document) when none are listed, and report how many items the listed + /// categories lost as `removed`. pub(crate) fn doc_remove_hidden(&mut self, a: &Args) -> Result { let which = match a.get("categories") { None => None, @@ -253,8 +256,11 @@ impl Automation { // One scan, for this check and for what was removed. The removal refuses when it counts // every listed category as 0 (an AcroForm with no fields counts 1), with an error about // redaction marks; this reads the same scan, so it refuses exactly then. A document that - // can't be edited has no scan: `apply` says why. - let info = self.doc(a)?.hidden_info(); + // can't be edited has no scan: `apply` says why. Only the listed categories are counted, + // here and after: the others would read every page's content and every object for + // nothing (#5873). + let listed = which.clone().unwrap_or_else(|| HIDDEN.to_vec()); + let info = self.doc(a)?.hidden_info_of(&listed); let edit = match which { None => Edit::Sanitize, Some(which) => { @@ -266,7 +272,7 @@ impl Automation { }; let before: usize = info.iter().map(|c| c.1).sum(); let mut out = self.apply(a, edit)?; - let after: usize = self.doc(a)?.hidden_info().iter().map(|c| c.1).sum(); + let after: usize = self.doc(a)?.hidden_info_of(&listed).iter().map(|c| c.1).sum(); out["removed"] = json!(before.saturating_sub(after)); Ok(out) } diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index fe4e1b89ac..281e5df7a5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -154,6 +154,17 @@ pub fn tools() -> Vec { "dpi": {"type":"number","minimum":72,"maximum":600}, "language": {"type":"string"}, "skip_text_pages": {"type":"boolean"} }), &["paths","out_dir","steps"])), + t("doc_fix_hairlines", "Fix hairlines", "Print production: strokes narrower than narrower_than points on the page (after every transformation; zero-width lines always) are given width points, in page content and form XObjects; Type 3 glyphs and tiling patterns only when asked, changed in place wherever they are used. Both widths at most 10. Returns fixed (strokes), pages, and elsewhere (glyphs and patterns changed that something outside pages also uses); nothing to fix gives 0 and no edit.") + .cmd("prepress.hairlines") + .with(schema( + json!({ + "doc": doc(), "pages": pages("to fix (default all)"), + "narrower_than": { "type": "number", "minimum": 0, "description": "Points on the page (default 0.216)." }, + "width": { "type": "number", "exclusiveMinimum": 0, "description": "Points on the page (default 0.72)." }, + "type3": { "type": "boolean" }, "patterns": { "type": "boolean" }, + }), + &["doc"], + )), t("attachment_add", "Attach a file", "Attach the file at path to the document (document level, the Attachments panel; not a paper-clip comment on a page). name defaults to the file's name; the attachment's key, unique in the document, is returned. Undoable.") .cmd("edit.attach") .with(schema(json!({"doc": doc(), "path": {"type": "string"}, "name": {"type": "string"}, "description": {"type": "string"}}), &["doc", "path"])), @@ -187,7 +198,7 @@ pub fn tools() -> Vec { }), &["doc"], )), - t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value. Undoable.") + t("doc_set_info", "Set document metadata", "Set a document information entry such as Title, Author, Subject or Keywords, or a custom property under any other name (no spaces, delimiters, / or #; at most 127 bytes; doc_info lists them under custom); an empty value removes it. When the file has XMP metadata, the matching XMP property (dc:title, dc:creator, dc:description, pdf:Keywords, xmp:CreatorTool, pdf:Producer) is changed too, so viewers that read XMP first show the new value, and a custom property is kept there as pdfx: when its name can be an XML name (in a PDF/A file only one already there is changed). Undoable.") .with(schema(json!({ "doc": doc(), "key": { "type": "string" }, "value": { "type": "string" } }), &["doc", "key", "value"])), t("page_render", "Render a page", "Render one page to a PNG image (default 96 dpi, at most 600). When the picture is incomplete or approximate (content cut at the page's safety budget, a colour painted in a fallback colour), a JSON part follows the image: { page, warnings: [sentence, ...] }.") .ro() @@ -235,7 +246,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. A page whose content was cut at the renderer's limits carries truncated: true; its text is incomplete. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") .ro() .cmd("edit.column_select") .with(schema( @@ -497,13 +508,13 @@ pub fn tools() -> Vec { }), &["doc", "from", "to"], )), - t("form_fields", "List form fields", "Every interactive form field: name, type (text, checkbox, radio, combo, list, button, signature), value, options, page, rect (top-left-origin points) and rotation (0, 90, 180 or 270 degrees counterclockwise), read-only and required flags. A calculated field includes calculate: {op, fields} or {notation} as form_set_props takes them, or {script} for a custom Calculate script (set with form_set_script). Push buttons include caption (first widget's normal label) and captions [{widget, caption}] for all widgets (1-based). A check box whose widgets have different on states also lists them as options, and which one is checked (null when none).") + 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).") .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 (or one of a check box's options, to check that widget and clear the others), 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.") @@ -730,7 +741,7 @@ pub fn tools() -> Vec { "cut_marks": { "type": "boolean" }, "orientation": { "type": "string", "enum": ["auto", "portrait", "landscape"] }, "comments_forms": { "type": "string", "enum": ["document", "document-and-markups", "document-and-stamps", "form-fields-only"] }, - "paper": { "type": "string" }, + "paper": { "type": "string", "description": "A paper name (US Letter, A4…) or a size of its own such as 8.5x14in, 210x297mm, 10x15cm or 612x792pt (1 to 200 inches a side)." }, "path": { "type": "string" }, "printer": { "type": "string" }, "copies": { "type": "integer", "minimum": 1, "maximum": 999 }, @@ -813,9 +824,9 @@ pub fn tools() -> Vec { t("content_delete", "Delete added content", "Delete an added item (page, index from content_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom).") + t("link_list", "List links", "Every link: page, 1-based index (for link_edit/link_delete), rect (points from the top-left of the page), and where it goes (url, or to_page with its zoom). With text_links: true, also the web and email addresses written as plain page text with no link over them, which the viewer opens on a click: page, rects (one per line), url, and blocked (why it won't open) when the link check refuses it. Only http, https and mailto addresses are found. At most 100 pages are read per call, from text_links_from (default 1); text_links_next_page then gives the page to continue from.") .ro() - .with(schema(json!({ "doc": doc() }), &["doc"])), + .with(schema(json!({ "doc": doc(), "text_links": { "type": "boolean" }, "text_links_from": { "type": "integer", "minimum": 1 } }), &["doc"])), t("link_open", "Open a linked PDF", "Follow a local GoToR link in PdfCraft: open the referenced PDF as a new document, respecting --root. page and index use link_list numbering. Named destinations are reported but not applied. Absolute file specifications require allow_absolute: true after reviewing the target; relative links need no opt-in.") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 }, "password": { "type": "string", "description": "Optional password for the linked PDF." }, "allow_absolute": { "type": "boolean", "description": "Explicitly allow an absolute local GoToR target (default false). Existing validation and --root confinement still apply." } }), @@ -846,7 +857,7 @@ pub fn tools() -> Vec { t("link_delete", "Delete a link", "Delete one link (page, index from link_list). Undoable.") .destructive() .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 } }), &["doc", "page", "index"])), - t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links. Returns the URLs. Undoable.") + t("links_from_urls", "Create links from URLs", "Find web addresses (http://, https://, www.) in the text of every page and make them clickable links, leaving out text on a layer the viewer hides (layer_set). Returns the URLs. Undoable.") .cmd("edit.links_from_urls") .with(schema(json!({ "doc": doc() }), &["doc"])), t("links_remove", "Remove all links", "Remove every link in the document (or on the given pages). Undoable.") @@ -1332,7 +1343,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 +1375,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() diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 25fae53238..fcdce0efc4 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2107,6 +2107,162 @@ fn destinations_are_listed_with_their_position() { assert_eq!((links["links"][2]["url"].clone(), links["links"][2].get("destination")), (json!("https://example.com"), None)); } +/// Web, `www.` and email addresses written as plain page text, which the viewer opens on a click +/// (#4207), are listed by link_list with text_links: true; one under a link annotation is not. +#[test] +fn addresses_written_as_page_text_are_listed_with_text_links() { + let dir = workdir("text-links"); + let content = [ + (700, "Visit https://example.com/plain-text-url."), + (650, "or www.example.com/no-scheme"), + (600, "Write to someone@example.com, please"), + (550, "https://example.org/covered"), + (500, "Version 1.2.3 and file:///etc/passwd"), + ] + .iter() + .map(|(y, s)| format!("BT /F1 14 Tf 72 {y} Td ({s}) Tj ET\n")) + .collect::(); + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /Annot /Subtype /Link /Rect [60 545 400 565] /Border [0 0 0] /A << /S /URI /URI (https://example.org/annotation) >> >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("text.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "text.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(&mut a, "link_list", json!({ "doc": doc })).get("text_links"), None, "only on request"); + let listed = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = listed["text_links"].as_array().unwrap(); + let urls: Vec<&str> = found.iter().map(|l| l["url"].as_str().unwrap()).collect(); + assert_eq!(urls, ["https://example.com/plain-text-url", "https://www.example.com/no-scheme", "mailto:someone@example.com"], "{listed}"); + assert!(found.iter().all(|l| l["page"] == 1 && l.get("blocked").is_none()), "{listed}"); + // Rectangles from the top-left of the page: the first line's baseline is 792 − 700 = 92 down. + let r = found[0]["rects"][0].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>(); + assert!(r[0] > 100.0 && r[0] < 140.0 && r[1] < 92.0 && r[3] > 80.0, "{r:?}"); +} + +/// One PDF of `pages` pages that all draw `content` in Helvetica. +fn same_text_pages(pages: usize, content: &str) -> Vec { + let kids: String = (0..pages).map(|k| format!("{} 0 R ", k + 5)).collect(); + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + format!("<< /Type /Pages /Kids [{kids}] /Count {pages} /MediaBox [0 0 612 792] >>"), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + ]; + objs.extend((0..pages).map(|_| "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 4 0 R >> >> /Contents 3 0 R >>".to_string())); + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + pdf +} + +/// Review on #4472: link_list with text_links reads a share of a large document's pages per +/// call and says where to go on, so a 10,000-page document is never read all at once; a call +/// also stops after the page that brings it to 2,000 addresses. +#[test] +fn text_links_of_a_large_document_are_read_a_share_at_a_time() { + let dir = workdir("text-links-large"); + std::fs::write(dir.join("long.pdf"), same_text_pages(10_000, "BT /F1 14 Tf 72 700 Td (See https://example.com/page now) Tj ET\n")).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "long.pdf" }))["doc"].as_u64().unwrap(); + let first = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + let found = first["text_links"].as_array().unwrap(); + assert_eq!(found.len(), 100, "the first 100 pages"); + assert_eq!((found[0]["page"].clone(), found[99]["page"].clone()), (json!(1), json!(100))); + assert_eq!(first["text_links_next_page"], json!(101)); + let last = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 9_951 })); + let found = last["text_links"].as_array().unwrap(); + assert_eq!((found.len(), found[0]["page"].clone()), (50, json!(9_951))); + assert_eq!(last.get("text_links_next_page"), None, "nothing after the last page"); + for from in [0, -1, 10_001, i64::MAX] { + err(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": from })); + } + + // 52 lines of 10 addresses: 520 on a page, kept to 512; the fourth page brings a call to + // 2,048, so it stops there. + let line = "x@example.org ".repeat(10); + let content: String = (0..52).map(|k| format!("BT /F1 6 Tf 20 {} Td ({line}) Tj ET\n", 760 - k * 7)).collect(); + std::fs::write(dir.join("dense.pdf"), same_text_pages(6, &content)).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "dense.pdf" }))["doc"].as_u64().unwrap(); + let dense = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true })); + assert_eq!(dense["text_links"].as_array().unwrap().len(), 4 * 512); + assert_eq!(dense["text_links_next_page"], json!(5)); + let rest = ok(&mut a, "link_list", json!({ "doc": doc, "text_links": true, "text_links_from": 5 })); + assert_eq!((rest["text_links"].as_array().unwrap().len(), rest.get("text_links_next_page")), (2 * 512, None)); +} + +/// An address on a layer the viewer hides is not a text link, and Create links from URLs makes +/// no link there: link_list and links_from_urls read the page text with the layers as layer_set +/// left them (ISO 32000-2 §8.11.3.1: hidden optional content "shall not be drawn"). +#[test] +fn text_links_leave_out_addresses_on_hidden_layers() { + let dir = workdir("text-links-layers"); + let content = + "BT /F1 14 Tf 72 700 Td (https://example.com/shown) Tj ET\n/OC /L1 BDC BT /F1 14 Tf 72 650 Td (https://example.com/layered) Tj ET EMC\n"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /Order [6 0 R] >> >> >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> /Properties << /L1 6 0 R >> >> /Contents 4 0 R >>".into(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /OCG /Name (Addresses) >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + std::fs::write(dir.join("layers.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "layers.pdf" }))["doc"].as_u64().unwrap(); + let urls = |a: &mut Automation| -> Vec { + let listed = ok(a, "link_list", json!({ "doc": doc, "text_links": true })); + listed["text_links"].as_array().unwrap().iter().map(|l| l["url"].as_str().unwrap().to_owned()).collect() + }; + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": false })); + assert_eq!(urls(&mut a), ["https://example.com/shown"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown"]), "no link over the hidden address: {created}"); + ok(&mut a, "edit_undo", json!({ "doc": doc })); + ok(&mut a, "layer_set", json!({ "doc": doc, "layer": "Addresses", "visible": true })); + assert_eq!(urls(&mut a), ["https://example.com/shown", "https://example.com/layered"]); + let created = ok(&mut a, "links_from_urls", json!({ "doc": doc })); + assert_eq!(created["created"], json!(["https://example.com/shown", "https://example.com/layered"]), "{created}"); +} + #[test] fn bookmarks_from_structure_through_tools() { let dir = workdir("bookmarks-structure"); @@ -2818,6 +2974,32 @@ 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] @@ -5165,6 +5347,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") @@ -9046,6 +9262,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"); @@ -9122,7 +9378,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"] { @@ -9333,6 +9590,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"); @@ -10723,6 +11068,32 @@ fn a_missing_path_under_the_root_names_where_it_was_looked_for() { let _ = std::fs::remove_dir_all(base); } +/// `doc_print` takes a paper size of its own besides the named ones, in inches, millimetres, +/// centimetres or points, either way round; a size it can't read or outside 1 to 200 inches is +/// refused. +#[test] +fn doc_print_takes_a_paper_size_of_its_own() { + let dir = workdir("print-paper-size"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + for (paper, want) in + [("8.5x14in", (612.0, 1008.0)), ("297x210mm", (595.28, 841.89)), ("10 x 15 cm", (283.46, 425.2)), ("288x432PT", (288.0, 432.0))] + { + let file = format!("{}.pdf", want.0); + ok(&mut a, "doc_print", json!({ "doc": doc, "pages": "1", "paper": paper, "orientation": "portrait", "path": file })); + let bytes = std::fs::read(dir.join(&file)).unwrap(); + let out = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&out)[0].display_size(&out); + assert!((page.0 - want.0).abs() < 0.5 && (page.1 - want.1).abs() < 0.5, "{paper}: {page:?}"); + } + for paper in ["8.5xbroad", "8.5x14", "0.5x11in", "8.5x300in", "-8.5x11in", "NaNx11in"] { + match a.call("doc_print", &json!({ "doc": doc, "paper": paper, "path": "x.pdf" })) { + Err(ToolError::InvalidArgs(m)) => assert!(m.contains("unknown paper"), "{paper}: {m}"), + other => panic!("{paper}: {other:?}"), + } + } +} + /// `doc_print` prints as image: pictures at `dpi`, grey with `grayscale`; a resolution outside /// 72–600 is refused. #[test] @@ -10761,6 +11132,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"); @@ -11700,6 +12095,34 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec { write_full(&doc, &SaveOptions::default()).unwrap() } +/// text_extract says when a page's text is incomplete because its content was cut at the +/// renderer's limits (#623); a page read whole carries no such mark. +#[test] +fn text_extract_says_when_a_pages_content_was_cut() { + use std::io::Write as _; + let dir = workdir("cut-content"); + // 288 MiB of whitespace, past the renderer's 256 MiB per-stream limit, as one Flate stream. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + let chunk = vec![0u8; 1 << 20]; + for _ in 0..288 { + z.write_all(&chunk).unwrap(); + } + let stream = z.finish().unwrap(); + let mut pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 300] /Contents 4 0 R >> endobj\n".to_vec(); + pdf.extend_from_slice(format!("4 0 obj << /Filter /FlateDecode /Length {} >> stream\n", stream.len()).as_bytes()); + pdf.extend_from_slice(&stream); + pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + std::fs::write(dir.join("cut.pdf"), pdf).unwrap(); + let mut a = auto(&dir); + let cut = ok(&mut a, "doc_open", json!({ "path": "cut.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": cut })); + assert_eq!(out["pages"][0]["truncated"], json!(true), "{out}"); + let whole = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let out = ok(&mut a, "text_extract", json!({ "doc": whole, "pages": [1] })); + assert!(out["pages"][0].get("truncated").is_none(), "{out}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// #3005: a table and a small signature drawn inside one transparency-group Form XObject (how /// HTML-to-PDF converters write a block with a translucent picture). Edit a PDF sees one piece /// of artwork and no text; `image_edit ungroup` draws it in place with the same pixels, its diff --git a/crates/automation/tests/it/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs index c017ff78d3..9835231c63 100644 --- a/crates/automation/tests/it/hostile_args.rs +++ b/crates/automation/tests/it/hostile_args.rs @@ -11,9 +11,10 @@ //! //! 1. **Nothing panics.** A panic in a tool is a crashed MCP server or CLI (AGENTS.md §4), so //! every call must come back as `Ok` or as a `ToolError` the agent can read. -//! 2. **Nothing escapes `--root`.** Every path argument is tried with `..` traversal, absolute -//! paths, Windows drive and UNC paths, and a planted symbolic link. The call must fail, and -//! afterwards nothing outside the root may have been created. +//! 2. **Nothing escapes `--root` or the automation roots.** Every path argument is tried with `..` +//! traversal, absolute paths, Windows drive and UNC paths, and a planted symbolic link, under +//! both `--root` and `--automation-read-root`/`--automation-write-root` (#4003). The call must +//! fail, and afterwards nothing outside the root may have been created. //! //! The second is the mechanized form of the D3 audit. Reading the code says every handler resolves //! its own paths and that no tool dispatches another internally; this keeps that true as tools are @@ -22,6 +23,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; +use pdfcraft_automation::file_access::AutomationRoots; use pdfcraft_automation::{Automation, ToolError, tools}; use serde_json::{Value, json}; @@ -84,6 +86,7 @@ fn zoo() -> Vec { /// bug can only touch what the test made. `linked`: the root holds `link-out`, a symbolic link /// to `base`. fn escapes(base: &std::path::Path, linked: bool) -> Vec { + let root = base.join("root"); let mut out: Vec = vec![ "../outside.pdf".into(), "../../outside.pdf".into(), @@ -93,13 +96,16 @@ fn escapes(base: &std::path::Path, linked: bool) -> Vec { base.join("outside.pdf").to_string_lossy().into_owned(), base.join("new-outside.pdf").to_string_lossy().into_owned(), base.join("sub").join("new-outside.pdf").to_string_lossy().into_owned(), + root.join("..").join("outside.pdf").to_string_lossy().into_owned(), ]; if linked { out.push("link-out/outside.pdf".into()); + out.push(root.join("link-out").join("outside.pdf").to_string_lossy().into_owned()); } // Backslashes separate only on Windows; elsewhere this is an ordinary file name in the root. if cfg!(windows) { out.push(r"..\..\outside.pdf".into()); + out.push(r"\\.\pipe\pdfcraft-hostile-escape.pdf".into()); } out } @@ -220,6 +226,20 @@ fn empty_dir(dir: &Path) { } } +/// `action_run` keeps going when one input path is refused and reports that refusal on the file. +/// That is still a confinement refusal: every file has an error naming the boundary, and none +/// has an output. +fn per_file_confinement_refusal(content: &[pdfcraft_automation::Content]) -> bool { + let Some(files) = content.iter().find_map(|c| match c { + pdfcraft_automation::Content::Json(v) => v.get("files").and_then(Value::as_array), + pdfcraft_automation::Content::Png { .. } => None, + }) else { + return false; + }; + !files.is_empty() + && files.iter().all(|file| file.get("output").is_none() && file.get("error").and_then(Value::as_str).is_some_and(|e| e.contains("outside"))) +} + /// Real values for the arguments a tool checks before the others, so a hostile value reaches the /// argument it is meant for instead of failing on a placeholder. `sign_document` checks its /// digital ID first and its document second: this ID opens with its password, and each call gets @@ -234,6 +254,12 @@ fn fixture(a: &mut Automation, root: &Path, tool: &str) -> serde_json::Map { + let args = json!({ "steps": [{ "step": "set_title", "arg": "Confined" }] }); + args.as_object().cloned().unwrap_or_default() + } _ => serde_json::Map::new(), } } @@ -313,28 +339,34 @@ fn no_tool_escapes_the_root() { } }; - let mut a = Automation::new().with_root(&root).expect("root"); - - // A real document, so tools reach their path arguments instead of failing on `doc` first. - let doc = blank_doc(&mut a); + let roots = AutomationRoots::new(Some(&root), Some(&root)).expect("roots"); + let modes = [("--root", Automation::new().with_root(&root).expect("root")), ("automation roots", Automation::new().with_automation_roots(roots))]; let mut leaked = Vec::new(); let mut tried = 0usize; let mut refused = 0usize; - for def in tools() { - for (prop, is_list) in path_props(&def) { - for escape in escapes(&base, linked) { - let mut obj = plausible_with(&def, Some(doc)); - obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); - let args = Value::Object(obj); - tried += 1; - match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { - // A path that leaves the root must be refused. Succeeding means the tool - // either used it or ignored an argument it declares — both are faults. - Ok(Ok(_)) => leaked.push(format!("{} accepted {prop}={escape:?}", def.name)), - Ok(Err(_)) => refused += 1, - Err(_) => leaked.push(format!("{} panicked on {prop}={escape:?}", def.name)), + for (mode, mut a) in modes { + // A real document, so tools reach their path arguments instead of failing on `doc` first. + let doc = blank_doc(&mut a); + + for def in tools() { + for (prop, is_list) in path_props(&def) { + for escape in escapes(&base, linked) { + let mut obj = plausible_with(&def, Some(doc)); + obj.insert(prop.clone(), if is_list { json!([escape]) } else { json!(escape) }); + let args = Value::Object(obj); + tried += 1; + match catch_unwind(AssertUnwindSafe(|| a.call(def.name, &args))) { + // A path that leaves the root must be refused. Succeeding means the tool + // either used it or ignored an argument it declares — both are faults. + // action_run reports a refused input inside an otherwise successful + // result, one error per file, and writes nothing for that file. + Ok(Ok(content)) if per_file_confinement_refusal(&content) => refused += 1, + Ok(Ok(_)) => leaked.push(format!("{mode}: {} accepted {prop}={escape:?}", def.name)), + Ok(Err(_)) => refused += 1, + Err(_) => leaked.push(format!("{mode}: {} panicked on {prop}={escape:?}", def.name)), + } } } } diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs index 92e54c99a6..f8e3f15f6d 100644 --- a/crates/automation/tests/it/main.rs +++ b/crates/automation/tests/it/main.rs @@ -17,4 +17,5 @@ mod optimize_content_boundaries; mod redact_after_stray_q_form; mod redact_text_after_q; mod render_warnings; +mod text_copy; mod unreadable_objects; diff --git a/crates/automation/tests/it/text_copy.rs b/crates/automation/tests/it/text_copy.rs new file mode 100644 index 0000000000..c635fba148 --- /dev/null +++ b/crates/automation/tests/it/text_copy.rs @@ -0,0 +1,106 @@ +//! `text_extract` and `text_find` on a page of the cases text copy has to get right: words drawn out +//! of order, runs of different sizes on one baseline, a ligature glyph named in the encoding and a +//! line turned upwards in a row of text. + +use std::path::PathBuf; + +use pdfcraft_automation::{Automation, Content}; +use serde_json::{Value, json}; + +/// A fresh directory for one test. +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pdfcraft-text-copy-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn ok(a: &mut Automation, tool: &str, args: Value) -> Value { + match a.call(tool, &args) { + Ok(mut c) => match c.remove(0) { + Content::Json(v) => v, + other => panic!("{tool}: expected JSON, got {other:?}"), + }, + Err(e) => panic!("{tool} {args}: {e}"), + } +} + +/// One page of labelled rows (`/F1` Helvetica, `/F2` Helvetica with code 128 named `/fi`): +/// 01 `ofce`; 06 `exam-` over `ple`; 07 two columns of two lines; 08 "second" drawn before +/// "first", to its right; 11 a 14 pt invisible word and a 9 pt note on one baseline; 12 letter +/// spacing; 13 "up" set upwards; 14 two words placed by `Td`. +fn copy_pdf() -> Vec { + let rows = [ + ("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"), + ("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"), + ("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"), + ("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"), + ("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"), + ("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"), + ("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"), + ("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"), + ]; + let mut body = String::new(); + let mut y = 740; + for (tag, row) in rows { + body += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", row.replace("{Y}", &y.to_string())); + y -= match tag { + "06" | "13" => 48, + "07" => 44, + _ => 38, + }; + } + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".to_string(), + format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding << /Type /Encoding /BaseEncoding /WinAnsiEncoding /Differences [128 /fi] >> >>" + .to_string(), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Copy keeps a baseline on one line in left-to-right order, columns column by column, the +/// ligature as its letters and the turned line in its row; Find matches what Copy reads, and its +/// rectangles cover the glyphs it names. +#[test] +fn text_extract_and_find_read_one_line_per_baseline() { + let dir = tmp("baseline"); + std::fs::write(dir.join("copy.pdf"), copy_pdf()).unwrap(); + let mut a = Automation::new().with_root(&dir).unwrap(); + let doc = ok(&mut a, "doc_open", json!({ "path": "copy.pdf" }))["doc"].as_u64().unwrap(); + let text = ok(&mut a, "text_extract", json!({ "doc": doc })); + assert_eq!( + text["pages"][0]["text"], + "01 office\n06 exam-\nple\n07 left one\nleft two\nright one\nright two\n08 first second\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words" + ); + let find = |a: &mut Automation, q: &str| ok(a, "text_find", json!({ "doc": doc, "query": q })); + let first_second = find(&mut a, "first second"); + assert_eq!(first_second["count"], 1); + assert_eq!(first_second["matches"][0]["text"], "first second"); + // One rectangle across both words, from the left edge of "first" (x = 90) past the right edge of "second". + let rects = first_second["matches"][0]["rects"].as_array().unwrap(); + assert_eq!(rects.len(), 1, "{rects:?}"); + let x = |k: usize| rects[0][k].as_f64().unwrap(); + assert!((x(0) - 90.0).abs() < 0.5 && x(2) > 200.0, "{rects:?}"); + let office = find(&mut a, "office"); + assert_eq!(office["count"], 1); + assert_eq!(office["matches"][0]["text"], "office"); + assert_eq!(find(&mut a, "hidden <- invisible")["count"], 1); + assert_eq!(find(&mut a, "left one right one")["count"], 0, "the columns stay apart"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index bef0d4f543..8f9d3a8a5b 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -2,7 +2,8 @@ use std::collections::HashSet; use super::reader::find; use super::state::{ - LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, loaded_bytes, note_overlay_read, objstm_member, overlay_reads, + LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, ScanLengths, Slot, SourceIdentity, loaded_bytes, loading, note_overlay_read, objstm_member, + overlay_reads, }; use super::xref::{Revision, XrefEntry, XrefIndex}; use super::{ @@ -705,23 +706,7 @@ impl Document { match self.entries.lookup(num, &self.data) { None | Some(XrefEntry::Free { .. }) => Ok(Object::Null), Some(XrefEntry::InFile { offset, .. }) => { - let resolve = |r: ObjRef| { - if self.overlay.contains_key(&r.num) { - note_overlay_read(); - } - // A length resolved through this document's edits counts as an edit read - // on every reuse. - if let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { - if edits { - note_overlay_read(); - } - return known; - } - let edits_read = overlay_reads(); - let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); - LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); - length - }; + let resolve = |r: ObjRef| self.stream_length(r); let off = offset as usize; let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), @@ -795,6 +780,29 @@ impl Document { Ok(stm) } + /// An indirect stream `/Length`, read through the loader's guard against cycles. Kept in + /// [`LENGTHS`] only during a load, which clears it when it ends: a scan of the file from + /// [`Self::objstm`] can come here without one. + fn stream_length(&self, r: ObjRef) -> Option { + if self.overlay.contains_key(&r.num) { + note_overlay_read(); + } + let cached = loading(); + // A length resolved through this document's edits counts as an edit read on every reuse. + if cached && let Some((known, edits)) = LENGTHS.with(|m| m.borrow().get(&r).copied()) { + if edits { + note_overlay_read(); + } + return known; + } + let edits_read = overlay_reads(); + let length = self.try_get_reference(r).ok().and_then(|o| o.as_int()); + if cached { + LENGTHS.with(|m| m.borrow_mut().insert(r, (length, overlay_reads() != edits_read))); + } + length + } + /// Last-resort lookup: scan the file for `num G obj`. What isn't there is remembered: each miss /// reads the whole file, and a file can refer to such an object from everywhere. fn scan_for(&self, num: u32) -> Option<(ObjRef, Object)> { @@ -805,6 +813,8 @@ impl Document { super::tests::FILE_SCANS.with(|scans| scans.set(scans.get() + 1)); let needle = format!("{num} "); let data = &self.data; + let budget = ScanLengths::enter(); + let resolve = |r: ObjRef| if budget.take() { self.stream_length(r) } else { None }; let mut found = None; let mut i = 0; while let Some(p) = find(data, needle.as_bytes(), i, data.len()) { @@ -812,7 +822,11 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams) + // An indirect `/Length` is resolved, as at the object's offset, or a stream that ends + // in a misspelt `endstream` is searched on into a later object's. A header that parses + // as far as the `/Length` starts with `num` itself, after white space. A budget shared + // with the scans this leads to bounds how often the file is read again ([`ScanLengths`]). + if let Ok((id, o)) = parse_indirect_shared(data, p, &resolve, &self.endstreams) && id.num == num { found = Some((id, o)); // keep the last (newest) definition diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index af71f95359..c5bd85c065 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -11,6 +11,52 @@ thread_local! { pub(super) static LENGTHS: std::cell::RefCell, bool)>> = std::cell::RefCell::new(HashMap::new()); /// Unsaved edits read on this thread (`Document::try_get`, and a `/Length` in `Document::load`). static OVERLAY_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep `Document::scan_for` is nested on this thread, and how many indirect `/Length` + /// values the outermost scan and those inside it have resolved ([`ScanLengths`]). + static SCAN_LENGTHS: std::cell::Cell<(u32, u32)> = const { std::cell::Cell::new((0, 0)) }; +} + +/// The indirect `/Length` values a scan of the file for an object may resolve, shared with the +/// scans it leads to. Each header in the file that starts with the object's number is parsed, and +/// a `/Length` that names an object the cross-reference table cannot place scans the file for that +/// one: a damaged file with many such headers, each naming another missing object, would read the +/// whole file once per header. Past the budget a `/Length` is left unresolved, and the stream is +/// read as before resolution, by its `endstream`. +pub(super) struct ScanLengths; + +impl ScanLengths { + /// A real object is defined once per revision, and its `/Length` once per definition. + pub(super) const MAX: u32 = 32; + + /// Marks a scan as running; the outermost one starts the budget afresh. + pub(super) fn enter() -> Self { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_add(1), if depth == 0 { 0 } else { used })); + }); + ScanLengths + } + + /// Whether one more `/Length` may be resolved, counting it if so. + pub(super) fn take(&self) -> bool { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + if used >= Self::MAX { + return false; + } + c.set((depth, used.saturating_add(1))); + true + }) + } +} + +impl Drop for ScanLengths { + fn drop(&mut self) { + SCAN_LENGTHS.with(|c| { + let (depth, used) = c.get(); + c.set((depth.saturating_sub(1), used)); + }); + } } /// Unsaved edits read so far on this thread: a load during which this changes depended on one. @@ -42,6 +88,12 @@ impl LoadGuard { } } +/// Whether an object is being loaded on this thread: [`LENGTHS`] holds only while one is, since +/// it is cleared when the outermost load ends. +pub(super) fn loading() -> bool { + LOADING.with(|l| !l.borrow().is_empty()) +} + /// What loading a file object gave, kept in the document's cache either way. pub(super) type Loaded = Result, CosError>; diff --git a/crates/cos/src/document/tests.rs b/crates/cos/src/document/tests.rs index a6116762ea..274672bc7c 100644 --- a/crates/cos/src/document/tests.rs +++ b/crates/cos/src/document/tests.rs @@ -30,6 +30,67 @@ fn what_a_scan_of_the_file_did_not_find_is_remembered() { assert_eq!(FILE_SCANS.with(std::cell::Cell::get), 1, "a clone has the same file"); } +/// An object found by a scan of the file, because its cross-reference offset is wrong, has its +/// indirect `/Length` resolved as at the right offset. Unresolved, a stream ending in a misspelt +/// `endstream` (`endstrea`) was searched on into a later object's `endstream`, and took that +/// object's header and data as its own. +#[test] +fn a_scanned_stream_resolves_its_indirect_length() { + let mut out = build( + &[ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [] /Count 0 >>", + "<< /Length 4 0 R >>\nstream\nHELLO\nendstrea", + "5", + "<< /Length 5 >>\nstream\nWORLD\nendstream", + ], + "/Root 1 0 R", + ); + // Point object 3's entry at the catalog, so only the scan finds it. + let at = |needle: &[u8], out: &[u8]| out.windows(needle.len()).position(|w| w == needle).unwrap(); + let catalog = format!("{:010} 00000 n ", at(b"1 0 obj", &out)); + let entry = at(format!("{:010} 00000 n ", at(b"3 0 obj", &out)).as_bytes(), &out); + out.splice(entry..entry + catalog.len(), catalog.bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + let stream = doc.try_get(3).unwrap(); + let Object::Stream(s) = &*stream else { panic!("stream expected: {stream:?}") }; + assert_eq!(&s.raw[..], b"HELLO"); +} + +/// A scan of the file resolves only so many indirect `/Length` values. Each header that starts +/// with the sought number is parsed, and a `/Length` naming an object the table cannot place scans +/// the file for that one: with a hundred such headers, each naming another misplaced object, one +/// lookup read the whole file a hundred and one times. +#[test] +fn a_scan_of_the_file_resolves_a_bounded_number_of_lengths() { + const HEADERS: u32 = 100; + let mut out = b"%PDF-1.7\n".to_vec(); + let catalog = out.len(); + out.extend_from_slice(b"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n"); + let pages = out.len(); + out.extend_from_slice(b"2 0 obj << /Type /Pages /Kids [] /Count 0 >> endobj\n"); + // Object 3 is defined only by headers inside this string, each with its own misplaced /Length. + out.extend_from_slice(b"4 0 obj (\n"); + for k in 0..HEADERS { + out.extend_from_slice(format!("3 0 obj << /Length {} 0 R >> stream\nX\nendstream endobj\n", 10 + k).as_bytes()); + } + out.extend_from_slice(b") endobj\n"); + let size = 10 + HEADERS; + let xref = out.len(); + out.extend_from_slice(format!("xref\n0 {size}\n0000000000 65535 f \n").as_bytes()); + for num in 1..size { + let at = if num == 2 { pages } else { catalog }; + out.extend_from_slice(format!("{at:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer << /Size {size} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes()); + let doc = Document::open(Arc::new(out)).unwrap(); + FILE_SCANS.with(|scans| scans.set(0)); + let three = doc.try_get(3).unwrap(); + assert!(matches!(&*three, Object::Stream(_)), "{three:?}"); + let scans = FILE_SCANS.with(std::cell::Cell::get); + assert!(scans <= 1 + super::state::ScanLengths::MAX as usize, "{scans} scans of the file"); +} + /// An object can be nothing but a reference to another. A chain of them is followed, and one that /// goes round ends in what isn't a dictionary or a name, without hanging. #[test] diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index d2cb904871..49793d950c 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -437,7 +437,7 @@ pub(crate) type EndstreamIndex = std::sync::OnceLock>; fn endstream_offsets(data: &[u8]) -> Vec { #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len())); - data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect() + data.windows(9).enumerate().filter(|(i, w)| *w == b"endstream" && endstream_at(data, *i)).map(|(i, _)| i).collect() } fn parse_indirect_in( @@ -491,26 +491,24 @@ fn parse_indirect_in( Some(Object::Ref(r)) => resolve_length(*r), _ => None, }; - let valid = |len: i64| -> bool { - if len < 0 { - return false; - } - let end = start.saturating_add(len as usize); - if end > data.len() { - return false; - } - let mut after = Lexer::new(data, end); - after.eat_keyword(b"endstream") - }; - // A /Length that fits in the file, for a stream whose `endstream` is missing or misspelt. + // A /Length that fits in the file (not negative, and no wrap on a 32-bit target). let fits = declared.and_then(|l| usize::try_from(l).ok()).filter(|l| start.checked_add(*l).is_some_and(|end| end <= data.len())); - let len = match declared { - Some(l) if valid(l) => l as usize, + // What follows the /Length bytes, white space aside. + let after = |len: usize| { + let mut after = Lexer::new(data, start.saturating_add(len)); + after.skip_ws(); + data.get(after.pos..).unwrap_or_default() + }; + let len = match fits { + // The /Length ends at `endstream`, or where it would be: misspelt (`endstrea`), or + // left out before `endobj` or the end of the file. The stream is the bytes its /Length + // gives, "how many bytes of the PDF file are used for the stream's data" (ISO 32000-2 + // §7.3.8.2), as other readers take it. A producer that wrote `endstrea` after every + // stream lost every font and every page's content, and searching on would run into a + // later object's `endstream`. + Some(l) if at_stream_end(after(l)) => l, // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). With none - // left in the file, a /Length that fits is the stream's extent, "how many bytes of the - // PDF file are used for the stream's data" (ISO 32000-2 §7.3.8.2), as other readers - // take it: a producer that wrote `endstrea` after every stream lost every font and - // every page's content. + // left in the file, a /Length that fits is still the stream's extent. _ => match find_endstream(data, start, endstreams) { Some(end) => end - start, None => fits.ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?, @@ -529,6 +527,45 @@ fn parse_indirect_in( Ok((id, obj)) } +/// Whether `rest`, what follows a stream's /Length bytes with white space skipped, is where the +/// stream ends. A whole `endstream` token is: a token runs on through regular characters (ISO +/// 32000-2 §7.2.3), so payload such as `endstreamer` is not one. A misspelt `endstream` +/// (`endstrea`), an `endobj` with the `endstream` left out, or both, end it only where the object +/// ends as well, before the end of the data, another object's `N G obj` header, or `xref`, +/// `trailer` or `startxref`: payload such as `endobj more` or `endobject`, where a wrong /Length +/// stops, is not, and the search for `endstream` runs instead. So does anything else but the end +/// of the data. +fn at_stream_end(rest: &[u8]) -> bool { + let mut lx = Lexer::new(rest, 0); + if lx.eat_keyword(b"endstream") { + return true; + } + let misspelt = lx.eat_keyword(b"endstrea"); + let endobj = lx.eat_keyword(b"endobj"); + lx.skip_ws(); + if lx.pos >= rest.len() { + return true; + } + if !(misspelt || endobj) { + return false; + } + if [&b"xref"[..], b"trailer", b"startxref"].iter().any(|kw| Lexer::new(rest, lx.pos).eat_keyword(kw)) { + return true; + } + let number = |lx: &mut Lexer| { + lx.skip_ws(); + let t = lx.token(); + !t.is_empty() && t.iter().all(u8::is_ascii_digit) + }; + number(&mut lx) && number(&mut lx) && lx.eat_keyword(b"obj") +} + +/// Whether a whole `endstream` token starts at `at` in `data`: one followed by a regular +/// character (`endstreamer`) is payload (ISO 32000-2 §7.2.3). +fn endstream_at(data: &[u8], at: usize) -> bool { + data.get(at..).is_some_and(|d| d.starts_with(b"endstream")) && !data.get(at.saturating_add(9)).copied().is_some_and(is_regular) +} + /// Offset where stream data ends (before the EOL preceding "endstream"). fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option { let at = match endstreams { @@ -538,7 +575,7 @@ fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) } None => { let hay = data.get(from..)?; - let found = hay.windows(9).position(|w| w == b"endstream"); + let found = hay.windows(9).enumerate().position(|(i, w)| w == b"endstream" && endstream_at(hay, i)); #[cfg(test)] ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9))); found? + from @@ -683,6 +720,109 @@ mod tests { assert!(parse_indirect(b"7 0 obj\n<< >>\nstream\nHELLO\nendstrea\nendobj\n", 0, &|_| None).is_err(), "no /Length"); } + /// A stream ending in `endstrea` is its /Length even when a later object does have an + /// `endstream`: the search for one would otherwise run on into that object and take its + /// header and data as this stream's. + #[test] + fn a_stream_without_endstream_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\r\nHELLO\r\nendstrea\rendobj\r\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let second = data.windows(7).position(|w| w == b"8 0 obj").unwrap(); + for (offset, want) in [(0, &b"HELLO"[..]), (second, b"WORLD")] { + let (_, o) = parse_indirect(data, offset, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected at {offset}") }; + assert_eq!(&s.raw[..], want, "at {offset}"); + } + // Through the loader's shared index of `endstream` offsets as well. + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(data, None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// The same for a stream whose `endstream` is left out before `endobj`: the later object's + /// `endstream` is not this stream's end. + #[test] + fn a_stream_ending_at_endobj_is_not_read_into_a_later_one() { + let data = b"7 0 obj\n<< /Length 5 >>\nstream\nHELLO\nendobj\n8 0 obj\n<< /Length 5 >>\nstream\nWORLD\nendstream\nendobj\n"; + let (_, o) = parse_indirect(data, 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO"); + } + + /// A wrong /Length that stops before payload bytes beginning like an end marker (`endobject`, + /// `endstreak`, `endstreamer`) is not taken for the stream's end: a token runs on through + /// regular characters (ISO 32000-2 §7.2.3), so the search for `endstream` finds the whole + /// payload, and skips `endstreamer` too, by itself and through the loader's shared index. + #[test] + fn a_length_ending_inside_a_longer_word_is_not_an_end_marker() { + for word in ["endobject", "endstreak", "endstreamer"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {word} more\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}"); + let index = EndstreamIndex::new(); + let (_, o) = parse_indirect_in(pdf.as_bytes(), None, 0, &|_| None, Some(&index)).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {word} more").as_bytes(), "{word}, indexed"); + } + // Whole markers still end it, followed by white space, a delimiter or the end of the data. + for tail in ["endobj\n", "endstrea%", "endobj"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{tail}"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{tail:?}"); + } + } + + /// A whole `endobj` or `endstrea` token where a wrong /Length stops is payload, not the + /// stream's end, unless the object ends there too: what follows is the end of the data, + /// another object's header, or `xref`, `trailer` or `startxref`. Otherwise the search for + /// `endstream` finds the whole payload. + #[test] + fn an_end_marker_inside_the_payload_does_not_end_the_stream() { + for payload in ["endobj more", "endstrea more", "endstrea endobj more", "endobj 12 more"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO {payload}\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(s.raw[..], *format!("HELLO {payload}").as_bytes(), "{payload}"); + } + // Where the object does end, the /Length is the stream, also with an `endstream` later. + for next in ["8 0 obj\n<< >>", "%c\n12 0 obj", "xref", "trailer", "startxref"] { + for marker in ["endobj", "endstrea\nendobj", "endstrea"] { + let pdf = format!("7 0 obj\n<< /Length 5 >>\nstream\nHELLO\n{marker}\n{next}\nstream\nWORLD\nendstream\nendobj\n"); + let (_, o) = parse_indirect(pdf.as_bytes(), 0, &|_| None).unwrap(); + let Object::Stream(s) = o else { panic!("stream expected") }; + assert_eq!(&s.raw[..], b"HELLO", "{marker:?} then {next:?}"); + } + } + } + /// Without an `endstream`, the /Length is trusted as written when it fits, also when it is + /// wrong, and an indirect one is resolved first; one that doesn't fit, a negative one and one + /// too large for any file are errors rather than wrapped into a short read. + #[test] + fn a_stream_without_endstream_takes_its_length_only_when_it_fits() { + let stream = |length: &str, tail: &str| format!("7 0 obj\n<< /Length {length} >>\nstream\nHELLO{tail}"); + let raw = |pdf: &str, lookup: &dyn Fn(ObjRef) -> Option| match parse_indirect(pdf.as_bytes(), 0, lookup) { + Ok((_, Object::Stream(s))) => Ok(s.raw.to_vec()), + Ok((_, other)) => panic!("{pdf:?} is {other:?}"), + Err(e) => Err(e), + }; + let none = |_: ObjRef| None; + // Wrong but fitting: the bytes it gives, not the bytes up to `endstrea`. + assert_eq!(raw(&stream("3", "\nendstrea\nendobj\n"), &none).unwrap(), b"HEL"); + // Exactly to the end of the file, and nothing at all. + assert_eq!(raw(&stream("5", ""), &none).unwrap(), b"HELLO"); + assert_eq!(raw(&stream("0", "\nendstrea\nendobj\n"), &none).unwrap(), b""); + // Indirect, resolved through the lookup. + let lookup = |r: ObjRef| (r == ObjRef::new(9, 0)).then_some(4); + assert_eq!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &lookup).unwrap(), b"HELL"); + assert!(raw(&stream("9 0 R", "\nendstrea\nendobj\n"), &none).is_err(), "an unresolved /Length"); + // One past the end, negative, the largest integer and one beyond it. + for length in ["6", "-1", "-9223372036854775808", "9223372036854775807", "18446744073709551621"] { + assert!(raw(&stream(length, ""), &none).is_err(), "/Length {length}"); + } + } + #[test] fn indirect_stream_with_a_space_before_the_line_break() { // `stream \r\n` as Microsoft Reporting Services writes it: the data starts after the diff --git a/crates/create/src/lib.rs b/crates/create/src/lib.rs index 149bd99d97..ef46ccb713 100644 --- a/crates/create/src/lib.rs +++ b/crates/create/src/lib.rs @@ -281,7 +281,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 +429,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 +653,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`. diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index 9d5a70ffcd..584b013503 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -316,6 +316,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/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..0cb7366716 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -802,10 +802,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 >>", @@ -817,11 +819,208 @@ fn flattening_keeps_an_undrawn_reply_to_a_flattened_comment() { "<< /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 @@ -9019,6 +9218,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. @@ -10980,8 +11225,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/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..b6c2d59cd1 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -567,32 +567,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); } } } @@ -761,8 +763,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 { @@ -1006,6 +1011,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/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/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..af34efff47 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 { diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 762e8ced2f..879601488a 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"), diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index c5444c35e0..e2b8a43e14 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 @@ -598,6 +599,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 +656,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)?, diff --git a/crates/engine/src/history.rs b/crates/engine/src/history.rs index 04d848eb8d..441fb7d1b5 100644 --- a/crates/engine/src/history.rs +++ b/crates/engine/src/history.rs @@ -96,6 +96,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope { | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. } + | Edit::FixHairlines { .. } // Recognized text is an invisible text layer stamped on its page, with its font in the // page's resources (#6310): a full re-inspection made applying one word cost seconds on // a large file. diff --git a/crates/engine/src/js.rs b/crates/engine/src/js.rs index dce233279c..0dd14a42b4 100644 --- a/crates/engine/src/js.rs +++ b/crates/engine/src/js.rs @@ -119,6 +119,49 @@ impl JsOutput { self.errors.push(e); } } + + /// Takes out the alerts, console lines, requests and errors. The record of the document + /// scripts' errors stays, so they are still reported once. + fn set_aside(&mut self) -> JsOutput { + JsOutput { + alerts: std::mem::take(&mut self.alerts), + console: std::mem::take(&mut self.console), + requests: std::mem::take(&mut self.requests), + errors: std::mem::take(&mut self.errors), + doc_errors: Vec::new(), + } + } + + /// Puts back what [`JsOutput::set_aside`] took out and returns what was added since. + fn put_back(&mut self, waiting: JsOutput) -> JsOutput { + let own = self.set_aside(); + self.alerts = waiting.alerts; + self.console = waiting.console; + self.requests = waiting.requests; + self.errors = waiting.errors; + own + } +} + +impl Session { + /// Runs `f` and returns its value with the script output `f` added to document `id`. The + /// output limits count that output alone, and the output that was waiting stays for + /// [`Session::take_js_output`]. When `f` fails, its output joins the waiting output. + pub fn with_own_js_output(&mut self, id: DocId, f: impl FnOnce(&mut Session) -> Result) -> Result<(T, JsOutput), E> { + let waiting = self.docs.iter_mut().find(|d| d.id == id).map(|d| d.js_output.set_aside()); + let result = f(self); + let mut doc = self.docs.iter_mut().find(|d| d.id == id); + let own = doc.as_mut().zip(waiting).map(|(d, w)| d.js_output.put_back(w)).unwrap_or_default(); + match result { + Ok(value) => Ok((value, own)), + Err(e) => { + if let Some(d) = doc { + d.js_output.append(own); + } + Err(e) + } + } + } } fn kind(k: FieldKind) -> pdfcraft_js::FieldType { @@ -342,8 +385,8 @@ impl Session { && let Some(field) = doc.form.iter().find(|f| f.name == t) && let Some(som) = crate::xfa::clickable_som(&cos, &tpl, field) { - self.apply(id, Edit::XfaEvent { som, activity: "click".into() })?; - let out = self.take_js_output(id); + // The click returns only the output of its own edit; output from opening or from earlier edits stays. + let ((), out) = self.with_own_js_output(id, |s| s.apply(id, Edit::XfaEvent { som, activity: "click".into() }))?; return Ok(Outcome { rc: true, alerts: out.alerts, @@ -570,6 +613,56 @@ mod review_tests { assert_eq!(reset_targets(&names, &requests), None, "a call without names resets the whole form"); } + /// A click returns its own alert when the output waiting in the document is full, and the + /// waiting output stays. + #[test] + fn a_click_returns_its_alert_when_the_waiting_output_is_full() { + let mut s = Session::new(); + let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()); + let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap(); + s.take_js_output(id); + s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output.alert(vec![Message::plain("waiting"); MAX_ALERTS]); + let o = s.run_javascript(id, "", Some("hello")).unwrap(); + assert_eq!(o.alerts.iter().map(|a| a.text.as_str()).collect::>(), ["Hello 2"]); + let out = s.take_js_output(id); + assert_eq!(out.alerts, vec![Message::plain("waiting"); MAX_ALERTS]); + } + + /// With the console full and closed, an operation still gets its own lines. Output of an + /// operation that fails joins the waiting output. + #[test] + fn output_limits_count_only_the_operations_own_output() { + let mut s = Session::new(); + let pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()); + let id = s.open("xfa.pdf", None, std::sync::Arc::new(pdf), None).unwrap(); + s.take_js_output(id); + fn output(s: &mut Session, id: DocId) -> &mut JsOutput { + &mut s.docs.iter_mut().find(|d| d.id == id).unwrap().js_output + } + output(&mut s, id).say(vec!["waiting".to_string(); MAX_CONSOLE_LINES + 1]); + let waiting = output(&mut s, id).console.clone(); + assert!(waiting.last().unwrap().starts_with("more console lines were left out")); + let ((), own) = s + .with_own_js_output::<_, ()>(id, |s| { + output(s, id).say(["mine".to_string()]); + output(s, id).errors.push("own error".into()); + Ok(()) + }) + .unwrap(); + assert_eq!(own.console, ["mine"]); + assert_eq!(own.errors, ["own error"]); + assert_eq!(output(&mut s, id).console, waiting); + assert!(output(&mut s, id).errors.is_empty()); + let failed = s.with_own_js_output::<(), _>(id, |s| { + output(s, id).errors.push("failed edit".into()); + Err("no") + }); + assert_eq!(failed.unwrap_err(), "no"); + let out = s.take_js_output(id); + assert_eq!(out.console, waiting); + assert_eq!(out.errors, ["failed edit"]); + } + /// A form of many calculated fields, each printing its own cap of console lines, keeps a /// bounded console: past the budget, lines are left out and one closing line says so. #[test] @@ -604,7 +697,8 @@ mod review_tests { }; let mut glyphs: Vec<_> = (0..n).map(|i| glyph(i, "_")).collect(); glyphs.push(glyph(n, "x")); - let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs }; + // Built by hand, not read from a page the renderer cut short, so the text is complete. + let text = pdfcraft_render::PageText { line_of: vec![0; glyphs.len()], space_before: vec![false; glyphs.len()], glyphs, truncated: false }; UNDERSCORE_CHARS_READ.with(|c| c.set(0)); let words = words_with(&text, |x, y| [x, y]); assert_eq!(words.iter().map(|(w, _)| w.len()).collect::>(), [n, 1]); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9be9666590..0dc7c231cb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -58,6 +58,7 @@ mod stamp_preview; #[cfg(test)] mod test_hooks; pub mod text_fonts; +pub mod text_links; mod text_preview; pub mod text_script; pub mod text_style_bookmarks; @@ -95,8 +96,8 @@ pub use pdfcraft_edit::added::{ }; pub use pdfcraft_edit::paragraph_align; pub use pdfcraft_edit::{ - Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, Ink as TextInk, - MarkKind, Watermark, + Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, Hairlines, HairlinesFixed, + HeaderFooter, Ink as TextInk, MarkKind, Watermark, }; pub use pdfcraft_forms::{ BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, diff --git a/crates/engine/src/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..b5db2312f8 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 { @@ -114,6 +114,15 @@ impl Session { .map_err(EditError::Write) } + /// How many hairlines [`Edit::FixHairlines`] would change on `pages` of `id`, and on how many + /// pages, without changing the document (the edit itself refuses when there is nothing to fix). + pub fn count_hairlines(&self, id: DocId, pages: &[usize], settings: &Hairlines) -> HairlinesFixed { + let Some(doc) = self.get(id) else { return HairlinesFixed::default() }; + let Some(editor) = doc.editor.as_ref() else { return HairlinesFixed::default() }; + let mut cos = editor.cos.clone(); + pdfcraft_edit::fix_hairlines(&mut cos, pages, settings).unwrap_or_default() + } + /// XFDF with these comments (0-based page, position in the page's `/Annots`) and their /// replies: what Copy puts on the clipboard; [`Edit::PasteComments`] takes it back in. pub fn export_comments(&self, id: DocId, which: &[(usize, usize)]) -> Result { diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index 711779903a..f6e75c2ba9 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -100,11 +100,11 @@ impl Session { } /// Web addresses in the text of every page (Create links from URLs): (page, one rectangle - /// per line in user space, URI). Text that already has a link over it is skipped. + /// per line in user space, URI). Text that already has a link over it is skipped, and so is + /// text on a layer the viewer hides: a link there would sit over nothing the reader can see. pub fn find_urls(&self, id: DocId) -> Vec<(usize, Vec<[f64; 4]>, String)> { let Some(doc) = self.get(id) else { return Vec::new() }; - let config = RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; - let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); let mut out = Vec::new(); for page in 0..doc.info.pages.len() { let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); @@ -133,6 +133,40 @@ impl Session { out } + /// The addresses written as plain text that the viewer makes clickable + /// ([`crate::text_links::find_text_links`]), leaving out any a link annotation lies over, from + /// the 0-based page `from` on. One call reads at most + /// [`MAX_TEXT_LINK_PAGES_PER_SCAN`](crate::text_links::MAX_TEXT_LINK_PAGES_PER_SCAN) pages + /// and stops after the page that brings it to + /// [`MAX_TEXT_LINKS_PER_SCAN`](crate::text_links::MAX_TEXT_LINKS_PER_SCAN) addresses; then + /// `next_page` says where to go on. + pub fn text_links(&self, id: DocId, from: usize) -> crate::text_links::TextLinkScan { + use crate::text_links::{MAX_TEXT_LINK_PAGES_PER_SCAN, MAX_TEXT_LINKS_PER_SCAN, TextLinkScan}; + let Some(doc) = self.get(id) else { return TextLinkScan::default() }; + // The viewer's settings, so text on a layer it hides is not a link. + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let mut scan = TextLinkScan::default(); + let pages = doc.info.pages.len(); + for (page, info) in doc.info.pages.iter().enumerate().skip(from).take(MAX_TEXT_LINK_PAGES_PER_SCAN) { + let res = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() }); + if let Some(text) = res.text { + let annotations: Vec<_> = doc.info.links.iter().filter(|l| l.page == page).collect(); + for link in crate::text_links::find_text_links(&text) { + if !annotations.iter().any(|l| link.covered_by(info, l.rect)) { + scan.links.push((page, link)); + } + } + } + let next = page.saturating_add(1); + let read = next.saturating_sub(from); + if next < pages && (read >= MAX_TEXT_LINK_PAGES_PER_SCAN || scan.links.len() >= MAX_TEXT_LINKS_PER_SCAN) { + scan.next_page = Some(next); + break; + } + } + scan + } + /// A page of the bookmark list in outline order: up to `limit` bookmarks from position `offset`. /// The document's own structure is read for it, so every bookmark can be reached by paging. pub fn bookmark_page(&self, id: DocId, offset: usize, limit: usize) -> Option { @@ -277,6 +311,8 @@ impl Session { /// with its number, author, type, date, text and replies, grouped by page. pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result>, EditError> { let doc = self.get(id).ok_or(EditError::NoDocument)?; + // The summary copies the comments' text out into a new document. + doc.check_copying()?; let text = comment_summary(&doc.name, &doc.info.annotations, sort); self.create_report(&format!("Summary of Comments on {}", doc.name), &text) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 57ad5829f3..f41d033abd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1647,11 +1647,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 +7950,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 +10504,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 +10528,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 +10541,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 +10565,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..90c587898e --- /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 } + } + + fn uris(s: &str) -> Vec { + find_text_links(&page_text(s)).into_iter().map(|l| l.uri).collect() + } + + #[test] + fn web_and_email_addresses_in_text_are_found() { + assert_eq!(uris("Visit https://example.com/plain-text-url today"), ["https://example.com/plain-text-url"]); + assert_eq!(uris("or http://example.org/a?b=c#d"), ["http://example.org/a?b=c#d"]); + assert_eq!(uris("Go to www.example.com/no-scheme now"), ["https://www.example.com/no-scheme"]); + assert_eq!(uris("Go to WWW.Example.com"), ["https://WWW.Example.com"]); + assert_eq!(uris("Write to someone@example.com please"), ["mailto:someone@example.com"]); + assert_eq!( + uris("a https://a.example\nb www.b.example\nc c@example.org"), + ["https://a.example", "https://www.b.example", "mailto:c@example.org"] + ); + } + + #[test] + fn sentence_punctuation_and_brackets_are_not_part_of_the_address() { + assert_eq!(uris("See https://example.com."), ["https://example.com"]); + assert_eq!(uris("See https://example.com/x, then"), ["https://example.com/x"]); + assert_eq!(uris("(see https://example.com/x)"), ["https://example.com/x"]); + assert_eq!(uris("See www.example.com!"), ["https://www.example.com"]); + assert_eq!(uris("Mail someone@example.com."), ["mailto:someone@example.com"]); + assert_eq!(uris("Ask (someone@example.com)."), ["mailto:someone@example.com"]); + } + + #[test] + fn text_that_only_looks_like_an_address_is_not_found() { + for s in ["example.com", "http://", "https://x", "www.", "user@localhost", "a@b.c", "notwww.example.com", "Version 1.2.3", "@example.com"] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + } + + /// Only `http`, `https` and `mailto` addresses are offered, whatever letter case another + /// scheme is written in: `javascript:`, `file:`, `data:` and any other scheme never become a + /// link. + #[test] + fn only_web_and_email_addresses_are_offered() { + for s in [ + "JaVaScRiPt:alert(1)", + "javascript:void(0)", + "file:///etc/passwd", + "FILE://server/share/report.pdf", + "ftp://ftp.example.com/pub/file.txt", + "data:text/html,example.com", + "data:text/html;base64,PGI+ZXhhbXBsZTwvYj4=", + "myapp://open?doc=example.com", + "vbscript:msgbox(1)", + "tel:+15551234567", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // An address inside one written with another scheme is not taken out of it and opened + // with a scheme the text doesn't say. + for s in [ + "ftp://www.example.com/pub", + "file://www.example.com/share", + "JavaScript:www.example.com", + "ftp://user@example.com/pub", + "smb://admin@example.org/share", + ] { + assert_eq!(uris(s), Vec::::new(), "{s}"); + } + // Every address offered opens with one of the three schemes, and passes the check every + // document link gets. + let offered = uris("a https://a.example b HTTP://B.EXAMPLE c www.c.example d d@example.org"); + assert_eq!(offered.len(), 4, "{offered:?}"); + for uri in offered { + let scheme = uri.split_once(':').map(|(s, _)| s.to_ascii_lowercase()).unwrap_or_default(); + assert!(["http", "https", "mailto"].contains(&scheme.as_str()), "{uri}"); + assert!(crate::links::document_url(&uri).is_ok(), "{uri}"); + } + } + + /// The text the hand appears over is the address that opens: the rectangle covers exactly + /// the characters of the address, the link check passes the address on unchanged, and the + /// only addition is the scheme a `www.` or email address is written without. + #[test] + fn the_address_opened_is_the_address_written() { + for (s, written, opened) in [ + ("Visit https://example.com/a?b=c&d=%20e#f now", "https://example.com/a?b=c&d=%20e#f", "https://example.com/a?b=c&d=%20e#f"), + ("Visit HTTPS://EXAMPLE.COM/Path now", "HTTPS://EXAMPLE.COM/Path", "HTTPS://EXAMPLE.COM/Path"), + ("Go to www.Example.com/X now", "www.Example.com/X", "https://www.Example.com/X"), + ("Mail Some.One+x@Example.org now", "Some.One+x@Example.org", "mailto:Some.One+x@Example.org"), + // Internationalized and punycode host names are kept as written; the confirmation + // shows the host and warns about mixed scripts (`links::display_host`). + ("See https://bücher.example/straße now", "https://bücher.example/straße", "https://bücher.example/straße"), + ("See https://xn--bcher-kva.example/ now", "https://xn--bcher-kva.example/", "https://xn--bcher-kva.example/"), + ] { + let links = find_text_links(&page_text(s)); + assert_eq!(links.len(), 1, "{s}"); + assert_eq!(links[0].uri, opened, "{s}"); + assert!(opened.ends_with(written), "{s}: only a scheme is added"); + assert_eq!(crate::links::document_url(&links[0].uri).ok().as_deref(), Some(opened), "{s}: opened unchanged"); + // `page_text` gives every character, spaces included, 6 points. + let at = s.find(written).and_then(|b| s.get(..b)).map(|p| p.chars().count()).expect("written in s"); + let len = written.chars().count(); + assert_eq!(links[0].rects, vec![[at as f32 * 6.0, 10.0, (at + len) as f32 * 6.0, 22.0]], "{s}"); + } + // An address hiding characters is offered as written, and the link check refuses it. + let hidden = uris("https://exa\u{200B}mple.com/x"); + assert_eq!(hidden, ["https://exa\u{200B}mple.com/x"]); + assert!(crate::links::document_url(&hidden[0]).is_err()); + } + + /// A full stop, comma or closing bracket after an address belongs to the sentence; brackets + /// inside an address (a Wikipedia title) belong to it when they pair up. + #[test] + fn punctuation_after_an_address_is_left_out_and_paired_brackets_kept() { + for (s, uri) in [ + ("See https://example.com/x.", "https://example.com/x"), + ("See https://example.com/x...", "https://example.com/x"), + ("Is it https://example.com/x?", "https://example.com/x"), + ("(see https://example.com/x).", "https://example.com/x"), + ("[https://example.com/x]", "https://example.com/x"), + ("\"https://example.com/x\",", "https://example.com/x"), + ("", "https://example.com/x"), + ("See https://en.wikipedia.org/wiki/Mercury_(planet).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("(see https://en.wikipedia.org/wiki/Mercury_(planet)).", "https://en.wikipedia.org/wiki/Mercury_(planet)"), + ("See www.example.com/a_(b)_c, then", "https://www.example.com/a_(b)_c"), + ("See https://example.com/a(b and more", "https://example.com/a"), + ("See https://example.com/a)b", "https://example.com/a"), + ("(someone@example.com).", "mailto:someone@example.com"), + ] { + assert_eq!(uris(s), [uri], "{s}"); + } + } + + /// An address ends where its line ends: the next line is never joined to it, because the page + /// text can't tell an address wrapped onto it from the start of the next sentence. + #[test] + fn an_address_ends_at_the_end_of_its_line() { + let links = find_text_links(&page_text("Read https://example.com/first\npart and more")); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://example.com/first"]); + assert_eq!(links[0].rects.len(), 1, "one line"); + assert!(!links[0].contains(3.0, 35.0), "nothing on the next line is part of it"); + } + + #[test] + fn an_address_is_found_where_it_is_written() { + let links = find_text_links(&page_text("ab https://example.com cd")); + // "ab " is 3 characters of 6 points; the address is 19 characters on the first line. + assert_eq!(links.len(), 1); + assert_eq!(links[0].rects, vec![[18.0, 10.0, 132.0, 22.0]]); + assert!(links[0].contains(20.0, 15.0) && links[0].contains(131.0, 21.0)); + assert!(!links[0].contains(10.0, 15.0), "the text before it"); + assert!(!links[0].contains(140.0, 15.0), "the text after it"); + assert!(!links[0].contains(20.0, 30.0), "the line below"); + } + + #[test] + fn a_link_annotation_over_the_address_covers_it() { + let page = PageInfo { width: 300.0, height: 300.0, label: String::new(), crop: [0.0, 0.0, 300.0, 300.0], rotation: 0 }; + let link = &find_text_links(&page_text("ab https://example.com cd"))[0]; + // View y 10–22 is user y 278–290. + assert!(link.covered_by(&page, [100.0, 280.0, 120.0, 285.0])); + assert!(!link.covered_by(&page, [100.0, 200.0, 120.0, 250.0]), "a link elsewhere on the page"); + assert!(!link.covered_by(&page, [140.0, 280.0, 160.0, 285.0]), "a link after it on the line"); + } + + /// `text` with glyph `i` and the one after it drawn as one glyph carrying both characters. + fn merged(mut text: PageText, i: usize) -> PageText { + let next = text.glyphs.remove(i + 1); + text.line_of.remove(i + 1); + text.space_before.remove(i + 1); + let g = &mut text.glyphs[i]; + g.text.push_str(&next.text); + g.rect[2] = next.rect[2]; + text + } + + #[test] + fn an_address_ending_inside_a_glyph_is_not_offered() { + // "m," is one glyph: the address ends on its first character, and its rectangle would + // take the comma too. + let s = "See https://example.com, then"; + // Glyph indexes skip the one space before the address. + let m = s.find(',').expect("a comma") - 2; + assert!(find_text_links(&merged(page_text(s), m)).is_empty()); + // A glyph of several characters inside the address is part of it. + let links = find_text_links(&merged(page_text("See https://fi.example.com"), 11)); + assert_eq!(links.iter().map(|l| l.uri.as_str()).collect::>(), ["https://fi.example.com"]); + } + + #[test] + fn work_per_page_is_capped() { + let many = "a@example.org ".repeat(MAX_TEXT_LINKS_PER_PAGE + 5); + assert_eq!(find_text_links(&page_text(&many)).len(), MAX_TEXT_LINKS_PER_PAGE); + let mut big = page_text("https://example.com"); + let filler = big.glyphs[0].clone(); + big.glyphs.resize(MAX_TEXT_LINK_GLYPHS + 1, filler); + big.line_of.resize(MAX_TEXT_LINK_GLYPHS + 1, 0); + big.space_before.resize(MAX_TEXT_LINK_GLYPHS + 1, false); + assert!(find_text_links(&big).is_empty()); + assert!(find_text_links(&PageText::default()).is_empty()); + } +} diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index 5cb008a4a0..20aa4174e7 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ use super::Zip; use super::as_html::{esc, language_tag}; -use super::layout::{Image, Item, Page, Table, body_size, items}; +use super::layout::{Image, Item, Page, Table, body_size, items, reading_order}; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { @@ -156,7 +156,10 @@ fn positioned_page<'a>(page: &'a Page, media: &mut Vec<(String, &'a Image)>, sha // page-sized watermark cannot push the invoice footer onto another page. let mut s = String::from(""); let body = body_size(std::slice::from_ref(page)).max(1.0); - for b in &page.blocks { + // Rotated pages are positioned shapes, so paragraph flow never orders them. Emit the + // text boxes in reading order; the file's block order is not the reading order. + let blocks = reading_order(page, page.blocks.iter().map(|b| (b.rect, b.size, b)).collect()); + for b in blocks { *shape_id = shape_id.saturating_add(1); let rotation = layout_number(-b.rotation).rem_euclid(360.0); let (sin, cos) = rotation.to_radians().sin_cos(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index a4efa9fc00..d222c61843 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -173,6 +173,9 @@ fn continued_from(blocks: &[Block]) -> Vec> { .iter() .enumerate() .map(|(bi, b)| { + if b.rotation.abs() > 0.1 { + return None; + } let k = by_end.partition_point(|(end, _)| *end < b.rect[0] - INLINE_GAP * b.size); by_end .get(k..) @@ -181,7 +184,10 @@ fn continued_from(blocks: &[Block]) -> Vec> { .take_while(|(end, _)| *end <= b.rect[0] + overhang(b.size)) .take(CONTINUATION_PROBE) .filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a))) - .filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1])) + .filter(|(_, a)| { + a.rotation.abs() <= 0.1 + && a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]) + }) .min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs())) .map(|(ai, _)| ai) }) @@ -581,6 +587,114 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec, Vec f64 { + let mut by: Vec<(i64, f64, usize)> = Vec::new(); + for b in &page.blocks { + if !b.rotation.is_finite() { + continue; + } + let k = b.rotation.round() as i64; + if let Some(e) = by.iter_mut().find(|e| e.0 == k) { + e.2 = e.2.saturating_add(b.text.len()); + } else { + by.push((k, b.rotation, b.text.len())); + } + } + by.into_iter().max_by_key(|e| e.2).filter(|e| e.1.abs() > 0.1).map_or(0.0, |e| e.1) +} + +/// Transform `rect` (`[x0, y0, x1, y1]`, `y` up) into the reading frame of text turned by +/// `rotation` degrees counterclockwise (`[along0, across0, along1, across1]`). For upright text +/// (`0°`), this is `rect` itself; for text turned 90° clockwise (`-90°`), `across` decreases from +/// right to left so lines read in their natural order (#3657). +fn in_reading_frame(rect: [f64; 4], rotation: f64) -> [f64; 4] { + if rotation.abs() <= 0.1 || !rotation.is_finite() { + return rect; + } + let (sin, cos) = rotation.to_radians().sin_cos(); + let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[0], rect[3]), (rect[2], rect[3])]; + let mut out = [f64::MAX, f64::MAX, f64::MIN, f64::MIN]; + for (x, y) in corners { + let along = x * cos + y * sin; + let across = y * cos - x * sin; + out = [out[0].min(along), out[1].min(across), out[2].max(along), out[3].max(across)]; + } + out +} + +/// Put a page's items (`(rect, line_height, item)`) into reading order (#3657): +/// - coordinates are projected into the page's reading frame so turned text reads from first line +/// to last line rather than left-to-right across vertical lines; +/// - a column continues across a paragraph gap (up to 3.5 line heights below the previous item) +/// before jumping to the top of the next column on its right, unless an unread item sits above +/// the continuation in its own horizontal span, sits to its left, or continues the previous +/// item on the same line. +pub(crate) fn reading_order(page: &Page, parts: Vec<([f64; 4], f64, T)>) -> Vec { + let rot = page_rotation(page); + let mut parts: Vec<([f64; 4], f64, T)> = parts.into_iter().map(|(r, h, item)| (in_reading_frame(r, rot), h.clamp(1.0, 200.0), item)).collect(); + if parts.len() <= 1 { + return parts.into_iter().map(|x| x.2).collect(); + } + const MAX_COLUMN_ORDER: usize = 2000; + if parts.len() > MAX_COLUMN_ORDER { + parts.sort_by(|a, b| b.0[3].total_cmp(&a.0[3]).then(a.0[0].total_cmp(&b.0[0]))); + return parts.into_iter().map(|x| x.2).collect(); + } + let beside_left = |cb: &[f64; 4], lb: &[f64; 4]| { + let v = lb[3].min(cb[3]) - lb[1].max(cb[1]); + lb[2] <= cb[0] + COL_TOL && v > 0.5 * (lb[3] - lb[1]).min(cb[3] - cb[1]) + }; + let inline_after = |pb: &[f64; 4], ph: f64, rb: &[f64; 4]| { + let v = rb[3].min(pb[3]) - rb[1].max(pb[1]); + rb[0] >= pb[2] - 1.0 && rb[0] <= pb[2] + INLINE_GAP * ph && v > 0.5 * (rb[3] - rb[1]).min(pb[3] - pb[1]) + }; + let mut remaining: Vec = (0..parts.len()).collect(); + let mut order: Vec = Vec::with_capacity(parts.len()); + while let Some(&top) = remaining.iter().max_by(|&&a, &&b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0]))) { + let next_in_column = order.last().and_then(|&prev| { + let (pb, ph) = (parts[prev].0, parts[prev].1); + if remaining.iter().any(|&r| inline_after(&pb, ph, &parts[r].0)) { + return None; + } + let tol = ph * 0.5; + let c = remaining + .iter() + .copied() + .filter(|&c| { + let cb = parts[c].0; + let overlap = cb[2].min(pb[2]) - cb[0].max(pb[0]); + let minw = (cb[2] - cb[0]).min(pb[2] - pb[0]).max(1.0); + cb[3] <= pb[1] + tol && pb[1] - cb[3] <= ph * 3.5 && overlap > minw * 0.3 + }) + .max_by(|&a, &b| parts[a].0[3].total_cmp(&parts[b].0[3]).then(parts[b].0[0].total_cmp(&parts[a].0[0])))?; + let cb = parts[c].0; + let blocked = remaining.iter().any(|&r| { + if r == c { + return false; + } + let rb = parts[r].0; + (rb[3] > cb[3] + tol && rb[2].min(cb[2]) > rb[0].max(cb[0])) || beside_left(&cb, &rb) + }); + (!blocked).then_some(c) + }); + let pick = next_in_column.unwrap_or_else(|| { + let tb = parts[top].0; + remaining + .iter() + .copied() + .filter(|&c| beside_left(&tb, &parts[c].0)) + .min_by(|&a, &b| parts[a].0[0].total_cmp(&parts[b].0[0]).then(parts[b].0[3].total_cmp(&parts[a].0[3]))) + .unwrap_or(top) + }); + remaining.retain(|&r| r != pick); + order.push(pick); + } + let mut slots: Vec> = parts.into_iter().map(|(_, _, item)| Some(item)).collect(); + order.into_iter().filter_map(|i| slots.get_mut(i).and_then(Option::take)).collect() +} + pub(crate) fn items(pages: &[Page]) -> Vec> { let body = body_size(pages); let mut out = Vec::new(); @@ -589,21 +703,20 @@ pub(crate) fn items(pages: &[Page]) -> Vec> { out.push(Item::PageBreak); } let (tables, consumed) = tables(&p.blocks, &p.rules); - // Blocks and images by their top edge, top to bottom. // A paragraph sits where its first run does: its other runs follow along its line and the // lines it wraps onto. - let mut parts: Vec<(f64, f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) + let mut parts: Vec<([f64; 4], f64, Item)> = flowing_paragraphs(&p.blocks, &consumed, body) .into_iter() .filter_map(|runs| { let first = runs.first()?.block; + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); let lvl = para_level(&runs, body); - Some((first.rect[3], first.rect[0], Item::Para(runs, lvl))) + Some(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, Item::Para(runs, lvl))) }) .collect(); - parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t)))); - parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im)))); - parts.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); - out.extend(parts.into_iter().map(|x| x.2)); + parts.extend(tables.into_iter().map(|t| (t.rect, body, Item::Table(t)))); + parts.extend(p.images.iter().map(|im| (im.rect, (im.rect[3] - im.rect[1]).max(body), Item::Img(im)))); + out.extend(reading_order(p, parts)); } out } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 885d415fc2..5f50122f90 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -42,7 +42,7 @@ use flow::list_item; pub use layout::{Block, Cell, Image, Page, Table}; #[cfg(test)] use layout::{LOOKUP_STEPS, MAX_COLS, body_size}; -pub(crate) use layout::{Run, flowing_paragraphs, tables}; +pub(crate) use layout::{Run, flowing_paragraphs, reading_order, tables}; #[cfg(test)] mod tests; diff --git a/crates/export/src/pptx.rs b/crates/export/src/pptx.rs index c662f9cb0f..ec33137eae 100644 --- a/crates/export/src/pptx.rs +++ b/crates/export/src/pptx.rs @@ -137,20 +137,21 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { .max_by(|(_, a), (_, b)| a.size.total_cmp(&b.size)) .map(|(i, _)| i); - let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new(); + let mut parts: Vec<([f64; 4], f64, SlideBodyItem)> = Vec::new(); // The title is a shape of its own, so it joins no paragraph. let skip: Vec = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect(); for runs in crate::flowing_paragraphs(&p.blocks, &skip, body) { let Some(first) = runs.first().map(|r| r.block) else { continue }; - items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs))); + let x1 = runs.iter().map(|r| r.block.rect[2]).fold(first.rect[2], f64::max); + parts.push(([first.rect[0], first.rect[1], x1, first.rect[3]], first.size, SlideBodyItem::Para(runs))); } for t in tables { - items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t))); + parts.push((t.rect, 11.0, SlideBodyItem::Table(t))); } for im in &p.images { - items.push((im.rect[3], im.rect[0], SlideBodyItem::Img(im))); + parts.push((im.rect, (im.rect[3] - im.rect[1]).max(11.0), SlideBodyItem::Img(im))); } - items.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.total_cmp(&b.1))); + let items = crate::reading_order(p, parts); let mut current_y = my; let mut shapes = String::new(); @@ -170,12 +171,12 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec { } let mut i = 0; - while let Some((_, _, item)) = items.get(i) { + while let Some(item) = items.get(i) { match item { SlideBodyItem::Para(_) => { let mut paras_xml = String::new(); let mut est_h_pt = 0.0; - while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) { + while let Some(SlideBodyItem::Para(runs)) = items.get(i) { paras_xml.push_str(""); for r in runs { paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color)); diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index cb520d002e..eeece99949 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -922,6 +922,100 @@ fn markdown_escapes_markup_and_keeps_emphasis_and_tables() { assert!(rows.iter().all(|r| r.matches(" | ").count() == 2), "every row has the header's three cells: {md}"); } +/// #3657: two-column text finishes the left column before starting the right column across +/// Word, HTML, RTF, Markdown and PowerPoint, and text turned 90° exports in reading order rather +/// than last line first. +#[test] +fn two_column_and_turned_text_export_in_reading_order() { + let para = |t: &str, x: f64, y0: f64, y1: f64, size: f64| Block { + text: t.into(), + rect: [x, y0, x + 130.0, y1], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: 0.0, + space_around: [false; 2], + }; + let columns = Page { + width: 595.0, + height: 842.0, + blocks: vec![ + para("Two columns", 50.0, 790.0, 808.0, 18.0), + para("A1 first paragraph A2 first paragraph A3 first paragraph", 50.0, 720.0, 762.0, 12.0), + para("B1 second paragraph B2 second paragraph", 50.0, 675.0, 702.0, 12.0), + para("B3 second paragraph B4 second paragraph", 320.0, 735.0, 762.0, 12.0), + para("C1 third paragraph C2 third paragraph", 320.0, 695.0, 722.0, 12.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_cols = ["Two columns", "A1 first paragraph", "B1 second paragraph", "B3 second paragraph", "C1 third paragraph"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&columns), "Columns")), + ("docx", part(&docx(std::slice::from_ref(&columns), "Columns"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&columns))), + ("md", markdown(std::slice::from_ref(&columns))), + ("pptx", part(&pptx(std::slice::from_ref(&columns), "Columns"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_cols.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // Text turned 90 degrees clockwise (rotation = -90°): lines run top to bottom (y = 540 + // down to 468) and follow each other right to left (x = 676, 654.4, 632.8, 611.2). + let turned_line = |t: &str, x: f64, size: f64| Block { + text: t.into(), + rect: [x - size, 468.0, x, 540.0], + size, + bold: false, + italic: false, + color: [0.0; 3], + rotation: -90.0, + space_around: [false; 2], + }; + let rotated = Page { + width: 792.0, + height: 612.0, + blocks: vec![ + turned_line("Line one", 676.0, 20.0), + turned_line("Line two", 654.4, 18.0), + turned_line("Line three", 632.8, 18.0), + turned_line("Line four", 611.2, 18.0), + ], + images: Vec::new(), + rules: Vec::new(), + background: None, + }; + let expected_lines = ["Line one", "Line two", "Line three", "Line four"]; + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&rotated), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&rotated), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&rotated))), + ("md", markdown(std::slice::from_ref(&rotated))), + ("pptx", part(&pptx(std::slice::from_ref(&rotated), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} out of order: {positions:?}\n{out}"); + } + + // The same lines, stored bottom-to-top. Rotated lines do not join one paragraph, and + // Word's positioned boxes must follow reading order rather than the file's block order. + let mut shuffled = rotated.clone(); + shuffled.blocks.reverse(); + for (fmt, out) in [ + ("html", html(std::slice::from_ref(&shuffled), "Rotated")), + ("docx", part(&docx(std::slice::from_ref(&shuffled), "Rotated"), "word/document.xml")), + ("rtf", rtf(std::slice::from_ref(&shuffled))), + ("md", markdown(std::slice::from_ref(&shuffled))), + ("pptx", part(&pptx(std::slice::from_ref(&shuffled), "Rotated"), "ppt/slides/slide1.xml")), + ] { + let positions: Vec = expected_lines.iter().map(|s| out.find(s).unwrap_or_else(|| panic!("{s} missing in {fmt}: {out}"))).collect(); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{fmt} shuffled out of order: {positions:?}\n{out}"); + } +} + /// #5149: two columns of two rows are a table when ruled lines draw its grid, and the docx keeps /// its borders; the same text without rules (or with rules that only underline it) stays paragraphs. #[test] diff --git a/crates/forms/README.md b/crates/forms/README.md index 6126dc1cbd..67ca3b3bfb 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -75,6 +75,12 @@ let cells: Vec = grid.cells_for(names)?; // "Table1_1", "Table original entry is preserved. Positive limits still apply. - Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a failed call changes nothing. +- Native AF recognition stops at the first genuine matching function call. If its arguments + are unsupported, the complete action remains a custom script for the caller's `Scripts` + interpreter; a later literal call must not replace the original conditional validation. + Async for-of loop headers and bodies do not expose AF-looking text inside regex literals. +- Moving choice fields preserves their own options rather than copying an ancestor's `/Opt`; + inherited check-box and radio-button export mappings remain intact. - `FieldProps::appearance` applies a `LookPatch` independently to each widget. `None` keeps a property; `Some(None)` removes a border/fill colour. Unchanged `/DA`, custom font resources, widget-specific `/DA` overrides, indirect `/MK` and `/BS` contents, dash arrays and unknown diff --git a/crates/forms/src/af.rs b/crates/forms/src/af.rs index f5167374d1..2ea52df387 100644 --- a/crates/forms/src/af.rs +++ b/crates/forms/src/af.rs @@ -75,6 +75,7 @@ impl CalcOp { } } + /// How the operation is named in a list of them (`sum (+)`, `average`). pub fn label(self) -> &'static str { match self { CalcOp::Sum => "sum (+)", @@ -109,6 +110,9 @@ pub struct Actions { pub unsupported: Vec<&'static str>, /// Those scripts' JavaScript, run through [`crate::Scripts`]. pub scripts: Scripts, + /// A date field PdfCraft laid out for an XFA form (`/PCXfaDate true`): text its format doesn't + /// read is kept and shown as typed rather than refused, as an XFA form keeps it. + pub xfa_date: bool, } /// Field scripts that aren't AF calls, by event. @@ -121,9 +125,52 @@ pub struct Scripts { } impl Actions { + /// Whether the field has no format, validation or calculation PdfCraft recognises. pub fn is_empty(&self) -> bool { self.format == Format::None && self.validate == Validate::None && self.calculate == Calculate::None } + + /// The Keystroke event on commit ([`keystroke`]); an XFA date field keeps text its format + /// doesn't read, as typed. + pub fn keystroke(&self, field: &str, value: &str) -> Result { + match keystroke(&self.format, field, value) { + Err(_) if self.xfa_date => Ok(value.to_string()), + checked => checked, + } + } + + /// What the Format event shows for `value` ([`format_value`]). An XFA date field shows a date + /// only for a value that is one, in its format already or as an ISO date (`2026-10-18` or + /// `20261018`); anything else, which its format would read loosely, shows as typed. + pub fn format_value(&self, value: &str) -> String { + self.format_value_in(value, &date_names()) + } + + /// [`Self::format_value`] with month and weekday names from `names`. + pub fn format_value_in(&self, value: &str, names: &DateNames) -> String { + match &self.format { + Format::Date(fmt) if self.xfa_date => { + let t = value.trim(); + let in_format = parse_date(t, fmt).is_some_and(|dt| format_date(dt, fmt).eq_ignore_ascii_case(t)); + match iso_date(t) { + Some(dt) if !in_format => format_date_in(dt, fmt, names), + _ => value.to_string(), + } + } + f => format_value_in(f, value, names), + } + } +} + +/// `text` as an ISO 8601 calendar date, `yyyy-mm-dd` or `yyyymmdd` (digits only, a real day). +fn iso_date(text: &str) -> Option { + let b = text.as_bytes(); + let fmt = match b.len() { + 10 if b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() }) => "yyyy-mm-dd", + 8 if b.iter().all(u8::is_ascii_digit) => "yyyymmdd", + _ => return None, + }; + parse_date(text, fmt).filter(|dt| format_date(*dt, fmt) == text) } #[cfg(test)] diff --git a/crates/forms/src/af/calc.rs b/crates/forms/src/af/calc.rs index 5c8098dfbb..1c3724ef4a 100644 --- a/crates/forms/src/af/calc.rs +++ b/crates/forms/src/af/calc.rs @@ -19,13 +19,21 @@ pub(super) enum Js { type Token = (Range, Js); /// Words after which `/` starts a regular expression instead of dividing. -const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"]; +const BEFORE_REGEX: [&str; 12] = ["return", "typeof", "case", "do", "else", "in", "new", "delete", "void", "throw", "instanceof", "yield"]; /// Words whose condition, in parentheses, comes before a statement, so a `/` after it starts a /// regular expression. The `while` that ends a `do` loop counts too: the loop ends at its `)`, so /// a `/` after it starts the next statement, and can't divide. const BEFORE_CONDITION: [&str; 4] = ["if", "while", "for", "with"]; +/// Context of an open delimiter. Only the left side of a for header can introduce `of`. +#[derive(Clone, Copy)] +enum Delimiter { + Group, + Condition, + For { left: bool }, +} + /// Whether a line ends at `s[j]`: JavaScript ends one at `\n`, `\r`, U+2028 and U+2029. fn line_ends_at(s: &[u8], j: usize) -> bool { matches!(s.get(j..), Some([b'\n' | b'\r', ..] | [0xE2, 0x80, 0xA8 | 0xA9, ..])) @@ -46,10 +54,11 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { // Where a regular expression that never ended stopped being read: every `/` before it // divides, so a line of them is read once, not once for each. let mut divides_until = 0; - // For each `(` still open, whether it opened the condition of `if`, `while`, `for` or - // `with`; and whether the last token is the `)` that closed one. - let mut conditions: Vec = Vec::new(); + // Nested groups hide the surrounding for header: an `of` in a destructuring default + // or nested expression is an identifier, not the for-of separator. + let mut delimiters: Vec<(char, Delimiter)> = Vec::new(); let mut after_condition = false; + let mut after_for_of = false; 'scan: while let Some(&c) = s.get(i) { let start = i; let token = match c { @@ -90,7 +99,12 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { loop { match s.get(j) { None => break 'scan, - Some(b'\\') => j += 2, + Some(b'\\') => { + let Some(step) = js.get(j + 1..).and_then(|r| r.chars().next()).map(char::len_utf8) else { + break 'scan; + }; + j += 1 + step; + } Some(b'`') => break, Some(_) => j += 1, } @@ -98,7 +112,7 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { i = j + 1; Js::Other } - b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition) => { + b'/' if i >= divides_until && regex_may_follow(js, &out, after_condition, after_for_of) => { // A regular expression ends at a `/` outside a class; a line break first means it // was a division after all. let (mut j, mut class) = (i + 1, false); @@ -106,7 +120,14 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { match s.get(j) { None => break None, Some(_) if line_ends_at(s, j) => break None, - Some(b'\\') => j += 2, + Some(b'\\') => { + if s.get(j + 1).is_none() || line_ends_at(s, j + 1) { + j += 1; + break None; + } + let step = js.get(j + 1..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8); + j += 1 + step; + } Some(b'/') if !class => break Some(j), Some(b) => { class = match b { @@ -133,6 +154,16 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { } } } + b'+' | b'-' + if s.get(i + 1) == Some(&c) + && !regex_may_follow(js, &out, after_condition, after_for_of) + && out.last().is_some_and(|(range, _)| !(range.end..i).any(|j| line_ends_at(s, j))) => + { + // Postfix update ends an expression: a following slash divides. A line + // terminator forbids postfix syntax, including one inside a comment. + i += 2; + Js::Other + } _ if c.is_ascii_digit() => { while word(i) || s.get(i) == Some(&b'.') { i += 1; @@ -150,38 +181,88 @@ pub(super) fn js_tokens(js: &str) -> (Vec, Vec>) { Js::Punct(char::from(c)) } }; - after_condition = match token { - Js::Punct('(') => { - conditions.push(opens_condition(js, &out)); - false - } - Js::Punct(')') => conditions.pop().unwrap_or(false), - _ => false, - }; - out.push((start..i, token)); + let token = (start..i, token); + (after_condition, after_for_of) = advance_context(js, &token, &out, &mut delimiters); + out.push(token); } (out, comments) } +/// Advance only the delimiter/for-header context after a token has been classified. +/// Returns whether it closes a statement's condition or is the for-of separator. +fn advance_context(js: &str, token: &Token, tokens: &[Token], delimiters: &mut Vec<(char, Delimiter)>) -> (bool, bool) { + let mut after_for_of = false; + if let Some((_, Delimiter::For { left })) = delimiters.last_mut() { + if matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("of") && *left && for_target_ends(js, tokens) { + *left = false; + after_for_of = true; + } else if matches!(token.1, Js::Punct(';' | '=')) + || (matches!(token.1, Js::Ident) && js.get(token.0.clone()) == Some("in") && !matches!(tokens.last(), Some((_, Js::Punct('.'))))) + { + *left = false; + } + } + let mut after_condition = false; + match token.1 { + Js::Punct('(') => delimiters.push((')', opens_condition(js, tokens))), + Js::Punct('[') => delimiters.push((']', Delimiter::Group)), + Js::Punct('{') => delimiters.push(('}', Delimiter::Group)), + Js::Punct(close @ (')' | ']' | '}')) if delimiters.last().is_some_and(|(end, _)| *end == close) => { + after_condition = matches!(delimiters.pop(), Some((_, Delimiter::Condition | Delimiter::For { .. }))); + } + _ => {} + } + (after_condition, after_for_of) +} + /// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening /// bracket, a keyword such as `return` or the condition of `if`, `while`, `for` or `with`, not /// after a value. `after_condition` says the last token is the `)` that ends such a condition. -fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool) -> bool { +fn regex_may_follow(js: &str, tokens: &[Token], after_condition: bool, after_for_of: bool) -> bool { match tokens.last() { None => true, - Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)), + Some((range, Js::Ident)) => { + !matches!(tokens.get(tokens.len().wrapping_sub(2)), Some((_, Js::Punct('.')))) + && js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w) || (w == "of" && after_for_of)) + } Some((_, Js::Punct(')'))) => after_condition, Some((_, Js::Punct(c))) => !matches!(c, ']' | '}'), Some((_, Js::Str(_) | Js::Other)) => false, } } -/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` or `with`, and not +/// Whether a `(` after `tokens` opens the condition of `if`, `while`, `for` (including +/// `for await`) or `with`, and not /// the arguments of a method that has one of those names (`o.if(x)`). -fn opens_condition(js: &str, tokens: &[Token]) -> bool { +fn opens_condition(js: &str, tokens: &[Token]) -> Delimiter { let mut back = tokens.iter().rev(); - let Some((range, Js::Ident)) = back.next() else { return false }; - js.get(range.clone()).is_some_and(|w| BEFORE_CONDITION.contains(&w)) && !matches!(back.next(), Some((_, Js::Punct('.')))) + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + let mut keyword = js.get(range.clone()); + if keyword == Some("await") { + let Some((range, Js::Ident)) = back.next() else { return Delimiter::Group }; + keyword = js.get(range.clone()); + if keyword != Some("for") { + return Delimiter::Group; + } + } + if matches!(back.next(), Some((_, Js::Punct('.')))) { + return Delimiter::Group; + } + match keyword { + Some("for") => Delimiter::For { left: true }, + Some(w) if BEFORE_CONDITION.contains(&w) => Delimiter::Condition, + _ => Delimiter::Group, + } +} + +/// A binding/assignment target before the for-of separator, not a declaration keyword, +/// expression-opening operator, or the start of the header itself. +fn for_target_ends(js: &str, tokens: &[Token]) -> bool { + match tokens.last() { + Some((range, Js::Ident)) => !matches!(js.get(range.clone()), Some("var" | "let" | "const")), + Some((_, Js::Punct(']' | '}' | ')'))) => true, + _ => false, + } } /// Where a Calculate script keeps its simplified field notation: between `BVCALC` and the @@ -455,10 +536,12 @@ fn splice(src: &str, edits: impl IntoIterator, String)>) -> } /// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment: -/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays. +/// Each field name becomes `AFMakeNumber(this.getField("name").value)`; adjacent signs +/// are separated so binary plus/minus followed by a unary sign cannot become an update. pub fn notation_js(expr: &str) -> String { let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t { Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))), + Notation::Op(sign @ ('+' | '-')) if expr.get(range.end..).is_some_and(|rest| rest.starts_with(sign)) => Some((range, format!("{sign} "))), _ => None, }); splice(expr, names) @@ -507,6 +590,10 @@ pub(super) fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option *i += 1; Some(*n) } + T::Op('+') => { + *i += 1; + factor(t, i, d + 1) + } T::Op('-') => { *i += 1; factor(t, i, d + 1).map(|v| -v) @@ -558,3 +645,33 @@ pub fn calculate(c: &Calculate, values: &dyn Fn(&str) -> Vec) -> Option< } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn regex_scan_does_not_cross_line_break_after_backslash_or_split_utf8() { + let js = "x = /a\\\nthis.getField(\"price\").value / 2;"; + assert_eq!(calculation_reads(js), vec!["price".to_owned()]); + + let js_utf8 = "var r = /\\é/; event.value = this.getField(\"tax\").value;"; + assert_eq!(calculation_reads(js_utf8), vec!["tax".to_owned()]); + + let trailing_backslash = "event.value = this.getField(\"subtotal\").value + /unterminated\\"; + assert_eq!(calculation_reads(trailing_backslash), vec!["subtotal".to_owned()]); + let template_trailing = "event.value = this.getField(\"shipping\").value + `unterminated\\"; + assert_eq!(calculation_reads(template_trailing), vec!["shipping".to_owned()]); + } + + #[test] + fn eval_notation_supports_unary_plus() { + let c = Calculate::Notation("+a + (+b)".to_owned()); + let out = calculate(&c, &|name| match name { + "a" => vec!["3".to_owned()], + "b" => vec!["4".to_owned()], + _ => Vec::new(), + }); + assert_eq!(out.as_deref(), Some("7")); + } +} diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index f15714c411..8212da5bf2 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -17,7 +17,7 @@ impl Arg { match self { Arg::Num(n) => Some(*n), Arg::Bool(b) => Some(f64::from(u8::from(*b))), - Arg::Str(s) => s.trim().parse().ok(), + Arg::Str(s) => s.trim().parse::().ok().filter(|n| n.is_finite()), Arg::List(_) => None, } } @@ -99,17 +99,36 @@ impl Parser<'_> { let mut encoded = [0u8; 4]; out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); } + b'u' if self.s.get(self.i) == Some(&b'{') => { + self.i += 1; + let start = self.i; + let mut code = 0u32; + while let Some(&digit) = self.s.get(self.i).filter(|&&b| b != b'}') { + code = code.checked_mul(16)?.checked_add(char::from(digit).to_digit(16)?)?; + if code > 0x10FFFF { + return None; + } + self.i += 1; + } + if self.i == start || self.s.get(self.i) != Some(&b'}') { + return None; + } + self.i += 1; + // Match the existing handling of unpaired UTF-16 surrogates. + let ch = char::from_u32(code).unwrap_or('\u{FFFD}'); + let mut encoded = [0u8; 4]; + out.extend_from_slice(ch.encode_utf8(&mut encoded).as_bytes()); + } b'u' => { let code = hex4(self.s.get(self.i..self.i.checked_add(4)?)?)?; self.i += 4; - let code = if (0xD800..=0xDBFF).contains(&code) && self.s.get(self.i..self.i.checked_add(6)?)?.starts_with(b"\\u") { - let low = hex4(self.s.get(self.i.checked_add(2)?..self.i.checked_add(6)?)?)?; - if (0xDC00..=0xDFFF).contains(&low) { - self.i += 6; - 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) - } else { - u32::from(code) - } + let code = if (0xD800..=0xDBFF).contains(&code) + && let Some(pair) = self.i.checked_add(6).and_then(|end| self.s.get(self.i..end)) + && pair.starts_with(b"\\u") + && let Some(low) = hex4(&pair[2..6]).filter(|low| (0xDC00..=0xDFFF).contains(low)) + { + self.i += 6; + 0x10000 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00) } else { u32::from(code) }; @@ -158,7 +177,7 @@ impl Parser<'_> { while self.i < self.s.len() && (self.s[self.i].is_ascii_digit() || matches!(self.s[self.i], b'.' | b'-' | b'+' | b'e' | b'E')) { self.i += 1; } - std::str::from_utf8(&self.s[start..self.i]).ok()?.parse().ok().map(Arg::Num) + std::str::from_utf8(&self.s[start..self.i]).ok()?.parse::().ok().filter(|n| n.is_finite()).map(Arg::Num) } } } @@ -196,15 +215,30 @@ impl Parser<'_> { } } -/// The first call to `name(` in `js` and its arguments. +/// The first genuine call to `name(` in `js` and its arguments. +/// An unsupported first call returns `None`, preserving the custom-script fallback; later calls +/// must not substitute a different native rule. Comments, strings and unrelated identifiers are skipped. pub(super) fn call(js: &str, name: &str) -> Option> { - let at = js.find(&format!("{name}("))?; - // Not part of a longer identifier (AFDate_Format vs AFDate_FormatEx). - if js[..at].chars().last().is_some_and(|c| c.is_alphanumeric() || c == '_') { - return None; + let (tokens, _) = js_tokens(js); + for (k, (range, tok)) in tokens.iter().enumerate() { + if *tok != super::calc::Js::Ident { + continue; + } + if k > 0 && matches!(tokens.get(k - 1), Some((_, super::calc::Js::Punct('.')))) { + continue; + } + let Some(target_end) = range.start.checked_add(name.len()) else { continue }; + if js.get(range.start..target_end) != Some(name) { + continue; + } + let Some(end_k) = (k..tokens.len()).take_while(|&m| tokens[m].0.end <= target_end).find(|&m| tokens[m].0.end == target_end) else { + continue; + }; + let Some((open, super::calc::Js::Punct('('))) = tokens.get(end_k + 1) else { continue }; + let mut p = Parser { s: js.as_bytes(), i: open.end, depth: 0 }; + return p.list(b')'); } - let mut p = Parser { s: js.as_bytes(), i: at + name.len() + 1, depth: 0 }; - p.list(b')') + None } pub fn parse_format(js: &str) -> Option { @@ -246,7 +280,8 @@ pub fn parse_validate(js: &str) -> Option { let args = call(js, "AFRange_Validate")?; let flag = |i: usize| args.get(i).is_some_and(Arg::truthy); let num = |i: usize| args.get(i).and_then(Arg::num); - Some(Validate::Range { min: if flag(0) { num(1) } else { None }, max: if flag(2) { num(3) } else { None } }) + // An enabled but unparseable bound is unsupported, not an absent bound. + Some(Validate::Range { min: if flag(0) { Some(num(1)?) } else { None }, max: if flag(2) { Some(num(3)?) } else { None } }) } pub fn parse_calculate(js: &str) -> Option { @@ -361,3 +396,89 @@ pub fn calculate_js(c: &Calculate) -> Option { Calculate::Notation(expr) => Some(format!("/** BVCALC {expr} EVCALC **/ event.value = {};", notation_js(expr))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unsupported_first_validation_call_does_not_select_a_later_rule() { + let js = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + assert_eq!(parse_validate(js), None); + assert_eq!( + parse_validate("AFRange_Validate(true, 15, false, 0); AFRange_Validate(false, 0, true, 100);"), + Some(Validate::Range { min: Some(15.0), max: None }) + ); + } + + #[test] + fn validation_call_still_skips_non_calls_before_literal_arguments() { + let js = r#"// AFRange_Validate(true, 10 + 5, false, 0) + "AFRange_Validate(true, 10 + 5, false, 0)"; + my_AFRange_Validate(true, 10 + 5, false, 0); + other.AFRange_Validate(true, 10 + 5, false, 0); + AFRange_Validate(false, 0, true, 100);"#; + assert_eq!(parse_validate(js), Some(Validate::Range { min: None, max: Some(100.0) })); + } + + #[test] + fn unpaired_high_surrogate_at_end_of_string_does_not_abort_parsing() { + let js = "AFDate_FormatEx(\"\\uD800\")"; + assert_eq!(parse_format(js), Some(Format::Date("\u{FFFD}".to_owned()))); + } + + #[test] + fn call_skips_commented_or_prefixed_identifiers_and_rejects_infinite_numbers() { + let js = "// AFPercent_Format(0, 0);\nmy_AFPercent_Format(0, 0); $AFPercent_Format(0, 0); AFPercent_Format (2, 1);"; + assert_eq!(parse_format(js), Some(Format::Percent { decimals: 2, sep: 1 })); + + assert_eq!(parse_validate("AFRange_Validate(true, 1e500, false, 0);"), None); + } + #[test] + fn review_enabled_nonfinite_bound_is_unsupported() { + for script in [r#"AFRange_Validate(true, "1e500", false, 0)"#, r#"AFRange_Validate(false, 0, true, "-1e500")"#] { + assert_eq!(parse_validate(script), None); + } + assert_eq!(parse_validate(r#"AFRange_Validate(false, "1e500", false, "NaN")"#), Some(Validate::Range { min: None, max: None })); + } + + #[test] + fn review_format_after_postfix_division() { + for op in ["++", "--"] { + let js = format!("var y = x{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let commented = format!("var y = x/* comment */{op} / 2; AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&commented), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("var of = 8; of / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!( + parse_format("for (var x of /AFPercent_Format(0, 0)/g) {} AFPercent_Format(2, 1);"), + Some(Format::Percent { decimals: 2, sep: 1 }) + ); + // A newline makes ++ a prefix operator; the following regex must remain opaque. + for js in ["x\n++/AFPercent_Format(2, 1)/.lastIndex;", "x/*\n*/++/AFPercent_Format(2, 1)/.lastIndex;"] { + assert_eq!(parse_format(js), None); + } + } + + #[test] + fn review_format_after_brace_unicode() { + assert_eq!(parse_format(r#"var s = "\u{1F600}"; AFPercent_Format(2, 1);"#), Some(Format::Percent { decimals: 2, sep: 1 })); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{1F600}")"#), Some(Format::Date("😀".to_owned()))); + assert_eq!(parse_format(r#"AFDate_FormatEx("\u{00000041}\u0042\uD83D\uDE00")"#), Some(Format::Date("AB😀".to_owned()))); + for escape in ["\\u{}", "\\u{110000}", "\\u{xyz}", "\\u{41"] { + assert_eq!(parse_format(&format!("AFDate_FormatEx(\"{escape}\")")), None); + } + } + + #[test] + fn review_notation_separates_adjacent_operators() { + let field = |name| format!("AFMakeNumber(this.getField(\"{name}\").value)"); + for (expr, operators) in [("a++b", "+ +"), ("a--b", "- -"), ("a+++b", "+ + +")] { + let js = calculate_js(&Calculate::Notation(expr.to_owned())).unwrap(); + let executable = js.split("event.value = ").nth(1).unwrap(); + assert_eq!(executable, format!("{}{operators}{};", field("a"), field("b"))); + assert_eq!(super::super::calc::calculate(&Calculate::Notation(expr.to_owned()), &|_| vec!["2".to_owned()]), Some("4".to_owned())); + } + } +} diff --git a/crates/forms/src/af/tests.rs b/crates/forms/src/af/tests.rs index 5e8c4f085f..3c6dab1bef 100644 --- a/crates/forms/src/af/tests.rs +++ b/crates/forms/src/af/tests.rs @@ -315,6 +315,8 @@ fn a_calculation_reads_each_named_field_once() { assert_eq!(calculation_reads("AFSimple_Calculate(\"SUM\", \"a, b\");"), ["a", "b"]); } +/// Number formats show separators, decimals, currency and negatives as AFNumber_Format does, +/// and the keystroke check stores the number. #[test] fn numbers_format_like_acrobat() { let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p }; @@ -335,6 +337,36 @@ fn numbers_format_like_acrobat() { assert_eq!(keystroke(&Format::None, "Name", " \t"), Ok(" \t".into())); } +/// A date field laid out for an XFA form keeps text its pattern doesn't read, as typed, and +/// shows a date only for a value that is one: in the pattern already, or an ISO date. Another +/// viewer keeps and shows such text the same way on a synthetic form; an ordinary date field +/// still refuses it. +#[test] +fn xfa_date_fields_keep_and_show_what_their_pattern_does_not_read() { + let xfa = |fmt: &str| Actions { format: Format::Date(fmt.into()), xfa_date: true, ..Actions::default() }; + let plain = |fmt: &str| Actions { format: Format::Date(fmt.into()), ..Actions::default() }; + for (fmt, typed) in [("yyyy-mm-dd", "10/18/2016"), ("mm/dd/yyyy", "xyz"), ("yyyymmdd", "10/18/2016")] { + assert_eq!(xfa(fmt).keystroke("Date", typed), Ok(typed.to_string()), "{typed} in {fmt}"); + assert!(plain(fmt).keystroke("Date", typed).is_err(), "an ordinary date field refuses {typed}"); + } + assert_eq!(xfa("mm/dd/yyyy").keystroke("Date", " pending "), Ok(" pending ".to_string()), "spaces and all"); + // Shown: in the pattern, as it is; an ISO date, in the pattern; anything else as typed, + // where an ordinary field's looser reading would show a date the value doesn't hold. + let ymd = xfa("yyyymmdd"); + assert_eq!(ymd.format_value("20161018"), "20161018"); + assert_eq!(ymd.format_value("2016-10-18"), "20161018"); + assert_eq!(ymd.format_value("26 10 18"), "26 10 18"); + assert_ne!(plain("yyyymmdd").format_value("26 10 18"), "26 10 18", "read loosely as a date"); + let mdy = xfa("mm/dd/yyyy"); + assert_eq!(mdy.format_value("2016-10-18"), "10/18/2016"); + assert_eq!(mdy.format_value("20161018"), "10/18/2016"); + assert_eq!(mdy.format_value("10/18/2016"), "10/18/2016"); + assert_eq!(mdy.format_value("xyz"), "xyz"); + assert_eq!(mdy.format_value("2016-02-30"), "2016-02-30", "not a real day: as typed"); + assert_eq!(xfa("ddmmmyy").format_value("18OCT16"), "18OCT16", "in the pattern, its case kept"); +} + +/// Date, time and mask formats show values in their pattern and refuse what doesn't fit. #[test] fn dates_times_and_masks() { let d = Format::Date("mm/dd/yyyy".into()); @@ -412,3 +444,45 @@ fn validation_and_calculation() { assert_eq!(calculate(&Calculate::Notation("(a + b) * 2 - Unit\\ Price / 4".into()), &vals), Some("10".into())); assert_eq!(calculate(&Calculate::Notation("a +".into()), &vals), None); } + +/// Async for-of headers have the same regex-literal contexts as ordinary for-of headers. +#[test] +fn for_await_regex_literals_do_not_become_format_calls() { + for header in ["for await", "for /* gap */ await"] { + let js = format!("async function f() {{ {header} (const x of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 })); + let body = format!("async function f() {{ {header} (const x of xs) /AFPercent_Format(0, 0)/.test(x); }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&body), Some(Format::Percent { decimals: 2, sep: 1 })); + } + assert_eq!(parse_format("obj.await(x) / 2; AFPercent_Format(2, 1); var z = 1 / 2;"), Some(Format::Percent { decimals: 2, sep: 1 })); +} + +#[test] +fn identifier_of_in_conditions_keeps_division_and_real_format_calls() { + for header in [ + "if (of / 2)", + "while (of / 2)", + "with (of / 2)", + "for (of / 2; ; )", + "for (var x = of / 2; ; )", + "for (var x = 0; of / 2; )", + "for (x of of / 2)", + "for (const x of of / 2)", + "for (x in of / 2)", + "for (var of / 2; ; )", + "for (obj.of / 2; ; )", + ] { + let js = format!("{header} AFPercent_Format(2, 1); var z = 1 / 2;"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header}"); + } +} + +#[test] +fn destructured_for_of_targets_keep_regex_iterables_opaque() { + for header in ["for", "for await"] { + for target in ["const [x]", "const {x}", "(x)", "const {x = of / 2}", "obj.in", "obj.of", "const of"] { + let js = format!("async function f() {{ {header} ({target} of /AFPercent_Format(0, 0)/g) {{}} }} AFPercent_Format(2, 1);"); + assert_eq!(parse_format(&js), Some(Format::Percent { decimals: 2, sep: 1 }), "{header} {target}"); + } + } +} diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index 891a533eaa..53c43a8226 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -782,10 +782,10 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String] && values.len() == 1 && f.actions.format != crate::af::Format::None { - let shown = crate::af::format_value(&f.actions.format, &values[0]); + let shown = f.actions.format_value(&values[0]); // Month and weekday names in the date language that no font here can draw ("окт." in // Helvetica) are drawn in English rather than as `?` (#5303). - let english = || crate::af::format_value_in(&f.actions.format, &values[0], &crate::af::ENGLISH_DATE_NAMES); + let english = || f.actions.format_value_in(&values[0], &crate::af::ENGLISH_DATE_NAMES); let dated = matches!(f.actions.format, crate::af::Format::Date(_) | crate::af::Format::Time(_)); formatted = vec![if !dated || draws(doc, f, &wd, &shown) { shown } else { english() }]; &formatted[..] diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index de597187d4..2c26d265a5 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -123,10 +123,13 @@ pub struct FieldProps { pub rotation: Option<(usize, i64)>, } +/// A [`FormError::Invalid`] with message `m`. fn invalid(m: impl Into) -> Result { Err(FormError::Invalid(m.into())) } +/// Put `value` into field `f` as its kind takes one (a text value through the field's Keystroke +/// and Validate events and its scripts first), and redraw the field. fn rgb(c: [f64; 3]) -> Object { Object::Array(c.iter().map(|v| Object::Real(*v)).collect()) } diff --git a/crates/forms/src/author/join.rs b/crates/forms/src/author/join.rs index e9fedbd655..b8c2520dd6 100644 --- a/crates/forms/src/author/join.rs +++ b/crates/forms/src/author/join.rs @@ -5,16 +5,17 @@ use std::collections::{HashMap, HashSet}; use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers}; -use super::tree::{detach, list_of}; +use super::tree::{ancestor_sets, detach, list_of}; use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid}; /// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA` /// keeps only the widget's own triggers. const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"]; -/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). +/// Whether `field` holds or inherits `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2). fn has_opt(doc: &Document, field: ObjRef) -> bool { - doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt")) + doc.get(field).as_dict().and_then(|d| d.get(b"Opt")).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) + || ancestor_sets(doc, field, b"Opt") } /// Refuse what [`join_radio_group`] can't do, before anything is written. @@ -127,3 +128,26 @@ fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool { d.set(b"AP".to_vec(), Object::Dict(ap)); true } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn has_opt_ignores_null_and_detects_inherited_opt() { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"Opt".to_vec(), Object::Array(vec![])); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut f_null = Dict::new(); + f_null.set(b"Opt".to_vec(), Object::Null); + let f_null_ref = doc.add(Object::Dict(f_null)); + assert!(!has_opt(&doc, f_null_ref)); + + let mut f_inherited = Dict::new(); + f_inherited.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + let f_inherited_ref = doc.add(Object::Dict(f_inherited)); + assert!(has_opt(&doc, f_inherited_ref)); + } +} diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index ce4ee687bb..55b84fa390 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -112,6 +112,11 @@ pub(crate) fn form_fonts_as_objects(doc: &mut Document, all: &[crate::Field]) -> Ok(()) } +/// Whether `d` has a non-null entry `key` (a `null` entry is absent per ISO 32000-2 §7.3.9). +fn dict_has(doc: &Document, d: &Dict, key: &[u8]) -> bool { + d.get(key).is_some_and(|v| !matches!(*doc.resolve(v), Object::Null)) +} + /// Whether the field `r`, or the nearest ancestor that has one, holds a `/V` (the entry is /// inheritable, ISO 32000-2 §12.7.4). The `/Parent` chain is followed at most 64 deep, as deep /// as the field walk goes, so a cyclic chain ends. @@ -120,7 +125,7 @@ fn holds_value(doc: &Document, r: ObjRef) -> bool { for _ in 0..=64 { let obj = doc.get(at); let Some(d) = obj.as_dict() else { return false }; - if d.contains(b"V") { + if dict_has(doc, d, b"V") { return true; } match d.reference(b"Parent") { @@ -160,8 +165,8 @@ fn add_to_fields(doc: &mut Document, af: ObjRef, field: ObjRef) -> Result<(), Fo /// The most parts a dotted field name may have ("a.b.c" has three). const MAX_NAME_PARTS: usize = 32; -/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1). -const INHERITED_KEYS: [&[u8]; 7] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen"]; +/// The field attributes a field inherits from its parents (ISO 32000-2 §12.7.4.1, §12.7.5.2). +const INHERITED_KEYS: [&[u8]; 8] = [b"FT", b"Ff", b"V", b"DV", b"DA", b"Q", b"MaxLen", b"Opt"]; /// The parts of a full field name: `a.b` is the field `b` under the parent field `a`, since a /// field's own (partial) name can't contain a period (ISO 32000-2 §12.7.4.2) (#2696). @@ -234,7 +239,7 @@ pub(super) fn check_place(doc: &Document, af: ObjRef, field: ObjRef, parts: &[&s break; }; // A value, reset value or length limit held by a parent would pass to this field. - let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| d.contains(k) && !owned.contains(k))); + let held = doc.get(r).as_dict().is_some_and(|d| PASSED_VALUES.iter().any(|k| dict_has(doc, d, k) && !dict_has(doc, &owned, k))); if held { return invalid(format!("{part:?} holds a value for the fields under it, so {:?} can't be put there", parts.join("."))); } @@ -259,7 +264,7 @@ pub(super) fn attach(doc: &mut Document, af: ObjRef, field: ObjRef, parts: &[&st for part in parents { let next = match child_named(doc, af, parent, part) { Some(r) => { - inherits |= doc.get(r).as_dict().is_some_and(|d| d.contains(b"Ff") || d.contains(b"Q")); + inherits |= doc.get(r).as_dict().is_some_and(|d| dict_has(doc, d, b"Ff") || dict_has(doc, d, b"Q")); r } None => { @@ -308,12 +313,20 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict { } let Some(d) = doc.get(p).as_dict().cloned() else { break }; for k in INHERITED_KEYS { - if let Some(v) = d.get(k).filter(|_| !own.contains(k) && !add.contains(k)) { + if let Some(v) = d.get(k).filter(|_| dict_has(doc, &d, k) && !dict_has(doc, &own, k) && !add.contains(k)) { add.set(k.to_vec(), v.clone()); } } parent = d.reference(b"Parent"); } + // /Opt is inheritable for check boxes and radio buttons (Table 230), not choice + // fields (Table 234). Classify by effective /FT and /Ff before materializing it. + let effective = |key: &[u8]| if dict_has(doc, &own, key) { own.get(key) } else { add.get(key) }; + let button = effective(b"FT").is_some_and(|v| doc.resolve(v).as_name() == Some(b"Btn")); + let flags = effective(b"Ff").and_then(|v| doc.resolve(v).as_int()).and_then(|v| u32::try_from(v).ok()).unwrap_or(0); + if !button || flags & crate::flags::PUSH_BUTTON != 0 { + add.remove(b"Opt"); + } add } @@ -327,7 +340,7 @@ pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool { return false; } let Some(d) = doc.get(p).as_dict().cloned() else { return false }; - if d.contains(key) { + if dict_has(doc, &d, key) { return true; } parent = d.reference(b"Parent"); @@ -396,3 +409,162 @@ pub(super) fn top_names(doc: &Document) -> Vec { let list = af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default(); list.iter().filter_map(|o| doc.resolve(o).as_dict().and_then(|d| d.get(b"T").cloned())).filter_map(|t| crate::text_of(&doc.resolve(&t))).collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// /Opt is inheritable only for check boxes and radio buttons, using effective FT/Ff. + #[test] + fn inherited_options_are_limited_to_checkboxes_and_radios() { + for (ft, flags, expected) in + [("Btn", 0, true), ("Btn", crate::flags::RADIO, true), ("Btn", crate::flags::PUSH_BUTTON, false), ("Ch", 0, false), ("Tx", 0, false)] + { + for own_type in [false, true] { + let mut doc = Document::new_empty(); + let mut parent = Dict::new(); + parent.set(b"FT".to_vec(), Object::name(ft)); + parent.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + parent.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("export"))])); + parent.set(b"DA".to_vec(), PdfString::text("/Helv 10 Tf 0 g")); + let p = doc.add(parent); + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(p)); + if own_type { + child.set(b"FT".to_vec(), Object::name(ft)); + child.set(b"Ff".to_vec(), Object::Int(i64::from(flags))); + } + let c = doc.add(child); + let inherited = inherited(&doc, c); + assert_eq!(inherited.contains(b"Opt"), expected, "{ft}, flags {flags}, own {own_type}"); + assert!(inherited.contains(b"DA")); + } + } + } + + fn one_page() -> Document { + let mut doc = Document::new_empty(); + let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap(); + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages)); + page.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()])); + let page = doc.add(page); + doc.update_dict(pages, |d| { + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])); + d.set(b"Count".to_vec(), Object::Int(1)); + }) + .unwrap(); + doc + } + + fn reopen(doc: &Document) -> Document { + let bytes = pdfcraft_cos::write_incremental(doc, &pdfcraft_cos::SaveOptions::default()).unwrap(); + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + Document::open(std::sync::Arc::new(bytes)).unwrap() + } + + /// Moving a choice field must not turn its ancestor's unrelated /Opt into local choices. + #[test] + fn moving_choice_fields_does_not_copy_ancestor_options() { + for combo in [false, true] { + for own_options in [false, true] { + let mut doc = one_page(); + let options = if own_options { vec!["local".into()] } else { Vec::new() }; + let kind = if combo { crate::NewField::Combo { options, editable: false } } else { crate::NewField::List { options, multi: false } }; + crate::add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &kind, Some("old.pick")).unwrap(); + let f = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap(); + let parent = doc.get(f.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + if !own_options { + doc.update_dict(f.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + } + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), Object::Array(vec![Object::String(PdfString::text("borrowed"))]))).unwrap(); + let before = crate::fields(&doc).into_iter().find(|f| f.name == "old.pick").unwrap().options; + crate::set_props(&mut doc, "old.pick", &crate::FieldProps { name: Some("new.pick".into()), ..Default::default() }).unwrap(); + let after = crate::fields(&doc).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(doc.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + let reopened = reopen(&doc); + let after = crate::fields(&reopened).into_iter().find(|f| f.name == "new.pick").unwrap(); + assert_eq!(after.options, before); + assert_eq!(reopened.get(after.obj).as_dict().unwrap().contains(b"Opt"), own_options); + } + } + } + + #[test] + fn moving_buttons_preserves_inherited_export_mapping() { + for kind in [crate::NewField::CheckBox, crate::NewField::Radio { group: None, export: "0".into() }] { + let mut doc = one_page(); + crate::add_field(&mut doc, 0, [50.0, 700.0, 70.0, 720.0], &kind, Some("old.button")).unwrap(); + let field = crate::fields(&doc).into_iter().find(|f| f.name == "old.button").unwrap(); + let state = field.widgets.first().unwrap().on_state.clone().unwrap(); + let parent = doc.get(field.obj).as_dict().unwrap().reference(b"Parent").unwrap(); + let opt = Object::Array(vec![Object::String(PdfString::text("exported"))]); + doc.update_dict(parent, |d| d.set(b"Opt".to_vec(), opt.clone())).unwrap(); + doc.update_dict(field.obj, |d| { + d.remove(b"Opt"); + }) + .unwrap(); + crate::set_props(&mut doc, "old.button", &crate::FieldProps { name: Some("new.button".into()), ..Default::default() }).unwrap(); + for doc in [&doc, &reopen(&doc)] { + let after = crate::fields(doc).into_iter().find(|f| f.name == "new.button").unwrap(); + assert_eq!(doc.get(after.obj).as_dict().unwrap().get(b"Opt"), Some(&opt)); + assert_eq!(after.export_of(0), Some("exported")); + assert_eq!(after.state_for("exported"), Some(state.as_str())); + } + } + } + + #[test] + fn null_entries_on_parents_and_unsigned_signatures_are_treated_as_absent() { + let mut doc = Document::new_empty(); + let mut sig = Dict::new(); + sig.set(b"FT".to_vec(), Object::name("Sig")); + sig.set(b"T".to_vec(), PdfString::text("sig")); + sig.set(b"V".to_vec(), Object::Null); + let sig_ref = doc.add(Object::Dict(sig)); + assert!(!holds_value(&doc, sig_ref)); + + let mut grand = Dict::new(); + grand.set(b"FT".to_vec(), Object::name("Tx")); + grand.set(b"MaxLen".to_vec(), Object::Int(10)); + let grand_ref = doc.add(Object::Dict(grand)); + + let mut parent = Dict::new(); + parent.set(b"Parent".to_vec(), Object::Ref(grand_ref)); + parent.set(b"T".to_vec(), PdfString::text("group")); + parent.set(b"V".to_vec(), Object::Null); + parent.set(b"MaxLen".to_vec(), Object::Null); + let parent_ref = doc.add(Object::Dict(parent)); + + let mut child = Dict::new(); + child.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + child.set(b"T".to_vec(), PdfString::text("item")); + let child_ref = doc.add(Object::Dict(child)); + + let inh = inherited(&doc, child_ref); + assert_eq!(inh.get(b"MaxLen"), Some(&Object::Int(10))); + assert!(!inh.contains(b"V")); + assert!(!ancestor_sets(&doc, child_ref, b"V")); + assert!(ancestor_sets(&doc, child_ref, b"MaxLen")); + + // Button /Opt is inherited, text /Opt is not; null kids and /Parent cycles are handled. + let opt_val = Object::Array(vec![Object::String(PdfString::text("Choice1"))]); + doc.update_dict(grand_ref, |d| { + d.set(b"Opt".to_vec(), opt_val.clone()); + d.set(b"Parent".to_vec(), Object::Ref(parent_ref)); + d.set(b"Kids".to_vec(), Object::Array(vec![Object::Null, Object::Ref(parent_ref)])); + }) + .unwrap(); + assert!(!inherited(&doc, child_ref).contains(b"Opt")); + doc.update_dict(grand_ref, |d| d.set(b"FT".to_vec(), Object::name("Btn"))).unwrap(); + let inh_cycle = inherited(&doc, child_ref); + assert_eq!(inh_cycle.get(b"Opt"), Some(&opt_val)); + assert!(!holds_value(&doc, child_ref)); + assert_eq!(child_named(&doc, grand_ref, Some(grand_ref), "group"), Some(parent_ref)); + } +} diff --git a/crates/forms/src/detect.rs b/crates/forms/src/detect.rs index 94ad7adb37..99f2447c02 100644 --- a/crates/forms/src/detect.rs +++ b/crates/forms/src/detect.rs @@ -391,6 +391,8 @@ fn label_for(words: &[Word], rect: [f64; 4], right_of: bool) -> String { /// enough: dates split into day and month groups of two cells. Cells are square or taller /// than wide; an empty table of wider cells is not a comb. fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { + // A repeated box adds nothing to a row, but every repeat would be searched against every cell. + let mut seen = std::collections::HashSet::new(); let cells: Vec<[f64; 4]> = boxes .iter() .copied() @@ -398,6 +400,7 @@ fn combs(boxes: &[[f64; 4]]) -> Vec<([f64; 4], usize)> { let (w, h) = (r[2] - r[0], r[3] - r[1]); (5.0..=30.0).contains(&w) && (8.0..=40.0).contains(&h) && w <= h * 1.2 }) + .filter(|r| seen.insert(r.map(f64::to_bits))) .collect(); // `b` comes right after `a` in a row: same size, its left edge on a's right edge. let follows = |a: &[f64; 4], b: &[f64; 4]| { @@ -521,3 +524,17 @@ pub fn detect(words: &[Word], shapes: &Shapes, existing: &[[f64; 4]], taken: &[S } out } + +#[cfg(test)] +mod comb_tests { + use super::combs; + + #[test] + fn repeated_boxes_do_not_change_or_multiply_a_comb() { + let row = [[10.0, 10.0, 20.0, 22.0], [20.0, 10.0, 30.0, 22.0], [30.0, 10.0, 40.0, 22.0]]; + let mut many = row.to_vec(); + many.extend(std::iter::repeat_n(row[1], 5000)); + assert_eq!(combs(&row), vec![([10.0, 10.0, 40.0, 22.0], 3)]); + assert_eq!(combs(&many), combs(&row)); + } +} diff --git a/crates/forms/src/lib.rs b/crates/forms/src/lib.rs index b5eb12cc5a..0c39ea8648 100644 --- a/crates/forms/src/lib.rs +++ b/crates/forms/src/lib.rs @@ -232,14 +232,24 @@ 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. + /// 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 }; - match w.state.as_deref() { - Some(s) => s == on, - None => self.value.first().map(String::as_str) == Some(on), + 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 @@ -273,8 +283,13 @@ pub enum FieldValue { Text(String), /// Check box: checked or not. Check(bool), - /// Radio group: the on-state to select (`None`: none selected). + /// Radio group: the on-state to select (`None`: none selected). Of radio buttons sharing that + /// state, the one already on stays on, else the first, unless the group has RadiosInUnison. Radio(Option), + /// Check box or radio group: turn on widget `i` (an index into [`Field::widgets`]); it never + /// turns it off. A radio button sharing its on state with others is the only one turned on + /// unless the group has RadiosInUnison (ISO 32000-2 Table 229). + Widget(usize), /// Combo box or list box: export values (several only for multi-select lists). Choice(Vec), } diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index 27dbc2d224..6fcb46f2e1 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -174,7 +174,11 @@ fn actions_of(doc: &Document, d: &Dict, widgets: &[ObjRef], scripts: &mut Decode .and_then(|w| w.as_dict().and_then(|wd| wd.get(b"AA").map(|a| doc.resolve(a)))) .and_then(|a| a.as_dict().cloned()) }); - let mut out = af::Actions::default(); + let mut out = af::Actions { + // A date field the XFA layout generated: what its format doesn't read is kept as typed. + xfa_date: matches!(d.get(b"PCXfaDate").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))), + ..af::Actions::default() + }; let Some(aa) = aa else { return out }; let mut js = |k: &[u8]| aa.get(k).and_then(|a| script(doc, a, scripts)); if let Some(f) = js(b"F") { diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 5b60e3927b..5c46293303 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -137,23 +137,34 @@ pub fn tab_orders(doc: &Document) -> Vec { /// annotations keep their places. pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Result<(), FormError> { let all = fields(doc); - let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?; - let page = f.widgets.iter().filter_map(|w| w.page).min().ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; + if !all.iter().any(|f| f.name == name) { + return Err(FormError::NoSuchField(name.into())); + } + let (target_fi, page) = all + .iter() + .enumerate() + .filter(|(_, f)| f.name == name) + .find_map(|(fi, f)| Some((fi, f.widgets.iter().filter_map(|w| w.page).min()?))) + .ok_or_else(|| FormError::Invalid(format!("{name} is not on a page")))?; // The page's fields in their current tab order (a field counts once, at its first widget). - let mut order: Vec<(usize, &str)> = - all.iter().filter_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, g.name.as_str()))).collect(); + let mut order: Vec<(usize, usize)> = all + .iter() + .enumerate() + .filter_map(|(fi, g)| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.tab).min().map(|t| (t, fi))) + .collect(); order.sort(); - let names: Vec<&str> = order.iter().map(|(_, n)| *n).collect(); - let Some(i) = names.iter().position(|n| *n == name) else { return invalid(format!("{name} is not on a page")) }; - let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < names.len()) }; + let Some(i) = order.iter().position(|&(_, fi)| fi == target_fi) else { return invalid(format!("{name} is not on a page")) }; + let j = if earlier { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < order.len()) }; let Some(j) = j else { return Ok(()) }; - let mut names = names.into_iter().map(str::to_string).collect::>(); - names.swap(i, j); - // The widgets of this page in the new order. + order.swap(i, j); + // The widgets of this page in the new order, preserving each field's relative widget tab order. let mut widgets: Vec = Vec::new(); - for n in &names { - if let Some(g) = all.iter().find(|g| &g.name == n) { - widgets.extend(g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)); + let mut queued = std::collections::HashSet::new(); + for &(_, fi) in &order { + if let Some(g) = all.get(fi) { + let mut own: Vec<(usize, ObjRef)> = g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| (w.tab, w.obj)).collect(); + own.sort_by_key(|&(tab, _)| tab); + widgets.extend(own.into_iter().map(|(_, obj)| obj).filter(|obj| queued.insert(*obj))); } } let pr = *page_refs(doc).get(page).ok_or_else(|| FormError::Invalid("no such page".into()))?; @@ -162,10 +173,11 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul let list = doc.resolve(&annots_obj).as_array().cloned().unwrap_or_default(); let mut next = widgets.into_iter(); let set: std::collections::HashSet = all.iter().flat_map(|g| g.widgets.iter().filter(|w| w.page == Some(page)).map(|w| w.obj)).collect(); + let mut seen_slots = std::collections::HashSet::new(); let new_list: Vec = list .iter() .map(|o| match o.as_ref() { - Some(r) if set.contains(&r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), + Some(r) if set.contains(&r) && seen_slots.insert(r) => next.next().map(Object::Ref).unwrap_or_else(|| o.clone()), _ => o.clone(), }) .collect(); @@ -178,3 +190,85 @@ pub fn move_in_tab_order(doc: &mut Document, name: &str, earlier: bool) -> Resul })?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use pdfcraft_cos::{Dict, PdfString}; + + #[test] + fn move_in_tab_order_preserves_multi_widget_relative_tab_order() { + let mut doc = Document::new_empty(); + let pages_ref = doc.add(Object::Null); + let page_ref = doc.add(Object::Null); + + let mut w1 = Dict::new(); + w1.set(b"Subtype".to_vec(), Object::name("Widget")); + w1.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(200), Object::Int(700), Object::Int(220), Object::Int(720)])); + let w1_ref = doc.add(Object::Dict(w1)); + + let mut w2 = Dict::new(); + w2.set(b"Subtype".to_vec(), Object::name("Widget")); + w2.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(700), Object::Int(70), Object::Int(720)])); + let w2_ref = doc.add(Object::Dict(w2)); + + let mut radio = Dict::new(); + radio.set(b"FT".to_vec(), Object::name("Btn")); + radio.set(b"Ff".to_vec(), Object::Int(1 << 15)); + radio.set(b"T".to_vec(), PdfString::text("choice")); + // /Kids order is [w1 (right), w2 (left)], opposite to /R (Row) left-to-right order. + radio.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref)])); + let radio_ref = doc.add(Object::Dict(radio)); + doc.update_dict(w1_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + doc.update_dict(w2_ref, |d| d.set(b"Parent".to_vec(), Object::Ref(radio_ref))).unwrap(); + + let mut text = Dict::new(); + text.set(b"FT".to_vec(), Object::name("Tx")); + text.set(b"Subtype".to_vec(), Object::name("Widget")); + text.set(b"T".to_vec(), PdfString::text("note")); + text.set(b"Rect".to_vec(), Object::Array(vec![Object::Int(50), Object::Int(600), Object::Int(150), Object::Int(620)])); + let text_ref = doc.add(Object::Dict(text)); + + let mut page = Dict::new(); + page.set(b"Type".to_vec(), Object::name("Page")); + page.set(b"Parent".to_vec(), Object::Ref(pages_ref)); + page.set(b"Tabs".to_vec(), Object::name("R")); + page.set(b"Annots".to_vec(), Object::Array(vec![Object::Ref(w1_ref), Object::Ref(w2_ref), Object::Ref(text_ref)])); + doc.set(page_ref, Object::Dict(page)); + + let mut pages = Dict::new(); + pages.set(b"Type".to_vec(), Object::name("Pages")); + pages.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(page_ref)])); + pages.set(b"Count".to_vec(), Object::Int(1)); + doc.set(pages_ref, Object::Dict(pages)); + + let mut af = Dict::new(); + af.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref)])); + let af_ref = doc.add(Object::Dict(af)); + + let mut catalog = Dict::new(); + catalog.set(b"Type".to_vec(), Object::name("Catalog")); + catalog.set(b"Pages".to_vec(), Object::Ref(pages_ref)); + catalog.set(b"AcroForm".to_vec(), Object::Ref(af_ref)); + let root_ref = doc.root().unwrap(); + doc.set(root_ref, Object::Dict(catalog)); + + // Move "note" earlier (before "choice"): "choice"'s own buttons should stay in their + // /R tab order (w2 then w1), not revert to /Kids order (w1 then w2). + move_in_tab_order(&mut doc, "note", true).unwrap(); + let annots = doc.get(page_ref).as_dict().and_then(|d| d.get(b"Annots")).and_then(Object::as_array).cloned().unwrap(); + assert_eq!(annots, vec![Object::Ref(text_ref), Object::Ref(w2_ref), Object::Ref(w1_ref)]); + + // Missing page and unplaced field return errors cleanly. + assert!(set_tab_order(&mut doc, &[99], TabOrder::Row).is_err()); + let mut unplaced = Dict::new(); + unplaced.set(b"FT".to_vec(), Object::name("Tx")); + unplaced.set(b"T".to_vec(), PdfString::text("orphan")); + let unplaced_ref = doc.add(Object::Dict(unplaced)); + doc.update_dict(af_ref, |d| { + d.set(b"Fields".to_vec(), Object::Array(vec![Object::Ref(radio_ref), Object::Ref(text_ref), Object::Ref(unplaced_ref)])); + }) + .unwrap(); + assert!(move_in_tab_order(&mut doc, "orphan", true).is_err()); + } +} diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 434c0d3723..eff4f51c99 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -1701,6 +1701,43 @@ fn a_script_that_spells_the_markers_in_a_string_runs_as_a_script() { assert_eq!(field(&fields(&doc), "total").value, ["ran"]); } +/// An unsupported first AF call keeps the entire script and reaches the caller's interpreter. +#[test] +fn unsupported_first_validation_call_preserves_custom_script_dispatch() { + const JS: &str = "if (event.value < 50) AFRange_Validate(true, 10 + 5, false, 0); else AFRange_Validate(false, 0, true, 100);"; + struct RejectingInterpreter { + calls: usize, + } + impl Scripts for RejectingInterpreter { + fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, _fields: &[Field]) -> ScriptResult { + assert_eq!(event, FieldEvent::Validate); + assert_eq!(script, JS); + assert_eq!(target.name, "amount"); + self.calls += 1; + ScriptResult { rc: false, value: value.into(), changes: Vec::new(), message: Some("custom validation ran".into()) } + } + } + let mut doc = one_page(); + add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("amount")).unwrap(); + set_value(&mut doc, "amount", &FieldValue::Text("20".into())).unwrap(); + set_field_script(&mut doc, "amount", "validate", Some(JS)).unwrap(); + let reopened = reopen(&doc); + for mut doc in [doc, reopened] { + let all = fields(&doc); + let actions = &field(&all, "amount").actions; + assert_eq!(actions.validate, af::Validate::None); + assert_eq!(actions.scripts.validate.as_deref(), Some(JS)); + assert!(actions.unsupported.contains(&"validate")); + let mut scripts = RejectingInterpreter { calls: 0 }; + assert_eq!( + set_value_with(&mut doc, "amount", &FieldValue::Text("10".into()), &mut scripts), + Err(FormError::Invalid("custom validation ran".into())) + ); + assert_eq!(scripts.calls, 1); + assert_eq!(field(&fields(&doc), "amount").value, ["20"]); + } +} + /// Renaming a field renames it in the calculations that read it: a sum dropped it, notation /// read it as 0 and a script read a field that no longer existed. #[test] @@ -6393,6 +6430,123 @@ fn locked_button_export_edits_leave_the_document_unchanged() { } } +/// Three radio groups whose buttons have the on states given (`S`, `M` … or a repeated `Yes`): +/// `free` (Radio), `plain` (Radio | NoToggleToOff) and `unison` (… | RadiosInUnison). +fn radio_groups() -> Document { + let mut objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1 + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2 + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 9 0 R 10 0 R 11 0 R 13 0 R 14 0 R 15 0 R] >>".into(), // 3 + "<< /Fields [5 0 R 8 0 R 12 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4 + "<< /FT /Btn /T (free) /Ff 32768 /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 + ]; + let kid = |parent: usize, x: u32, state: &str| { + format!( + "<< /Type /Annot /Subtype /Widget /Parent {parent} 0 R /AS /Off /Rect [{x} 700 {} 715] /P 3 0 R /AP << /N << /{state} 16 0 R /Off 17 0 R >> >> >>", + x + 15 + ) + }; + objs.push(kid(5, 100, "S")); // 6 + objs.push(kid(5, 200, "M")); // 7 + objs.push("<< /FT /Btn /T (plain) /Ff 49152 /V /Off /Kids [9 0 R 10 0 R 11 0 R] >>".into()); // 8 + objs.push(kid(8, 100, "Yes")); // 9 + objs.push(kid(8, 200, "Yes")); // 10 + objs.push(kid(8, 300, "No")); // 11 + objs.push("<< /FT /Btn /T (unison) /Ff 33603584 /V /Off /Kids [13 0 R 14 0 R 15 0 R] >>".into()); // 12 + objs.push(kid(12, 100, "Yes")); // 13 + objs.push(kid(12, 200, "Yes")); // 14 + objs.push(kid(12, 300, "No")); // 15 + objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 16 + objs.push("<< /Length 0 >>\nstream\n\nendstream".into()); // 17 + document(&objs) +} + +/// Which widgets of field `name` are on, and its value. +fn on_widgets(doc: &Document, name: &str) -> (Vec, Vec) { + let all = fields(doc); + let f = field(&all, name); + ((0..f.widgets.len()).map(|i| f.widget_on(i)).collect(), f.value.clone()) +} + +/// ISO 32000-2 Table 229, RadiosInUnison: "If clear, the buttons are mutually exclusive"; and +/// §12.7.5.2.3: without it "at most one radio button in a field shall be set at a time". Two +/// buttons sharing the on state `Yes` both show `/V /Yes`: their `/AS` alone says which is on, +/// and it survives a save. +#[test] +fn radio_buttons_sharing_an_on_state_are_exclusive_unless_in_unison() { + let mut doc = radio_groups(); + set_value(&mut doc, "plain", &FieldValue::Widget(0)).unwrap(); + assert_eq!(on_widgets(&doc, "plain"), (vec![true, false, false], vec!["Yes".to_string()]), "only the clicked button"); + set_value(&mut doc, "plain", &FieldValue::Widget(1)).unwrap(); + let mut doc = reopen(&doc); + assert_eq!(on_widgets(&doc, "plain"), (vec![false, true, false], vec!["Yes".to_string()]), "the other Yes, after a save"); + // Setting the value by its name keeps the button already on. + set_value(&mut doc, "plain", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + assert_eq!(on_widgets(&doc, "plain").0, [false, true, false]); + set_value(&mut doc, "plain", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "plain"), (vec![false, false, true], vec!["No".to_string()])); + // From another state, the name turns on the first button that has it. + set_value(&mut doc, "plain", &FieldValue::Radio(Some("Yes".into()))).unwrap(); + assert_eq!(on_widgets(&doc, "plain").0, [true, false, false]); + // NoToggleToOff still keeps one on. + assert!(matches!(set_value(&mut doc, "plain", &FieldValue::Radio(None)), Err(FormError::Invalid(_)))); + // RadiosInUnison: the buttons sharing the state turn on and off together. + set_value(&mut doc, "unison", &FieldValue::Widget(1)).unwrap(); + assert_eq!(on_widgets(&doc, "unison"), (vec![true, true, false], vec!["Yes".to_string()])); + set_value(&mut doc, "unison", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "unison").0, [false, false, true]); + // Without NoToggleToOff the group can be cleared. + set_value(&mut doc, "free", &FieldValue::Widget(0)).unwrap(); + assert_eq!(on_widgets(&doc, "free"), (vec![true, false], vec!["S".to_string()])); + set_value(&mut doc, "free", &FieldValue::Radio(None)).unwrap(); + assert_eq!(on_widgets(&doc, "free"), (vec![false, false], vec![])); + // A widget that isn't there, or a field that isn't a button, is refused. + assert!(matches!(set_value(&mut doc, "free", &FieldValue::Widget(9)), Err(FormError::Invalid(_)))); + assert!(matches!(set_value(&mut doc, "free", &FieldValue::Widget(usize::MAX)), Err(FormError::Invalid(_)))); +} + +/// A radio group whose buttons share the on state `Yes`, have no `/AS` and whose `/V` is `Yes`: +/// field flags `ff` on the field, entries `own` on the second button's own dictionary. +fn radio_without_as(ff: u32, own: &str) -> Document { + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), + "<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), + format!("<< /FT /Btn /T (r) /Ff {ff} /V /Yes /Kids [6 0 R 7 0 R 8 0 R] >>"), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [100 700 115 715] /P 3 0 R /AP << /N << /Yes 9 0 R /Off 10 0 R >> >> >>".into(), + format!("<< /Type /Annot /Subtype /Widget /Parent 5 0 R {own} /Rect [200 700 215 715] /P 3 0 R /AP << /N << /Yes 9 0 R /Off 10 0 R >> >> >>"), + "<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [300 700 315 715] /P 3 0 R /AP << /N << /No 9 0 R /Off 10 0 R >> >> >>".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + "<< /Length 0 >>\nstream\n\nendstream".into(), + ]) +} + +/// Table 229: without RadiosInUnison the buttons are mutually exclusive. Buttons sharing the +/// state `/V` names and lacking `/AS` can't all be on: the first is, as a set by name picks it. +#[test] +fn radio_buttons_without_as_sharing_the_value_show_one_on() { + let doc = radio_without_as(49152, ""); + assert_eq!(on_widgets(&doc, "r").0, [true, false, false]); + // RadiosInUnison: they are on together. + let doc = radio_without_as(33603584, ""); + assert_eq!(on_widgets(&doc, "r").0, [true, true, false]); + // One that has `/AS` naming the state is the one on. + let doc = radio_without_as(49152, "/AS /Yes"); + assert_eq!(on_widgets(&doc, "r").0, [false, true, false]); +} + +/// Table 227, ReadOnly: a processor "shall not allow a user to change the value". A button whose own +/// dictionary sets it can't be turned on by number. +#[test] +fn a_read_only_button_is_not_turned_on_by_number() { + let mut doc = radio_without_as(49152, "/Ff 1"); + assert!(matches!(set_value(&mut doc, "r", &FieldValue::Widget(1)), Err(FormError::ReadOnly(_)))); + assert_eq!(on_widgets(&doc, "r").0, [true, false, false]); + set_value(&mut doc, "r", &FieldValue::Widget(2)).unwrap(); + assert_eq!(on_widgets(&doc, "r").0, [false, false, true]); +} + /// 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 { @@ -6419,13 +6573,6 @@ fn check_box_kids() -> Document { document(&objs) } -/// Which kids of check box `name` are ticked, and its value. -fn ticked(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 §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 @@ -6434,29 +6581,29 @@ fn ticked(doc: &Document, name: &str) -> (Vec, Vec) { 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!(ticked(&doc, "pair"), (vec![true, false], vec!["A".to_string()])); + 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!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "B, after a save"); + 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!(ticked(&doc, "pair").0, [false, true], "checked keeps the kid that is on"); + 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!(ticked(&doc, "pair"), (vec![false, false], vec![])); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, false], vec![])); set_value(&mut doc, "pair", &FieldValue::Check(true)).unwrap(); - assert_eq!(ticked(&doc, "pair").0, [true, false], "from off, checked takes the first kid"); + assert_eq!(on_widgets(&doc, "pair").0, [true, false], "from off, checked takes the first kid"); reset(&mut doc, None).unwrap(); - assert_eq!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "reset goes back to /DV /B"); + 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!(ticked(&doc, "same"), (vec![true, true], vec!["Yes".to_string()])); + assert_eq!(on_widgets(&doc, "same"), (vec![true, true], vec!["Yes".to_string()])); set_value(&mut doc, "same", &FieldValue::Check(false)).unwrap(); - assert_eq!(ticked(&doc, "same").0, [false, false]); + 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!(ticked(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "a script's B"); + assert_eq!(on_widgets(&doc, "pair"), (vec![false, true], vec!["B".to_string()]), "a script's B"); } /// A `/MK /BC` or `/BG` written `[1 0 0 null]` is CMYK with the null as 0, as annotation `/C` diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 3694334daa..6462c3804c 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -139,11 +139,11 @@ fn share_value(doc: &mut Document, source: ObjRef, twin: &Field, value: &[String match twin.kind { FieldKind::CheckBox => { let state = twin.widgets.iter().find_map(|w| w.on_state.clone()).unwrap_or_else(|| "Yes".into()); - set_states(doc, twin, (!value.is_empty()).then_some(state.as_str())) + set_states(doc, twin, (!value.is_empty()).then_some(state.as_str()), None) } FieldKind::Radio => { let choice = value.first().filter(|c| twin.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))); - set_states(doc, twin, choice.map(String::as_str))?; + set_states(doc, twin, choice.map(String::as_str), None)?; // The same value (§12.7.4.2), even when none of the twin's buttons draws it: they all // stay off, and only the field that draws the choice shows it. match value.first() { @@ -278,7 +278,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr return invalid(format!("{:?} can't take this text: {why}", f.name)); } // Keystroke (on commit) and Validate, as Acrobat runs them before accepting a value. - let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?; + let t = &f.actions.keystroke(&f.name, t).map_err(FormError::Format)?; af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?; let t = &scripting::accept(doc, f, t, scripts)?; let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V"); @@ -292,6 +292,16 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr })?; redraw(doc, f, std::slice::from_ref(t), scripts) } + (FieldKind::CheckBox | FieldKind::Radio, FieldValue::Widget(i)) => { + // A button can be read-only on its own (its own `/Ff`), though the field isn't. + if f.widgets.get(*i).is_some_and(|w| w.read_only) { + return Err(FormError::ReadOnly(f.name.clone())); + } + let Some(state) = f.widgets.get(*i).and_then(|w| w.on_state.clone()) else { + return invalid(format!("{:?} has no button {} with an on state", f.name, i.saturating_add(1))); + }; + set_states(doc, f, Some(&state), Some(*i)) + } (FieldKind::CheckBox, v) => { // 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). @@ -315,9 +325,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr } } FieldValue::Radio(None) => None, - FieldValue::Choice(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), + FieldValue::Choice(_) | FieldValue::Widget(_) => return invalid(format!("{:?} is a check box: use true or false", f.name)), }; - set_states(doc, f, state.as_deref()) + set_states(doc, f, state.as_deref(), None) } (FieldKind::Radio, v) => { let choice = match v { @@ -338,7 +348,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() { return invalid(format!("{:?} must keep one option selected", f.name)); } - set_states(doc, f, choice.as_deref()) + set_states(doc, f, choice.as_deref(), None) } (FieldKind::Combo | FieldKind::List, FieldValue::Choice(_) | FieldValue::Text(_)) => { let vals: Vec = match value { @@ -432,13 +442,24 @@ pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], co Ok(()) } -/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. -fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> { +/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget. The widgets whose on +/// state is `on` turn on together in a check box (ISO 32000-2 §12.7.5.2.3, EXAMPLE 2) and in a +/// radio group with RadiosInUnison. Without it, at most one radio button is on (Table 229, +/// §12.7.5.2.3): widget `pick` when it has that state, else the one already on, else the first. +fn set_states(doc: &mut Document, f: &Field, on: Option<&str>, pick: Option) -> Result<(), FormError> { + let exclusive = f.kind == FieldKind::Radio && !f.has(flags::RADIOS_IN_UNISON); + let chosen = on.filter(|_| exclusive).and_then(|c| { + let has = |i: &usize| f.widgets.get(*i).is_some_and(|w| w.on_state.as_deref() == Some(c)); + let n = f.widgets.len(); + pick.filter(has).or_else(|| (0..n).filter(has).find(|&i| f.widget_on(i))).or_else(|| (0..n).find(has)) + }); + // `/V` names the on state, the same for every widget that has it: which of them is on is + // their `/AS` alone (§12.7.5.2.4). let v = on.unwrap_or("Off"); doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?; - for w in &f.widgets { + for (i, w) in f.widgets.iter().enumerate() { let state = match (on, w.on_state.as_deref()) { - (Some(c), Some(s)) if c == s => s, + (Some(c), Some(s)) if c == s && chosen.is_none_or(|k| k == i) => s, _ => "Off", }; // A widget without appearances for its states gets PdfCraft's own. diff --git a/crates/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/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/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..c79c3d8667 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(); 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/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/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 1ef9a1f6de..35eac975f8 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,6 +580,8 @@ 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. @@ -672,19 +689,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 +730,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 +755,8 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> { count_sub(b"Link") + usize::from(cat.get(b"OpenAction").is_some_and(|a| doc.resolve(a).as_dict().is_some())) + owned(Hidden::LinksActionsScripts) - + annots.iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() - + sub(doc, &names, b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + + annots().iter().filter(|a| !a.contains(b"AA") && a.name(b"Subtype") == Some(b"Widget") && a.contains(b"A")).count() + + sub(doc, names(), b"JavaScript").map_or(0, |t| name_tree_len(doc, &t)) + outline_action_count(doc, &cat) } Hidden::PrivateData => owned(Hidden::PrivateData), @@ -764,36 +802,15 @@ fn strip_outline_actions(doc: &mut Document, cat: &mut Dict) -> Result<(), Redac /// sensitive content still present. Call this on a working clone and replace the original only /// on success. On any error, discard the working clone; do not save or reuse it. pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result, RedactError> { - let counts = scan(doc); - let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); - let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; - let mut done = Vec::new(); - // Every annotation the pages list now: whatever is no longer listed at the end (flattened - // widgets included) leaves the structure tree too. - let listed_before = listed_annotations(doc); - let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); - let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); - if fields { - // Fields without appearances get one first, so their values stay visible. - for f in pdfcraft_forms::fields(doc) { - if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { - pdfcraft_forms::redraw_field(doc, &f.name)?; - } - } - let n = pdfcraft_model::pages(doc).len(); - pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; - doc.update_dict(root, |d| { - d.remove(b"AcroForm"); - })?; - } - // Content next (it needs the layers still in place). Flattened appearances are page content - // by now, so hidden text or layers inside them are removed with the rest. - let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); - let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { - content_pass(doc, text, layers, true)?.1 - } else { - std::collections::HashSet::new() - }; + // Only what the chosen categories need is counted: a full scan reads every page's content + // twice and walks the whole object graph, which removing bookmarks alone doesn't need. + let counts = scan_only(doc, which); + remove_counted(doc, which, &counts, false) +} + +/// Take off every page the annotations the chosen categories remove (with `fields`, the widgets +/// flattening kept), and the actions of those that stay when links, actions and scripts go. +fn drop_page_annotations(doc: &mut Document, which: &[Hidden], fields: bool) -> Result<(), RedactError> { // Annotations: comments, attachments, links, actions, and the widgets flattening kept (hidden // ones, and ones with nothing to draw), which still hold their field's value and parent. let drop_annot = |a: &Dict| -> bool { @@ -805,13 +822,14 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = pdfcraft_model::pages(doc) + let pages = pdfcraft_model::pages(doc); + let removed: std::collections::HashSet = pages .iter() .flat_map(|p| annots_of(doc, &p.dict)) .filter_map(|a| a.as_ref()) .filter(|&r| doc.get(r).as_dict().is_some_and(drop_annot)) .collect(); - for p in pdfcraft_model::pages(doc) { + for p in pages { let list = annots_of(doc, &p.dict); let mut kept = Vec::new(); for a in list { @@ -842,12 +860,58 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result = listed_before.difference(&still).copied().collect(); - crate::tags::drop_object_refs(doc, &gone)?; + Ok(()) +} + +/// [`remove_hidden`] with `counts` from a scan of at least the categories in `which`. With +/// `every_page`, every page's annotation list is rewritten even when no chosen category changes +/// it, as removal did before it skipped that work; tests compare the two. +pub(crate) fn remove_counted( + doc: &mut Document, + which: &[Hidden], + counts: &[(Hidden, usize)], + every_page: bool, +) -> Result, RedactError> { + let count = |h: Hidden| counts.iter().find(|c| c.0 == h).map_or(0, |c| c.1); + let (root, _) = catalog(doc).ok_or(RedactError::NothingToApply)?; + let mut done = Vec::new(); + let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm")); + let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform); + // Pages' annotation lists change only for these; otherwise every page is left as it is. + let annotations = every_page || fields || which.iter().any(|h| matches!(h, Hidden::Comments | Hidden::Attachments | Hidden::LinksActionsScripts)); + // Every annotation the pages list now: whatever is no longer listed at the end (flattened + // widgets included) leaves the structure tree too. + let listed_before = if annotations { listed_annotations(doc) } else { std::collections::HashSet::new() }; + if fields { + // Fields without appearances get one first, so their values stay visible. + for f in pdfcraft_forms::fields(doc) { + if f.widgets.iter().any(|w| !doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"AP"))) { + pdfcraft_forms::redraw_field(doc, &f.name)?; + } + } + let n = pdfcraft_model::pages(doc).len(); + pdfcraft_edit::flatten(doc, &(0..n).collect::>(), false, true)?; + doc.update_dict(root, |d| { + d.remove(b"AcroForm"); + })?; + } + // Content next (it needs the layers still in place). Flattened appearances are page content + // by now, so hidden text or layers inside them are removed with the rest. + let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers)); + let retained_off_layers = if (text && (fields || count(Hidden::HiddenText) > 0)) || (layers && (fields || count(Hidden::HiddenLayers) > 0)) { + content_pass(doc, text, layers, true)?.1 + } else { + std::collections::HashSet::new() + }; + if annotations { + drop_page_annotations(doc, which, fields)?; + // A tagged PDF's structure tree still points at the removed links, comments and flattened + // widgets (#2447): drop those references, or the full rewrite keeps the annotations, URLs, + // text and field values included. + let still = listed_annotations(doc); + let gone: std::collections::HashSet = listed_before.difference(&still).copied().collect(); + crate::tags::drop_object_refs(doc, &gone)?; + } let off = hidden_layers(doc); let mut cat = doc.get(root).as_dict().cloned().unwrap_or_default(); let mut names = sub(doc, &cat, b"Names"); diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 6518817899..d4d16920ef 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -4113,6 +4113,7 @@ fn unused_descriptions_in_shared_or_inherited_resources_are_retired() { } mod descriptions; +mod hidden_scope; mod inline_images; mod limits; mod property_retirement; diff --git a/crates/redact/src/tests/hidden_scope.rs b/crates/redact/src/tests/hidden_scope.rs new file mode 100644 index 0000000000..a44546b31f --- /dev/null +++ b/crates/redact/src/tests/hidden_scope.rs @@ -0,0 +1,111 @@ +//! Remove Hidden Information does the work of the categories it is asked for, and no more: a +//! textbook of 1,412 pages took over 15 minutes to lose its bookmarks while every page's content +//! was read for hidden text and layers it was not removing (#5873). + +use super::*; +use crate::sanitize::{HIDDEN, Hidden, passes, remove_hidden, scan, scan_only}; + +/// Content passes and owner walks `work` makes on this thread. +fn passes_of(work: impl FnOnce() -> T) -> (T, (usize, usize)) { + let before = passes(); + let out = work(); + let after = passes(); + (out, (after.0 - before.0, after.1 - before.1)) +} + +/// Every category counted alone gives what the full scan gives it; lists come back in scan +/// order, once each. +#[test] +fn each_category_counts_alone_what_the_full_scan_counts() { + let doc = hidden_fixture(); + let full = scan(&doc); + assert_eq!(full.iter().map(|c| c.0).collect::>(), HIDDEN, "the full scan lists every category in order"); + assert!(full.iter().all(|c| c.1 > 0), "the fixture holds something in every category: {full:?}"); + for &(h, n) in &full { + assert_eq!(scan_only(&doc, &[h]), [(h, n)], "{h:?}"); + } + // Listed out of order and twice, the categories come back in scan order, once each. + let mut every: Vec = HIDDEN.into_iter().rev().collect(); + every.push(Hidden::Bookmarks); + assert_eq!(scan_only(&doc, &every), full); + assert!(scan_only(&doc, &[]).is_empty()); +} + +/// What each category costs: content passes for hidden text and layers, one shared object walk +/// for the owned entries, neither for the rest. +#[test] +fn a_category_reads_page_content_or_walks_every_object_only_when_it_needs_to() { + let doc = hidden_fixture(); + assert_eq!(passes_of(|| scan(&doc)).1, (2, 1), "hidden text and hidden layers read the pages; four categories share one walk"); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Bookmarks, Hidden::Comments, Hidden::FormFields])).1, (0, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::HiddenText])).1, (1, 0)); + assert_eq!(passes_of(|| scan_only(&doc, &[Hidden::Metadata, Hidden::PrivateData])).1, (0, 1), "one walk counts both"); +} + +/// Bookmarks alone: no content pass or walk, only the catalog edited, the rest still counted. +#[test] +fn removing_bookmarks_changes_the_catalog_alone() { + let mut doc = hidden_fixture(); + let before = scan(&doc); + // The fixture adds its /Info dictionary as an edit. + let edited = doc.modified_objects(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Bookmarks])); + assert_eq!(done.unwrap(), [(Hidden::Bookmarks, 2)]); + assert_eq!(work, (0, 0), "no page content read, no object walk"); + let root = doc.root().unwrap(); + let mut expected = [edited, vec![root.num]].concat(); + expected.sort_unstable(); + assert_eq!(doc.modified_objects(), expected, "the pages, their annotations and everything else stay as they were"); + let cat = doc.get(root).as_dict().cloned().unwrap(); + assert!(!cat.contains(b"Outlines") && !cat.contains(b"PageMode")); + // Every other category still holds what it held. + let after: Vec<(Hidden, usize)> = scan(&doc).into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect(); + assert_eq!(after, before.into_iter().filter(|c| c.0 != Hidden::Bookmarks).collect::>()); +} + +/// Hidden text alone: one pass to count it and one to remove it, no object walk. +#[test] +fn removing_hidden_text_reads_the_pages_for_it_alone() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::HiddenText])); + assert_eq!(done.unwrap(), [(Hidden::HiddenText, 13)]); + assert_eq!(work, (2, 0), "one pass to count the hidden text, one to remove it"); + let c = content(&doc, 0); + assert!(c.contains("(Visible)") && !c.contains("Hidden") && c.contains("(Layer)"), "{c}"); +} + +/// Comments alone still rewrite the pages' annotation lists, without reading their content. +#[test] +fn removing_comments_still_takes_them_off_every_page() { + let mut doc = hidden_fixture(); + let (done, work) = passes_of(|| remove_hidden(&mut doc, &[Hidden::Comments])); + assert_eq!(done.unwrap(), [(Hidden::Comments, 1)]); + assert_eq!(work, (0, 0)); + let p = pdfcraft_model::pages(&doc).swap_remove(0); + let left: Vec = annots_of(&doc, &p.dict).iter().filter_map(Object::as_ref).collect(); + // The square and its pop-up go; the widget, the link and the attachment stay. + assert_eq!(left, [ObjRef::new(17, 0), ObjRef::new(18, 0), ObjRef::new(19, 0)]); +} + +/// Each category alone, every pair and all of them remove what the full scan and a rewrite of +/// every page's annotation list removed: the same report and the same bytes in a full save. +#[test] +fn removal_writes_what_it_wrote_with_the_full_scan() { + let mut cases: Vec> = HIDDEN.iter().map(|&h| vec![h]).collect(); + for (i, &a) in HIDDEN.iter().enumerate() { + cases.extend(HIDDEN[i + 1..].iter().map(|&b| vec![a, b])); + } + cases.push(HIDDEN.to_vec()); + assert_eq!(cases.len(), 9 + 36 + 1); + for which in cases { + let mut before = hidden_fixture(); + let counts = scan(&before); + let old = crate::sanitize::remove_counted(&mut before, &which, &counts, true); + let mut after = hidden_fixture(); + let new = remove_hidden(&mut after, &which); + assert_eq!(new, old, "{which:?}"); + // Removal gives the file a new identifier, salted at random unless the caller pins it. + let save = SaveOptions { id_entropy: Some([7; 16]), ..SaveOptions::default() }; + assert!(write_full(&after, &save).unwrap() == write_full(&before, &save).unwrap(), "{which:?}: the saved files differ"); + } +} diff --git a/crates/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/raster/tests.rs b/crates/render/src/raster/tests.rs index 679e42cd5e..e42ed7c1cc 100644 --- a/crates/render/src/raster/tests.rs +++ b/crates/render/src/raster/tests.rs @@ -5778,6 +5778,28 @@ fn content_bombs_stop_at_the_budget_and_say_so() { assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "the cut content is observable"); } +/// The text of a cut page says so too (#623): what was extracted is incomplete, and a page +/// read whole is not marked. +#[test] +fn cut_content_marks_the_pages_text_incomplete() { + let text_of = |pdf: Vec| { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false })); + }); + rx.recv_timeout(std::time::Duration::from_secs(60)).expect("text extraction must not stall") + }; + let bomb = zlib_zeros(hayro::hayro_syntax::MAX_DECODED_STREAM + (32 << 20)); + let page = text_of(one_page_pdf(&bomb, "/Filter /FlateDecode")); + assert!(page.error.is_none(), "{:?}", page.error); + assert!(page.text.as_ref().is_some_and(|t| t.truncated), "the text says it is incomplete"); + assert!(page.warnings.contains(&RenderWarning::ContentTruncated), "and the page says so"); + let page = text_of(one_page_pdf(b"1 0 0 rg 0 0 4 4 re f", "")); + assert!(page.text.as_ref().is_some_and(|t| !t.truncated), "a page read whole is not marked"); + assert!(page.warnings.is_empty()); +} + /// A stream read in pieces of any size gives the same bytes: stored and dynamic blocks, and in /// a `/Contents` array a stream that is not Flate-coded, between two that are. #[test] @@ -10507,6 +10529,101 @@ fn content_after_an_inline_image_holding_ei_is_still_read() { assert!(page.contains("AFTER"), "the content after the image was lost: {page:?}"); } +/// #5631: a reply of type R (`/IRT`, no `/RT` or `/RT /R`) is not drawn on the page, whatever +/// its appearance: ISO 32000-2 Table 172, "shall not display replies to an annotation +/// individually". The comment it answers draws, and so does a `/RT /Group` member. +#[test] +fn replies_are_not_drawn_but_their_comment_and_group_members_are() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // A reply with no /RT, and one with /RT /R, to it and to that reply: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 5 0 R /RT /R /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A member of the comment's group: green. + "<< /Type /Annot /Subtype /Square /F 4 /IRT 4 0 R /RT /Group /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + // The middle of each annotation, top-left based at scale 1. + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, reply, reply_r, member) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(reply.iter().all(|v| *v > 240), "a reply with no /RT is not drawn: {reply:?}"); + assert!(reply_r.iter().all(|v| *v > 240), "a reply with /RT /R is not drawn: {reply_r:?}"); + assert!(member[1] > 200 && member[0] < 60, "a group member draws: {member:?}"); +} + +/// #5632 review: an `/RT` that isn't `/Group` reads as the default R, as the comment list reads +/// it, so a reply with an unknown name or a string there isn't drawn either. `/IRT` is a markup +/// annotation's entry: a form field carrying one still draws. +#[test] +fn an_unknown_or_malformed_rt_is_a_reply_and_a_widget_with_irt_still_draws() { + let form = |rgb: &str| { + let content = format!("{rgb} rg 0 0 20 18 re f"); + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 20 18] /Length {} >>\nstream\n{content}\nendstream", content.len()) + }; + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 100] /Annots [4 0 R 5 0 R 6 0 R 7 0 R] >>".to_string(), + // The comment: blue. + "<< /Type /Annot /Subtype /Text /F 28 /Rect [10 40 30 58] /AP << /N 8 0 R >> >>".to_string(), + // Replies with an unknown /RT name and with a string there: red. + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT /Answer /Rect [50 40 70 58] /AP << /N 9 0 R >> >>".to_string(), + "<< /Type /Annot /Subtype /Text /F 28 /IRT 4 0 R /RT (Group) /Rect [90 40 110 58] /AP << /N 9 0 R >> >>".to_string(), + // A form field with a stray /IRT: green. + "<< /Type /Annot /Subtype /Widget /F 4 /IRT 4 0 R /Rect [130 40 150 58] /AP << /N 10 0 R >> >>".to_string(), + form("0 0 1"), + form("1 0 0"), + form("0 1 0"), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes()); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes()); + let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default()); + let p = r.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(p.error.is_none(), "{:?}", p.error); + let px = |x: u32| -> [u8; 4] { p.rgba[((51 * p.width + x) * 4) as usize..][..4].try_into().unwrap() }; + let (comment, unknown, string, widget) = (px(20), px(60), px(100), px(140)); + assert!(comment[2] > 200 && comment[0] < 60, "the comment draws: {comment:?}"); + assert!(unknown.iter().all(|v| *v > 240), "a reply with an unknown /RT is not drawn: {unknown:?}"); + assert!(string.iter().all(|v| *v > 240), "a reply with a string /RT is not drawn: {string:?}"); + assert!(widget[1] > 200 && widget[0] < 60, "a form field with /IRT draws: {widget:?}"); +} + /// `/Crypt` has nothing to undo, so a chain naming it thousands of times costs no copy of the data /// for each. A chain of filters that does work has its own cost, which this isn't about. #[test] @@ -10872,19 +10989,27 @@ trailer << /Root 1 0 R >> /// A stream with no `endstream` after it, as a producer that writes `endstrea` after every stream /// leaves it, is the bytes its `/Length` gives: "how many bytes of the PDF file are used for the /// stream's data" (ISO 32000-2 7.3.8.2). Nothing found its end, so the page's content was lost and -/// the page drew blank, while other readers draw it. Vendored hayro-syntax patch 36. +/// the page drew blank, while other readers draw it. The objects after it are still read: a second +/// content stream paints the right half. A `/Length` past the end of the file, or none, leaves the +/// stream unreadable and its half blank, without a panic. Vendored hayro-syntax patch 36. #[test] fn a_content_stream_without_endstream_is_drawn_to_its_length() { use super::*; - let content = "1 0 0 rg 0 0 200 200 re f"; - let centre = |end: &str| -> [u8; 4] { + const RED: [u8; 4] = [255, 0, 0, 255]; + const BLUE: [u8; 4] = [0, 0, 255, 255]; + const WHITE: [u8; 4] = [255, 255, 255, 255]; + let left = "1 0 0 rg 0 0 100 200 re f"; + let right = "0 0 1 rg 100 0 100 200 re f"; + // The left and right pixels of a page whose content is objects 4 and 5, with `stream_4` the + // whole of object 4 and `rest` written after the trailer. + let pixels = |stream_4: &str, rest: &str| -> [[u8; 4]; 2] { let page = format!( "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\ - 4 0 obj << /Length {} >> stream\n{content}\n{end}\nendobj\n\ - trailer << /Root 1 0 R >>\n%%EOF", - content.len() + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents [4 0 R 5 0 R] >> endobj\n\ + 5 0 obj << /Length {} >> stream\n{right}\nendstream\nendobj\n\ + {stream_4}trailer << /Root 1 0 R >>\n{rest}", + right.len() ); let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { page: 0, @@ -10894,15 +11019,101 @@ fn a_content_stream_without_endstream_is_drawn_to_its_length() { tag: 0, preview: false, }); - let start = (100 * page.width as usize + 100) * 4; - let mut pixel = [0; 4]; - pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("centre pixel")); - pixel + [50, 150].map(|x| { + let start = (100 * page.width as usize + x) * 4; + let mut pixel = [0; 4]; + pixel.copy_from_slice(page.rgba.get(start..start + 4).expect("pixel")); + pixel + }) }; - assert_eq!(centre("endstream"), [255, 0, 0, 255]); + let object_4 = |length: &str, end: &str| format!("4 0 obj << {length} >> stream\n{left}\n{end}\nendobj\n"); + let length = format!("/Length {}", left.len()); + assert_eq!(pixels(&object_4(&length, "endstream"), "%%EOF"), [RED, BLUE]); for end in ["endstrea", "", "end"] { - assert_eq!(centre(end), [255, 0, 0, 255], "{end:?}"); + assert_eq!(pixels(&object_4(&length, end), "%%EOF"), [RED, BLUE], "{end:?}"); } + // The last object in the file, which ends with its data. + let last = format!("4 0 obj << {length} >> stream\n{left}"); + assert_eq!(pixels("", &last), [RED, BLUE], "at the end of the file"); + // A /Length past the end of the file, and none: object 4 can't be read and its half stays + // blank (as does the rest of the page: a `/Contents` array with an unreadable member isn't + // drawn, as before this change). + let past = format!("4 0 obj << /Length {} >> stream\n{left}", left.len() + 1); + assert_eq!(pixels("", &past)[0], WHITE, "past the end of the file"); + assert_eq!(pixels(&object_4("", "endstrea"), "%%EOF")[0], WHITE, "no /Length"); +} + +/// The raw bytes hayro-syntax reads for object 4 of a file whose objects are `objects`. +fn hayro_stream_4(objects: &str) -> Vec { + use hayro_syntax::object::{ObjectIdentifier, Stream}; + let pdf = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 20 20] >> endobj\n\ + {objects}trailer << /Root 1 0 R >>\n%%EOF" + ); + let parsed = hayro_syntax::Pdf::new(pdf.into_bytes()).unwrap(); + let stream = parsed.xref().get::>(ObjectIdentifier::new(4, 0)).expect("object 4 is a stream"); + stream.raw_data().to_vec() +} + +/// A stream whose `endstream` is misspelt (`endstrea`) or left out before `endobj` is its +/// `/Length` in hayro-syntax too, not the bytes up to a later object's `endstream` (vendored +/// hayro-syntax patch 36). +#[test] +fn hayro_reads_a_stream_without_endstream_to_its_length_before_a_later_one() { + for end in ["endstrea\nendobj", "endobj"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}\n5 0 obj << /Length 5 >> stream\nWORLD\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), b"HELLO", "{end:?}"); + } +} + +/// A wrong `/Length` that stops before payload bytes beginning like an end marker (`endobject`, +/// `endstreak`, `endstreamer`) does not end the stream there: a token runs on through regular +/// characters (ISO 32000-2 7.2.3), and the search for `endstream` finds the whole payload, past +/// `endstreamer` too. Neither does a whole `endobj` or `endstrea` that the object does not end +/// after (`endobj more`); one followed by another object's header, by `trailer` or by the end of +/// the data still does. +#[test] +fn hayro_does_not_end_a_stream_inside_a_longer_word() { + for word in ["endobject", "endstreak", "endstreamer", "endobj more", "endstrea more", "endstrea endobj more"] { + let objects = format!("4 0 obj << /Length 5 >> stream\nHELLO {word} more\nendstream\nendobj\n"); + assert_eq!(hayro_stream_4(&objects), format!("HELLO {word} more").as_bytes(), "{word}"); + } + for end in ["endobj\n", "endstrea\n", "endstrea\nendobj\n%c\n"] { + assert_eq!(hayro_stream_4(&format!("4 0 obj << /Length 5 >> stream\nHELLO\n{end}")), b"HELLO", "{end:?} then trailer"); + } +} + +/// A stream ending in `endstrea` is its `/Length` even when a later object has an `endstream`: +/// the search for one ran on into that object. Here the content is ASCIIHex-encoded without its +/// `>` end marker, so reading on took the next object's header as hex data, which it isn't, and +/// the page drew blank instead of red. +#[test] +fn a_content_stream_without_endstream_is_not_read_into_a_later_stream() { + use super::*; + let content: String = "1 0 0 rg 0 0 200 200 re f".bytes().map(|b| format!("{b:02X}")).collect(); + let unused = "0 1 0 rg 0 0 200 200 re f"; + let page = format!( + "%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >> endobj\n\ + 4 0 obj << /Length {} /Filter /ASCIIHexDecode >> stream\n{content}\nendstrea\nendobj\n\ + 5 0 obj << /Length {} >> stream\n{unused}\nendstream\nendobj\n\ + trailer << /Root 1 0 R >>\n%%EOF", + content.len(), + unused.len() + ); + let page = PageRenderer::new(Arc::new(page.into_bytes()), RenderConfig::default()).render(RenderRequest { + page: 0, + kind: RequestKind::Pixels, + tile: None, + scale: 1.0, + tag: 0, + preview: false, + }); + let start = (100 * page.width as usize + 100) * 4; + assert_eq!(page.rgba.get(start..start + 4), Some(&[255, 0, 0, 255][..])); } /// A ToUnicode CMap with a token the scanner can't read before its mappings (a stray `>` after diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index bc132466b1..abe506ca07 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -11,7 +11,8 @@ use hayro::hayro_interpret::font::Glyph; use hayro::hayro_interpret::hayro_syntax::Pdf; use hayro::hayro_interpret::{ - BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask, interpret_page, + BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, InterpreterWarning, Paint, PathDrawMode, + SoftMask, interpret_page, }; use kurbo::{Affine, BezPath, Rect, Shape}; use std::collections::{HashMap, HashSet}; @@ -100,6 +101,9 @@ pub struct PageText { pub line_of: Vec, /// `true` when a word break precedes the glyph on the same line. pub space_before: Vec, + /// The page's content was cut at the renderer's limits (#623), so this text is incomplete: + /// whatever the cut part drew is missing, and a search over it can miss. + pub truncated: bool, } impl PageText { @@ -315,13 +319,21 @@ impl PageText { /// turned by `/Rotate`) rows mean nothing, and the lines come out one per row in reading order /// instead. pub fn column_text(&self, glyphs: &[usize]) -> String { - // The selected glyphs of each line, with their box: (glyphs, bbox). + // The selected glyphs of each line, with their box: (glyphs, bbox). A line is cut where a gap wider than one run + // of text can hold separates two glyphs (`arrange` joins runs on one baseline into a line, as table cells are), so + // cells keep their tab. let mut lines: Vec<(Vec, [f32; 4])> = Vec::new(); let mut line_ids: Vec = Vec::new(); + let mut previous: Option<&TextGlyph> = None; for &i in glyphs { let (Some(g), Some(&line)) = (self.glyphs.get(i), self.line_of.get(i)) else { continue }; + let apart = previous.is_some_and(|p| { + let h = (p.rect[3] - p.rect[1]).max(g.rect[3] - g.rect[1]); + g.rect[0] - p.rect[2] >= CELL_GAP * h + }); + previous = Some(g); match (lines.last_mut(), line_ids.last()) { - (Some((members, bb)), Some(&l)) if l == line => { + (Some((members, bb)), Some(&l)) if l == line && !apart => { members.push(i); union(bb, &g.rect); } @@ -386,9 +398,9 @@ mod device; mod layout; mod order; -use device::WORD_GAP; +use device::{CELL_GAP, WORD_GAP}; #[cfg(test)] -use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice}; +use device::{MAX_REDRAW_TEXT, REDRAW_TEXT_BYTES, Replaced, TextDevice, ligature_letters}; pub(crate) use device::{extract_page, extract_page_filtered}; pub use layout::layout; #[cfg(test)] diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index a04e380788..1a6d1aadb5 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,7 +1,7 @@ -use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; +use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, union, unrolled}; use super::{ - Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, - Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, + InterpreterWarning, PageText, Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, }; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be @@ -13,6 +13,10 @@ pub(super) const DIRECTION_TOLERANCE: f64 = WORD_GAP as f64; /// A word gap is wider than the line's typical letter gap by this fraction of the line height. pub(super) const WORD_GAP: f32 = 0.15; +/// A gap of this many heights between two glyphs of a line is wider than one run of text holds (`arrange` continues +/// a run across less than three heights), so it separates cells that share a baseline. +pub(super) const CELL_GAP: f32 = 3.0; + /// A glyph with its writing direction to the nearest quarter turn (right, down, left, up) and as /// a unit vector. pub(super) type Drawn = (usize, [f64; 2], TextGlyph); @@ -397,6 +401,35 @@ impl TextDevice { } } +/// `text` with each Latin compatibility ligature (U+FB00–U+FB06: ff, fi, fl, ffi, ffl, long s t, +/// st) written as the letters it stands for, their compatibility decomposition. A font whose +/// encoding names a ligature glyph (`/fi`) reads as the ligature character, and copied or searched +/// text wants the letters, as a ToUnicode map gives them (ISO 32000-2 §9.10.3 maps the ff, fi and +/// ffl glyphs to "LATIN SMALL LETTER F … I"). Nothing else is normalised. The glyph keeps its one +/// box: the PDF gives no position for the letters inside it. +pub(super) fn ligature_letters(text: String) -> String { + let letters = |c: char| match c { + '\u{FB00}' => Some("ff"), + '\u{FB01}' => Some("fi"), + '\u{FB02}' => Some("fl"), + '\u{FB03}' => Some("ffi"), + '\u{FB04}' => Some("ffl"), + '\u{FB05}' | '\u{FB06}' => Some("st"), + _ => None, + }; + if !text.chars().any(|c| letters(c).is_some()) { + return text; + } + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match letters(c) { + Some(s) => out.push_str(s), + None => out.push(c), + } + } + out +} + /// Whether `PDFCRAFT_TEXT_DEBUG` is set. `draw_glyph` runs once per glyph and `getenv` scans the /// whole environment under a lock, so read it once. fn text_debug() -> bool { @@ -450,6 +483,7 @@ impl<'a> Device<'a> for TextDevice { if !replaced && text.chars().all(char::is_control) { return; } + let text = ligature_letters(text); // Glyph space → view space. Glyph space uses 1000 units per em for outline glyphs; the em // box spans descender (−200) to ascender (800), the advance gives the width. let t = transform * glyph_transform; @@ -573,7 +607,22 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret let cache = InterpreterCache::new(); // Annotation appearances are included: form-field values and comment text are searchable, // as in Acrobat, and match what other extractors report. - let settings = settings.clone(); + // The interpreter says when the content was cut at its limits (#623): the text is then + // marked incomplete, and the caller's own sink still hears it. + let cut = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let settings = { + let flag = cut.clone(); + let inner = settings.warning_sink.clone(); + InterpreterSettings { + warning_sink: std::sync::Arc::new(move |warning| { + if matches!(warning, InterpreterWarning::ContentTruncated) { + flag.store(true, std::sync::atomic::Ordering::Relaxed); + } + inner(warning); + }), + ..settings.clone() + } + }; let initial = p.initial_transform(true).to_kurbo(); let c = p.intersected_crop_box(); let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings); @@ -638,7 +687,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret flows.sort_by(|a, b| a.top.total_cmp(&b.top)); flows }; - let flows = upright(quarters); + let mut flows = upright(quarters); + let (main, mut inline, place) = turned_lines_in_place(&glyphs, &mut flows); let mut extra: Vec = curves .into_iter() .map(|ids| (None, ids)) @@ -684,7 +734,8 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret text.space_before.push(space); } }; - for flow in flows { + let placed = |ids: &[usize]| ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX); + for (fi, flow) in flows.into_iter().enumerate() { let mut k = 0; while k < flow.ids.len() { let line = flow.line_of[k]; @@ -693,12 +744,99 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret while let Some(f) = extra.next_if(|f| first(&f.ids) < before) { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + if Some(fi) == main { + let at = placed(&flow.ids[k..end]); + while let Some(l) = inline.next_if(|l| l.at < at) { + emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter); + } + } emit(&mut text, &flow.ids[k..end], &vec![0; end - k], &flow.space_before[k..end], flow.quarter); k = end; } + if Some(fi) == main { + for l in inline.by_ref() { + emit(&mut text, &l.ids, &vec![0; l.ids.len()], &l.space_before, l.quarter); + } + } } for f in extra { emit(&mut text, &f.ids, &f.line_of, &f.space_before, None); } + text.truncated = cut.load(std::sync::atomic::Ordering::Relaxed); Ok(text) } + +/// A line turned a quarter from the page's main text, read in place among it. +struct InPlace { + /// Where laying it out with the main text puts its first glyph. + at: usize, + ids: Vec, + space_before: Vec, + /// The quarter turn of the flow it came from. + quarter: Option, +} + +/// Takes out of `flows` the lines that are turned a quarter from the page's main text (the unturned flow, when it +/// holds more glyphs than any other) and sit inside that text's area: a label set upwards beside a row, a turned +/// table heading. Each is placed where laying it out among the main text, by its box on the page, puts its first +/// glyph, rather than after all the main text. Lines outside the area (a stamp in the margin) and every other flow +/// keep their place, as does a page whose main text is vertical. +/// +/// Returns the main flow's index, the lines in place order, and each glyph's place in that layout. +fn turned_lines_in_place(glyphs: &[Drawn], flows: &mut [Flow]) -> (Option, std::iter::Peekable>, Vec) { + let nothing = || (None, Vec::new().into_iter().peekable(), Vec::new()); + let Some((main, size)) = flows.iter().enumerate().find(|(_, f)| f.quarter == Some(0)).map(|(i, f)| (i, f.ids.len())) else { return nothing() }; + if flows.iter().enumerate().any(|(i, f)| i != main && f.ids.len() >= size) { + return nothing(); + } + let rect = |i: usize| glyphs.get(i).map_or([0.0; 4], |g| g.2.rect); + let bounds = |ids: &[usize]| { + let mut b = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY]; + for &i in ids { + union(&mut b, &rect(i)); + } + b + }; + let Some(area) = flows.get(main).map(|f| bounds(&f.ids)) else { return nothing() }; + let mut lines = Vec::new(); + for (fi, flow) in flows.iter_mut().enumerate() { + // Upside-down text (a half turn) is not turned a quarter, and keeps its own flow. + if fi == main || !matches!(flow.quarter, Some(1 | 3)) { + continue; + } + let (mut ids, mut line_of, mut space_before) = (Vec::new(), Vec::new(), Vec::new()); + let mut k = 0; + while let Some(&line) = flow.line_of.get(k) { + let end = flow.line_of.get(k..).and_then(|rest| rest.iter().position(|l| *l != line)).map_or(flow.line_of.len(), |n| k + n); + let (Some(line_ids), Some(line_spaces)) = (flow.ids.get(k..end), flow.space_before.get(k..end)) else { break }; + let b = bounds(line_ids); + let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0); + if (area[0]..=area[2]).contains(&cx) && (area[1]..=area[3]).contains(&cy) { + lines.push(InPlace { at: usize::MAX, ids: line_ids.to_vec(), space_before: line_spaces.to_vec(), quarter: flow.quarter }); + } else { + ids.extend_from_slice(line_ids); + line_of.extend(std::iter::repeat_n(line, line_ids.len())); + space_before.extend_from_slice(line_spaces); + } + k = end; + } + (flow.ids, flow.line_of, flow.space_before) = (ids, line_of, space_before); + } + if lines.is_empty() { + return nothing(); + } + let ids: Vec = + flows.get(main).map_or(&[][..], |f| &f.ids[..]).iter().copied().chain(lines.iter().flat_map(|l| l.ids.iter().copied())).collect(); + let rects = ids.iter().map(|&i| rect(i)).collect(); + let mut place = vec![usize::MAX; glyphs.len()]; + for (r, i) in arrange_flow(glyphs, ids, rects, Some(0)).ids.into_iter().enumerate() { + if let Some(p) = place.get_mut(i) { + *p = r; + } + } + for l in &mut lines { + l.at = l.ids.iter().filter_map(|&i| place.get(i).copied()).min().unwrap_or(usize::MAX); + } + lines.sort_by_key(|l| l.at); + (Some(main), lines.into_iter().peekable(), place) +} diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index e147cda84d..334599318b 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,7 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{ - NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, reverse_rtl, + NONE, REDRAW_CANDIDATES, Run, SAME_LINE_GAP, Scratch, Seg, fingerprint, is_script_on, measure_cluster_gaps, place_glyphs, redraws, restarts_run, + reverse_rtl, same_line, }; use super::{HashMap, PageText, TextGlyph}; @@ -183,13 +184,17 @@ pub(super) fn union(a: &mut [f32; 4], b: &[f32; 4]) { /// (text on a baseline grid), it counts as a row and the top-most rule applies, so the column /// order of #3658 is lost there. Telling prose from a row in that case needs more evidence. /// 4. Within a segment glyphs run left to right; runs of right-to-left script are reversed so the -/// text comes out in logical order. +/// text comes out in logical order. A block whose first line sits on the baseline of the line read +/// just before it, to its right and less than [`SAME_LINE_GAP`] heights away, continues that line +/// after a space (a label and its value, words drawn out of order or in another size). A block of +/// several lines does so only after a one-line block narrower than [`SAME_LINE_GAP`] heights (a +/// label): beside a wider one, or the last line of a column, it is the next column. pub fn layout(glyphs: Vec) -> PageText { let (order, line_of, space_before) = arrange(&glyphs); let mut slots: Vec> = glyphs.into_iter().map(Some).collect(); // Each glyph is emitted exactly once. let glyphs = order.iter().filter_map(|i| slots.get_mut(*i).and_then(Option::take)).collect(); - PageText { glyphs, line_of, space_before } + PageText { glyphs, line_of, space_before, truncated: false } } /// [`layout`] as the reading order of indices into `glyphs`, with each one's line and word gap. @@ -456,8 +461,10 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { // `spaces[w]`: a word gap left of the w-th glyph of the segment, by position on the line, not by drawing order // (#1062). Reused by every segment. let mut spaces: Vec = Vec::new(); + // The segment emitted last, whether it had right-to-left text, and whether its block is that one line. + let mut last: Option<(usize, bool, bool)> = None; for b in block_order { - for &si in &blocks[b].0 { + for (k, &si) in blocks[b].0.iter().enumerate() { place_glyphs(&glyphs, &height, &segs[si], &mut sc); let h = segs[si].h; measure_cluster_gaps(&glyphs, &mut sc); @@ -501,7 +508,13 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { let tight_cluster = (loose_sign(pt) || loose_sign(ct)) && gap < h * 0.6; let run_start = sc.breaks.binary_search(&c).is_ok(); if let Some(space) = spaces.get_mut(w + 1) { - *space = (gap > threshold || run_start) && !cjk && !tight_cluster && !pt.trim().is_empty() && !ct.trim().is_empty(); + *space = (gap > threshold || run_start) + && !cjk + && !tight_cluster + && !pt.trim().is_empty() + && !ct.trim().is_empty() + && !pt.ends_with(char::is_whitespace) + && !ct.starts_with(char::is_whitespace); } } // Right-to-left spans are reversed (visual → logical) with their gaps, and only then is a second copy made. @@ -512,13 +525,51 @@ fn arrange(glyphs: &[TextGlyph]) -> (Vec, Vec, Vec) { reverse_rtl(&glyphs, &sc.anchor, &mut sc.logical, &mut spaces); } let idx: &[usize] = if has_rtl { &sc.logical } else { &sc.placed }; + let joined = joins_line(&segs, last, si, k == 0, blocks[b].0.len() > 1, has_rtl); + let start = order.len(); order.extend_from_slice(idx); - line_of.resize(order.len(), line); - // `spaces[0]` stays false: the first glyph of a segment never takes a space. + // `spaces[0]` stays false: the first glyph of a segment never takes a space, unless it joins a line. space_before.extend(spaces.iter().take(idx.len())); space_before.resize(order.len(), false); - line += 1; + line = put_on_line(&mut line_of, &mut space_before, start..order.len(), line, &segs[si], joined); + last = Some((si, has_rtl, blocks[b].0.len() == 1)); } } (order.into_iter().map(|i| kept[i]).collect(), line_of, space_before) } + +/// Puts the reading-order glyphs `glyphs` of segment `seg` on their line: `line`, or the line +/// before it when `seg` continues the line of `joined` (see [`joins_line`]), with a space before +/// its first glyph when the two are a word gap apart. Returns the line the next segment starts on. +fn put_on_line(line_of: &mut Vec, space_before: &mut [bool], glyphs: std::ops::Range, line: u32, seg: &Seg, joined: Option<&Seg>) -> u32 { + let Some(p) = joined else { + line_of.resize(glyphs.end, line); + return line.saturating_add(1); + }; + line_of.resize(glyphs.end, line.saturating_sub(1)); + if let Some(space) = space_before.get_mut(glyphs.start) { + *space = seg.bbox[0] - p.bbox[2] > WORD_GAP * p.h.min(seg.h); + } + line +} + +/// The segment read just before (`last`, with whether it had right-to-left text) whose line segment +/// `si` goes on, when `si` is the first line of its block (`first`) and sits on that line's baseline +/// to its right (a label and its value, a word drawn out of order or set in another size). +/// Same-baseline segments are always in different blocks. A column of several lines is read after +/// the column to its left ends, so their lines don't meet. A paragraph of several lines that starts +/// beside a line as wide as [`SAME_LINE_GAP`] heights or more is the next column, not that line's +/// value, so it starts a line of its own; only a narrower line that is a block of its own (a label) +/// takes it: the short last line of a column of several lines is no label (review on #4468). One-line +/// paragraphs of two columns that share a baseline cannot be told from a table row, and are further +/// apart than [`same_line`] allows unless the left one fills its column. Right-to-left lines are +/// left alone: their blocks still read left first. +fn joins_line(segs: &[Seg], last: Option<(usize, bool, bool)>, si: usize, first: bool, paragraph: bool, has_rtl: bool) -> Option<&Seg> { + let (p, p_rtl, p_alone) = last?; + if !first || p_rtl || has_rtl { + return None; + } + let (p, s) = (segs.get(p)?, segs.get(si)?); + let label = p_alone && p.bbox[2] - p.bbox[0] < SAME_LINE_GAP * p.h; + (same_line(p, s) && (!paragraph || label)).then_some(p) +} diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index c0ff03a706..ab88f76d76 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,4 +1,5 @@ use super::TextGlyph; +use super::device::WORD_GAP; use super::layout::is_rtl; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. @@ -765,3 +766,19 @@ pub(super) fn reverse_rtl(glyphs: &[&TextGlyph], anchor: &[usize], idx: &mut [us } } } + +/// Most space between two runs on one baseline that still reads as one line, in heights of the +/// taller run: a label and its value or table cells, and not two column lines that happen to share +/// a baseline (a single-line paragraph in each column), whose gap spans most of a column. +pub(super) const SAME_LINE_GAP: f32 = 8.0; + +/// Whether segment `s` continues the line of segment `p` to its right: their baselines (a fifth of +/// the em box above its bottom, as `draw_glyph` builds it) lie within a fifth of the smaller +/// height, whatever their sizes, and `s` starts no earlier than `p` ends and less than +/// [`SAME_LINE_GAP`] heights after it. +pub(super) fn same_line(p: &Seg, s: &Seg) -> bool { + let h = p.h.min(s.h); + let baseline = |seg: &Seg| seg.bbox[3] - 0.2 * seg.h; + let gap = s.bbox[0] - p.bbox[2]; + (baseline(p) - baseline(s)).abs() < 0.2 * h && gap > -WORD_GAP * h && gap < SAME_LINE_GAP * p.h.max(s.h) +} diff --git a/crates/render/src/text/search.rs b/crates/render/src/text/search.rs index 71670cc271..bf64334d6e 100644 --- a/crates/render/src/text/search.rs +++ b/crates/render/src/text/search.rs @@ -73,7 +73,11 @@ pub(super) fn find(page: &PageText, query: &str, sensitive: bool, whole_words: b } } matched = 0; - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric()); + // A neighbour bounds a word only in a script that puts spaces between words. Han, kana, Thai + // and the like run their words together, so a character of theirs next to the match is no + // reason to reject it: Acrobat finds 台北 inside 台北市 with Whole words only on. Digits still + // bound, in any script: ๑ is not a whole word inside the number ๑๒. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|c| c.0.is_alphanumeric() && (c.0.is_numeric() || !unspaced_script(c.0))); let mut hits = Vec::new(); for (i, &(c, owner)) in chars.iter().enumerate() { matched = advance(&needle, &prefix, matched, c); @@ -205,3 +209,34 @@ fn advance(needle: &[char], prefix: &[usize], mut matched: usize, c: char) -> us } if needle.get(matched) == Some(&c) { matched.saturating_add(1) } else { 0 } } + +/// Whether `c` is a letter of a script written without spaces between words (CJK ideographs, +/// kana, bopomofo, Thai, Lao, Myanmar, Khmer, Tibetan), so that a whole-word search cannot take a +/// neighbouring letter as a word boundary. +fn unspaced_script(c: char) -> bool { + matches!( + u32::from(c), + 0x0E00..=0x0EFF // Thai, Lao + | 0x0F00..=0x0FFF // Tibetan + | 0x1000..=0x109F // Myanmar + | 0x1780..=0x17FF // Khmer + | 0x2E80..=0x2FDF // CJK and Kangxi radicals + | 0x3005..=0x3007 // 々 〆 〇 + | 0x3031..=0x3035 // vertical kana repeat marks + | 0x303B..=0x303C // 〻 〼 + | 0x3040..=0x30FF // hiragana, katakana + | 0x3100..=0x312F // bopomofo + | 0x31A0..=0x31BF // bopomofo extended + | 0x31F0..=0x31FF // katakana phonetic extensions + | 0x3400..=0x4DBF // CJK extension A + | 0x4E00..=0x9FFF // CJK unified ideographs + | 0xA9E0..=0xA9FF // Myanmar extended-B + | 0xAA60..=0xAA7F // Myanmar extended-A + | 0xF900..=0xFAFF // CJK compatibility ideographs + | 0xFF66..=0xFF9F // halfwidth katakana + | 0x1AFF0..=0x1B16F // kana extended-B, kana supplement, kana extended-A, small kana + // The supplementary and tertiary ideographic planes, all CJK ideographs (extensions B + // to J, compatibility supplement). + | 0x20000..=0x3FFFF + ) +} diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index a3c67696fe..15e091a5b9 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -1052,6 +1052,48 @@ fn upright_mapping_round_trips() { assert_eq!(to_upright([290.0, 0.0, 300.0, 10.0], 1, 300.0, 200.0), [0.0, 0.0, 10.0, 10.0]); } +#[test] +fn whole_words_ignore_neighbours_in_scripts_written_without_spaces() { + // "台北市的PDF檔案" in one run: Chinese writes no spaces between words, so "台北" is a + // whole word inside "台北市" and "PDF" inside "的PDF檔", as Acrobat finds them with Whole + // words only on. A Latin neighbour still bounds: "cat" is not a whole word in "category". + let mut v = Vec::new(); + word(&mut v, "台北市的PDF檔案", 10.0, 10.0, 6.0); + word(&mut v, "category", 74.0, 10.0, 6.0); + let t = layout(v); + assert_eq!(t.plain_text(), "台北市的PDF檔案 category"); + assert_eq!(t.find_opts("台北", false, true), vec![0..2], "a word inside a longer run of ideographs"); + assert_eq!(t.find_opts("北市", false, true), vec![1..3]); + assert_eq!(t.find_opts("PDF", false, true), vec![4..7], "Latin between ideographs"); + assert_eq!(t.find_opts("pdf檔", false, true), vec![4..8]); + assert!(t.find_opts("cat", false, true).is_empty(), "Latin neighbours still bound a word"); + assert_eq!(t.find_opts("category", false, true), vec![9..17]); + // Thai and Japanese kana are written without spaces too. + let mut v = Vec::new(); + word(&mut v, "ภาษาไทย", 10.0, 30.0, 6.0); + word(&mut v, "ひらがなとカタカナ", 62.0, 30.0, 6.0); + let t = layout(v); + assert_eq!(t.find_opts("ไทย", false, true), vec![4..7]); + assert_eq!(t.find_opts("カタカナ", false, true), vec![12..16]); +} + +/// #2075 review: digits still bound a whole word in those scripts (๑ is not a word inside +/// the number ๑๒), and letters from the later Myanmar and Han blocks don't. +#[test] +fn whole_words_in_unspaced_scripts_keep_digits_and_later_blocks() { + let one = |text: &str| { + let mut v = Vec::new(); + word(&mut v, text, 10.0, 10.0, 6.0); + layout(v) + }; + assert!(one("๑๒").find_opts("๑", false, true).is_empty(), "a Thai digit beside it bounds"); + assert!(one("台北3").find_opts("台北", false, true).is_empty(), "so does an ASCII digit"); + // Myanmar Extended-A (U+AA60), Myanmar Extended-B (U+A9E0), CJK Extension H (U+31350). + for text in ["\u{AA60}PDF", "\u{A9E0}PDF", "\u{31350}PDF"] { + assert_eq!(one(text).find_opts("PDF", false, true), vec![1..4], "{text}"); + } +} + #[test] fn turkish_dotted_and_dotless_i_match_in_any_case() { // "İSTANBUL IRMAK KIŞ" on one line: each word typed in lower case must find it, and the @@ -1213,7 +1255,7 @@ fn a_match_ending_on_a_separator_stops_before_the_next_glyph() { let mut space_before = vec![false; 10]; space_before[5] = !line_break; let line_of = (0..10).map(|i| u32::from(line_break && i >= 5)).collect(); - let page = PageText { glyphs: glyphs.clone(), line_of, space_before }; + let page = PageText { glyphs: glyphs.clone(), line_of, space_before, truncated: false }; let plain = if line_break { "quick\nbrown" } else { "quick brown" }; assert_eq!(page.plain_text(), plain); assert_eq!(page.find("quick "), vec![0..5]); @@ -1238,7 +1280,7 @@ fn phrase_search_ignores_repeated_spaces() { v.push(g(" ", 52.0, 10.0, 58.0)); word(&mut v, "brown", 58.0, 10.0, 6.0); let n = v.len(); - let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n] }; + let t = PageText { glyphs: v, line_of: vec![0; n], space_before: vec![false; n], truncated: false }; assert_eq!(t.plain_text(), "quick brown"); assert_eq!(t.find("quick brown"), vec![0..12]); assert_eq!(t.find("quick brown"), vec![0..12], "the query's own run of spaces is collapsed too"); @@ -1354,6 +1396,197 @@ fn two_columns_read_left_then_right_even_if_drawn_interleaved() { assert_eq!(t.plain_text(), "Left1\nLeft2\nRight1\nRight2"); } +/// Runs on one baseline read as one line, left to right, with a space at the gap between them, however they were +/// drawn and whatever their sizes. Columns of several lines still read one after the other. +#[test] +fn runs_on_one_baseline_read_as_one_line_in_any_drawing_order() { + // Glyphs of `h` pt on the baseline `base`, 0.6 em apart. + let sized = |v: &mut Vec, s: &str, x: f32, base: f32, h: f32| { + for (i, c) in s.chars().enumerate() { + let x0 = x + i as f32 * h * 0.6; + v.push(em_box(&c.to_string(), x0, base - 0.8 * h, x0 + h * 0.6, base + 0.2 * h)); + } + }; + // The second word drawn first, three and a half heights to the right of the first. + let mut v = Vec::new(); + sized(&mut v, "second", 160.0, 100.0, 14.0); + sized(&mut v, "first", 90.0, 100.0, 14.0); + let t = layout(v); + assert_eq!(t.plain_text(), "first second"); + assert_eq!(t.line_rects(0..t.glyphs.len()).len(), 1, "one line, one highlight"); + // A 9 pt label, a 14 pt word and a 9 pt note on one baseline. + let mut v = Vec::new(); + sized(&mut v, "11", 40.0, 100.0, 9.0); + sized(&mut v, "hidden", 90.0, 100.0, 14.0); + sized(&mut v, "note", 170.0, 100.0, 9.0); + assert_eq!(layout(v).plain_text(), "11 hidden note"); + // A label beside two columns of two lines: the label joins the first line of the left column, and the + // columns keep their own lines. + let mut v = Vec::new(); + sized(&mut v, "07", 40.0, 100.0, 9.0); + sized(&mut v, "leftone", 90.0, 100.0, 12.0); + sized(&mut v, "lefttwo", 90.0, 114.0, 12.0); + sized(&mut v, "rightone", 300.0, 100.0, 12.0); + sized(&mut v, "righttwo", 300.0, 114.0, 12.0); + assert_eq!(layout(v).plain_text(), "07 leftone\nlefttwo\nrightone\nrighttwo"); + // Two columns of prose 20 pt apart, where a one-line paragraph that fills the left column shares its baseline + // with the first line of a paragraph in the right one: the paragraph is the next column, not the line's value. + let mut v = Vec::new(); + for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() { + sized(&mut v, &format!("leftparagraphlinenumber{k}xxxxxxxxxxxxxxx"), 72.0, *y, 10.0); + } + sized(&mut v, "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx", 72.0, 160.0, 10.0); + for (k, y) in [100.0, 112.0, 124.0].iter().enumerate() { + sized(&mut v, &format!("rightparagraphline{k}"), 326.0, *y, 10.0); + } + sized(&mut v, "rightsecondpara", 326.0, 160.0, 10.0); + sized(&mut v, "rightsecondparaline2", 326.0, 172.0, 10.0); + let t = layout(v).plain_text(); + assert!(t.lines().any(|l| l == "leftlastonelinexxxxxxxxxxxxxxxxxxxxxxxx"), "{t}"); + assert!(t.lines().any(|l| l == "rightsecondpara"), "{t}"); + // Staggered columns (review on #4468): the left column's short last line shares its baseline with + // the right column's first line. That last line is no label, so the columns keep their lines. + let mut v = Vec::new(); + sized(&mut v, "leftcol1", 10.0, 100.0, 10.0); + sized(&mut v, "leftcol2", 10.0, 112.0, 10.0); + sized(&mut v, "end", 10.0, 124.0, 10.0); + sized(&mut v, "rightone", 82.0, 124.0, 10.0); + sized(&mut v, "righttwo", 82.0, 136.0, 10.0); + assert_eq!(layout(v).plain_text(), "leftcol1\nleftcol2\nend\nrightone\nrighttwo"); + // Half a height lower is another line. + let mut v = Vec::new(); + sized(&mut v, "first", 90.0, 100.0, 14.0); + sized(&mut v, "lower", 160.0, 107.0, 14.0); + assert_eq!(layout(v).plain_text(), "first\nlower"); + // Right-to-left runs on one baseline keep their lines: their blocks read left first. + let mut v = Vec::new(); + sized(&mut v, "שלום", 90.0, 100.0, 14.0); + sized(&mut v, "עולם", 200.0, 100.0, 14.0); + assert_eq!(layout(v).plain_text().lines().count(), 2); +} + +/// A page of fourteen labelled rows, each a case of text copy: a `/fi` glyph by name, a ToUnicode map, an +/// `/ActualText` span, word gaps by `TJ` and `Td`, a hyphen at a line end, two columns, words drawn out of order, +/// fake bold, invisible text, letter spacing and a line turned upwards. +fn copy_test_page() -> Pdf { + use lopdf::{Document, Object, Stream, dictionary}; + let rows: [(&str, &str); 14] = [ + ("01", "BT /F2 14 Tf 90 {Y} Td (of\\200ce) Tj ET"), + ("02", "BT /F3 14 Tf 90 {Y} Td (XEY) Tj ET"), + ("03", "/Span << /ActualText (replaced) >> BDC BT /F1 14 Tf 90 {Y} Td (shown) Tj ET EMC"), + ("04", "BT /F1 14 Tf 90 {Y} Td [(Hello) -400 (world)] TJ ET"), + ("05", "BT /F1 14 Tf 90 {Y} Td [(W) 80 (ave)] TJ ET"), + ("06", "BT /F1 14 Tf 90 {Y} Td (exam-) Tj 0 -16 Td (ple) Tj ET"), + ("07", "BT /F1 12 Tf 90 {Y} Td (left one) Tj 0 -14 Td (left two) Tj ET BT /F1 12 Tf 300 {Y} Td (right one) Tj 0 -14 Td (right two) Tj ET"), + ("08", "BT /F1 14 Tf 160 {Y} Td (second) Tj ET BT /F1 14 Tf 90 {Y} Td (first) Tj ET"), + ("09", "BT /F1 14 Tf 90 {Y} Td (caf\\351 na\\357ve) Tj ET"), + ("10", "BT /F1 14 Tf 90 {Y} Td (Bold) Tj ET BT /F1 14 Tf 90.4 {Y} Td (Bold) Tj ET"), + ("11", "BT /F1 14 Tf 3 Tr 90 {Y} Td (hidden) Tj ET BT /F1 9 Tf 0.5 g 160 {Y} Td (<- invisible word here) Tj ET"), + ("12", "BT /F1 14 Tf 8 Tc 90 {Y} Td (SPACED) Tj ET"), + ("13", "BT /F1 14 Tf 0 1 -1 0 110 {Y} Tm (up) Tj ET"), + ("14", "BT /F1 14 Tf 90 {Y} Td (split) Tj 40 0 Td (words) Tj ET"), + ]; + let mut content = String::from("BT /F1 9 Tf 40 770 Td (Text copy and search test) Tj ET\n"); + let mut y = 740; + for (tag, body) in rows { + content += &format!("BT /F1 9 Tf 0.4 g 40 {y} Td ({tag}) Tj ET 0 g\nq {} Q\n", body.replace("{Y}", &y.to_string())); + y -= 38 + + match tag { + "06" | "13" => 10, + "07" => 6, + _ => 0, + }; + } + let mut doc = Document::with_version("1.7"); + let helvetica = || dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => "WinAnsiEncoding" }; + let f1 = doc.add_object(helvetica()); + let differences = dictionary! { "Type" => "Encoding", "BaseEncoding" => "WinAnsiEncoding", "Differences" => vec![Object::Integer(128), Object::Name(b"fi".to_vec())] }; + let f2 = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica", "Encoding" => differences }); + let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def /CMapName /Test-UCS def /CMapType 2 def 1 begincodespacerange <00> endcodespacerange 3 beginbfchar <58> <004B> <45> <0045> <59> <0059> endbfchar endcmap CMapName currentdict /CMap defineresource pop end end"; + let to_unicode = doc.add_object(Stream::new(dictionary! {}, cmap.as_bytes().to_vec())); + let mut f3 = helvetica(); + f3.set("ToUnicode", to_unicode); + let f3 = doc.add_object(f3); + let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => f1, "F2" => f2, "F3" => f3 } }, "Contents" => contents + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + Pdf::new(std::sync::Arc::new(bytes)).unwrap() +} + +/// Copy of the whole page: one line per baseline in left-to-right order whatever the drawing order, the `/fi` +/// glyph as its letters, and the line turned upwards in its row, not after the page. `/ActualText` replaces what +/// is drawn (ISO 32000-2 §14.9.4), a hard hyphen at a line end stays (§14.8.2.3: an incidental one is U+00AD), +/// fake bold reads once, and letter spacing that is the same between every letter is tracking, not word gaps. +#[test] +fn a_copied_page_reads_one_line_per_baseline_in_reading_order() { + let text = extract_page(©_test_page(), 0, &InterpreterSettings::default()).unwrap(); + assert_eq!( + text.plain_text(), + "Text copy and search test\n01 office\n02 KEY\n03 replaced\n04 Hello world\n05 Wave\n06 exam-\nple\n07 left one\nleft two\n\ + right one\nright two\n08 first second\n09 café naïve\n10 Bold\n11 hidden <- invisible word here\n12 SPACED\n13\nup\n14 split words" + ); + assert_eq!(text.find("first second").len(), 1); + assert_eq!(text.find("office").len(), 1); +} + +/// A turned line inside the page's text reads where it stands; one in the margin, beside every line (a stamp +/// set up the edge of the page), still reads after the text instead of splitting a paragraph. +#[test] +fn a_turned_line_reads_in_place_inside_the_text_and_after_it_in_the_margin() { + use lopdf::{Document, Object, Stream, dictionary}; + let page = |before: &str, after: &str| { + let mut doc = Document::with_version("1.7"); + let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" }); + let mut content = before.to_owned(); + for (k, word) in ["Alpha", "Beta", "Gamma", "Delta"].iter().enumerate() { + content += &format!("BT /F1 12 Tf 72 {} Td ({word}) Tj ET\n", 700 - 30 * k); + } + content += after; + let contents = doc.add_object(Stream::new(dictionary! {}, content.into_bytes())); + let pages = doc.new_object_id(); + let page = doc.add_object(dictionary! { + "Type" => "Page", "Parent" => pages, "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font } }, "Contents" => contents + }); + doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into()); + let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages }); + doc.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + doc.save_to(&mut bytes).unwrap(); + let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap(); + extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap().plain_text() + }; + // "up", set upwards from the baseline of "Beta" beside it, and "dn", set downwards in the space under it. + let up = "BT /F1 12 Tf 0 1 -1 0 110 670 Tm (up) Tj ET\n"; + let down = "BT /F1 8 Tf 0 -1 1 0 100 662 Tm (dn) Tj ET\n"; + assert_eq!(page("", up), "Alpha\nBeta\nup\nGamma\nDelta"); + assert_eq!(page(up, ""), "Alpha\nBeta\nup\nGamma\nDelta", "drawn before the text"); + assert_eq!(page("", down), "Alpha\nBeta\ndn\nGamma\nDelta"); + // Upside-down text inside the text's area, under "Beta", is not turned a quarter and keeps its own flow. + let upside_down = "BT /F1 12 Tf -1 0 0 -1 110 660 Tm (ud) Tj ET\n"; + assert_eq!(page("", upside_down), "Alpha\nBeta\nGamma\nDelta\nud"); + // A stamp up the left margin, beside "Beta" and "Gamma": laid out with the text, it would read after "Alpha". + let stamp = "BT /F1 10 Tf 0 1 -1 0 30 640 Tm (arXiv stamp) Tj ET\n"; + assert_eq!(page("", stamp), "Alpha\nBeta\nGamma\nDelta\narXiv stamp"); +} + +#[test] +fn ligature_characters_read_as_their_letters() { + assert_eq!(ligature_letters("o\u{FB01}ce".into()), "ofice"); + assert_eq!(ligature_letters("\u{FB00}\u{FB02}\u{FB03}\u{FB04}\u{FB05}\u{FB06}".into()), "ffflffifflstst"); + // Only those seven: other compatibility characters stay as they are. + assert_eq!(ligature_letters("\u{FB13}\u{FF21}\u{2460}é".into()), "\u{FB13}\u{FF21}\u{2460}é"); + assert_eq!(ligature_letters(String::new()), ""); +} + /// Lays out one-word lines, each `(text, x, y)`, 10 pt high: 14 pt apart is a line in a paragraph, 24 pt a /// paragraph gap. fn lines(at: &[(&str, f32, f32)]) -> String { @@ -1451,7 +1684,32 @@ fn a_full_width_line_ends_the_columns_above_it() { fn table_rows_apart_still_read_across() { // Rows a paragraph gap apart whose cells start level read row by row, as before #3658. let rows = [("Name", 10.0, 10.0), ("Qty", 100.0, 10.0), ("Apple", 10.0, 34.0), ("12", 100.0, 34.0), ("Pear", 10.0, 58.0), ("7", 100.0, 58.0)]; - assert_eq!(lines(&rows), "Name\nQty\nApple\n12\nPear\n7"); + // The cells of a row share its baseline, so they share a line too. + assert_eq!(lines(&rows), "Name Qty\nApple 12\nPear 7"); + // Column select still separates them with a tab. + let mut v = Vec::new(); + for &(s, x, y) in &rows { + word(&mut v, s, x, y, 6.0); + } + let t = layout(v); + assert_eq!(t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0])), "Name\tQty\nApple\t12\nPear\t7"); +} + +/// Two cells two heights apart (closer than [`CELL_GAP`]) copy the same whichever is drawn first: +/// drawn left to right they were always one run, and the right cell drawn first now joins it too. +#[test] +fn close_cells_copy_the_same_in_either_drawing_order() { + let copy = |cells: &[(&str, f32)]| { + let mut v = Vec::new(); + for &(s, x) in cells { + word(&mut v, s, x, 10.0, 6.0); + } + let t = layout(v); + (t.plain_text(), t.column_text(&t.glyphs_in([0.0, 0.0, 300.0, 300.0]))) + }; + let in_order = copy(&[("Name", 10.0), ("Qty", 54.0)]); + assert_eq!(copy(&[("Qty", 54.0), ("Name", 10.0)]), in_order); + assert_eq!(in_order.0, "Name Qty"); } #[test] @@ -1470,7 +1728,7 @@ fn form_rows_in_mixed_sizes_still_read_across() { sized(&mut v, label, 10.0, base, 8.0); sized(&mut v, value, 100.0, base, 12.0); } - assert_eq!(layout(v).plain_text(), "Name\nAlice\nStreet\nMain\nCity\nOslo"); + assert_eq!(layout(v).plain_text(), "Name Alice\nStreet Main\nCity Oslo"); // Values drawn 6 pt below their labels, rows a paragraph gap apart. let offset = [("Name", 10.0, 10.0), ("Val1", 100.0, 16.0), ("Addr", 10.0, 40.0), ("Val2", 100.0, 46.0), ("City", 10.0, 70.0), ("Val3", 100.0, 76.0)]; @@ -1479,7 +1737,8 @@ fn form_rows_in_mixed_sizes_still_read_across() { #[test] fn a_column_does_not_jump_to_a_distant_footer_footnote_or_caption() { - // A page number far below the left column reads after the right column. + // A page number far below the left column reads after the right column. L3 and R3, one-line paragraphs + // on one baseline a column apart, keep their own lines. let footer = [ ("L1", 10.0, 10.0), ("L2", 10.0, 24.0), @@ -1941,6 +2200,7 @@ fn words_and_lines_around_a_glyph() { })), line_of: vec![0, 0, 0, 0, 0, 1, 1, 1], space_before: vec![false, false, false, false, false, false, true, false], + truncated: false, }; assert_eq!(t.word_at(1), Some((0, 1))); assert_eq!(t.word_at(3), Some((3, 4))); @@ -2440,6 +2700,7 @@ fn vertical_lines_keep_reading_order() { glyphs: vec![cell("日", 40.0, 10.0), cell("本", 40.0, 20.0), cell("語", 20.0, 10.0), cell("文", 20.0, 20.0)], line_of: vec![0, 0, 1, 1], space_before: vec![false; 4], + truncated: false, }; let all = t.glyphs_in([0.0, 0.0, 60.0, 40.0]); assert_eq!(t.column_text(&all), "日本\n語文"); @@ -3134,6 +3395,7 @@ fn the_glyph_at_a_point_is_the_first_whose_box_holds_it() { glyphs: vec![em_box("a", 0.0, 0.0, 10.0, 10.0), em_box("b", 8.0, 0.0, 20.0, 10.0)], line_of: vec![0, 0], space_before: vec![false, false], + truncated: false, }; assert_eq!(text.glyph_at(5.0, 5.0), Some(0)); assert_eq!(text.glyph_at(9.0, 5.0), Some(0), "the first of overlapping boxes"); diff --git a/crates/render/tests/it/search.rs b/crates/render/tests/it/search.rs index 5b2a1e4c46..d78b18087a 100644 --- a/crates/render/tests/it/search.rs +++ b/crates/render/tests/it/search.rs @@ -6,9 +6,88 @@ fn page(parts: &[&str]) -> PageText { glyphs: parts.iter().map(|text| TextGlyph { text: (*text).into(), rect: [0.0; 4], quad: [[0.0; 2]; 4], direction: [1.0, 0.0] }).collect(), line_of: vec![0; parts.len()], space_before: vec![false; parts.len()], + truncated: false, } } +/// The letters of the property test's alphabet that come from scripts written without spaces +/// between words: 界 (CJK Unified Ideographs, U+4E00..U+9FFF) and ก (Thai, U+0E00..U+0E7F). +/// 한 (Hangul Syllables) is written with spaces and bounds a word. The reference knows only +/// these three letters, so it does not share the block table in `text/search.rs`; that table is +/// checked against `UNSPACED_BLOCK_SAMPLES` instead. +fn unspaced(c: char) -> bool { + matches!(c, '界' | 'ก') +} + +/// Letters at the edges of each block of a script written without spaces (`true`), and letters +/// just outside them (`false`), named by their block as Unicode's Blocks.txt gives it. Each one is +/// alphabetic and not a digit, so whether it bounds a whole word depends on the table alone. +const UNSPACED_BLOCK_SAMPLES: &[(char, bool, &str)] = &[ + ('\u{0DC6}', false, "Sinhala (0D80..0DFF), before Thai"), + ('\u{0E01}', true, "Thai (0E00..0E7F), first letter"), + ('\u{0E46}', true, "Thai, MAIYAMOK"), + ('\u{0E81}', true, "Lao (0E80..0EFF), first letter"), + ('\u{0EDF}', true, "Lao, last letter"), + ('\u{0F00}', true, "Tibetan (0F00..0FFF), first letter"), + ('\u{0F8C}', true, "Tibetan, last letter"), + ('\u{1000}', true, "Myanmar (1000..109F), first letter"), + ('\u{108E}', true, "Myanmar, last letter"), + ('\u{10A0}', false, "Georgian (10A0..10FF), after Myanmar"), + ('\u{1770}', false, "Tagbanwa (1760..177F), before Khmer"), + ('\u{1780}', true, "Khmer (1780..17FF), first letter"), + ('\u{17DC}', true, "Khmer, last letter"), + ('\u{1820}', false, "Mongolian (1800..18AF), after Khmer"), + ('\u{3005}', true, "CJK Symbols and Punctuation (3000..303F), ideographic iteration mark"), + ('\u{3006}', true, "CJK Symbols and Punctuation, ideographic closing mark"), + ('\u{3031}', true, "CJK Symbols and Punctuation, vertical kana repeat mark"), + ('\u{3035}', true, "CJK Symbols and Punctuation, vertical kana repeat mark lower half"), + ('\u{303B}', true, "CJK Symbols and Punctuation, vertical ideographic iteration mark"), + ('\u{303C}', true, "CJK Symbols and Punctuation, masu mark"), + ('\u{3041}', true, "Hiragana (3040..309F), first letter"), + ('\u{309F}', true, "Hiragana, last letter"), + ('\u{30A1}', true, "Katakana (30A0..30FF), first letter"), + ('\u{30FF}', true, "Katakana, last letter"), + ('\u{3105}', true, "Bopomofo (3100..312F), first letter"), + ('\u{312F}', true, "Bopomofo, last letter"), + ('\u{3131}', false, "Hangul Compatibility Jamo (3130..318F), after Bopomofo"), + ('\u{318E}', false, "Hangul Compatibility Jamo, last letter, before Bopomofo Extended"), + ('\u{31A0}', true, "Bopomofo Extended (31A0..31BF), first letter"), + ('\u{31BF}', true, "Bopomofo Extended, last letter"), + ('\u{31F0}', true, "Katakana Phonetic Extensions (31F0..31FF), first letter"), + ('\u{31FF}', true, "Katakana Phonetic Extensions, last letter"), + ('\u{3400}', true, "CJK Unified Ideographs Extension A (3400..4DBF), first"), + ('\u{4DBF}', true, "CJK Unified Ideographs Extension A, last"), + ('\u{4E00}', true, "CJK Unified Ideographs (4E00..9FFF), first"), + ('\u{9FFF}', true, "CJK Unified Ideographs, last"), + ('\u{A000}', false, "Yi Syllables (A000..A48F), after CJK Unified Ideographs"), + ('\u{A9CF}', false, "Javanese (A980..A9DF), before Myanmar Extended-B"), + ('\u{A9E0}', true, "Myanmar Extended-B (A9E0..A9FF), first letter"), + ('\u{A9FE}', true, "Myanmar Extended-B, last letter"), + ('\u{AA00}', false, "Cham (AA00..AA5F), after Myanmar Extended-B"), + ('\u{AA4B}', false, "Cham, before Myanmar Extended-A"), + ('\u{AA60}', true, "Myanmar Extended-A (AA60..AA7F), first letter"), + ('\u{AA7F}', true, "Myanmar Extended-A, last letter"), + ('\u{AA80}', false, "Tai Viet (AA80..AADF), after Myanmar Extended-A"), + ('\u{D7FB}', false, "Hangul Jamo Extended-B (D7B0..D7FF), before CJK Compatibility Ideographs"), + ('\u{F900}', true, "CJK Compatibility Ideographs (F900..FAFF), first"), + ('\u{FAD9}', true, "CJK Compatibility Ideographs, last"), + ('\u{FB00}', false, "Alphabetic Presentation Forms (FB00..FB4F), after CJK Compatibility Ideographs"), + ('\u{FF5A}', false, "Halfwidth and Fullwidth Forms (FF00..FFEF), fullwidth z, before halfwidth katakana"), + ('\u{FF66}', true, "Halfwidth and Fullwidth Forms, first halfwidth katakana"), + ('\u{FF9F}', true, "Halfwidth and Fullwidth Forms, last halfwidth katakana"), + ('\u{FFA1}', false, "Halfwidth and Fullwidth Forms, halfwidth Hangul, after halfwidth katakana"), + ('\u{1AFF0}', true, "Kana Extended-B (1AFF0..1AFFF), first letter"), + ('\u{1B000}', true, "Kana Supplement (1B000..1B0FF), first letter"), + ('\u{1B122}', true, "Kana Extended-A (1B100..1B12F), last letter"), + ('\u{1B132}', true, "Small Kana Extension (1B130..1B16F), first letter"), + ('\u{1B167}', true, "Small Kana Extension, last letter"), + ('\u{1B170}', false, "Nushu (1B170..1B2FF), after Small Kana Extension"), + ('\u{20000}', true, "CJK Unified Ideographs Extension B (20000..2A6DF), first"), + ('\u{2A6DF}', true, "CJK Unified Ideographs Extension B, last"), + ('\u{2F800}', true, "CJK Compatibility Ideographs Supplement (2F800..2FA1F), first"), + ('\u{323AF}', true, "CJK Unified Ideographs Extension H (31350..323AF), last"), +]; + // The original straightforward search is an independent oracle for valid page metadata. fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: usize) -> Vec> { let fold = |s: &str| if sensitive { s.to_owned() } else { fold_case(s) }; @@ -50,7 +129,9 @@ fn reference(page: &PageText, query: &str, sensitive: bool, whole: bool, limit: } } } - let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric()); + // A neighbour bounds a word only in a script written with spaces between words, or when it + // is a digit. + let word = |i: Option| i.and_then(|i| chars.get(i)).is_some_and(|&(c, ..)| c.is_alphanumeric() && (c.is_numeric() || !unspaced(c))); // A word ends at a skipped zero width space as it does at a space. let edge = |i: usize| chars.get(i).is_some_and(|&(.., zwsp)| zwsp); let mut hits = Vec::new(); @@ -92,9 +173,24 @@ fn whole_word_rejection_keeps_overlapping_candidates() { assert_eq!(page(&["aaaaa"]).find("aa"), vec![0..1, 0..1]); } +/// A letter from a block in `UNSPACED_BLOCK_SAMPLES` next to a whole-word match leaves it standing +/// when the block's script is written without spaces, and bounds it otherwise, on either side. +#[test] +fn whole_words_follow_the_unicode_blocks_of_unspaced_scripts() { + for &(c, unspaced, block) in UNSPACED_BLOCK_SAMPLES { + let (neighbour, cp) = (c.to_string(), u32::from(c)); + assert!(c.is_alphanumeric() && !c.is_numeric(), "U+{cp:04X} ({block}) is a letter, so the sample tests the table"); + // Case-sensitive, so that case folding does not move a neighbour into another block + // (Georgian U+10A0 folds to U+2D00). + let (before, after) = if unspaced { (Some(1..2), Some(0..1)) } else { (None, None) }; + assert_eq!(page(&[&neighbour, "PDF"]).find_opts("PDF", true, true), Vec::from_iter(before), "U+{cp:04X} ({block}) before the match"); + assert_eq!(page(&["PDF", &neighbour]).find_opts("PDF", true, true), Vec::from_iter(after), "U+{cp:04X} ({block}) after the match"); + } +} + #[test] fn search_matches_reference_for_unicode_gaps_limits_and_word_boundaries() { - let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; + let alphabet = ["a", "b", " ", "-", "İ", "ı", "ς", "Σ", "\t", "\n", "ffi", "", "界", "ก", "한", "e\u{301}", "العربية", "\u{AD}", "a\u{200B}"]; let mut seed = 1234567u64; let mut next = || { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); diff --git a/crates/sign/src/der.rs b/crates/sign/src/der.rs index f9f2f3e6d9..a4740b4707 100644 --- a/crates/sign/src/der.rs +++ b/crates/sign/src/der.rs @@ -16,6 +16,7 @@ use crate::SignError; pub mod tag { pub const BOOLEAN: u8 = 0x01; pub const INTEGER: u8 = 0x02; + pub const ENUMERATED: u8 = 0x0A; pub const BIT_STRING: u8 = 0x03; pub const OCTET_STRING: u8 = 0x04; /// An OCTET STRING split into segments (BER only, X.690 §8.21); see [`super::Tlv::octets`]. @@ -205,12 +206,26 @@ impl<'a> Tlv<'a> { /// A non-negative INTEGER that fits in a u64. pub fn u64(&self) -> Result { - if self.tag != tag::INTEGER || self.value.is_empty() || self.value.len() > 9 || self.value[0] & 0x80 != 0 { + // (nine bytes only with a 0x00 pad: a nonzero first byte is past 64 bits) + if self.tag != tag::INTEGER + || self.value.is_empty() + || self.value.len() > 9 + || self.value[0] & 0x80 != 0 + || (self.value.len() == 9 && self.value[0] != 0) + { return Err(bad("expected a small non-negative INTEGER")); } Ok(self.value.iter().fold(0u64, |acc, b| (acc << 8) | *b as u64)) } + /// A non-negative ENUMERATED that fits in a u64 (same encoding as an INTEGER). + pub fn enumerated(&self) -> Result { + if self.tag != tag::ENUMERATED { + return Err(bad("expected an ENUMERATED")); + } + Tlv { tag: tag::INTEGER, ..*self }.u64() + } + /// The magnitude of an INTEGER (leading zero dropped). pub fn uint_bytes(&self) -> &'a [u8] { match self.value { @@ -544,6 +559,20 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec { mod tests { use super::*; + #[test] + fn a_nine_byte_integer_needs_a_zero_pad_to_fit_64_bits() { + let tlv = |value: &[u8]| { + let mut b = vec![tag::INTEGER, value.len() as u8]; + b.extend_from_slice(value); + b + }; + // 2^64 would fold to 0: refused, not read as 0 + assert!(Tlv::parse_all(&tlv(&[1, 0, 0, 0, 0, 0, 0, 0, 0])).unwrap().u64().is_err()); + // u64::MAX with its pad is fine + let max = [0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]; + assert_eq!(Tlv::parse_all(&tlv(&max)).unwrap().u64().unwrap(), u64::MAX); + } + #[test] fn try_oid_rejects_signs_and_empty_arcs() { for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] { diff --git a/crates/sign/src/revocation.rs b/crates/sign/src/revocation.rs index 057e0717de..93191b2528 100644 --- a/crates/sign/src/revocation.rs +++ b/crates/sign/src/revocation.rs @@ -181,7 +181,7 @@ impl OcspResponse { return Err(bad("OCSP response is empty or exceeds the 2 MiB limit")); } let resp = Tlv::parse_all(bytes)?.expect(tag::SEQUENCE, "OCSPResponse")?.children()?; - let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.u64()?; + let status = resp.first().ok_or_else(|| bad("missing responseStatus"))?.enumerated()?; if status != 0 { return Err(bad(format!("responder reported an error (status {status})"))); } diff --git a/crates/sign/src/rsa_pad.rs b/crates/sign/src/rsa_pad.rs index 8cc6e08f1e..531f703d52 100644 --- a/crates/sign/src/rsa_pad.rs +++ b/crates/sign/src/rsa_pad.rs @@ -14,6 +14,9 @@ use crate::keys::{DigestAlg, PssParams}; /// signature value was not written with exactly the modulus' length (RFC 8017 §8.2.2 wants /// that); some signers drop or add leading zero bytes. fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str>) -> Result>, SignError> { + // Redundant leading zeros would size the big-integer arithmetic (and the backend's own size + // limit) by the encoding rather than the number, so they are dropped before anything is built. + let (n, e) = (trim_zeros(n), trim_zeros(e)); let k = n.len(); let key = RsaPublicKey::new(BoxedUint::from_be_slice_vartime(n), BoxedUint::from_be_slice_vartime(e)) .map_err(|e| SignError::Malformed(format!("RSA key: {e}")))?; @@ -40,6 +43,10 @@ fn encoded_message(n: &[u8], e: &[u8], sig: &[u8], notes: &mut Vec<&'static str> Ok(Some(out)) } +fn trim_zeros(b: &[u8]) -> &[u8] { + b.get(b.iter().position(|x| *x != 0).unwrap_or(b.len())..).unwrap_or_default() +} + /// `DigestInfo ::= SEQUENCE { AlgorithmIdentifier, OCTET STRING }`. fn digest_info(alg: DigestAlg, digest: &[u8], null_params: bool) -> Vec { let id = der::algorithm(alg.oid(), null_params.then(der::null).as_deref()); @@ -99,6 +106,7 @@ pub fn pss_verify( notes: &mut Vec<&'static str>, ) -> Result { let Some(raw) = encoded_message(n, e, sig, notes)? else { return Ok(false) }; + let n = trim_zeros(n); let mod_bits = n.first().map_or(0, |b| (n.len() * 8) as u32 - b.leading_zeros()); let Some(em_bits) = mod_bits.checked_sub(1) else { return Ok(false) }; let em_len = em_bits.div_ceil(8) as usize; diff --git a/crates/sign/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..010b7f7bd7 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(), @@ -328,6 +332,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 +473,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 +613,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 +739,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 +804,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..065bd7a228 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"); @@ -365,6 +371,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..1ec33884fe 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -160,6 +160,9 @@ impl PdfCraftApp { v["pinned_tools"] = serde_json::json!(self.pinned_tools); // Set outside json! above, which is at serde_json's macro recursion limit. v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover); + // The Print dialog's paper list and each printer's paper (print.page-setup). + v["print_custom_papers"] = serde_json::json!(self.print_draft.custom_papers); + v["print_paper_by_printer"] = serde_json::json!(self.print_draft.remembered_paper); v["show_page_edge_shadows"] = self.show_page_edge_shadows.into(); // Not `check_updates_at_start`: that key was an opt-in of its own, off unless chosen, and // settings written then would turn this off unasked. @@ -167,9 +170,12 @@ impl PdfCraftApp { v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default(); v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned); v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice); + v["text_links"] = serde_json::Value::Bool(self.text_links); v["organize_zoom"] = serde_json::json!(self.organize_zoom); - // Canonical names survive changes to the order of PAPERS and do not depend on UI language. - v["print_paper"] = serde_json::json!(pdfcraft_engine::print::PAPERS.get(self.print_draft.paper).map(|(name, _)| *name)); + // Canonical names survive changes to the order of PAPERS and do not depend on UI language; + // a custom paper is stored by the name it was given. + let d = &self.print_draft; + v["print_paper"] = serde_json::json!((d.paper < d.paper_count()).then(|| d.paper_name())); v.to_string() } @@ -242,6 +248,9 @@ 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"]); if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) { let mut tools = Vec::new(); @@ -286,11 +295,16 @@ impl PdfCraftApp { if let Some(on) = v["always_default_view"].as_bool() { self.always_default_view = on; } - if let Some(paper) = v - .get("print_paper") - .and_then(serde_json::Value::as_str) - .and_then(|name| pdfcraft_engine::print::PAPERS.iter().position(|(canonical, _)| *canonical == name)) - { + // The Print dialog's paper list and each printer's paper; settings are untrusted, so each + // custom paper is checked as if it were typed. + if v.get("print_custom_papers").is_some() { + self.print_draft.custom_papers = print_paper::read_custom(&v["print_custom_papers"]); + } + if v.get("print_paper_by_printer").is_some() { + self.print_draft.remembered_paper = print_paper::read_remembered(&v["print_paper_by_printer"]); + } + // After the custom papers, so a custom paper chosen last time is found by its name. + if let Some(paper) = v.get("print_paper").and_then(serde_json::Value::as_str).and_then(|name| self.print_draft.paper_index(name)) { self.print_draft.paper = paper; } if let Some(on) = v["highlight_fields"].as_bool() { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 26d1bd4946..f20cfca29f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -9,6 +9,7 @@ use std::ops::Range; use std::sync::Arc; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2}; +use pdfcraft_engine::text_links::TextLink; use pdfcraft_engine::{DocId, Edit}; use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels}; @@ -500,6 +501,9 @@ pub(crate) fn snapshot_tab_name(title: &str, file_name: &str, snapshot: &Snapsho } pub struct DocView { + /// Where the tab shows (split view's panes) and the key that tells two + /// views of one document apart (`crate::panes`). + pub(crate) pane: crate::panes::ViewPane, pub id: DocId, pub zoom: f32, pub fit: Fit, @@ -563,6 +567,10 @@ pub struct DocView { pages: HashMap, /// Pages the renderer could not draw, with the reason (never re-requested). errors: HashMap, + /// Pages whose content was cut at the renderer's limits (#623): drawn and searched in part. + cut_pages: std::collections::BTreeSet, + /// Cut pages whose notice the reader dismissed: a page cut later shows it again. + cut_notice_dismissed: std::collections::BTreeSet, /// When each still-missing page was first shown, to flag unusually slow renders. waiting_since: HashMap, thumbs: HashMap, @@ -592,6 +600,8 @@ pub struct DocView { tiles: HashMap<(usize, u64, u32, u32), TextureHandle>, /// Text layers, extracted in the background on demand (selection, find, copy). pub(crate) texts: HashMap>, + /// Addresses written as plain text on a page (#4207), with the text layer they were found in. + text_links: HashMap, Arc<[TextLink]>)>, pub(crate) text_failed: HashSet, pub find: Option, selection: Option, @@ -989,6 +999,7 @@ impl DocView { /// any layout or zoom the PDF itself asks for. pub fn new(id: DocId, info: &DocInfo, defaults: ViewDefaults) -> Self { Self { + pane: Default::default(), id, zoom: defaults.zoom, fit: defaults.fit, @@ -1021,6 +1032,8 @@ impl DocView { presentation: None, pages: HashMap::new(), errors: HashMap::new(), + cut_pages: Default::default(), + cut_notice_dismissed: Default::default(), waiting_since: HashMap::new(), thumbs: HashMap::new(), thumb_demand: HashMap::new(), @@ -1035,6 +1048,7 @@ impl DocView { grayscale: false, tiles: HashMap::new(), texts: HashMap::new(), + text_links: HashMap::new(), text_failed: HashSet::new(), find: None, selection: None, @@ -1179,10 +1193,11 @@ impl DocView { fresh.organize = self.organize; fresh.grid_zoom = self.grid_zoom; fresh.thumbnail_zoom = self.thumbnail_zoom; - // Same document id, so the scroll offset egui keeps for it is still this document's. + // Same view key (`pane`), so the scroll offset egui keeps for it is still this view's. fresh.shown = self.shown; fresh.highlight_fields = self.highlight_fields; fresh.notice_dismissed = self.notice_dismissed; + fresh.cut_notice_dismissed = std::mem::take(&mut self.cut_notice_dismissed); fresh.snapshot = self.snapshot.clone(); fresh.close_requested = self.close_requested; fresh.cover = self.cover; @@ -1196,6 +1211,7 @@ impl DocView { fresh.viewport_w = self.viewport_w; fresh.viewport_h = self.viewport_h; fresh.viewport_screen = self.viewport_screen; + fresh.pane = self.pane.clone(); *self = fresh; // Marks the kept images out of date and clamps the page to the new page count. self.document_changed(info); @@ -1203,6 +1219,45 @@ impl DocView { self.page_input_reselect = true; } + /// Another view of this document changed it (split view: an edit, Undo, + /// a reload): start afresh as a reload does, keeping the reader's place, so nothing here + /// points into the old version. + pub(crate) fn follow_document(&mut self, info: &DocInfo) { + let continuous = self.continuous; + self.document_replaced(info); + self.continuous = continuous; + } + + /// A second view of this document (split view), showing what this one shows. + pub(crate) fn twin(&self, info: &DocInfo) -> Self { + let mut v = Self::new(self.id, info, ViewDefaults::default()); + v.zoom = self.zoom; + v.fit = self.fit; + v.layout = self.layout; + v.continuous = self.continuous; + v.show_page_gaps = self.show_page_gaps; + v.rotation = self.rotation; + v.cover = self.cover; + v.current = self.current; + v.page_input = self.page_input.clone(); + v.highlight_fields = self.highlight_fields; + v.notice_dismissed = self.notice_dismissed; + v.scripts_off_noted = self.scripts_off_noted; + v.goto = Some((self.current, 0.0)); + v + } + + /// The key that tells this view apart from another view of the same document (split view): + /// unique for the app's life. Ids of what is drawn over the page are keyed by it. + pub fn view_key(&self) -> u64 { + self.pane.key + } + + /// Where this tab shows: the main pane or split view's second pane. + pub fn place(&self) -> crate::panes::Place { + self.pane.place + } + /// Select pages in the organize grid (tests, automation). Empty clears the selection. pub(crate) fn page_count(&self) -> usize { self.page_count @@ -1277,6 +1332,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 +1697,10 @@ fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i } let slot = PageSlot { i, r, xf }; + // Addresses written as plain text are found once per text layer, before the tools hit-test them. + if frame.env.text_links { + view.refresh_text_links(i); + } let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; // A cancelled press takes nothing until the button is up, though the page's menu still opens // where a right-click is. @@ -2481,20 +2542,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 +2578,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); @@ -3206,6 +3262,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 +3279,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { can_paste_image, }; let mut page_out = PageOut::default(); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| { + // Keyed by the view, not the document: with one document in both panes of split view, shared + // ids gave the main pane's clicks on the page to the second pane's widgets. + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.view_key())), app.show_page_edge_shadows, |ui, viewport| { draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) }); let PageOut { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 3e12bd6ce3..a8a538e86c 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -59,8 +59,8 @@ pub(super) enum Notice { } /// The notice bar above the pages: a file changed on disk first, then what a historical -/// snapshot is, then the signature status bar, a form's scripts having been turned -/// off, security, forms and warnings. +/// snapshot is, then pages cut at the renderer's limits, then the signature status bar, a +/// form's scripts having been turned off, security, forms and warnings. pub(super) fn notices( view: &mut DocView, doc: &pdfcraft_engine::Document, @@ -119,6 +119,13 @@ pub(super) fn notices( return Some(Notice::Close); } } + // Content cut at the renderer's limits (#623): what is missing is not visible, so the page + // is named in a bar of its own, which the signature, form and security bars below can't + // replace and dismissing them doesn't hide. Dismissing it covers the pages cut so far: a + // page found cut later shows it again. + if view.cut_pages.iter().any(|p| !view.cut_notice_dismissed.contains(p)) { + cut_pages_bar(view, ui, t); + } if let Some((icon, color, template, arg)) = signed { let mut open = false; notice_bar(ui, t, icon, color, crate::i18n::fmt(tl!(template), &[("by", &arg)]), |ui| { @@ -242,6 +249,33 @@ pub(super) fn notices( open_security.then_some(Notice::Security).or(toggled) } +/// The bar naming the pages whose content was cut at the renderer's limits (#623). +fn cut_pages_bar(view: &mut DocView, ui: &mut egui::Ui, t: &Tokens) { + let Some(&first) = view.cut_pages.iter().next() else { return }; + let page = first.saturating_add(1).to_string(); + let n = view.cut_pages.len(); + let text = if n == 1 { + crate::i18n::fmt( + tl!("Page {page} holds more content than PdfCraft can read: part of it isn't drawn, and only part of its text can be found or copied."), + &[("page", &page)], + ) + } else { + crate::i18n::fmt( + tl!( + "{n} pages hold more content than PdfCraft can read, page {page} first: parts of them aren't drawn, and only part of their text can be found or copied." + ), + &[("n", &n.to_string()), ("page", &page)], + ) + }; + let mut dismiss = false; + notice_bar(ui, t, "triangle-alert", t.accent_text, text, |ui| { + dismiss = icons::button(ui, "x", 22.0, false, tl!("Dismiss")).clicked(); + }); + if dismiss { + view.cut_notice_dismissed.extend(view.cut_pages.iter().copied()); + } +} + /// One notice bar: an icon and a message on the left, `buttons` (added right to left) on the /// right. The buttons are laid out first, so the message only gets the width they leave: it /// wraps there (or, when very little is left, is cut short with the full text on hover) instead diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index 9a0abad953..2b2e519f4c 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,7 +1,7 @@ use super::find::Follow; use super::{ - Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, - device_pixels, + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextLink, TextureHandle, + TextureOptions, device_pixels, }; pub(super) const THUMB_TAG: u64 = 1 << 63; @@ -140,6 +140,12 @@ impl DocView { } } + /// Pages whose content was cut at the renderer's limits, so part of each is not drawn and + /// its text is incomplete (#623; for automation; 0-based, in order). + pub fn cut_pages(&self) -> Vec { + self.cut_pages.iter().copied().collect() + } + /// Pages that could not be rendered, with the reason (for automation; 0-based pages). pub fn page_errors(&self) -> Vec<(usize, &str)> { let mut v: Vec<(usize, &str)> = self.errors.iter().map(|(p, e)| (*p, e.as_str())).collect(); @@ -186,6 +192,7 @@ impl DocView { self.texts.clear(); self.text_failed.clear(); self.errors.clear(); + self.cut_pages.clear(); self.slow_pages.clear(); self.waiting_since.clear(); self.last_queue.clear(); @@ -229,6 +236,7 @@ impl DocView { self.texts.remove(&page); self.text_failed.remove(&page); self.errors.remove(&page); + self.cut_pages.remove(&page); self.last_queue.clear(); if self.selection.is_some_and(|s| s.page == page) { self.selection = None; @@ -245,6 +253,12 @@ impl DocView { self.pages.get(&page).map(|p| p.tex.id()) } + /// The size of `page`'s whole-page raster, when one is drawn and up to date (not waiting for + /// its replacement after a change; tests and automation). + pub fn drawn_page_size(&self, page: usize) -> Option<[usize; 2]> { + self.pages.get(&page).filter(|p| p.tag != STALE_TAG).map(|p| p.tex.size()) + } + /// Whether `page`'s whole-page raster (a tiled page's backdrop too) is still the one from /// before its last change, whatever its tiles are. pub(crate) fn page_raster_out_of_date(&self, page: usize) -> bool { @@ -267,6 +281,37 @@ impl DocView { self.texts.get(&page).cloned() } + /// Finds the addresses written as plain text on `page` (#4207): once per text layer, and only + /// once the page's text is known. The addresses of text layers that were dropped or replaced + /// are forgotten. + pub(crate) fn refresh_text_links(&mut self, page: usize) { + let texts = &self.texts; + self.text_links.retain(|p, (t, _)| texts.get(p).is_some_and(|current| Arc::ptr_eq(current, t))); + if self.text_links.contains_key(&page) { + return; + } + if let Some(text) = self.texts.get(&page).cloned() { + let found: Arc<[TextLink]> = pdfcraft_engine::text_links::find_text_links(&text).into(); + self.text_links.insert(page, (text, found)); + } + } + + /// The address written as plain text on `page` under the view-space point `(x, y)` (#4207), + /// with its index among the page's, unless a link annotation lies over it: the annotation is + /// what the document asks for there. Only addresses [`Self::refresh_text_links`] found count. + pub(crate) fn text_link_at(&self, info: &DocInfo, page: usize, x: f32, y: f32) -> Option<(usize, &TextLink)> { + let (_, links) = self.text_links.get(&page)?; + let page_info = info.pages.get(page)?; + let (index, hit) = links.iter().enumerate().find(|(_, l)| l.contains(x, y))?; + let covered = info.links.iter().any(|l| l.page == page && hit.covered_by(page_info, l.rect)); + (!covered).then_some((index, hit)) + } + + /// The address of the `index`-th text link found on `page` (#4207). + pub(crate) fn text_link_uri(&self, page: usize, index: usize) -> Option { + self.text_links.get(&page).and_then(|(_, links)| links.get(index)).map(|l| l.uri.clone()) + } + /// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and /// find matches on a page all wait for it (tests and automation). pub fn has_page_text(&self, page: usize) -> bool { @@ -309,6 +354,22 @@ impl DocView { /// Only the displayed document owns raster textures. Tabs keep their document and view /// state, but do not each retain a separate full application texture allowance. pub(crate) fn suspend_rendering(&mut self, pool: &RenderPool) { + let queued = !self.last_queue.is_empty(); + self.release_rasters(); + if queued { + pool.set_queue(Vec::new()); + } + // Results already finished for this tab would otherwise sit in its pool, rasters and + // all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.) + if !pool.is_inline() { + while pool.try_recv().is_some() {} + } + } + + /// The view's half of [`DocView::suspend_rendering`]: its rasters and its demand go, and its + /// document's pool is left alone. On its own for a view whose document another view on screen + /// renders through the same pool (split view). + pub(crate) fn release_rasters(&mut self) { self.begin_render_frame(); self.pages.clear(); self.tiles.clear(); @@ -322,15 +383,8 @@ impl DocView { self.signature_drag = Default::default(); self.text_background = Default::default(); self.move_background = Default::default(); - if !self.last_queue.is_empty() { - pool.set_queue(Vec::new()); - self.last_queue.clear(); - } - // Results already finished for this tab would otherwise sit in its pool, rasters and - // all, until the tab is shown again. (An inline pool renders inside try_recv: skip it.) - if !pool.is_inline() { - while pool.try_recv().is_some() {} - } + self.last_queue.clear(); + self.pane.shared = false; } pub(super) fn thumbnail_requests(&self, info: &DocInfo, ppp: f32) -> Vec { @@ -527,7 +581,8 @@ impl DocView { return; } let queue = self.prepare_render_queue(info, ctx.pixels_per_point()); - if queue != self.last_queue { + // (Shared with another view of the document last frame, the pool's queue was both views'.) + if queue != self.last_queue || std::mem::take(&mut self.pane.shared) { pool.set_queue(queue.clone()); self.last_queue = queue; } @@ -539,25 +594,69 @@ impl DocView { } } + /// [`DocView::finish_render_frame`] for several views of one document on screen at once (split + /// view), `group` in `views`: the document's one render pool works for + /// all of them, and a request they share (the same page at the same scale) is rendered once + /// and handed to each. + pub(crate) fn finish_shared_render_frame(ctx: &egui::Context, info: &DocInfo, pool: &RenderPool, views: &mut [DocView], group: &[usize]) { + // As for one view (#3149): an edit after the canvases collected demand leaves its scales + // and indexes on the old pages, in every view of the document (the pool's queue is theirs). + if group.iter().any(|&i| views.get(i).is_some_and(|v| v.frame_geometry_changed)) { + for &i in group { + if let Some(view) = views.get_mut(i) { + view.begin_render_frame(); + view.last_queue.clear(); + } + } + pool.set_queue(Vec::new()); + ctx.request_repaint(); + return; + } + let ppp = ctx.pixels_per_point(); + let mut changed = false; + let mut union: Vec = Vec::new(); + for &i in group { + let Some(view) = views.get_mut(i) else { continue }; + let queue = view.prepare_render_queue(info, ppp); + changed |= !view.pane.shared || queue != view.last_queue; + for r in &queue { + if !union.contains(r) { + union.push(*r); + } + } + view.last_queue = queue; + view.pane.shared = true; + } + if changed { + pool.set_queue(union); + } + // A result can go to both views, so one view holding its rasters holds them for both. + if !group.iter().any(|&i| views.get(i).is_some_and(|v| v.hold_rasters)) { + receive_batch(pool, |r| { + let wanting: Vec = + group.iter().copied().filter(|&i| views.get(i).is_some_and(|v| v.last_queue.contains(&r.request))).collect(); + let Some((&last, rest)) = wanting.split_last() else { return 0 }; + let mut uploaded = 0usize; + for &i in rest { + if let Some(view) = views.get_mut(i) { + uploaded = uploaded.saturating_add(view.receive_result(ctx, copy_result(&r))); + } + } + if let Some(view) = views.get_mut(last) { + uploaded = uploaded.saturating_add(view.receive_result(ctx, r)); + } + uploaded + }); + } + if group.iter().any(|&i| views.get(i).is_some_and(|v| !v.last_queue.is_empty())) { + ctx.request_repaint_after(std::time::Duration::from_millis(30)); + } + } + /// Pull a bounded batch of current results into textures. Demand is committed first, so /// offscreen results and obsolete scale/configuration tags never allocate a ColorImage. pub fn receive(&mut self, ctx: &egui::Context, pool: &RenderPool) { - let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME }; - let mut uploaded = 0usize; - #[cfg(not(target_arch = "wasm32"))] - let started = std::time::Instant::now(); - for _ in 0..budget { - let Some(r) = pool.try_recv() else { break }; - uploaded = uploaded.saturating_add(self.receive_result(ctx, r)); - // One whole-page upload may exceed the frame allowance; it must still progress. - if uploaded >= UPLOAD_BYTES_PER_FRAME { - break; - } - #[cfg(not(target_arch = "wasm32"))] - if started.elapsed() >= std::time::Duration::from_millis(4) { - break; - } - } + receive_batch(pool, |r| self.receive_result(ctx, r)); } pub(super) fn receive_result(&mut self, ctx: &egui::Context, r: pdfcraft_render::RenderedPage) -> usize { @@ -565,6 +664,10 @@ impl DocView { return 0; } ctx.request_repaint(); + // Whether drawn or read for its text, a page cut at the limits is named (#623). + if r.warnings.contains(&pdfcraft_render::RenderWarning::ContentTruncated) { + self.cut_pages.insert(r.request.page); + } if r.request.kind == RequestKind::Text { match r.text { Some(t) => { @@ -745,6 +848,44 @@ impl DocView { } } +/// Take a bounded batch of finished renders from `pool` and hand each to `receive`, which +/// returns the bytes it uploaded: at most a few results, one frame's upload allowance, and 4 ms +/// a frame. +fn receive_batch(pool: &RenderPool, mut receive: impl FnMut(pdfcraft_render::RenderedPage) -> usize) { + let budget = if pool.is_inline() { 1 } else { RESULTS_PER_FRAME }; + let mut uploaded = 0usize; + #[cfg(not(target_arch = "wasm32"))] + let started = std::time::Instant::now(); + for _ in 0..budget { + let Some(r) = pool.try_recv() else { break }; + uploaded = uploaded.saturating_add(receive(r)); + // One whole-page upload may exceed the frame allowance; it must still progress. + if uploaded >= UPLOAD_BYTES_PER_FRAME { + break; + } + #[cfg(not(target_arch = "wasm32"))] + if started.elapsed() >= std::time::Duration::from_millis(4) { + break; + } + } +} + +/// A copy of a render result, for a second view of the document that asked for the same request +/// (split view). Not `Clone` on purpose: page rasters are large, and this is the one place they +/// are copied. +fn copy_result(r: &pdfcraft_render::RenderedPage) -> pdfcraft_render::RenderedPage { + pdfcraft_render::RenderedPage { + request: r.request, + width: r.width, + height: r.height, + rgba: r.rgba.clone(), + error: r.error.clone(), + text: r.text.clone(), + warnings: r.warnings.clone(), + millis: r.millis, + } +} + /// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536). pub(super) fn scale_tag(scale: f32) -> u64 { (f64::from(scale) * 65536.0).round() as u64 diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index bcb4b42a20..22d6e02d84 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -204,6 +204,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 +227,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 +242,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/page_input.rs b/crates/ui-egui/src/canvas/page_input.rs index e253fd6b59..793211fec7 100644 --- a/crates/ui-egui/src/canvas/page_input.rs +++ b/crates/ui-egui/src/canvas/page_input.rs @@ -2,7 +2,7 @@ //! router, the text layer, and what is drawn over the page after its text. use egui::{Color32, CornerRadius, Pos2, Rect, Stroke}; -use pdfcraft_render::{DocInfo, PageText}; +use pdfcraft_render::{DocInfo, LinkTarget, PageText}; use super::{ DocView, MARK_ICON_SHARE, PageEnv, PageFrame, PageSlot, PageXform, Selection, Span, cached_edit_images, click_unit, mark_anchor, @@ -67,6 +67,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut redact_mark, table_preview, text_style, + text_links, tool, .. } = frame.env; @@ -100,6 +101,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut lines: &lines, images: &images, clip: ui.clip_rect(), + text_links, }; if let Some(o) = ptr.pressed() { // A press with Shift or ⌘ in Edit text is the objects', whatever lies under it @@ -453,6 +455,8 @@ struct PageHits<'a> { images: &'a [pdfcraft_engine::PageImage], /// The pages' visible area (the picture's rotate knob stays inside it). clip: Rect, + /// Addresses written as plain page text open like links (#4207). + text_links: bool, } impl PageHits<'_> { @@ -534,6 +538,7 @@ impl PageHits<'_> { lines, images, clip, + text_links, } = *self; // A press with Shift or ⌘ is the objects' (`object_ui::modified_hit`), off crop mode's page. if let Some(objects) = objects { @@ -589,11 +594,27 @@ impl PageHits<'_> { let added = (editing_content && can_modify && picks_up).then(|| crate::content_ui::hit(xf, info, page, added, view, p)).flatten(); let corner = (!context && tool == QuickTool::EnhanceCamera && can_modify).then(|| crate::camera_ui::hit(xf, page, view, p)).flatten(); let link = crate::link_ui::hit(xf, info, page, doc_links, tool, p); - let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, link.is_some()) }; - [link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() + let text_link = (text_links && link.is_none()).then(|| text_link_hit(view, info, xf, page, p)).flatten(); + let on_link = link.is_some() || text_link.is_some(); + let comment = if context { comments::context_hit(pcx, p) } else { comments::hit(pcx, view, p, on_link) }; + [link, text_link, comment, pictures, paragraphs, added, objects, grips, corner, fields].into_iter().flatten().collect() } } +/// The web or email address written as plain text under `p` on `page` (#4207), with no link +/// annotation over it: the same hand and tip (its address) as a link annotation's. +fn text_link_hit(view: &DocView, info: &DocInfo, xf: &PageXform, page: usize, p: Pos2) -> Option { + let (vx, vy) = xf.screen_to_view(p); + let (index, link) = view.text_link_at(info, page, vx, vy)?; + let owner = crate::page_router::Owner::TextLink { index }; + // A drag from the address selects its text, as from any other text: only a click opens it. + Some(crate::page_router::Hit { + tip: Some(link.uri.clone()), + declines_drag: true, + ..crate::page_router::Hit::new(owner, Layer::Link, egui::CursorIcon::PointingHand) + }) +} + /// Text layer: find matches, selection, I-beam and drag-to-select. pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { let PageEnv { selects_text, tool, .. } = frame.env; @@ -605,9 +626,9 @@ pub(super) fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut Page && !input.consumed && let Some(p) = pointer.filter(|p| r.contains(*p)) { - // The I-beam drops where a press would go to a link (ruling E: the tools that select a - // link's text show it there). - let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. })); + // The I-beam drops where a press would go to a link, an address written as page text + // included (#4207) (ruling E: the tools that select a link's text show it there). + let over_link = input.hover.as_ref().is_some_and(|h| matches!(h.owner, Owner::Link { .. } | Owner::TextLink { .. })); let pointer = TextPointer { page: i, xf, rect: r, tool, resp, ptr: ptr.clone(), pointer: p, over_link, text_drag: input.text_drag }; text_pointer_input(ui, view, &text, pointer); } @@ -905,6 +926,19 @@ pub(super) fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut P { frame.out.clicked_link = Some(l.target.clone()); } + // A web or email address written as plain text (#4207): the same click as a link annotation, + // and the same check and confirmation on opening. + if unobstructed + && resp.clicked() + && tool == QuickTool::Select + && !input.consumed + && frame.out.clicked_link.is_none() + && let PagePress::Mine(Owner::TextLink { index }) = input.press + && input.released_over + && let Some(uri) = view.text_link_uri(i, index) + { + frame.out.clicked_link = Some(LinkTarget::Uri(uri)); + } // Comments say what they are with any tool, beside its cursor, as Acrobat's do. comments::describe(&pcx, view); for (mp, mr, mc) in &view.compare_marks { diff --git a/crates/ui-egui/src/canvas/page_router.rs b/crates/ui-egui/src/canvas/page_router.rs index 30a3f2126f..5dbe5d4461 100644 --- a/crates/ui-egui/src/canvas/page_router.rs +++ b/crates/ui-egui/src/canvas/page_router.rs @@ -24,6 +24,8 @@ pub(crate) enum Owner { Comment { index: usize, part: CommentPart }, /// A link, by its index in its page's `/Annots`. Link { index: usize }, + /// A web or email address written as plain page text (#4207), by its index among the page's. + TextLink { index: usize }, /// A form field's widget, by the field's index in the form and the widget's in the field. Field { field: usize, widget: usize }, /// Prepare Form: a field's widget, to select and move. diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index f872f8915e..c9bc8cd3e1 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -206,7 +206,7 @@ pub(super) fn selection_actions( let x = rect.left().clamp(viewport.left() + 8.0, (viewport.right() - 520.0).max(viewport.left() + 8.0)); let y = if rect.bottom() + 48.0 < viewport.bottom() { rect.bottom() + 6.0 } else { (rect.top() - 44.0).max(viewport.top() + 6.0) }; let mut chosen = None; - egui::Area::new(egui::Id::unique(("text-selection-actions", view.id.0))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( + egui::Area::new(egui::Id::unique(("text-selection-actions", view.view_key()))).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show( ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { diff --git a/crates/ui-egui/src/canvas/tests.rs b/crates/ui-egui/src/canvas/tests.rs index d8a5259251..85c7d3fac3 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(), ); @@ -550,6 +551,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 +1180,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 +1211,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 +2068,59 @@ fn edit_text_boxes_stay_drawn_while_page_input_is_blocked() { assert_eq!(app.views[0].edit_lines.get(&0).map(|(g, _)| *g), Some(generation), "the boxes follow the edit while input is blocked"); } +/// A cut page (#623) is named in a bar of its own: a static XFA form's notice, which comes +/// first among the form notices, still shows beside it instead of replacing it (review on #2113). +#[test] +fn a_cut_page_is_named_beside_an_xfa_form_notice() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = pdfcraft_xfa::fixtures::static_shell("Ada"); + app.open_bytes("form.pdf", None, bytes).unwrap(); + app + }); + h.run_steps(4); + h.get_by_label_contains("XFA data are kept in step"); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label_contains("XFA data are kept in step"); +} + +/// Dismissing another notice doesn't hide a page found cut later (#623), and dismissing the +/// cut-page notice lasts until another page is found cut (review on #2113). +#[test] +fn a_page_found_cut_after_a_dismissal_is_still_named() { + use egui_kittest::kittest::Queryable; + + let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + let bytes = app.session.create_blank(300.0, 400.0, 2).unwrap(); + app.open_bytes("blank.pdf", None, bytes.as_ref().clone()).unwrap(); + app + }); + h.run_steps(3); + // The reader dismissed the document's notice before the render found the cut page. + h.state_mut().views[0].notice_dismissed = true; + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + h.get_by_label_contains("Page 1 holds more content than PdfCraft can read"); + h.get_by_label("Dismiss").click(); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the cut-page notice can be dismissed"); + // Re-reading the same page (an undo, a layer toggle) doesn't bring it back. + h.state_mut().views[0].invalidate_content(); + h.state_mut().views[0].cut_pages.insert(0); + h.run_steps(2); + assert!(h.query_by_label_contains("holds more content").is_none(), "the dismissed page stays dismissed"); + h.state_mut().views[0].cut_pages.insert(1); + h.run_steps(2); + h.get_by_label_contains("2 pages hold more content than PdfCraft can read, page 1 first"); +} + #[test] fn virtualized_organizer_keeps_insert_gaps_at_document_positions() { use egui_kittest::kittest::Queryable; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 3531dffca5..dba8419919 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -4,6 +4,8 @@ use craft_ui::panes::{Rail, RailItem, RailRequest}; use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2}; use crate::canvas::{DocView, Fit, PageLayout}; +use crate::panes::{Place, TabDrop}; +use crate::tab_context::Action; use crate::theme::{self, ThemePreference, Tokens}; use crate::{Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, icons, widgets, window_controls}; @@ -41,6 +43,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { .frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); + app.panes.main_strip = Some(full); let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag()); if drag.drag_started() { window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag); @@ -80,7 +83,8 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // Tabs shrink to share the strip (names end in "…") and scroll only once they reach // their narrowest; then arrows either side say so and step through them. let mut natural: Vec = Vec::with_capacity(app.views.len() + 1); - for i in 0..app.views.len() { + // (The main pane's tabs: split view's second pane has a strip of its own.) + for i in app.tabs_in(Place::Main) { if let Some(name) = crate::canvas::tab_label(app, i) { natural.push(tab_natural_width(ui, &t, &name)); } @@ -126,7 +130,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { // (with whether it was just released). let mut slots = Vec::with_capacity(app.views.len()); let mut dragged = None; - for i in 0..app.views.len() { + for i in app.tabs_in(Place::Main) { let Some(doc) = app.session.get(app.views[i].id) else { continue }; let Some(name) = crate::canvas::tab_label(app, i) else { continue }; let dirty = doc.dirty; @@ -141,7 +145,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { name: &name, file, dirty, - active: app.active == Some(i), + active: app.pane_front(Place::Main) == Some(i), index: i, cap, height: tab_h, @@ -156,23 +160,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { if changed && app.active == Some(i) && !app.combine_showing() { response.scroll_to_me(Some(Align::Center)); } - let id = app.views[i].id; - let reveal = cfg!(not(target_arch = "wasm32")) && doc.path.is_some(); - response.context_menu(|ui| { - if ui.button(tl!("Close Tab")).clicked() { - action = Some(crate::tab_context::Action::Close(id)); - ui.close(); - } - if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() { - action = Some(crate::tab_context::Action::Reopen); - ui.close(); - } - let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; - if ui.add_enabled(reveal, egui::Button::new(label)).clicked() { - action = Some(crate::tab_context::Action::Reveal(id)); - ui.close(); - } - }); + response.context_menu(|ui| tab_menu(app, ui, i, &mut action)); if response.clicked() { app.active = Some(i); } @@ -182,33 +170,11 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) { close = Some(i); } } - // Dragging a tab moves it (#1537): a line marks the gap it will land - // in, and releasing the button drops it there. - if let Some((from, released)) = dragged - && let Some(x) = ui.ctx().pointer_latest_pos().map(|p| p.x) - { - let to = drop_index(&slots, from, x); - if released { - if close.is_none() && action.is_none() { - app.move_tab(from, to); - } - } else { - drag_x = Some(x); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if let Some(marker) = drop_marker(&slots, from, to, ui.spacing().item_spacing.x) { - let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range()); - // In front of the first tab the gap lies outside the scroll area's - // clip rect (#2225): keep the whole line inside it. - let clip = ui.clip_rect(); - let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0)); - ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent)); - } - } - } + drag_x = tab_drag(app, ui, &slots, dragged, Place::Main, close.is_some() || action.is_some()); if let Some(i) = close && let Some(view) = app.views.get(i) { - action.get_or_insert(crate::tab_context::Action::Close(view.id)); + action.get_or_insert(Action::Close(view.view_key())); } if let Some(action) = action { app.tab_action(action); @@ -326,6 +292,143 @@ fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) { } } +/// A document tab's right-click menu, in any strip: the choice is left in `action`. +fn tab_menu(app: &PdfCraftApp, ui: &mut egui::Ui, index: usize, action: &mut Option) { + let Some(view) = app.views.get(index) else { return }; + let (tab, id) = (view.view_key(), view.id); + if ui.button(tl!("Close Tab")).clicked() { + *action = Some(Action::Close(tab)); + ui.close(); + } + if ui.add_enabled(!app.closed_tabs.is_empty(), egui::Button::new(tl!("Reopen Closed Tab"))).clicked() { + *action = Some(Action::Reopen); + ui.close(); + } + ui.separator(); + // Split view (view.split-window). + if ui.button(tl!("Split right")).clicked() { + *action = Some(Action::Split { tab, down: false }); + ui.close(); + } + if ui.button(tl!("Split down")).clicked() { + *action = Some(Action::Split { tab, down: true }); + ui.close(); + } + ui.separator(); + let reveal = cfg!(not(target_arch = "wasm32")) && app.session.get(id).is_some_and(|d| d.path.is_some()); + let label = if cfg!(target_os = "macos") { tl!("Reveal in Finder") } else { tl!("Reveal in File Explorer") }; + if ui.add_enabled(reveal, egui::Button::new(label)).clicked() { + *action = Some(Action::Reveal(id)); + ui.close(); + } +} + +/// A tab of `place`'s strip (its tabs `slots`) being dragged (`dragged`: its index, and whether it +/// was let go this frame). It moves the tab along the strip (#1537): a line marks the gap it will +/// land in, and letting go drops it there. Over the right half of a page area that isn't split, or +/// over split view's other pane, a hint shows where it would go instead, and letting go puts it +/// there (`crate::panes`). `busy` is a close or menu choice this frame, which wins. Returns the +/// pointer's x while the tab moves along the strip, for scrolling at the strip's edges. +fn tab_drag(app: &mut PdfCraftApp, ui: &egui::Ui, slots: &[(usize, Rect)], dragged: Option<(usize, bool)>, place: Place, busy: bool) -> Option { + let (from, released) = dragged?; + let pos = ui.ctx().pointer_latest_pos()?; + match app.drop_target(pos, place) { + TabDrop::Strip => { + let x = pos.x; + let to = drop_index(slots, from, x); + if released { + if !busy { + app.move_tab_in_strip(from, slots, to); + } + return None; + } + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if let Some(marker) = drop_marker(slots, from, to, ui.spacing().item_spacing.x) { + let t = Tokens::get(ui.ctx()); + let span = slots.first().map_or(ui.max_rect().y_range(), |(_, r)| r.y_range()); + // In front of the first tab the gap lies outside the scroll area's clip rect + // (#2225): keep the whole line inside it. + let clip = ui.clip_rect(); + let marker = marker.clamp(clip.left() + 1.0, (clip.right() - 1.0).max(clip.left() + 1.0)); + ui.painter().vline(marker, span.shrink(4.0), Stroke::new(2.0, t.accent)); + } + Some(x) + } + target => { + if !released { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + app.panes.drop_hint = app.drop_hint_rect(target); + } else if !busy { + app.drop_tab(from, target); + } + None + } + } +} + +/// The tab strip of split view's second pane, in `strip`: its tabs share the width (no scrolling), +/// and are clicked, closed, moved along and dragged to the other pane as in the main strip. +pub(crate) fn pane_tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui, strip: Rect, place: Place) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(strip, 0.0, t.titlebar); + ui.painter().hline(strip.x_range(), strip.bottom() - 0.5, Stroke::new(1.0, t.divider)); + let tabs = app.tabs_in(place); + let front = app.pane_front(place); + let inner = Rect::from_min_max(strip.min + vec2(6.0, 2.0), strip.max - vec2(6.0, 0.0)); + let mut row = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(Layout::left_to_right(Align::Max)).id_salt(("tab-row", place))); + row.spacing_mut().item_spacing.x = 4.0; + let natural: Vec = tabs.iter().filter_map(|&i| crate::canvas::tab_label(app, i)).map(|name| tab_natural_width(&row, &t, &name)).collect(); + let cap = tab_cap(&natural, row.available_width(), 4.0, TAB_MIN_WIDTH); + let mut close = None; + let mut action = None; + let mut slots = Vec::with_capacity(tabs.len()); + let mut dragged = None; + for &i in &tabs { + let Some(view) = app.views.get(i) else { continue }; + let Some(doc) = app.session.get(view.id) else { continue }; + let Some(name) = crate::canvas::tab_label(app, i) else { continue }; + let dirty = doc.dirty; + let file = (name != doc.name).then(|| doc.name.clone()); + let id = row.scope_id().with(("pane-tab", view.view_key())); + let spec = TabSpec { + id, + theme: &t, + icon: "file-text", + name: &name, + file: file.as_deref(), + dirty, + active: front == Some(i), + index: i, + cap, + height: inner.height(), + draggable: true, + }; + let response = tab(&mut row, spec, &mut close); + slots.push((i, response.rect)); + if response.dragged() || response.drag_stopped() { + dragged = Some((i, response.drag_stopped())); + } + response.context_menu(|ui| tab_menu(app, ui, i, &mut action)); + if response.clicked() { + app.active = Some(i); + app.combine_tab.focused = false; + } + // A middle click closes the tab, as in the main strip (#1853). + if response.middle_clicked() { + close = Some(i); + } + } + tab_drag(app, &row, &slots, dragged, place, close.is_some() || action.is_some()); + if let Some(i) = close + && let Some(view) = app.views.get(i) + { + action.get_or_insert(Action::Close(view.view_key())); + } + if let Some(action) = action { + app.tab_action(action); + } +} + /// A tab's chrome around its name: the icon on the left, the close button on the right. const TAB_CHROME: f32 = 64.0; /// The narrowest a tab shrinks to before the strip scrolls: room for most of a name. diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index d455d0a74b..3d277fb2e9 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -218,6 +218,7 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } + "prepress.hairlines" => self.dialog = Some(Dialog::FixHairlines), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); @@ -330,6 +331,7 @@ impl PdfCraftApp { } } "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), + "view.split" => self.toggle_split(), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { return Some(v.view_history(id == "view.next_view")); @@ -800,13 +802,15 @@ impl PdfCraftApp { /// end (#1542). The Combine files tab, when open, comes after the documents. From Home the /// next tab is the first and the previous one the last. fn step_tab(&mut self, forward: bool) { - let count = self.views.len() + usize::from(self.combine_tab.open); - let current = if self.combine_showing() { Some(self.views.len()) } else { self.active }; + // The tabs of the strip being worked in: split view's second pane steps through its own. + let place = self.active.and_then(|i| self.place_of(i)).unwrap_or_default(); + let tabs = self.tabs_in(place); + let count = tabs.len() + usize::from(place == crate::panes::Place::Main && self.combine_tab.open); + let current = if self.combine_showing() { Some(tabs.len()) } else { self.active.and_then(|a| tabs.iter().position(|&i| i == a)) }; let Some(next) = tab_after(current, count, forward) else { return }; - if next < self.views.len() { - self.active = Some(next); - } else { - self.open_combine_tab(); + match tabs.get(next) { + Some(&i) => self.active = Some(i), + None => self.open_combine_tab(), } } @@ -837,6 +841,9 @@ impl PdfCraftApp { continue; } let Some(key) = Key::from_name(s.key) else { continue }; + if ctx.input(|i| altgr_pressed(i, key)) { + continue; + } let mut m = Modifiers::NONE; if s.command { m |= Modifiers::COMMAND; @@ -873,6 +880,17 @@ impl PdfCraftApp { } } +/// Whether `key` was pressed with Ctrl+Alt this frame. That is how AltGr can reach us, and egui's +/// shortcut match ignores an extra Alt, so German or Spanish AltGr+ß or AltGr+º, which type a +/// backslash, would run ⌘\ (and AltGr+8 ⌘[) instead. No registered shortcut uses Alt. On macOS +/// the command key is not Ctrl, and Option stays free to type a shortcut's character. +fn altgr_pressed(input: &egui::InputState, key: egui::Key) -> bool { + input + .events + .iter() + .any(|e| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.ctrl && modifiers.alt)) +} + /// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From /// no tab, forward is the first and back the last. `None` when there are no tabs. fn tab_after(current: Option, count: usize, forward: bool) -> Option { diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 12c98007cc..acccf5ead3 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 })), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34e282ea21..1db3be1218 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -11,6 +11,7 @@ pub(super) mod appearance; pub mod bookmark_styles_ui; pub(super) mod comment_props; pub(super) mod credits; +pub(super) mod hairlines_ui; pub(super) mod marks_ui; pub(super) mod pageboxes; pub(super) mod palette; @@ -43,6 +44,7 @@ struct Pending { link_command: Option<&'static str>, protect_now: bool, boxes_now: bool, + fix_hairlines_now: bool, marks_now: bool, export_now: bool, props_now: bool, @@ -446,6 +448,12 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m req.close = req.apply || cancel; return false; } + Dialog::FixHairlines => { + let (go, cancel) = crate::hairlines_ui::body(ui, app, &t); + req.fix_hairlines_now = go; + req.close = go || cancel; + return false; + } Dialog::BookmarksFromTextStyles => { req.close = crate::bookmark_styles_ui::body(ui, app, &t); return false; @@ -1977,6 +1985,9 @@ fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } + if req.fix_hairlines_now { + app.fix_hairlines(); + } if req.apply_number && let Some(edit) = req.number_now.take() { diff --git a/crates/ui-egui/src/dialogs/hairlines_ui.rs b/crates/ui-egui/src/dialogs/hairlines_ui.rs new file mode 100644 index 0000000000..6b5c093c76 --- /dev/null +++ b/crates/ui-egui/src/dialogs/hairlines_ui.rs @@ -0,0 +1,129 @@ +//! Print production ▸ Fix hairlines: lines narrower than a width on the page become a chosen +//! width; Type 3 glyphs and pattern strokes on request; the pages. Fix says how many strokes +//! changed, and refuses nothing: a document with no hairlines is told so. + +use pdfcraft_engine::{Edit, Hairlines}; + +use crate::pageboxes::Range; +use crate::theme::{self, Tokens}; +use crate::units::Unit; +use crate::{PdfCraftApp, widgets}; + +#[derive(Clone, Debug, PartialEq)] +pub struct HairlinesDraft { + /// Both widths in points (shown in the preferences' unit). + pub settings: Hairlines, + pub range: Range, +} + +impl Default for HairlinesDraft { + fn default() -> Self { + Self { settings: Hairlines::default(), range: Range::All } + } +} + +impl HairlinesDraft { + pub fn pages(&self, current: usize, count: usize) -> Vec { + match self.range { + Range::All => (0..count).collect(), + Range::Current => vec![current.min(count.saturating_sub(1))], + Range::Pages(a, b) => (a.max(1) - 1..b.min(count)).collect(), + } + } +} + +/// A width in points edited in `unit`, named by the label `label`. +fn width_field(ui: &mut egui::Ui, pts: &mut f64, unit: Unit, label: egui::Id) { + let k = unit.per_point(); + let mut v = *pts * k; + let step = 0.001 * k; + if ui + .add(egui::DragValue::new(&mut v).range(0.0..=Hairlines::MAX_POINTS * k).speed(step.max(0.0005)).fixed_decimals(unit.decimals().max(3))) + .labelled_by(label) + .changed() + { + *pts = v / k; + } + ui.label(unit.label()); +} + +/// The dialog: (fix, cancel). +pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) { + let Some((i, id)) = app.active_ids() else { return (false, true) }; + let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let current = app.views[i].current; + let unit = app.units; + let d = &mut app.hairlines_draft; + ui.label(egui::RichText::new(tl!("Fix hairlines")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("hairlines").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + let label = ui.label(tl!("Narrower than")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.narrower_than, unit, label)); + ui.end_row(); + let label = ui.label(tl!("New width")).id; + ui.horizontal(|ui| width_field(ui, &mut d.settings.width, unit, label)); + ui.end_row(); + }); + ui.label(egui::RichText::new(tl!("Zero-width lines always count as narrower.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Also")); + ui.horizontal(|ui| { + ui.add(widgets::checkbox(&mut d.settings.type3, tl!("Type 3 font glyphs"))); + ui.add(widgets::checkbox(&mut d.settings.patterns, tl!("Pattern strokes"))); + }); + ui.label(egui::RichText::new(tl!("Lines drawn inside Type 3 glyphs and tiling patterns; off by default.")).small().color(t.text_muted)); + widgets::section_title(ui, tl!("Pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.range, Range::All, tl!("All")); + widgets::radio_value(ui, &mut d.range, Range::Current, crate::i18n::fmt(tl!("Current page ({n})"), &[("n", &(current + 1).to_string())])); + let (mut a, mut b) = match d.range { + Range::Pages(a, b) => (a, b), + _ => (1, count.max(1)), + }; + let on = matches!(d.range, Range::Pages(..)); + if ui.add(widgets::radio(on, tl!("From"))).clicked() { + d.range = Range::Pages(a, b); + } + let ra = ui.add(egui::DragValue::new(&mut a).range(1..=count.max(1))); + ui.label(tl!("to")); + let rb = ui.add(egui::DragValue::new(&mut b).range(1..=count.max(1))); + if ra.changed() || rb.changed() { + d.range = Range::Pages(a.min(b), b.max(a)); + } + }); + ui.add_space(12.0); + let (mut go, mut cancel) = (false, false); + let ready = count > 0 && d.settings.width > 0.0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.add_enabled_ui(ready, |ui| widgets::pill_button(ui, tl!("Fix"), true)).inner.clicked() { + go = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + cancel = true; + } + }); + (go, cancel) +} + +impl PdfCraftApp { + /// Fix: count first (the edit refuses when there is nothing to fix), then apply and say. + pub(crate) fn fix_hairlines(&mut self) { + let Some((i, id)) = self.active_ids() else { return }; + let count = self.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let pages = self.hairlines_draft.pages(self.views[i].current, count); + let settings = self.hairlines_draft.settings.clone(); + let found = self.session.count_hairlines(id, &pages, &settings); + if found.lines == 0 { + self.notify_tr("No hairlines found on those pages"); + return; + } + if self.apply_edit(Edit::FixHairlines { pages, settings }) { + let (n, p) = (found.lines.to_string(), found.pages.to_string()); + match (found.lines, found.pages) { + // One stroke can only be on one page. + (1, _) => self.notify_tr("Fixed 1 hairline on 1 page"), + (_, 1) => self.notify_fmt("Fixed {n} hairlines on 1 page", &[("n", &n)]), + _ => self.notify_fmt("Fixed {n} hairlines on {p} pages", &[("n", &n), ("p", &p)]), + } + } + } +} diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs index ea985f3207..830144277c 100644 --- a/crates/ui-egui/src/dialogs/preferences.rs +++ b/crates/ui-egui/src/dialogs/preferences.rs @@ -741,6 +741,8 @@ fn card_documents_and_view(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, }); ctx.label_secondary(ui, tl!("Used when a PDF doesn't ask for a layout or zoom. Continuous scrolling never snaps between pages."), t); ctx.checkbox(ui, &mut app.always_default_view, tl!("Always use my default zoom and page layout"), t); + ctx.checkbox(ui, &mut app.text_links, tl!("Open web and email addresses written in the page text"), t); + ctx.label_secondary(ui, tl!("Only web (http, https) and email addresses open, and PdfCraft asks first, as for other links."), t); ui.add_space(6.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index fff7eea9a9..91e069bca3 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -75,7 +75,7 @@ pub(crate) fn save_prompt_key(ctx: &egui::Context, chosen: &mut PromptButton) -> pub(super) fn save_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) { let Some(req) = app.close_request else { return }; let index = match req { - CloseRequest::Tab(id) => app.views.iter().position(|v| v.id == id), + CloseRequest::Tab(id) => app.asking_tab(id), CloseRequest::Quit | CloseRequest::All => app.first_dirty(), }; let Some(name) = index.and_then(|i| app.views.get(i)).and_then(|v| app.session.get(v.id)).map(|d| d.name.clone()) else { diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index c205eb377c..4588db9a06 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -292,13 +292,13 @@ impl PdfCraftApp { fn undo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; - let doc = view.id; + let (doc, place) = (view.id, view.place()); if let Some(ed) = view.line_editor.as_mut().filter(|ed| ed.has_unsaved_text()) { - ed.claim_typing(&ctx, doc); - let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, true) else { return false }; + ed.claim_typing(&ctx, doc, place); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, true) else { return false }; let before = std::mem::replace(&mut ed.text, text); ed.selection = None; - ed.follow_typed_rows(&ctx, &before); + ed.follow_typed_rows(&ctx, &before, place); return true; } if let Some(d) = view.content.draft.as_mut().filter(|d| d.has_unapplied_typing()) { @@ -327,13 +327,13 @@ impl PdfCraftApp { fn redo_in_page_box(&mut self) -> bool { let Some(ctx) = self.ctx.clone() else { return false }; let Some(view) = self.active.and_then(|i| self.views.get_mut(i)) else { return false }; - let doc = view.id; + let (doc, place) = (view.id, view.place()); if let Some(ed) = view.line_editor.as_mut() { - ed.claim_typing(&ctx, doc); - let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(), &ed.text, false) else { return ed.has_unsaved_text() }; + ed.claim_typing(&ctx, doc, place); + let Some(text) = typing_step(&ctx, crate::edit_text_ui::input_id(place), &ed.text, false) else { return ed.has_unsaved_text() }; let before = std::mem::replace(&mut ed.text, text); ed.selection = None; - ed.follow_typed_rows(&ctx, &before); + ed.follow_typed_rows(&ctx, &before, place); return true; } if let Some(d) = view.content.draft.as_mut() { @@ -432,7 +432,8 @@ impl PdfCraftApp { /// text. pub(crate) fn page_box_focused(&self, ctx: &egui::Context) -> bool { let focused = ctx.memory(|m| m.focused()); - focused == Some(crate::edit_text_ui::input_id()) || focused == Some(crate::content_ui::input_id()) + let paragraph = self.active.and_then(|i| self.views.get(i)).map(|v| crate::edit_text_ui::input_id(v.place())); + focused.is_some() && (focused == paragraph || focused == Some(crate::content_ui::input_id())) } /// Whether the focused box on the page has typing its own Redo would put back: after Undo in @@ -441,10 +442,10 @@ impl PdfCraftApp { pub(crate) fn page_box_can_redo(&self, ctx: &egui::Context) -> bool { let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; let focused = ctx.memory(|m| m.focused()); - let (id, text) = if focused == Some(crate::edit_text_ui::input_id()) + let (id, text) = if focused == Some(crate::edit_text_ui::input_id(view.place())) && let Some(ed) = &view.line_editor { - (crate::edit_text_ui::input_id(), &ed.text) + (crate::edit_text_ui::input_id(view.place()), &ed.text) } else if focused == Some(crate::content_ui::input_id()) && let Some(d) = &view.content.draft { @@ -462,7 +463,7 @@ impl PdfCraftApp { let Some(ctx) = &self.ctx else { return false }; let Some(view) = self.active.and_then(|i| self.views.get(i)) else { return false }; if let Some(ed) = &view.line_editor { - return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(), &ed.text); + return has_typing_to_redo(ctx, crate::edit_text_ui::input_id(view.place()), &ed.text); } view.content.draft.as_ref().is_some_and(|d| has_typing_to_redo(ctx, crate::content_ui::input_id(), &d.text)) } @@ -479,7 +480,11 @@ impl PdfCraftApp { /// Returns false when a field refused the typing queued for it (its editor is open again). pub(crate) fn process_pending_edits(&mut self) -> bool { let Some(i) = self.active else { return true }; - let applied = self.apply_queued_edit(i); + // Split view: the other pane's tab can hold an edit too (its field committed when the + // click that brought this pane forward took the focus). It goes in first, as it came first. + let (other_applied, stay) = self.apply_other_pane_edit(i); + let modal = self.modal_open(); + let applied = self.apply_queued_edit(i) && other_applied; if !applied { // Nor may a shortcut pressed with it (⌘S right after Enter) run behind the refusal. self.deferred_commands.clear(); @@ -519,9 +524,56 @@ impl PdfCraftApp { } None => {} } + // That tab comes forward when its value was refused (its editor is open again with the + // text) or its edit opened a dialog for its document, unless this tab's action opened one. + if let Some(key) = stay + && self.active == Some(i) + && (modal || !self.modal_open()) + { + self.active = self.tab_with_key(key).or(self.active); + } applied } + /// Split view: apply the edit queued on the tab the other pane shows, with that tab active + /// while it goes in (tab `i`, the active one, is active again afterwards). Left queued, it was + /// lost when the next frame replaced that tab to follow a change made through tab `i` + /// (`settle_panes`). Returns false when a field refused it, and that tab's view key when it + /// should come forward: its value was refused, or its edit opened a dialog. + fn apply_other_pane_edit(&mut self, i: usize) -> (bool, Option) { + use crate::panes::Place; + let Some(place) = self.views.get(i).map(|v| v.place()) else { return (true, None) }; + let other = if place == Place::Main { Place::Second } else { Place::Main }; + let Some((k, key, id)) = self + .panes + .split + .and_then(|_| self.pane_front(other)) + .filter(|&k| k != i) + .and_then(|k| self.views.get(k).filter(|v| v.pending_edit.is_some()).map(|v| (k, v.view_key(), v.id))) + else { + return (true, None); + }; + let generation = |app: &Self| app.session.get(id).map(|d| d.edit_generation()); + let (before, modal) = (generation(self), self.modal_open()); + self.active = Some(k); + let applied = self.apply_queued_edit(k); + let opened = !modal && self.modal_open(); + self.active = Some(i); + // Tab `i` on the same document catches up now, keeping what it holds (this frame's click + // may have queued an edit or opened a field): the next frame replaces only the tab that + // isn't active, as if this change had come through the active one. + if generation(self) != before + && let Some(doc) = self.session.get(id) + { + for (j, v) in self.views.iter_mut().enumerate() { + if j != k && v.id == id { + v.document_changed(&doc.info); + } + } + } + (applied, (!applied || opened).then_some(key)) + } + /// Enhance ▸ Camera image ▸ clean-up slider: undo the enhancement (and the turns made since) /// and enhance again from the photo at the new level, turning it as before. Only when the /// top of the undo history is still those steps; otherwise the slider goes back. @@ -752,8 +804,27 @@ impl PdfCraftApp { /// [`Self::commit_form_typing`] for document `id`, which may not be the active tab (a save /// panel answering later, Close All). If its tab has typing or a queued edit, the tab is /// brought forward first, so the commit and any field scripts act on that document. + /// + /// Each tab of the document holds typing of its own (split view). The tab in use stays active + /// when the others have been committed. pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool { - let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; + let keep = self.active_ids().filter(|(_, active)| *active == id).and_then(|(i, _)| self.views.get(i)).map(|v| v.pane.key); + let tabs: Vec = self.views.iter().filter(|v| v.id == id).map(|v| v.pane.key).collect(); + for key in tabs { + if let Some(i) = self.tab_with_key(key) + && !self.commit_typing_of_tab(i, id) + { + return false; + } + } + if let Some(i) = keep.and_then(|k| self.tab_with_key(k)) { + self.active = Some(i); + } + true + } + + /// [`Self::commit_typing_in`] for one tab of the document. + fn commit_typing_of_tab(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { // Added text the standard fonts can't draw can't be saved: show it rather than drop it. // Its page is brought into view, so the text, the warning and Discard are on screen. let blocked = self.views.get_mut(i).and_then(|v| { @@ -782,15 +853,15 @@ impl PdfCraftApp { } self.active = Some(i); } - self.commit_form_typing() && self.commit_open_fill_text(id) && self.commit_open_text_edits(id) + self.commit_form_typing() && self.commit_open_fill_text(i, id) && self.commit_open_text_edits(i, id) } /// The Edit a PDF ▸ Text boxes (new text, and a paragraph open for editing) commit on /// click-away. Save does that first, so the typed text is in the file instead of being left /// open on the screen (#3496). - fn commit_open_text_edits(&mut self, id: pdfcraft_engine::DocId) -> bool { + fn commit_open_text_edits(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { let Some(doc) = self.session.get(id) else { return true }; - let Some(view) = self.views.iter_mut().find(|v| v.id == id) else { return true }; + let Some(view) = self.views.get_mut(i) else { return true }; // Both are taken out of the view before either edit runs, so keep what is needed to put // back whichever one the document refuses: typed text must not be lost. let (draft, select_added, editor) = (view.content.draft.clone(), view.content.select_added, view.line_editor.clone()); @@ -798,7 +869,7 @@ impl PdfCraftApp { let paragraph = view.line_editor.take_if(|ed| ed.has_changes()).and_then(crate::edit_text_ui::finished_edit); let added_ok = added.is_none_or(|edit| self.apply_edit(edit)); let paragraph_ok = paragraph.is_none_or(|edit| self.apply_edit(edit)); - if let Some(view) = self.views.iter_mut().find(|v| v.id == id) { + if let Some(view) = self.views.get_mut(i) { if !added_ok { (view.content.draft, view.content.select_added) = (draft, select_added); } @@ -811,9 +882,8 @@ impl PdfCraftApp { /// The Fill & Sign type box commits on click-away. Save does that first, so the text is in /// the file (and can be flattened) instead of being left on the screen. - fn commit_open_fill_text(&mut self, id: pdfcraft_engine::DocId) -> bool { - let Some(i) = self.views.iter().position(|v| v.id == id) else { return true }; - let Some(tb) = self.views[i].fill_text.clone() else { return true }; + fn commit_open_fill_text(&mut self, i: usize, id: pdfcraft_engine::DocId) -> bool { + let Some(tb) = self.views.get(i).and_then(|v| v.fill_text.clone()) else { return true }; let text = tb.text.trim().to_string(); if text.is_empty() { if let Some(view) = self.views.get_mut(i) { @@ -849,8 +919,9 @@ impl PdfCraftApp { let after = self.session.get(id).map(|d| d.edit_generation()); if before != after && let Some(doc) = self.session.get(id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + && let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i)) { + // The tab in use: `sync_views` brings any other tab of the document up to date. view.document_changed(&doc.info); } true @@ -858,10 +929,16 @@ impl PdfCraftApp { /// Whether tab `index` has work that isn't saved: edits, or form typing not committed yet. pub(crate) fn has_unsaved_work(&self, index: usize) -> bool { + self.views.get(index).and_then(|v| self.session.get(v.id)).is_some_and(|doc| doc.dirty) || self.has_unsaved_typing(index) + } + + /// Whether tab `index` holds work of its own that isn't in its document yet: form typing not + /// committed, an edit waiting, text being typed. (The document's own edits are shared with + /// any other tab of it, in split view.) + pub(crate) fn has_unsaved_typing(&self, index: usize) -> bool { let Some(v) = self.views.get(index) else { return false }; let Some(doc) = self.session.get(v.id) else { return false }; - doc.dirty - || v.pending_edit.is_some() + v.pending_edit.is_some() || v.content.blocked().is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some()) || v.fill_text.as_ref().is_some_and(|t| !t.text.trim().is_empty()) diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index a6803e9dd4..c6d91b64e8 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -20,10 +20,15 @@ impl PdfCraftApp { if self.hold_close_for_ocr_review(index) { return; } - // Text typed into a field counts as an unsaved change. - if self.has_unsaved_work(index) + // Text typed into a field counts as an unsaved change. Another tab of the document (split + // view) keeps the document's changes open, so closing this one asks only about its own. + if self.asks_before_closing(index) && let Some(id) = self.views.get(index).map(|v| v.id) { + if self.has_twin(index) { + // The question is about this tab of the document (`asking_tab`). + self.active = Some(index); + } self.close_request = Some(CloseRequest::Tab(id)); } else { self.close_tab(index); @@ -107,7 +112,7 @@ impl PdfCraftApp { // The next question (another dirty document) starts on Save again. self.save_prompt_button = Default::default(); let index = match req { - CloseRequest::Tab(id) => match self.views.iter().position(|v| v.id == id) { + CloseRequest::Tab(id) => match self.asking_tab(id) { Some(i) => i, None => return, // already closed }, @@ -142,7 +147,7 @@ impl PdfCraftApp { // document open. let ctx = ctx.clone(); self.save_then(index, SaveTarget::InPlace, move |app| { - if let Some(i) = app.views.iter().position(|v| v.id == id) { + if let Some(i) = app.asking_tab(id) { app.close_and_continue(&ctx, i, req); } }); @@ -150,6 +155,12 @@ impl PdfCraftApp { } } + /// The tab a close question about document `id` is about: the active one when it shows `id` + /// (in split view two tabs can), else the document's first. + pub(crate) fn asking_tab(&self, id: pdfcraft_engine::DocId) -> Option { + self.active.filter(|&a| self.views.get(a).is_some_and(|v| v.id == id)).or_else(|| self.views.iter().position(|v| v.id == id)) + } + /// Close tab `index` for a close request, then ask about the next dirty document, or quit. /// The save it waited for may have taken a later frame (the user choosing where), and an OCR /// review started meanwhile holds the close as it holds a new one: the document stays open, diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index b6c04dd9e0..ffb533b243 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -194,11 +194,14 @@ impl PdfCraftApp { match self.session.mark_saved(id, bytes, Some(dest.to_string())) { Ok(()) => { self.forget_recovery(id); + // The tab in use: `sync_views` brings any other tab of the document up to date. if let Some(doc) = self.session.get(id) - && let Some(view) = self.views.iter_mut().find(|v| v.id == id) + && let Some(view) = self.asking_tab(id).and_then(|i| self.views.get_mut(i)) { view.document_changed(&doc.info); - // Saving over a version that changed on disk keeps this one: nothing to ask. + } + // Saving over a version that changed on disk keeps this one: nothing to ask. + for view in self.views.iter_mut().filter(|v| v.id == id) { view.disk_changed = false; } self.notify_fmt("Saved {name}", &[("name", &short_name(dest))]); diff --git a/crates/ui-egui/src/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..0d527d5ef5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -58,6 +58,7 @@ mod editing; mod home; mod hotbar; mod panels; +pub mod panes; pub use print_ui::{Handling as PrintHandling, OptionsSource, PrintDraft, PrinterSource, Which as PrintWhich, system_printers}; pub use redact_ui::{HiddenDraft, PagesDraft as RedactPagesDraft, RedactPrefs, SearchDraft as RedactSearchDraft, SearchMode as RedactSearchMode}; pub mod i18n; @@ -110,6 +111,7 @@ use dialogs::appearance; pub use dialogs::bookmark_styles_ui; use dialogs::comment_props; use dialogs::credits; +use dialogs::hairlines_ui; use dialogs::marks_ui; use dialogs::pageboxes; use dialogs::palette; @@ -195,6 +197,7 @@ use workflows::ocr_verify; #[cfg(target_arch = "wasm32")] use workflows::ocr_web; use workflows::optimize_ui; +use workflows::print_paper; use workflows::print_ui; use workflows::scan_clean_ui; use workflows::web_print; @@ -281,6 +284,8 @@ struct DeferredOpen { pub type HtmlClipboard = Box bool>; pub struct PdfCraftApp { + /// Split view (`panes`); which pane shows each tab is its view's. + pub panes: panes::Panes, /// Tools side and pane widths; which panes are open is `left_open` and `right`. pub side_panels: side_panels::SidePanels, pub session: Session, @@ -420,6 +425,9 @@ pub struct PdfCraftApp { /// Preferences: tell the user, above the pages, that a document is secured (its security /// settings restrict changes). Off only hides that notice (#4035); kept in the settings. pub show_secured_notice: bool, + /// Preferences: web and email addresses written as plain page text open on a click, like + /// links (#4207). + pub text_links: bool, /// Preferences: open every document at Default zoom and Default page display, whatever /// layout or zoom it asks for (#1618). pub always_default_view: bool, @@ -708,6 +716,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/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/platform/updates.rs b/crates/ui-egui/src/platform/updates.rs index bcf6783cdb..2f6630afdb 100644 --- a/crates/ui-egui/src/platform/updates.rs +++ b/crates/ui-egui/src/platform/updates.rs @@ -27,6 +27,9 @@ pub(crate) struct Updates { startup_check: bool, #[cfg(not(target_arch = "wasm32"))] check_requested: bool, + /// How many times `on_exit` called [`Updates::exiting`]. + #[cfg(test)] + exits: u32, } impl Default for Updates { @@ -39,6 +42,20 @@ impl Default for Updates { startup_check: false, #[cfg(not(target_arch = "wasm32"))] check_requested: false, + #[cfg(test)] + exits: 0, + } + } +} + +impl Updates { + /// Starts the helper of an update that waits for the app to exit. Later calls do nothing. + pub(crate) fn exiting(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + self.updater.start_pending_update(); + #[cfg(test)] + { + self.exits += 1; } } } @@ -113,9 +130,25 @@ pub(crate) fn dialog(app: &mut PdfCraftApp, ctx: &egui::Context) { app.updates.updater.dismiss_status(); } CheckResultResponse::CheckAgain => app.updates.check_requested = true, + CheckResultResponse::RestartToUpdate => { + app.updates.updater.restart_to_update(); + } } } } #[cfg(target_arch = "wasm32")] let _ = (app, ctx); } + +#[cfg(test)] +mod tests { + use super::*; + use eframe::App as _; + + #[test] + fn on_exit_calls_the_update_exit_hook_once() { + let mut app = PdfCraftApp::new(); + app.on_exit(None); + assert_eq!(app.updates.exits, 1); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 915d40e6ec..f03d2d4936 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -109,6 +109,8 @@ pub enum Dialog { Split, /// Pages ▸ Number pages… (page labels). NumberPages, + /// Print production ▸ Fix hairlines. + FixHairlines, /// Organize pages ▸ Page properties (the tab order of the selected pages). PageProps, /// Protect Using Password. diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 41c3a1e6c5..837141b4d3 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -55,7 +55,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, anchor = anchor.clamp(viewport.min, viewport.max); } let pos = pos2(anchor.x + 12.0, anchor.y); - let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport); + let area = egui::Area::new(egui::Id::unique(("comment-composer", view.pane.key))).order(egui::Order::Foreground).constrain_to(viewport); area.fixed_pos(pos).show(ctx, |ui| { egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| { ui.set_width(260.0); diff --git a/crates/ui-egui/src/tools/comments/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..7abee76dd9 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. @@ -858,6 +859,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 +913,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())) @@ -971,8 +975,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 +1014,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(); } 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 d2e45f9d0d..54d7924458 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -129,9 +129,14 @@ fn open_focus(f: &FormField, widget: usize) -> Focus { } } -/// Select a radio button, if it isn't selected already. -fn select_edit(f: &FormField, w: &FormWidget) -> Option { - (w.on_state.as_ref() != f.value.first()).then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Radio(w.on_state.clone()) }) +/// Select radio button `wi`, if it isn't selected already. The button itself, not its on state: +/// buttons sharing a state are exclusive unless the group has RadiosInUnison (ISO 32000-2 +/// Table 229). +fn select_edit(f: &FormField, wi: usize) -> Option { + // A button read-only on its own (its widget's `/Ff`, Table 227) keeps its focus stop, but + // the form would refuse turning it on. + let selectable = f.widgets.get(wi).is_some_and(|w| w.on_state.is_some() && !w.read_only) && !f.widget_on(wi); + selectable.then(|| Edit::SetFieldValue { name: f.name.clone(), value: FieldValue::Widget(wi) }) } /// What clicking widget `wi` of a check box or radio button does. @@ -146,15 +151,15 @@ fn toggle_edit(f: &FormField, wi: usize) -> Option { Some(_) => FieldValue::Check(false), None => FieldValue::Check(f.value.is_empty()), }, - FormFieldKind::Radio => { - let on = w.on_state.clone(); - // Clicking the selected button again turns it off unless the group forbids it. - let value = if f.value.first() == on.as_ref() && !f.has(field_flags::NO_TOGGLE_TO_OFF) { None } else { on }; - if value.as_ref() == f.value.first() { + // Clicking the selected button again turns it off unless the group forbids it + // (NoToggleToOff, Table 229). + FormFieldKind::Radio if f.widget_on(wi) => { + if f.has(field_flags::NO_TOGGLE_TO_OFF) { return None; } - FieldValue::Radio(value) + FieldValue::Radio(None) } + FormFieldKind::Radio => return select_edit(f, wi), _ => return None, }; Some(Edit::SetFieldValue { name: f.name.clone(), value }) @@ -348,7 +353,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) @@ -366,7 +372,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) { @@ -408,7 +414,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| { @@ -444,7 +450,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()); } @@ -657,7 +663,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); @@ -760,7 +766,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() { @@ -822,7 +828,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) @@ -847,7 +853,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); } @@ -902,7 +908,7 @@ pub(crate) fn overlay( if editable_combo(f) { combo_editor(ctx, view, ComboEditor { form, field: f, widget: w, rect, zoom, index: focus.widget }); } - let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0))) + let resp = egui::Area::new(egui::Id::unique(("form-choices", view.view_key()))) .order(egui::Order::Foreground) .fixed_pos(rect.left_bottom() + vec2(0.0, 2.0)) .show(ctx, |ui| { @@ -961,10 +967,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() @@ -1002,7 +1008,7 @@ pub(crate) fn overlay( } 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, focus.widget) }; + 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); } diff --git a/crates/ui-egui/src/workflows.rs b/crates/ui-egui/src/workflows.rs index 7b1bbeb9f3..f20be70394 100644 --- a/crates/ui-egui/src/workflows.rs +++ b/crates/ui-egui/src/workflows.rs @@ -15,6 +15,7 @@ pub(super) mod ocr_verify; #[cfg(target_arch = "wasm32")] pub(super) mod ocr_web; pub(super) mod optimize_ui; +pub(super) mod print_paper; pub(super) mod print_ui; pub(super) mod scan_clean_ui; pub(super) mod web_print; diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index 7ef50d5ffc..2297e70933 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -401,6 +401,33 @@ impl PdfCraftApp { let _ = recursive; } + /// Add the files at `paths` to the Combine files list and show it: `pdfcraft --combine` and + /// File Explorer's "Combine files in PdfCraft" (#585), where each selected file arrives as a + /// launch of its own and the running PdfCraft collects them here. Nothing to add shows the + /// tab all the same. + #[cfg(not(target_arch = "wasm32"))] + pub fn combine_paths(&mut self, paths: &[String]) { + // A file still opening from before (another launch's Open), or waiting for its password, + // opens in the background, so finishing doesn't hide the tab shown here; a later Open + // still comes to the front. An open that something waits on keeps its focus: a chosen + // tool, a link's destination or a recovery runs on the document once it is in front. + for pending in &mut self.pending_opens { + if pending.then.is_none() && pending.after_open.is_none() && pending.recovered.is_none() { + pending.activate = false; + } + } + if let Some(prompt) = self.password_prompt.as_mut() + && prompt.then.is_none() + && prompt.after_open.is_none() + && prompt.recovered.is_none() + { + prompt.activate = false; + } + self.open_combine_tab(); + let paths: Vec = paths.iter().map(std::path::PathBuf::from).collect(); + self.use_paths(crate::files::FilePurpose::Combine, &paths); + } + /// Add the PDFs, images and text files in `dir` to the Combine files list, as one undo step. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn add_folder_to_combine(&mut self, dir: &std::path::Path, recursive: bool) { diff --git a/crates/ui-egui/src/workflows/combine_ui/tests.rs b/crates/ui-egui/src/workflows/combine_ui/tests.rs index b882dcf52d..7df65d134b 100644 --- a/crates/ui-egui/src/workflows/combine_ui/tests.rs +++ b/crates/ui-egui/src/workflows/combine_ui/tests.rs @@ -210,3 +210,26 @@ fn removing_pages_drops_emptied_parts_and_joins_what_meets() { // A page that isn't there: nothing changes, and it says so. assert_eq!(removed_pages(&two, &BTreeSet::from([(1, 9)]), &mut new_id), None); } + +/// #2503 review: a PDF waiting for its password when Combine arrives opens behind the Combine +/// files tab once unlocked, like one still opening; a prompt that a chosen tool waits on keeps +/// its focus, since the tool runs on the document in front. +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn a_password_answered_after_combine_opens_behind_the_combine_tab() { + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + assert!(app.password_prompt.is_some() && app.views.is_empty()); + app.combine_paths(&[]); + assert!(app.combine_showing()); + app.submit_password(Some("pw".into())); + assert!(app.password_prompt.is_none()); + assert_eq!(app.views.len(), 1, "the document is open"); + assert!(app.combine_showing(), "and the Combine files tab still shows"); + + let mut app = PdfCraftApp::new(); + app.open_bytes("locked.pdf", None, locked()).unwrap(); + app.password_prompt.as_mut().unwrap().then = Some("export.text"); + app.combine_paths(&[]); + assert!(app.password_prompt.as_ref().unwrap().activate, "a tool's open keeps its focus"); +} diff --git a/crates/ui-egui/src/workflows/print_paper.rs b/crates/ui-egui/src/workflows/print_paper.rs new file mode 100644 index 0000000000..30edb4dc91 --- /dev/null +++ b/crates/ui-egui/src/workflows/print_paper.rs @@ -0,0 +1,224 @@ +//! The Print dialog's paper list and the paper each printer is remembered with (print.page-setup): +//! paper sizes of a person's own, added with Custom… beside Paper and kept with the settings, and +//! for each printer the paper chosen when "Remember this paper for …" was ticked at Print time. +//! Two-sided, grayscale, collate, sizing and comments & forms are left to the dialog itself. + +use std::collections::BTreeMap; + +use pdfcraft_engine::print::PAPERS; +use serde::{Deserialize, Serialize}; + +use crate::print_ui::PrintDraft; + +/// The largest side a custom paper can have, in points: PDF's own page limit (200 inches). +pub const MAX_SIDE: f64 = 14_400.0; +/// The smallest side, in points (one inch). +pub const MIN_SIDE: f64 = 72.0; +/// How many custom papers the settings keep. +pub const MAX_CUSTOM: usize = 100; +/// The longest name a custom paper keeps, in characters. +const MAX_NAME: usize = 64; + +/// A paper size added in the Print dialog (points, portrait). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct CustomPaper { + pub name: String, + pub width: f64, + pub height: f64, +} + +impl CustomPaper { + /// A custom paper from a name and two sides in points, portrait; None when the name is empty + /// or a side is outside [`MIN_SIDE`]..=[`MAX_SIDE`], or a standard paper already has the name + /// (any case), which would otherwise be found first when the paper is looked up by name. + pub fn new(name: &str, a: f64, b: f64) -> Option { + let name: String = name.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect(); + let fits = |v: f64| v.is_finite() && (MIN_SIDE..=MAX_SIDE).contains(&v); + let standard = PAPERS.iter().any(|(n, _)| n.eq_ignore_ascii_case(&name)); + (!name.is_empty() && !standard && fits(a) && fits(b)).then(|| Self { name, width: a.min(b), height: a.max(b) }) + } +} + +/// The paper remembered for each printer, by the printer's name ("" is Save as PDF). +pub type RememberedPaper = BTreeMap; + +/// Custom papers read back from the settings, which are untrusted: each one is checked as if it +/// were typed, a name already taken keeps its first size, and at most [`MAX_CUSTOM`] are kept. +pub fn read_custom(value: &serde_json::Value) -> Vec { + let Some(list) = value.as_array() else { return Vec::new() }; + let mut out: Vec = Vec::new(); + for item in list { + let (Some(name), Some(w), Some(h)) = (item["name"].as_str(), item["width"].as_f64(), item["height"].as_f64()) else { continue }; + if let Some(paper) = CustomPaper::new(name, w, h) + && !out.iter().any(|p| p.name == paper.name) + && out.len() < MAX_CUSTOM + { + out.push(paper); + } + } + out +} + +/// The remembered papers read back from the settings: printer name to paper name, strings only. +pub fn read_remembered(value: &serde_json::Value) -> RememberedPaper { + let Some(map) = value.as_object() else { return RememberedPaper::new() }; + map.iter().filter_map(|(printer, paper)| paper.as_str().map(|p| (printer.clone(), p.to_string()))).collect() +} + +impl PrintDraft { + /// The paper list: the standard sizes, then the custom ones. + pub fn paper_count(&self) -> usize { + PAPERS.len() + self.custom_papers.len() + } + + /// The chosen paper's size (points, portrait). + pub fn paper_size(&self) -> (f64, f64) { + match self.paper.checked_sub(PAPERS.len()) { + None => PAPERS[self.paper.min(PAPERS.len() - 1)].1, + Some(c) => self.custom_papers.get(c).map_or(PAPERS[0].1, |p| (p.width, p.height)), + } + } + + /// The chosen paper's name (a standard name, or the custom paper's). + pub fn paper_name(&self) -> String { + match self.paper.checked_sub(PAPERS.len()) { + None => PAPERS[self.paper.min(PAPERS.len() - 1)].0.to_string(), + Some(c) => self.custom_papers.get(c).map_or_else(|| PAPERS[0].0.to_string(), |p| p.name.clone()), + } + } + + /// The index of the paper called `name`, standard or custom. + pub fn paper_index(&self, name: &str) -> Option { + PAPERS.iter().position(|(n, _)| *n == name).or_else(|| self.custom_papers.iter().position(|p| p.name == name).map(|i| PAPERS.len() + i)) + } + + /// True when the chosen paper is a custom one (which can be removed). + pub fn paper_is_custom(&self) -> bool { + self.paper >= PAPERS.len() + } + + /// Add (or replace, by name) a custom paper and choose it. + pub fn add_custom_paper(&mut self, paper: CustomPaper) { + self.custom_papers.retain(|p| p.name != paper.name); + if self.custom_papers.len() >= MAX_CUSTOM { + self.custom_papers.remove(0); + } + self.custom_papers.push(paper); + self.paper = PAPERS.len() + self.custom_papers.len() - 1; + } + + /// Take the chosen custom paper off the list; the dialog goes back to the first size. + pub fn remove_custom_paper(&mut self) { + if let Some(i) = self.paper.checked_sub(PAPERS.len()) + && i < self.custom_papers.len() + { + self.custom_papers.remove(i); + self.paper = 0; + } + } + + /// The key the printer's paper is remembered under ("" is Save as PDF). + fn printer_key(&self) -> String { + self.printer.clone().unwrap_or_default() + } + + /// Start from the paper remembered for the printer chosen, if one was and it is still on the + /// list; the Remember box says whether one was. + pub fn apply_remembered(&mut self) { + let remembered = self.remembered_paper.get(&self.printer_key()).cloned(); + self.remember_paper = remembered.is_some(); + if let Some(i) = remembered.and_then(|name| self.paper_index(&name)) { + self.paper = i; + } + } + + /// At Print time: keep the paper for the printer when the box is ticked, forget it when not. + pub fn remember_now(&mut self) { + let key = self.printer_key(); + if self.remember_paper { + let name = self.paper_name(); + self.remembered_paper.insert(key, name); + } else { + self.remembered_paper.remove(&key); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_custom_paper_is_portrait_named_and_within_pdfs_limits() { + let p = CustomPaper::new(" Legal, tray 2 ", 1008.0, 612.0).unwrap(); + assert_eq!((p.name.as_str(), p.width, p.height), ("Legal, tray 2", 612.0, 1008.0)); + assert!(CustomPaper::new("", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("\u{1}", 612.0, 792.0).is_none()); + assert!(CustomPaper::new("Tiny", 10.0, 792.0).is_none()); + assert!(CustomPaper::new("Huge", 612.0, 20_000.0).is_none()); + assert!(CustomPaper::new("NaN", f64::NAN, 792.0).is_none()); + assert_eq!(CustomPaper::new(&"x".repeat(500), 612.0, 792.0).unwrap().name.chars().count(), MAX_NAME); + } + + #[test] + fn settings_are_read_as_untrusted() { + let v = serde_json::json!([ + { "name": "Label", "width": 288.0, "height": 432.0 }, + { "name": "Label", "width": 300.0, "height": 400.0 }, + { "name": "Bad", "width": -1.0, "height": 432.0 }, + { "name": 5, "width": 288.0, "height": 432.0 }, + "nonsense", + ]); + assert_eq!(read_custom(&v), vec![CustomPaper { name: "Label".into(), width: 288.0, height: 432.0 }]); + assert!(read_custom(&serde_json::json!(5)).is_empty()); + let many: Vec<_> = (0..300).map(|i| serde_json::json!({ "name": format!("P{i}"), "width": 300.0, "height": 400.0 })).collect(); + assert_eq!(read_custom(&serde_json::Value::Array(many)).len(), MAX_CUSTOM); + let r = read_remembered(&serde_json::json!({ "Office": "A4", "": "Label", "Odd": 3 })); + assert_eq!(r.len(), 2); + assert_eq!(r.get("").map(String::as_str), Some("Label")); + assert!(read_remembered(&serde_json::json!([1])).is_empty()); + } + + #[test] + fn the_paper_list_is_the_standard_sizes_then_the_custom_ones() { + let mut d = PrintDraft::default(); + assert_eq!(d.paper_count(), PAPERS.len()); + d.add_custom_paper(CustomPaper::new("Label", 288.0, 432.0).unwrap()); + assert_eq!((d.paper, d.paper_name().as_str(), d.paper_size()), (PAPERS.len(), "Label", (288.0, 432.0))); + assert_eq!(d.paper_index("A4"), PAPERS.iter().position(|(n, _)| *n == "A4")); + assert_eq!(d.paper_index("Label"), Some(PAPERS.len())); + // The same name replaces the size; removing goes back to the first standard size. + d.add_custom_paper(CustomPaper::new("Label", 300.0, 400.0).unwrap()); + assert_eq!((d.paper_count(), d.paper_size()), (PAPERS.len() + 1, (300.0, 400.0))); + d.remove_custom_paper(); + assert_eq!((d.paper_count(), d.paper), (PAPERS.len(), 0)); + // A stale index reads as the first size, never out of bounds. + d.paper = PAPERS.len() + 7; + assert_eq!(d.paper_size(), PAPERS[0].1); + } + + #[test] + fn the_paper_is_remembered_per_printer_only_when_asked() { + let mut d = PrintDraft::default(); + let a4 = d.paper_index("A4").unwrap(); + d.printer = Some("Office".into()); + d.paper = a4; + d.remember_paper = true; + d.remember_now(); + d.printer = Some("Plotter".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, false), "another printer keeps the dialog's paper"); + d.printer = Some("Office".into()); + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (a4, true)); + d.remember_paper = false; + d.remember_now(); + assert!(d.remembered_paper.is_empty()); + // A remembered custom paper that was removed since leaves the paper alone. + d.remembered_paper.insert("Office".into(), "Gone".into()); + d.paper = 0; + d.apply_remembered(); + assert_eq!((d.paper, d.remember_paper), (0, true)); + } +} diff --git a/crates/ui-egui/src/workflows/print_ui.rs b/crates/ui-egui/src/workflows/print_ui.rs index 7ea7a18602..880dccbd00 100644 --- a/crates/ui-egui/src/workflows/print_ui.rs +++ b/crates/ui-egui/src/workflows/print_ui.rs @@ -171,8 +171,10 @@ impl PdfCraftApp { sheet: 0, selected, which, + custom_new: None, ..keep }; + self.print_draft.apply_remembered(); self.dialog = Some(crate::Dialog::Print); } @@ -314,7 +316,13 @@ impl PdfCraftApp { // A picked printer that is no longer listed falls back to the default too. if !d.printer_picked || !offered(d.printer.as_deref(), &printers) { d.printer_picked = false; - d.printer = default_printer(&printers); + // Only a different printer starts over with its remembered paper; the + // same one keeps the paper and the Remember box as they were left. + let default = default_printer(&printers); + if default != d.printer { + d.printer = default; + d.apply_remembered(); + } } d.printers = printers; d.printers_loading = false; diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 690dc3142e..d38272a001 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -158,6 +158,7 @@ pub(crate) fn body( d.printer_picked = true; d.show_driver_options = false; d.driver_error = None; + d.apply_remembered(); } ui.end_row(); // The spooler answered with no printer at all (#160): say so, rather than @@ -202,15 +203,35 @@ pub(crate) fn body( ui.end_row(); } let paper_label = ui.label(tl!("Paper:")); - let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); + d.paper = d.paper.min(d.paper_count().saturating_sub(1)); let before = d.paper; - craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { - for (i, (name, _)) in PAPERS.iter().enumerate() { - ui.selectable_value(&mut d.paper, i, tl!(name)); + ui.horizontal(|ui| { + // A standard size's name is translated; a custom paper keeps the name it was given. + let shown = if d.paper_is_custom() { d.paper_name() } else { tl!(d.paper_name().as_str()).to_string() }; + craft_ui::ComboBox::from_id_salt("paper").selected_text(shown).width(260.0).show_ui(ui, |ui| { + for (i, (name, _)) in PAPERS.iter().enumerate() { + ui.selectable_value(&mut d.paper, i, tl!(name)); + } + if !d.custom_papers.is_empty() { + ui.separator(); + } + for (i, p) in d.custom_papers.iter().enumerate() { + let label = format!("{} ({})", p.name, d.units.size(p.width, p.height)); + ui.selectable_value(&mut d.paper, PAPERS.len() + i, label); + } + }) + .response + .labelled_by(paper_label.id); + if ui.button(tl!("Custom…")).on_hover_text(tl!("Add a paper size of your own to the list")).clicked() { + d.custom_new = match d.custom_new { + Some(_) => None, + None => Some((String::new(), String::new(), String::new())), + }; } - }) - .response - .labelled_by(paper_label.id); + if d.paper_is_custom() && ui.button(tl!("Remove")).on_hover_text(tl!("Take this paper size off the list")).clicked() { + d.remove_custom_paper(); + } + }); // Label stock gets the label layout (cut, turned upright, fitted). if d.paper != before && d.paper == print::LABEL_PAPER { d.paper_before_label = Some(before); @@ -218,6 +239,11 @@ pub(crate) fn body( d.sheet = 0; } ui.end_row(); + if d.custom_new.is_some() { + ui.label(""); + custom_paper_row(ui, d, t); + ui.end_row(); + } }); // Windows says which drivers can't print two-sided as they're asked; they keep their // own setting, even when Two-sided is Off. @@ -559,6 +585,11 @@ pub(crate) fn body( }); ui.add_space(12.0); let (mut go, mut cancel) = (false, false); + // The paper is kept for this printer (or Save as PDF) when this is ticked at Print time. On its + // own row above the buttons: sharing their row moved them while the dialog settled. + let for_whom = d.printer.as_deref().map_or_else(|| tl!("Save as PDF").to_string(), printer_label); + ui.add(widgets::checkbox(&mut d.remember_paper, crate::i18n::fmt(tl!("Remember this paper for {printer}"), &[("printer", &for_whom)]))); + ui.add_space(8.0); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let looking = still_looking(d); let label = if d.printer.is_some() || looking { tl!("Print") } else { tl!("Save as PDF") }; @@ -569,5 +600,35 @@ pub(crate) fn body( cancel = true; } }); + if go { + d.remember_now(); + } (go, cancel) } + +/// The row that adds a custom paper: name, width and height in the preferences' unit. +fn custom_paper_row(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { + let unit = d.units; + let Some((name, width, height)) = d.custom_new.as_mut() else { return }; + let side = |s: &str| s.trim().replace(',', ".").parse::().ok().map(|v| v / unit.per_point()); + let paper = match (side(width), side(height)) { + (Some(w), Some(h)) => crate::print_paper::CustomPaper::new(name, w, h), + _ => None, + }; + let (mut add, mut cancel) = (false, false); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(name).hint_text(tl!("Name")).desired_width(110.0)).accessible_name(tl!("Name")); + ui.add(egui::TextEdit::singleline(width).hint_text(tl!("Width")).desired_width(52.0)).accessible_name(tl!("Width")); + ui.label("×"); + ui.add(egui::TextEdit::singleline(height).hint_text(tl!("Height")).desired_width(52.0)).accessible_name(tl!("Height")); + ui.label(egui::RichText::new(unit.label()).color(t.text_muted)); + add = ui.add_enabled(paper.is_some(), egui::Button::new(tl!("Add"))).clicked(); + cancel = ui.button(tl!("Cancel")).clicked(); + }); + if add && let Some(paper) = paper { + d.add_custom_paper(paper); + d.custom_new = None; + } else if cancel { + d.custom_new = None; + } +} diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 27080a7329..988880a550 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { @@ -62,7 +62,16 @@ pub struct PrintDraft { /// Print as image (Acrobat's Advanced Print Setup) at `dpi`. pub as_image: bool, pub dpi: f64, + /// Index into the paper list: the standard sizes, then `custom_papers`. pub paper: usize, + /// Paper sizes added with Custom…, kept with the settings. + pub custom_papers: Vec, + /// The paper each printer is remembered with, kept with the settings. + pub remembered_paper: crate::print_paper::RememberedPaper, + /// "Remember this paper for …", applied at Print time. + pub remember_paper: bool, + /// The custom paper being added: name, width and height as typed (the preferences' unit). + pub custom_new: Option<(String, String, String)>, /// The paper chosen before Label put label stock in, so leaving Label gives it back (#3564). pub paper_before_label: Option, /// The sheet shown in the preview (0-based). @@ -126,6 +135,10 @@ impl Default for PrintDraft { as_image: false, dpi: 300.0, paper: 0, + custom_papers: Vec::new(), + remembered_paper: Default::default(), + remember_paper: false, + custom_new: None, paper_before_label: None, sheet: 0, current_page: 0, @@ -206,7 +219,7 @@ impl PrintDraft { }; Ok(print::Settings { pages, - paper: PAPERS[self.paper.min(PAPERS.len() - 1)].1, + paper: self.paper_size(), orientation: self.orientation, layout, content: self.content, diff --git a/crates/ui-egui/tests/it/document_links.rs b/crates/ui-egui/tests/it/document_links.rs index 01660a3a60..982099aaf9 100644 --- a/crates/ui-egui/tests/it/document_links.rs +++ b/crates/ui-egui/tests/it/document_links.rs @@ -912,3 +912,129 @@ fn highlighted_link_can_be_requested_again_after_cancel() { assert_eq!(h.state().last_opened_url, None); } } + +/// One 300×400 page of plain text, no link annotations except over the fourth line: a web +/// address, a `www.` address, an email address, an address under a link annotation, and text +/// with no address (#4207). Each line starts at x 40, 14 points high, at the y given. +fn text_links_fixture() -> Vec { + let content = [ + (350, "https://example.com/plain-text-url."), + (300, "www.example.com/no-scheme"), + (250, "someone@example.com"), + (200, "https://example.org/covered"), + (150, "Nothing to open here"), + ] + .iter() + .map(|(y, s)| format!("BT /F1 14 Tf 40 {y} Td ({s}) Tj ET\n")) + .collect::(); + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R /Annots [6 0 R] >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}endstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Type /Annot /Subtype /Link /Rect [30 195 290 215] /Border [0 0 0] /A << /S /URI /URI ({LINK_URL}) >> >>"), + ]; + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offs = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offs.push(out.len()); + out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let x = out.len(); + out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + out +} + +/// Click the text line whose baseline is at `y` until the page's text has arrived and the click +/// asks to open something, or 30 seconds pass. +fn click_text_until_pending(h: &mut Harness<'static, PdfCraftApp>, y: f32) -> bool { + support::step_until(h, |h| { + click_link_at(h, y + 4.0); + h.state().pending_link.is_some() + }) +} + +/// Web, `www.` and email addresses written as plain page text open like links (#4207): the same +/// confirmation, `https://` for `www.` and `mailto:` for email; a link annotation over an address +/// wins, text without an address opens nothing, and the setting turns it off. +#[test] +fn addresses_written_in_the_page_text_open_like_links() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + for (y, url) in [ + (350.0, "https://example.com/plain-text-url"), + (300.0, "https://www.example.com/no-scheme"), + (250.0, "mailto:someone@example.com"), + (200.0, LINK_URL), + ] { + assert!(click_text_until_pending(&mut h, y), "a click on the line at {y} asked to open nothing"); + assert_eq!(h.state().pending_link, pending(url, LinkOrigin::Link), "line at {y}"); + assert_eq!(h.state().last_opened_url, None, "nothing opens before the user answers"); + h.get_by_label("Cancel").click(); + h.run_steps(4); + assert_eq!(h.state().pending_link, None); + } + // The page's text is known by now: plain text with no address opens nothing. + click_link_at(&mut h, 154.0); + assert_eq!(h.state().pending_link, None, "text without an address"); + // Preferences can turn it off; the link annotation still works. + h.state_mut().set_option("text-links", "false").unwrap(); + h.run_steps(2); + click_link_at(&mut h, 354.0); + assert_eq!(h.state().pending_link, None, "turned off"); + click_link_at(&mut h, 204.0); + assert_eq!(h.state().pending_link, pending(LINK_URL, LinkOrigin::Link), "a link annotation is unaffected"); +} + +/// A drag that starts on an address written as page text selects its text, as from any other +/// text; only a click opens it (#4207, review). +#[test] +fn a_drag_from_an_address_in_the_page_text_selects_it() { + let _gpu = gpu(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("text-links.pdf", None, text_links_fixture()).unwrap(); + app.set_option("left", "closed").unwrap(); + app.set_option("panel", "none").unwrap(); + app.set_option("zoom", "150").unwrap(); + app + }); + h.run_steps(8); + // Once a click asks to open the address, its text and the address are known. + assert!(click_text_until_pending(&mut h, 350.0), "the address opens on a click"); + h.get_by_label("Cancel").click(); + h.run_steps(4); + // Glyphs 2 to 12 of the first line are inside "https://example.com/plain-text-url.". + let (from, to) = { + let v = &h.state().views[0]; + (v.glyph_screen_pos(0, 2).expect("text layer"), v.glyph_screen_pos(0, 12).expect("glyph")) + }; + h.hover_at(from); + h.run_steps(1); + h.drag_at(from); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(from + (to - from) * (k as f32 / 4.0)); + h.run_steps(1); + } + h.drop_at(to); + h.run_steps(3); + let selected = h.state().views[0].selected_text(); + assert!(selected.as_deref().is_some_and(|t| t.starts_with("tp")), "the drag selects the address's text: {selected:?}"); + assert_eq!(h.state().pending_link, None, "a drag opens nothing"); +} diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index c5d197f64c..9752f0ff24 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -9428,6 +9428,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/forms.rs b/crates/ui-egui/tests/it/forms.rs index bb45ee21e3..654c64158a 100644 --- a/crates/ui-egui/tests/it/forms.rs +++ b/crates/ui-egui/tests/it/forms.rs @@ -404,7 +404,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)); @@ -1580,13 +1580,131 @@ fn a_form_button_shows_and_hides_the_attachments_panel() { assert_eq!(h.state().right, None); } -/// Which kids of check box `name` are ticked (their `/AS` is their on state). -fn ticked(h: &Harness<'static, PdfCraftApp>, name: &str) -> Vec { +/// 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| { @@ -1630,18 +1748,18 @@ fn check_box_kids() -> Harness<'static, PdfCraftApp> { fn clicking_check_box_kids_with_different_on_states() { let mut h = check_box_kids(); click_field(&mut h, "pair", 0); - assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()])); + assert_eq!((on_widgets(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()])); click_field(&mut h, "pair", 1); - assert_eq!((ticked(&h, "pair"), value(&h, "pair")), (vec![false, true], vec!["B".to_string()]), "B ticks and A clears"); + 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!((ticked(&h, "pair"), value(&h, "pair")), (vec![false, false], vec![]), "clicking B again clears the field"); + 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!((ticked(&h, "pair"), value(&h, "pair")), (vec![true, false], vec!["A".to_string()]), "the tick moves back to A"); + 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!(ticked(&h, "same"), [true, true], "kids sharing Yes tick together"); + assert_eq!(on_widgets(&h, "same"), [true, true], "kids sharing Yes tick together"); click_field(&mut h, "same", 1); - assert_eq!(ticked(&h, "same"), [false, false], "and untick together"); + 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"]); diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 972bc78b82..f9ca6a2977 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -66,6 +66,8 @@ mod pointer; mod preferences_layout; mod prepare; mod print; +mod print_custom_paper_names; +mod print_late_printer_list; mod protect; mod reading_colors; mod recent_files; @@ -77,6 +79,10 @@ mod script_noop; mod scroll_prefetch; mod side_panels; mod signing; +mod split_view; +mod split_view_edits; +mod split_view_find; +mod split_view_render; mod stamps; mod status_layers; mod text_select; diff --git a/crates/ui-egui/tests/it/print.rs b/crates/ui-egui/tests/it/print.rs index 3e0fec7a6e..a6346ca1e2 100644 --- a/crates/ui-egui/tests/it/print.rs +++ b/crates/ui-egui/tests/it/print.rs @@ -918,6 +918,98 @@ fn restored_paper_keeps_preview_and_saved_sheet_dimensions() { std::fs::remove_file(out).unwrap(); } +/// After the Print dialog opens: frames until its printer list has loaded (its arrival changes +/// the dialog's rows, even an empty list, which adds the "no printers" line), then a few more so +/// the dialog has settled where it stays. A click before then can land where a control was +/// (windows-latest CI). +fn settle(h: &mut Harness<'static, PdfCraftApp>) { + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); +} + +/// Page setup: a paper size of one's own joins the Paper list (Custom…), the paper is kept for +/// the printer when "Remember this paper for …" is ticked at Print time, starts the dialog next +/// time, survives a restart with the settings, and is forgotten when unticked; Remove takes a +/// custom size off the list. +#[test] +fn custom_papers_and_the_paper_remembered_for_a_printer() { + let mut h = harness(); + // No printers here: Save as PDF is the dialog's printer, now and when it opens again. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!(h.state().print_draft.printer, None); + h.get_by_label("Custom…").click(); + h.run_steps(2); + // Typed in the preferences' unit: 4 × 6 inches. + let per_point = h.state().print_draft.units.per_point(); + h.state_mut().print_draft.custom_new = Some(("Label 4×6".into(), format!("{}", 432.0 * per_point), format!("{}", 288.0 * per_point))); + h.run_steps(2); + h.get_by_label("Add").click(); + h.run_steps(2); + { + let d = &h.state().print_draft; + assert!(d.paper_is_custom() && d.paper_name() == "Label 4×6", "{}", d.paper_name()); + let (w, l) = d.paper_size(); + assert!((w - 288.0).abs() < 0.01 && (l - 432.0).abs() < 0.01, "portrait: {w}×{l}"); + } + // The custom row has closed: let the dialog settle at its new height before clicking. + h.run_steps(8); + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(h.state().print_draft.remember_paper, "ticked"); + let out = std::env::temp_dir().join(format!("pdfcraft-print-paper-test-{}.pdf", std::process::id())); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None); + let bytes = std::fs::read(&out).expect("saved"); + let _ = std::fs::remove_file(&out); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(bytes)).unwrap(); + let sheet = pdfcraft_model::pages(&saved)[0].display_size(&saved); + assert!((sheet.0.min(sheet.1) - 288.0).abs() < 0.5 && (sheet.0.max(sheet.1) - 432.0).abs() < 0.5, "the label paper: {sheet:?}"); + assert_eq!(h.state().print_draft.remembered_paper.get("").map(String::as_str), Some("Label 4×6")); + // Next time the dialog starts with it. + h.state_mut().print_draft.paper = 0; + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Label 4×6", true)); + // And after a restart. + let settings = h.state().persist(); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings); + assert_eq!(fresh.print_draft.paper_count(), h.state().print_draft.paper_count()); + assert_eq!(fresh.print_draft.remembered_paper, h.state().print_draft.remembered_paper); + // Unticked at Print time, the paper is forgotten for Save as PDF. + h.get_by_label("Remember this paper for Save as PDF").click(); + h.run_steps(1); + assert!(!h.state().print_draft.remember_paper, "unticked"); + h.state_mut().save_override = Some(out.to_string_lossy().into_owned()); + h.run_steps(8); + h.get_by_label("Save as PDF").click(); + h.run_steps(3); + assert_eq!(h.state().dialog, None, "saved"); + let _ = std::fs::remove_file(&out); + assert!(h.state().print_draft.remembered_paper.is_empty()); + // Remove takes the custom size off the list. + assert!(h.state_mut().execute("print.dialog")); + settle(&mut h); + let custom = h.state().print_draft.paper_count() - 1; + h.state_mut().print_draft.paper = custom; + h.run_steps(2); + h.get_by_label("Remove").click(); + h.run_steps(2); + assert_eq!((h.state().print_draft.paper_count(), h.state().print_draft.paper), (custom, 0)); +} + /// #2707: a booklet decides two-sided itself, as Acrobat does (its Print dialog hides Two-sided /// while Booklet is chosen): the job flips on the short edge for both sides and prints /// one-sided for the front or back only, and the Two-sided choice is kept for other layouts. diff --git a/crates/ui-egui/tests/it/print_custom_paper_names.rs b/crates/ui-egui/tests/it/print_custom_paper_names.rs new file mode 100644 index 0000000000..ba083b3c0b --- /dev/null +++ b/crates/ui-egui/tests/it/print_custom_paper_names.rs @@ -0,0 +1,62 @@ +//! A custom paper cannot take the name of a standard one: papers are looked up by name, so the +//! standard size would be found first and a remembered custom paper would come back as it. + +use egui::accesskit::Role; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use pdfcraft_ui_egui::PdfCraftApp; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +#[test] +fn a_custom_paper_cannot_take_a_standard_name() { + let mut h = harness(); + // No printers here: the list is empty as soon as it is asked for. + h.state_mut().printer_source = Some(std::sync::Arc::new(Vec::new)); + assert!(h.state_mut().execute("print.dialog")); + for _ in 0..500 { + h.run_steps(1); + if !h.state().print_draft.printers_loading { + break; + } + std::thread::sleep(std::time::Duration::from_millis(2)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); + h.run_steps(8); + h.get_by_label("Custom…").click(); + h.run_steps(2); + let before = h.state().print_draft.paper_count(); + let per_point = h.state().print_draft.units.per_point(); + let (w, l) = (format!("{}", 288.0 * per_point), format!("{}", 432.0 * per_point)); + // The name of a standard paper, in either case, is refused: Add stays disabled. + for name in ["A4", " a4 "] { + h.state_mut().print_draft.custom_new = Some((name.into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled(), "{name:?} is a standard paper's name"); + assert_eq!(h.state().print_draft.paper_count(), before); + } + // Any other name is accepted by the same row. + h.state_mut().print_draft.custom_new = Some(("A4 label".into(), w.clone(), l.clone())); + h.run_steps(3); + assert!(!h.get_by_role_and_label(Role::Button, "Add").accesskit_node().is_disabled()); + + // Settings read back are checked the same way: the custom "A4" is dropped, the rest kept. + let paper = |name: &str| serde_json::json!({ "name": name, "width": 288.0, "height": 432.0 }); + let settings = serde_json::json!({ + "print_custom_papers": [paper("A4"), paper("Label"), paper("us letter")], + "print_paper_by_printer": { "": "A4" }, + }); + let mut fresh = PdfCraftApp::new(); + fresh.restore(&settings.to_string()); + let names: Vec<&str> = fresh.print_draft.custom_papers.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["Label"]); +} diff --git a/crates/ui-egui/tests/it/print_late_printer_list.rs b/crates/ui-egui/tests/it/print_late_printer_list.rs new file mode 100644 index 0000000000..b32772df20 --- /dev/null +++ b/crates/ui-egui/tests/it/print_late_printer_list.rs @@ -0,0 +1,66 @@ +//! A printer list that arrives late must not undo what was picked while it was being read, when +//! the printer it names is the one the dialog already has. + +use egui_kittest::Harness; +use pdfcraft_engine::print::spool::Printer; +use pdfcraft_ui_egui::PdfCraftApp; +use std::sync::{Arc, Mutex}; + +fn harness() -> Harness<'static, PdfCraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("form.pdf", None, include_bytes!("../data/form.pdf").to_vec()).unwrap(); + app + }); + h.run_steps(4); + h +} + +fn wait_for_list(h: &mut Harness<'static, PdfCraftApp>) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while h.state().print_draft.printers_loading && std::time::Instant::now() < deadline { + h.run_steps(1); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(!h.state().print_draft.printers_loading, "the printer list loaded"); +} + +#[test] +fn a_late_list_keeps_the_paper_picked_meanwhile() { + let mut h = harness(); + // The source waits for the test to let it answer, once the first list is in. + let gate = Arc::new(Mutex::new(())); + let wait = gate.clone(); + h.state_mut().printer_source = Some(Arc::new(move || { + let _open = wait.lock(); + vec![Printer { name: "Office".into(), default: true, two_sided: None, description: String::new() }] + })); + assert!(h.state_mut().execute("print.dialog")); + wait_for_list(&mut h); + h.run_steps(4); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office")); + + // "Office" is remembered with Tabloid (the third size). + h.state_mut().print_draft.remembered_paper.insert("Office".into(), "Tabloid".into()); + h.state_mut().dialog = None; + let hold = gate.lock().unwrap(); + assert!(h.state_mut().execute("print.dialog")); + h.run_steps(2); + assert!(h.state().print_draft.printers_loading, "the fresh list is still being read"); + assert_eq!(h.state().print_draft.printer.as_deref(), Some("Office"), "last time's list shows at once"); + assert_eq!((h.state().print_draft.paper_name().as_str(), h.state().print_draft.remember_paper), ("Tabloid", true)); + + // Meanwhile the person picks another paper and unticks Remember. + { + let d = &mut h.state_mut().print_draft; + d.paper = 0; + d.remember_paper = false; + } + drop(hold); + wait_for_list(&mut h); + h.run_steps(2); + let d = &h.state().print_draft; + assert_eq!(d.printer.as_deref(), Some("Office"), "the same printer"); + assert_eq!((d.paper, d.remember_paper), (0, false), "the paper and the Remember box are as they were left"); +} diff --git a/crates/ui-egui/tests/it/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..464b63a17c 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. 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/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/parse.rs b/crates/xfa/src/parse.rs index 620cc49e7c..cec433f469 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"), } } 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..1d62f28b18 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 = @@ -164,8 +164,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| { diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index b7725f0f13..632f6a3f0d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4894,6 +4894,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 6b1acb4e78..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] { 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/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