diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 007e0330e4..afe3006217 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -4,7 +4,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE). -## In this repository (255) +## In this repository (257) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -17,8 +17,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 5261bc9763..b33510f345 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8444a3bd135d61feb6c5b559a9eb0babb50de2ebbd1d86b1e87ccb064e2d3f0b" +sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b" [[asset]] path = "crates/ui-egui/src/i18n/zh-hant.tsv" @@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "8771d80405b8fba9da4a909fdd97b760f6b25a822807ca83ff20dc5cec83a73c" +sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7" [[asset]] path = "crates/ui-egui/src/i18n/cs.tsv" @@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "bf1ab8079af725264f9c4298b9737359ba831170dcba76a6bd39228239c87e83" +sha256 = "8bab483a3f5ba3a724365c2a5a892291a10f41fdc443eaceb66f49c8a20cae20" [[asset]] path = "crates/ui-egui/src/i18n/pt-br.tsv" @@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "b23b03cf4ac163e7a63e49a0900a125507c05ad4fc7930468b859acf32b09ecd" +sha256 = "2cd5dcc6cf7e94914b955ee0f2f5c3e8951b16e26ab49fcb7111246a6e31ee2a" [[asset]] path = "crates/ui-egui/src/i18n/es.tsv" @@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "1b3371f239ca89a658e2c9b80cc070be5cd941dd1ecd3e7a247696716f25721d" +sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "dbf725ffc9177a435e894f4405834e87c70b6fe31a34f1be533c60c356510b97" +sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a" [[asset]] path = "crates/ui-egui/src/i18n/de.tsv" @@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "3827d04146f65a02e9d5e69336a97efc46862a48b18a16acbbc023ee0c8b7e1b" +sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "f7a5ab0bcbdb8641b1bd4ae25db54ef187757dde3e4fe372e433a9e5c0fc4f3a" +sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -107,7 +107,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "259f795a3cdc032aa670693d2ebff010c5dd3bcf57dda11548d5bd46e78969b8" +sha256 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193" + +[[asset]] +path = "crates/ui-egui/src/i18n/hu.tsv" +title = "Hungarian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b" [[asset]] path = "crates/ui-egui/src/i18n/bg.tsv" @@ -118,7 +129,7 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "e71f683e410618200c0abe66a362a720cb8ded5bfd155bf825f92644373c54b5" +sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -129,7 +140,18 @@ licence = "MIT OR Apache-2.0" licence_file = "LICENSE-MIT" kind = "translation" usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" -sha256 = "cdcdba828efea9d402cb9ce8be2b52ab1d58c8f7837d75d3b9c86f2a05e7fb2e" +sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f" + +[[asset]] +path = "crates/ui-egui/src/i18n/uk.tsv" +title = "Ukrainian interface translation catalog" +author = "PdfCraft contributors" +source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources" +licence = "MIT OR Apache-2.0" +licence_file = "LICENSE-MIT" +kind = "translation" +usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5" [[asset]] path = "assets/icons/accessibility.svg" diff --git a/Cargo.lock b/Cargo.lock index e4a8c91103..b174b4aee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3487,6 +3487,7 @@ version = "0.4.0" dependencies = [ "base64 0.22.1", "image", + "pdfcraft-cos", "pdfcraft-engine", "pdfcraft-organize", "pdfcraft-platform", @@ -3641,6 +3642,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", ] @@ -3791,6 +3793,7 @@ version = "0.4.0" dependencies = [ "arboard", "base64 0.22.1", + "bytemuck", "eframe", "egui", "egui_extras", @@ -6589,8 +6592,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ "ahash", "android-activity", diff --git a/Cargo.toml b/Cargo.toml index c713ac86ca..e80a0b467c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,3 +98,4 @@ hayro = { path = "vendor/hayro" } hayro-syntax = { path = "vendor/hayro-syntax" } hayro-jbig2 = { path = "vendor/hayro-jbig2" } lopdf = { path = "vendor/lopdf" } +winit = { path = "vendor/winit" } diff --git a/ROADMAP.md b/ROADMAP.md index ac1a769346..ecf9f4125f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -80,6 +80,7 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P 4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users. 5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12). 6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. +6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit. Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only. @@ -104,6 +105,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA | | M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers | | M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, German, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese, French and German coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets | +| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Ukrainian catalogs have ≈1,800–2,000 entries each; Simplified Chinese, French and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one. Missing: more catalogs filled in, installers, performance budgets | | | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | | **Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05). @@ -125,6 +127,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run ## Log +- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged. - **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%). @@ -139,6 +143,11 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run Newest first. One line per session: the date, what moved, and the new overall percentage. +- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%. + +- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%). + +- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%. - **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%). @@ -146,6 +155,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe - **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%). +- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%). + - **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%). - **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%). diff --git a/apps/pdfcraft-cli/src/main.rs b/apps/pdfcraft-cli/src/main.rs index 3fe28824d7..1940d63e49 100644 --- a/apps/pdfcraft-cli/src/main.rs +++ b/apps/pdfcraft-cli/src/main.rs @@ -1,23 +1,6 @@ //! pdfcraft-cli — headless PdfCraft. //! -//! ```text -//! pdfcraft-cli info [--password PW] document summary as JSON -//! pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) -//! pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) -//! pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] -//! [--insert-blank 1] [--title T] [--author A] [--full] -//! pdfcraft-cli combine … --out combined.pdf -//! pdfcraft-cli extract --pages 1,3,5 --out out.pdf -//! pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] -//! pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] -//! pdfcraft-cli tools automation tools and their JSON Schemas -//! pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] -//! pdfcraft-cli run --script steps.json [--root DIR] [{"tool": "doc_open", "args": {…}}, …] -//! pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in) -//! --compact lists a core set of tools plus tool_search and tool_call -//! pdfcraft-cli ui --control FILE [key=value …] [--out shot.png] -//! drive a running app started with --control FILE -//! ``` +//! Usage: `pdfcraft-cli --help`, which prints `USAGE` below. //! //! `run` and `mcp` drive the same tool table (`pdfcraft-automation`). In `run`, values parse as //! JSON when they can (`pages=[1,3]`, `degrees=90`) and are strings otherwise. A script runs its @@ -43,28 +26,75 @@ use std::time::{Duration, Instant}; use pdfcraft_engine::export::ImageFormat; use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind, inspect}; +/// The `mcp` lines of [`USAGE`]: only a build with the `mcp` feature has the command. +#[cfg(feature = "mcp")] +macro_rules! mcp_usage { + () => { + "\ +pdfcraft-cli mcp [--root DIR] [--compact] MCP server on stdin/stdout (opt-in) + --compact lists a core set of tools plus tool_search and tool_call +" + }; +} +#[cfg(not(feature = "mcp"))] +macro_rules! mcp_usage { + () => { + "" + }; +} + +/// The full usage, printed by `--help` (also after a command, e.g. `render --help`). +const USAGE: &str = concat!( + "\ +pdfcraft-cli info [--password PW] document summary as JSON +pdfcraft-cli render --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam) +pdfcraft-cli text [--page N] extracted text (pages separated by form feeds) +pdfcraft-cli edit --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1] + [--insert-blank 1] [--title T] [--author A] [--full] +pdfcraft-cli combine … --out combined.pdf +pdfcraft-cli extract --pages 1,3,5 --out out.pdf +pdfcraft-cli split (--every N | --before 3,7) [--out-dir DIR] +pdfcraft-cli check [--timeout 20] [--dpi 36] [--json out.json] +pdfcraft-cli tools automation tools and their JSON Schemas +pdfcraft-cli run [key=value …] [--root DIR] [--out image.png] +pdfcraft-cli run --script steps.json [--root DIR] [{\"tool\": \"doc_open\", \"args\": {…}}, …] +", + mcp_usage!(), + "\ +pdfcraft-cli ui --control FILE [key=value …] [--out shot.png] + drive a running app started with --control FILE +help and feedback: https://discord.gg/artcraft" +); + fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); - let result = - match args.first().map(String::as_str) { - Some("info") => info(&args[1..]), - Some("render") => render(&args[1..]), - Some("text") => text(&args[1..]), - Some("edit") => edit(&args[1..]), - Some("combine") => combine(&args[1..]), - Some("extract") => extract(&args[1..]), - Some("split") => split(&args[1..]), - Some("check") => check(&args[1..]), - Some("check-one") => check_one(&args[1..]), - Some("tools") => tools(), - Some("run") => run(&args[1..]), - Some("ui") => ui(&args[1..]), - #[cfg(feature = "mcp")] - Some("mcp") => mcp(&args[1..]), - Some("--version") => version(), - _ => Err("usage: pdfcraft-cli … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft" - .into()), - }; + // ` --help` too; only right after the command, so a later `-h` (a value) still reaches it. + let command = match args.get(1).map(String::as_str) { + Some("--help" | "-h") => Some("--help"), + _ => args.first().map(String::as_str), + }; + let result = match command { + Some("--help" | "-h" | "help") => stdout_line(format_args!("{USAGE}")), + None => Err(format!("usage:\n{USAGE}").into()), + Some("info") => info(&args[1..]), + Some("render") => render(&args[1..]), + Some("text") => text(&args[1..]), + Some("edit") => edit(&args[1..]), + Some("combine") => combine(&args[1..]), + Some("extract") => extract(&args[1..]), + Some("split") => split(&args[1..]), + Some("check") => check(&args[1..]), + Some("check-one") => check_one(&args[1..]), + Some("tools") => tools(), + Some("run") => run(&args[1..]), + Some("ui") => ui(&args[1..]), + #[cfg(feature = "mcp")] + Some("mcp") => mcp(&args[1..]), + #[cfg(not(feature = "mcp"))] + Some("mcp") => Err("mcp: this build leaves out the MCP server (built without the `mcp` feature)".into()), + Some("--version") => version(), + Some(other) => Err(format!("unknown command '{other}'; see pdfcraft-cli --help").into()), + }; match result { Ok(()) => ExitCode::SUCCESS, Err(CliError::Stdout(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => ExitCode::SUCCESS, diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/help.rs new file mode 100644 index 0000000000..9a86150f73 --- /dev/null +++ b/apps/pdfcraft-cli/tests/help.rs @@ -0,0 +1,65 @@ +//! `--help` prints the full usage to stdout and succeeds; a missing or unknown command fails with +//! the usage (or a pointer to `--help`) on stderr. + +use std::process::{Command, Output}; + +const BIN: &str = env!("CARGO_BIN_EXE_pdfcraft-cli"); + +fn run(args: &[&str]) -> Output { + Command::new(BIN).args(args).output().unwrap() +} + +fn text(bytes: &[u8]) -> String { + String::from_utf8_lossy(bytes).into_owned() +} + +#[test] +fn help_flags_print_full_usage_to_stdout() { + for args in [&["--help"][..], &["-h"], &["help"], &["render", "--help"], &["check", "-h"]] { + let out = run(args); + assert!(out.status.success(), "{args:?} exited {:?}", out.status); + let stdout = text(&out.stdout); + // Per-command options, not just the command list. + assert!(stdout.contains("pdfcraft-cli render --page N"), "{args:?}: {stdout}"); + assert!(stdout.contains("--every N"), "{args:?}: {stdout}"); + assert!(out.stderr.is_empty(), "{args:?}: {}", text(&out.stderr)); + } +} + +#[test] +fn no_command_fails_with_usage_on_stderr() { + let out = run(&[]); + assert!(!out.status.success()); + assert!(out.stdout.is_empty()); + assert!(text(&out.stderr).contains("pdfcraft-cli render --page N")); +} + +/// `mcp` is listed only when the build has it (`--no-default-features` leaves it out). +#[test] +fn usage_lists_mcp_only_when_built_with_it() { + let out = run(&["--help"]); + let stdout = text(&out.stdout); + assert_eq!(stdout.contains("pdfcraft-cli mcp"), cfg!(feature = "mcp"), "{stdout}"); + assert_eq!(stdout.contains("--compact"), cfg!(feature = "mcp"), "{stdout}"); + // The lines around it are still there. + assert!(stdout.contains("pdfcraft-cli run --script steps.json"), "{stdout}"); + assert!(stdout.contains("pdfcraft-cli ui --control FILE"), "{stdout}"); +} + +#[cfg(not(feature = "mcp"))] +#[test] +fn mcp_without_the_feature_says_it_was_left_out() { + let out = run(&["mcp"]); + assert!(!out.status.success()); + let stderr = text(&out.stderr); + assert!(stderr.contains("without the `mcp` feature"), "{stderr}"); +} + +#[test] +fn unknown_command_names_it_and_points_to_help() { + let out = run(&["frobnicate"]); + assert!(!out.status.success()); + let stderr = text(&out.stderr); + assert!(stderr.contains("unknown command 'frobnicate'"), "{stderr}"); + assert!(stderr.contains("pdfcraft-cli --help"), "{stderr}"); +} diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 78d9fb5c80..2400fe9ea3 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -14,7 +14,9 @@ workspace = true ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] } rustls-native-certs = "0.8" pdfcraft-ui-egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "persistence", "links"] } +# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or +# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl. +eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] } egui = { workspace = true } serde_json = { workspace = true } # Records from every crate go to standard error and the log file (src/logging.rs). diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 3d7f61a5ef..5d567b8195 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -19,6 +19,9 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +use std::cell::Cell; +use std::rc::Rc; + use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] @@ -150,6 +153,85 @@ fn main() -> eframe::Result { } } let integrated = cfg!(target_os = "macos"); + migrate_legacy_folders(); + // The log file lives in the settings folder; opened after the arguments (so `--version` leaves + // no file behind) and after the PrintCraft migration (which a fresh folder would block). + // Records logged until now are written to it first. + if let (Some(logger), Some(dir)) = (logger, settings_dir()) { + match logger.attach_dir(&dir.join("logs")) { + Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), + // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. + Err(e) => log::warn!("no log file: {e}"), + } + } + let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref()); + let launch = Launch { files, options, control_file, create_images, integrated }; + // 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")] + let apple_events = apple_events::AppleEvents::install(); + // Set once the app is created, which is after the renderer has started. + let started = Rc::new(Cell::new(false)); + let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu }; + let result = eframe::run_native( + "PdfCraft", + native_options(integrated, first), + app_creator( + launch.clone(), + Rc::clone(&started), + #[cfg(target_os = "macos")] + &apple_events, + ), + ); + match result { + // Only a renderer that couldn't start: without a window or display at all, OpenGL can't + // help either, and winit's own error says more. + Err(e @ eframe::Error::Wgpu(_)) if retry_with_gl(choice, started.get()) => { + // Old or unusual GPUs and drivers (#461, #435, #392) can't give wgpu a device; OpenGL + // usually still works there, so that's better than quitting. + log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu."); + eframe::run_native( + "PdfCraft", + native_options(integrated, eframe::Renderer::Glow), + app_creator( + launch, + started, + #[cfg(target_os = "macos")] + &apple_events, + ), + ) + } + other => other, + } +} + +/// Which renderer to start with, from `PDFCRAFT_RENDERER`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RendererChoice { + /// wgpu, then OpenGL if wgpu can't start (unset, or anything unrecognised). + Auto, + /// wgpu only (`wgpu`): a failure is reported, not worked around. + Wgpu, + /// OpenGL only (`gl`, `opengl` or `glow`): for drivers where wgpu starts but misbehaves. + Gl, +} + +fn renderer_choice(value: Option<&str>) -> RendererChoice { + match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() { + Some("wgpu") => RendererChoice::Wgpu, + Some("gl" | "opengl" | "glow") => RendererChoice::Gl, + _ => RendererChoice::Auto, + } +} + +/// Whether a failed run should be retried with OpenGL: only when wgpu was tried first by choice of +/// nobody, and it failed before the app was created (so while starting the renderer, not later). +fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool { + choice == RendererChoice::Auto && !app_started +} + +/// The window and renderer settings for one run. +fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions { let mut viewport = egui::ViewportBuilder::default() .with_title("PdfCraft") .with_inner_size([1440.0, 920.0]) @@ -165,80 +247,82 @@ fn main() -> eframe::Result { if integrated { viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false); } - migrate_legacy_folders(); - // The log file lives in the settings folder; opened after the arguments (so `--version` leaves - // no file behind) and after the PrintCraft migration (which a fresh folder would block). - // Records logged until now are written to it first. - if let (Some(logger), Some(dir)) = (logger, settings_dir()) { - match logger.attach_dir(&dir.join("logs")) { - Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), - // Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics. - Err(e) => log::warn!("no log file: {e}"), - } - } let persistence_path = settings_dir().map(|d| d.join("app.ron")); - let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() }; - configure_gpu(&mut native); - // 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")] - let apple_events = apple_events::AppleEvents::install(); - #[cfg(target_os = "macos")] - let apple_events = &apple_events; - eframe::run_native( - "PdfCraft", - native, - Box::new(move |cc| { - let mut app = PdfCraftApp::new(); - if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { - app.restore(&json); + // Set explicitly, so the renderer never depends on which one eframe defaults to. + let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() }; + if renderer == eframe::Renderer::Wgpu { + configure_gpu(&mut native); + } + native +} + +/// What the command line asked for, kept so a second run (with OpenGL) can start the same way. +#[derive(Clone)] +struct Launch { + files: Vec, + options: Vec<(String, String)>, + control_file: Option, + create_images: bool, + integrated: bool, +} + +fn app_creator<'a>( + launch: Launch, + started: Rc>, + #[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents, +) -> eframe::AppCreator<'a> { + let Launch { files, options, control_file, create_images, integrated } = launch; + Box::new(move |cc| { + started.set(true); + let mut app = PdfCraftApp::new(); + if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) { + app.restore(&json); + } + app.integrated_titlebar = integrated; + app.update_source = Some(std::sync::Arc::new(updates::latest_release)); + app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); + #[cfg(target_os = "macos")] + { + app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + } + if let Some(file) = &control_file { + let client = app.attach_control(&cc.egui_ctx); + match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { + // Never the token (AGENTS.md §3): it stays in the owner-only file. + Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"), + Err(e) => log::error!("--control {file}: {e}"), } - app.integrated_titlebar = integrated; - app.update_source = Some(std::sync::Arc::new(updates::latest_release)); - app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows")); - #[cfg(target_os = "macos")] - { - app.os_events = Some(apple_events.connect(&cc.egui_ctx)); + } + // Autosave unsaved changes; offer to recover documents a crashed session left behind. + if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { + app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); + } + // A portable marker whose data folder can't be written (#157): say where settings went. + if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable { + app.notify_fmt( + "Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.", + &[("folder", &w.folder.display().to_string()), ("error", &w.error)], + ); + } + if create_images { + if let Err(e) = app.begin_image_import_paths(&files) { + app.notify(e); } - if let Some(file) = &control_file { - let client = app.attach_control(&cc.egui_ctx); - match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) { - // Never the token (AGENTS.md §3): it stays in the owner-only file. - Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"), - Err(e) => log::error!("--control {file}: {e}"), - } + } else { + // With the preference on, last session's files come back first; files named on + // the command line open after them, in front (#442). + app.reopen_last_files(&files); + for f in files { + app.open_path(&f); } - // Autosave unsaved changes; offer to recover documents a crashed session left behind. - if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() { - app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir)); + } + for (k, v) in options { + if let Err(e) = app.set_option(&k, &v) { + log::warn!("--{k} {v}: {e}"); } - // A portable marker whose data folder can't be written (#157): say where settings went. - if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable { - app.notify_fmt( - "Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.", - &[("folder", &w.folder.display().to_string()), ("error", &w.error)], - ); - } - if create_images { - if let Err(e) = app.begin_image_import_paths(&files) { - app.notify(e); - } - } else { - // With the preference on, last session's files come back first; files named on - // the command line open after them, in front (#442). - app.reopen_last_files(&files); - for f in files { - app.open_path(&f); - } - } - for (k, v) in options { - if let Err(e) = app.set_option(&k, &v) { - log::warn!("--{k} {v}: {e}"); - } - } - Ok(Box::new(app)) - }), - ) + } + Ok(Box::new(app)) + }) } /// Write the control endpoint so that only the current user can read the token. @@ -297,7 +381,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// It also saves battery. Machines with one GPU are unaffected. /// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang /// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland -/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins. +/// compositors on NVIDIA. When several GPUs drive a display, the one driving a built-in panel +/// wins (a hybrid laptop, issue #8); without a built-in panel (a desktop with a monitor on each +/// GPU, issue #445) the discrete one wins, as the integrated one may fail to present there. /// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before @@ -333,52 +419,78 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { } } -/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors -/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`). +/// A GPU with a connected monitor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +struct DisplayGpu { + /// PCI `(vendor, device)` ids. + pci: (u32, u32), + /// Whether one of its connected connectors is a built-in panel (`eDP`, `LVDS` or `DSI`). + internal_panel: bool, +} + +/// Whether a DRM connector name (`eDP-1`, `LVDS-1`, `DSI-1`) is a laptop's built-in panel. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn is_internal_panel(connector: &str) -> bool { + ["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind)) +} + +/// The GPUs with a connected monitor, read from the DRM connectors under `drm` (`card1-DP-3/status` +/// is `connected`, `card1/device/{vendor,device}` hold `0x10de`). #[cfg(target_os = "linux")] -fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> { +fn linux_display_gpus(drm: &std::path::Path) -> Vec { let read_hex = |p: std::path::PathBuf| -> Option { let s = std::fs::read_to_string(p).ok()?; u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() }; - let mut gpus = Vec::new(); + let mut gpus: Vec = Vec::new(); let Ok(entries) = std::fs::read_dir(drm) else { return gpus }; // A machine has a handful of connectors; the cap only bounds a pathological sysfs. for entry in entries.flatten().take(256) { let name = entry.file_name(); - let Some((card, _connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue }; + let Some((card, connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue }; let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected"); if !connected { continue; } let device = drm.join(card).join("device"); - if let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) - && !gpus.contains(&(v, d)) - { - gpus.push((v, d)); + let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device"))) else { continue }; + let internal_panel = is_internal_panel(connector); + match gpus.iter_mut().find(|g| g.pci == (v, d)) { + Some(gpu) => gpu.internal_panel |= internal_panel, + None => gpus.push(DisplayGpu { pci: (v, d), internal_panel }), } } gpus } -/// Index of the adapter to draw with: one that drives a display (by PCI ids) first, then the most -/// frugal kind — integrated, discrete, other, virtual, software. +/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several +/// of those, the one driving a built-in panel (a hybrid laptop, #8), or else, with no built-in +/// panel anywhere (a desktop with a monitor on each GPU, #445), the discrete one. Otherwise the +/// most frugal kind: integrated, discrete, other, virtual, software. #[cfg_attr(not(target_os = "linux"), allow(dead_code))] -fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[(u32, u32)]) -> Option { +fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option { use eframe::wgpu::DeviceType; + let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci); + let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.internal_panel); adapters .iter() .enumerate() .min_by_key(|(_, (vendor, device, kind))| { - let drives_display = displays.contains(&(*vendor, *device)); - let frugality = match kind { - DeviceType::IntegratedGpu => 0, - DeviceType::DiscreteGpu => 1, - DeviceType::Other => 2, - DeviceType::VirtualGpu => 3, - DeviceType::Cpu => 4, + let shown = display((*vendor, *device)); + let drives_display = shown.is_some(); + let drives_panel = shown.is_some_and(|g| g.internal_panel); + let rank = match kind { + // On a desktop with monitors on both, the integrated GPU can accept the window and + // still fail to present to it (#445). + DeviceType::DiscreteGpu if drives_display && multi_gpu_desktop => 0, + DeviceType::IntegratedGpu => 1, + DeviceType::DiscreteGpu => 2, + DeviceType::Other => 3, + DeviceType::VirtualGpu => 4, + DeviceType::Cpu => 5, }; - (!drives_display, frugality) + (!drives_display, !drives_panel, rank) }) .map(|(i, _)| i) } @@ -406,6 +518,26 @@ mod tests { assert_eq!(super::path_from_arg("file://".to_string()), "file://"); } + #[test] + fn opengl_is_the_fallback_unless_a_renderer_was_chosen() { + use super::{RendererChoice, native_options, renderer_choice, retry_with_gl}; + assert_eq!(renderer_choice(None), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto); + assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu); + for gl in ["gl", "OpenGL", "glow"] { + assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl); + } + // Retried with OpenGL only when wgpu failed to start by default, never after the app ran. + assert!(retry_with_gl(RendererChoice::Auto, false)); + assert!(!retry_with_gl(RendererChoice::Auto, true)); + assert!(!retry_with_gl(RendererChoice::Wgpu, false)); + assert!(!retry_with_gl(RendererChoice::Gl, false)); + // Each run states its renderer rather than relying on eframe's default. + assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu); + assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow); + } + #[test] fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() { let mut native = eframe::NativeOptions::default(); @@ -423,6 +555,23 @@ mod tests { } } + #[test] + fn winit_carries_the_windows_11_monitor_scale_fix() { + // Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another + // scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong + // monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A + // dependency bump that resolves winit from crates.io again, or a re-vendored copy without + // the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a + // winit 0.30 release has the fix (vendor/README.md). + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap(); + let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock"); + assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}"); + let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap(); + let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;"; + assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch"); + } + const NVIDIA: (u32, u32) = (0x10de, 0x2684); const AMD_IGPU: (u32, u32) = (0x1002, 0x164e); @@ -431,29 +580,65 @@ mod tests { vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)] } + const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780); + + fn monitor(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, internal_panel: false } + } + + fn panel(pci: (u32, u32)) -> super::DisplayGpu { + super::DisplayGpu { pci, internal_panel: true } + } + #[test] fn pick_adapter_prefers_the_gpu_driving_the_monitors() { // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA]), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0)); } #[test] fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA, AMD_IGPU]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1)); + } + + #[test] + fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() { + // Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel. + use eframe::wgpu::DeviceType; + let desktop = + [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1)); + // A GPU driving no display still loses to one that does. + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1)); } #[test] fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1)); - assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None); + } + + #[test] + fn internal_panels_are_edp_lvds_and_dsi_connectors() { + for name in ["eDP-1", "LVDS-1", "DSI-1"] { + assert!(super::is_internal_panel(name), "{name}"); + } + for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] { + assert!(!super::is_internal_panel(name), "{name}"); + } } #[cfg(target_os = "linux")] #[test] fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> { let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> { std::fs::create_dir_all(dir.join(name).join("device"))?; std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?; @@ -469,9 +654,19 @@ mod tests { connector("card1-DP-4", "connected")?; connector("card2-HDMI-A-1", "disconnected")?; connector("card2-Writeback-1", "unknown")?; - let gpus = super::linux_display_gpus(&dir); + let desktop = super::linux_display_gpus(&dir); + // Issue #445: a monitor on the integrated GPU too, still no built-in panel. + connector("card2-HDMI-A-1", "connected")?; + let mut both = super::linux_display_gpus(&dir); + // A hybrid laptop: the integrated GPU drives the built-in panel as well. + connector("card2-eDP-1", "connected")?; + let mut laptop = super::linux_display_gpus(&dir); std::fs::remove_dir_all(&dir)?; - assert_eq!(gpus, vec![NVIDIA]); + assert_eq!(desktop, vec![monitor(NVIDIA)]); + both.sort_by_key(|g| g.pci); + assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]); + laptop.sort_by_key(|g| g.pci); + assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]); assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty()); Ok(()) } diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 11b2143480..9a02606935 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -26,23 +26,77 @@ pub fn cloud_radius(w: f64) -> f64 { 6.0 + 1.5 * w.max(0.0) } -/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. Open and closed arrows only. -fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) { +/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn. +pub(crate) fn known_ending(kind: &[u8]) -> bool { + std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some() +} + +/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. +/// +/// Returns false for an unknown style or a non-finite direction, so the caller keeps the +/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing. +fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool { if kind == b"None" { - return; + return true; + } + if !known_ending(kind) { + return false; } let (dx, dy) = (from.0 - tip.0, from.1 - tip.1); + if !dx.is_finite() || !dy.is_finite() { + return false; + } let len = dx.hypot(dy); + if !len.is_finite() { + return false; + } if len == 0.0 { - return; + return true; } - let (ux, uy) = (dx / len, dy / len); let s = arrow_size(w); - let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); - let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos)); - let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos)); - let op = if kind == b"ClosedArrow" { "h B" } else { "S" }; - c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1))); + if !s.is_finite() { + return false; + } + let (ix, iy) = (dx / len, dy / len); + match kind { + b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => { + // Open and closed arrows keep their original geometry. A reverse arrow uses the same + // head, pointing back along the line. + let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) }; + let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin()); + let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos)); + let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos)); + let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" }; + c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1))); + } + b"Square" | b"Circle" | b"Diamond" => { + let h = s / 2.0; + if kind == b"Square" { + c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s))); + } else if kind == b"Circle" { + c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h)); + c.push_str("B\n"); + } else { + let (px, py) = (-iy, ix); + let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across); + let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1))); + } + } + _ => { + // Butt is perpendicular to the line. Slash is that stroke turned 45 degrees. + let (px, py) = (-iy, ix); + let (sx, sy) = if kind == b"Slash" { + let q = std::f64::consts::FRAC_1_SQRT_2; + (px * q - py * q, px * q + py * q) + } else { + (px, py) + }; + let h = s / 2.0; + c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h))); + } + } + true } /// The cubic Bézier segments (first control point, second control point, end) of a smooth curve @@ -340,32 +394,45 @@ pub fn build(d: &Dict) -> Option { }); } b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4)?; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; let col = stroke?; let ends: Vec> = match d.get(b"LE") { None => vec![b"None".to_vec(), b"None".to_vec()], Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, }; - if ends.len() != 2 || ends.iter().any(|e| !matches!(e.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow")) { + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { return None; } let fill = color(d, b"IC")?.unwrap_or(col); c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); c.push_str(&format!("{} {} m {} {} l S\n[] 0 d\n", n(l[0]), n(l[1]), n(l[2]), n(l[3]))); for (end, (tip, from)) in ends.iter().zip([((l[0], l[1]), (l[2], l[3])), ((l[2], l[3]), (l[0], l[1]))]) { - line_end(&mut c, end, tip, from, w); + if !line_end(&mut c, end, tip, from, w) { + return None; + } } } b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0)?; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); let col = stroke; let closed = subtype == b"Polygon"; let fill = if closed { color(d, b"IC")? } else { None }; - if subtype == b"PolyLine" && d.get(b"LE").is_some_and(|e| e.as_array().is_none_or(|a| a.iter().any(|x| x.as_name() != Some(b"None")))) { - // Line endings on connected lines aren't drawn yet. - return None; - } + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; + } + Some(names) + } + } + } else { + None + }; if let Some(f) = fill { c.push_str(&rg(f)); } @@ -389,6 +456,17 @@ pub fn build(d: &Dict) -> Option { (false, true) => "S\n", (false, false) => "n\n", }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = color(d, b"IC")?.unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { + return None; + } + } } b"Caret" => { // A filled caret: two curved flanks meeting at the top centre. @@ -553,17 +631,22 @@ pub fn build(d: &Dict) -> Option { None => b"None".to_vec(), Some(o) => o.as_name()?.to_vec(), }; - if !matches!(end.as_slice(), b"None" | b"OpenArrow" | b"ClosedArrow") { + if !known_ending(&end) { return None; } let lw = bw.max(0.5); let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); for (i, p) in pts.iter().enumerate() { c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); } c.push_str("S\n"); - line_end(&mut c, &end, pts[0], pts[1], lw); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } } if let Some(bg) = bg { c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index ff553ac2a8..73bf42a455 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -274,6 +274,55 @@ impl StampKind { } } +/// A line ending (`/LE`): the ten styles in ISO 32000-2 Table 217. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineEnding { + None, + Square, + Circle, + Diamond, + OpenArrow, + ClosedArrow, + Butt, + ROpenArrow, + RClosedArrow, + Slash, +} + +impl LineEnding { + pub const ALL: [Self; 10] = [ + Self::None, + Self::Square, + Self::Circle, + Self::Diamond, + Self::OpenArrow, + Self::ClosedArrow, + Self::Butt, + Self::ROpenArrow, + Self::RClosedArrow, + Self::Slash, + ]; + + pub fn name(self) -> &'static str { + match self { + Self::None => "None", + Self::Square => "Square", + Self::Circle => "Circle", + Self::Diamond => "Diamond", + Self::OpenArrow => "OpenArrow", + Self::ClosedArrow => "ClosedArrow", + Self::Butt => "Butt", + Self::ROpenArrow => "ROpenArrow", + Self::RClosedArrow => "RClosedArrow", + Self::Slash => "Slash", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|ending| ending.name() == name) + } +} + /// Geometry of a new comment, in PDF user space of its page. #[derive(Clone, Debug, PartialEq)] pub enum Shape { @@ -294,11 +343,12 @@ pub enum Shape { Oval { rect: [f64; 4], }, - /// A line, with an open arrowhead at `to` when `arrow` is set. + /// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool). Line { from: [f64; 2], to: [f64; 2], - arrow: bool, + start: LineEnding, + end: LineEnding, }, /// Freehand strokes (Draw tool). Ink { @@ -357,9 +407,11 @@ pub enum Shape { vertices: Vec<[f64; 2]>, cloud: bool, }, - /// Connected lines (Polygonal Line tool). + /// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last. PolyLine { vertices: Vec<[f64; 2]>, + start: LineEnding, + end: LineEnding, }, /// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via /// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`). @@ -368,6 +420,7 @@ pub enum Shape { knee: [f64; 2], point: [f64; 2], font_size: f64, + ending: LineEnding, }, /// Insert text: a caret in `rect` (its point at the top centre). Caret { @@ -726,6 +779,14 @@ fn grow(r: [f64; 4], by: f64) -> [f64; 4] { [r[0] - by, r[1] - by, r[2] + by, r[3] + by] } +fn ending_draws(ending: LineEnding) -> bool { + ending != LineEnding::None +} + +fn ending_pair(start: LineEnding, end: LineEnding) -> Object { + Object::Array(vec![Object::name(start.name()), Object::name(end.name())]) +} + /// Validate and compute `/Rect` for a new comment. fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}")); @@ -765,11 +826,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { } r } - Shape::Line { from, to, arrow } => { + Shape::Line { from, to, start, end } => { if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 { return Err(bad("line (too short)")); } - let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 }; + let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0) } Shape::Ink { strokes } | Shape::Signature { strokes } => { @@ -790,10 +851,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> { let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?; grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 }) } - Shape::PolyLine { vertices } => { + Shape::PolyLine { vertices, start, end } => { let b = bounds(vertices.iter().copied()) .filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0); - grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0) + let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 }; + grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra) } Shape::Callout { rect, knee, point, .. } => { let r = normalize(*rect); @@ -846,7 +908,7 @@ fn subject(shape: &Shape) -> &'static str { Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly", Shape::Rectangle { .. } => "Rectangle", Shape::Oval { .. } => "Oval", - Shape::Line { arrow: true, .. } => "Arrow", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow", Shape::Line { .. } => "Line", Shape::Ink { .. } => "Pencil", Shape::TextBox { .. } => "Text Box", @@ -943,11 +1005,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); } - Shape::Line { from, to, arrow } => { + Shape::Line { from, to, start, end } => { d.set(b"C".to_vec(), rgb(style.color)); d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]])); - if *arrow { - d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("OpenArrow")])); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); } border(&mut d); } @@ -1008,9 +1070,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R } border(&mut d); } - Shape::PolyLine { vertices } => { + Shape::PolyLine { vertices, start, end } => { d.set(b"C".to_vec(), rgb(style.color)); d.set(b"Vertices".to_vec(), num_array(&vertices.concat())); + if ending_draws(*start) || ending_draws(*end) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } border(&mut d); } Shape::Caret { .. } => { @@ -1047,12 +1112,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"Contents".to_vec(), PdfString::text(file.trim())); } } - Shape::Callout { rect: tb, knee, point, .. } => { + Shape::Callout { rect: tb, knee, point, ending, .. } => { let tb = normalize(*tb); let attach = callout_attach(tb, *knee); d.set(b"IT".to_vec(), Object::name("FreeTextCallout")); d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]])); - d.set(b"LE".to_vec(), Object::name("OpenArrow")); + d.set(b"LE".to_vec(), Object::name(ending.name())); // The text box inside /Rect (§12.5.6.6 /RD). d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]])); } @@ -1526,7 +1591,8 @@ fn border_width_of(d: &Dict) -> f64 { d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0) } -/// Change a comment's colour, opacity and/or line width, and redraw it. +/// Change a comment's colour, opacity, line width and/or line endings, and redraw it. +#[allow(clippy::too_many_arguments)] pub fn set_style( doc: &mut Document, page: usize, @@ -1534,6 +1600,7 @@ pub fn set_style( color: Option, opacity: Option, width: Option, + endings: Option<&[LineEnding]>, meta: &Meta, ) -> Result<(), AnnotError> { let (_, r) = annot_ref(doc, page, index)?; @@ -1542,11 +1609,18 @@ pub fn set_style( let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); // Check before changing anything: a stale appearance would contradict the new style. if appearance::build(&d).is_none() { - return Err(AnnotError::Unsupported(subtype)); + return Err(AnnotError::Unsupported(subtype.clone())); } if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { return Err(AnnotError::Invalid("invalid style".into())); } + if let Some(ends) = endings { + let callout = subtype == "FreeText" && d.contains(b"CL"); + let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); + if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { + return Err(AnnotError::Invalid("this comment has no line endings to change".into())); + } + } let free_text = subtype == "FreeText"; doc.update_dict(r, |d| { if let Some(c) = color { @@ -1573,11 +1647,55 @@ pub fn set_style( d.set(b"BS".to_vec(), Object::Dict(bs)); d.remove(b"Border"); } + if let Some(ends) = endings { + if ends.len() == 1 { + if let Some(ending) = ends.first() { + d.set(b"LE".to_vec(), Object::name(ending.name())); + } + } else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) { + d.set(b"LE".to_vec(), ending_pair(*start, *end)); + } + } + ensure_ending_room(d); touch(d, meta); })?; set_appearance(doc, r) } +/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent. +fn ensure_ending_room(d: &mut Dict) { + let subtype = d.name(b"Subtype").unwrap_or_default(); + if !matches!(subtype, b"Line" | b"PolyLine") { + return; + } + if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) { + return; + } + let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" }; + let Some(pts) = pair_points(d, key) else { return }; + let Some(bounds) = bounds(pts.iter().copied()) else { return }; + let w = border_width_of(d); + let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0); + let vals: Vec = d.get(b"Rect").and_then(|o| o.as_array()).map(|a| a.iter().filter_map(|x| x.as_f64()).collect()).unwrap_or_default(); + let Some(rect) = four(&vals) else { return }; + let union = [rect[0].min(need[0]), rect[1].min(need[1]), rect[2].max(need[2]), rect[3].max(need[3])]; + if union != rect { + d.set(b"Rect".to_vec(), num_array(&union)); + } +} + +fn four(v: &[f64]) -> Option<[f64; 4]> { + Some([*v.first()?, *v.get(1)?, *v.get(2)?, *v.get(3)?]) +} + +fn pair_points(d: &Dict, key: &[u8]) -> Option> { + let v: Vec = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect(); + if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) { + return None; + } + Some(v.as_chunks::<2>().0.to_vec()) +} + // ── reading ───────────────────────────────────────────────────────────────────────────────── /// One comment as the viewer lists it (the same fields and rules as @@ -1723,6 +1841,8 @@ pub struct Props { pub restylable: bool, /// The Locked flag. pub locked: bool, + /// `/LE`: two names for a line or polyline (`None` when unset), one for a callout. + pub endings: Option>, } /// The current properties of the comment at `(page, index)`. @@ -1747,9 +1867,23 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option { restylable: appearance::build(d).is_some(), locked: d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0) & FLAG_LOCKED != 0, subtype, + endings: line_endings_of(d), }) } +/// `/LE` for a line, polyline or callout. Unknown names yield `None` so the control stays hidden. +fn line_endings_of(d: &Dict) -> Option> { + let parse = |name: &[u8]| std::str::from_utf8(name).ok().and_then(LineEnding::parse); + match d.name(b"Subtype")? { + b"Line" | b"PolyLine" => match d.get(b"LE") { + None => Some(vec![LineEnding::None, LineEnding::None]), + Some(o) => o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| parse(e.as_name()?)).collect(), + }, + b"FreeText" if d.contains(b"CL") => Some(vec![parse(d.name(b"LE").unwrap_or(b"None"))?]), + _ => None, + } +} + /// Replace Text (Acrobat's proposal): strike out `quads` and add a caret at the end of the /// struck text holding `replacement`, grouped with the strikeout (`/IRT` + `/RT /Group`) so they /// move, list and delete as one. Returns the strikeout's index. diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index 3664e7cabe..de75b3232f 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -84,7 +84,7 @@ fn every_tool_creates_a_drawable_comment() { Shape::TextMarkup { kind: Markup::Squiggly, quads: vec![[72.0, 412.0, 200.0, 412.0, 72.0, 400.0, 200.0, 400.0]] }, Shape::Rectangle { rect: [300.0, 300.0, 200.0, 200.0] }, Shape::Oval { rect: [300.0, 300.0, 400.0, 350.0] }, - Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], arrow: true }, + Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow }, Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [30.0, 10.0]], vec![[40.0, 40.0]]] }, Shape::TextBox { rect: [300.0, 600.0, 500.0, 650.0], font_size: 14.0 }, ]; @@ -179,7 +179,7 @@ fn invalid_geometry_is_rejected() { let bad = [ Shape::TextMarkup { kind: Markup::Highlight, quads: vec![] }, Shape::Rectangle { rect: [0.0, 0.0, 0.5, 10.0] }, - Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], arrow: false }, + Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], start: LineEnding::None, end: LineEnding::None }, Shape::Ink { strokes: vec![vec![]] }, Shape::Note { at: [f64::NAN, 0.0], icon: NoteIcon::Note }, ]; @@ -261,7 +261,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), &meta("")).unwrap(); + set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -271,10 +271,15 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. - let l = add_annotation(&mut doc, &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], arrow: false }), &meta("l")).unwrap(); + let l = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }), + &meta("l"), + ) + .unwrap(); assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_)))); } @@ -283,7 +288,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -337,7 +342,16 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() { let mut doc = fixture(); let c = add_annotation( &mut doc, - &new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }), + &new( + 0, + Shape::Callout { + rect: [300.0, 600.0, 500.0, 612.0], + knee: [260.0, 560.0], + point: [200.0, 520.0], + font_size: 12.0, + ending: LineEnding::OpenArrow, + }, + ), &meta("c"), ) .unwrap(); @@ -429,8 +443,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); } #[test] @@ -515,11 +529,24 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() { let tri = vec![[100.0, 100.0], [200.0, 100.0], [150.0, 180.0]]; let i = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: false }), &meta("p")).unwrap(); let c = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: true }), &meta("c")).unwrap(); - let l = - add_annotation(&mut doc, &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]] }), &meta("l")).unwrap(); + let l = add_annotation( + &mut doc, + &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]], start: LineEnding::None, end: LineEnding::None }), + &meta("l"), + ) + .unwrap(); let co = add_annotation( &mut doc, - &new(0, Shape::Callout { rect: [300.0, 500.0, 420.0, 540.0], knee: [260.0, 520.0], point: [220.0, 460.0], font_size: 10.0 }), + &new( + 0, + Shape::Callout { + rect: [300.0, 500.0, 420.0, 540.0], + knee: [260.0, 520.0], + point: [220.0, 460.0], + font_size: 10.0, + ending: LineEnding::OpenArrow, + }, + ), &meta("co"), ) .unwrap(); @@ -576,7 +603,7 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() { for bad in [ Shape::Polygon { vertices: vec![[0.0, 0.0], [10.0, 10.0]], cloud: false }, - Shape::PolyLine { vertices: vec![[0.0, 0.0]] }, + Shape::PolyLine { vertices: vec![[0.0, 0.0]], start: LineEnding::None, end: LineEnding::None }, Shape::Caret { rect: [0.0, 0.0, 0.0, 10.0] }, ] { assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err()); @@ -735,3 +762,93 @@ fn comments_without_appearances_get_one_for_display_only() { ]); assert!(with_missing_appearances(&complete).is_none()); } + +fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict { + let mut d = pdfcraft_cos::Dict::new(); + d.set(b"Subtype".to_vec(), Object::name("Line")); + d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 300.0, 300.0].into_iter().map(Object::Real).collect())); + d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect())); + d.set(b"L".to_vec(), Object::Array(coords.into_iter().map(Object::Real).collect())); + d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name(ending)])); + d +} + +#[test] +fn every_line_ending_is_drawn() { + let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow]; + let (mut open, mut reverse) = (String::new(), String::new()); + for ending in LineEnding::ALL { + let mut doc = fixture(); + add_annotation( + &mut doc, + &new(0, Shape::Line { from: [80.0, 100.0], to: [200.0, 160.0], start: LineEnding::None, end: ending }), + &meta("ending"), + ) + .unwrap(); + let doc = reopen(&doc); + let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); + let content = ap_content(&doc, &line); + assert!(!content.is_empty(), "{ending:?}"); + let paints = content.contains("h B") || content.contains("B\n"); + assert_eq!(paints, filled.contains(&ending), "{ending:?} fill: {content}"); + if ending != LineEnding::None { + let r = rect(&line); + assert!(r[0] < 70.0 && r[2] > 210.0, "{ending:?} rectangle includes the ending: {r:?}"); + } + if ending == LineEnding::OpenArrow { + open = content.clone(); + } + if ending == LineEnding::ROpenArrow { + reverse = content; + } + } + assert_ne!(open, reverse, "a reverse arrow points the other way"); + + let mut doc = fixture(); + let index = add_annotation( + &mut doc, + &new(0, Shape::Line { from: [40.0, 40.0], to: [140.0, 40.0], start: LineEnding::None, end: LineEnding::None }), + &meta("plain"), + ) + .unwrap(); + let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); + set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + let mut doc = reopen(&doc); + let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); + let after = rect(&line); + assert!(after[1] < before[1] - 5.0 && after[3] > before[3] + 5.0, "the square is not clipped: {before:?} → {after:?}"); + assert!(ap_content(&doc, &line).contains("re h B"), "square ending"); + + add_annotation( + &mut doc, + &new(0, Shape::PolyLine { vertices: vec![[40.0, 200.0], [90.0, 260.0], [150.0, 200.0]], start: LineEnding::Circle, end: LineEnding::Slash }), + &meta("poly"), + ) + .unwrap(); + add_annotation( + &mut doc, + &new( + 0, + Shape::Callout { + rect: [300.0, 500.0, 420.0, 540.0], + knee: [260.0, 520.0], + point: [220.0, 460.0], + font_size: 10.0, + ending: LineEnding::Diamond, + }, + ), + &meta("call"), + ) + .unwrap(); + let doc = reopen(&doc); + let all = list(&doc, 0); + let poly = all.iter().find(|d| d.name(b"Subtype") == Some(b"PolyLine")).expect("polyline"); + let poly_ap = ap_content(&doc, poly); + assert!(poly_ap.contains("B\n") && poly_ap.contains(" l S"), "circle fills and the slash only strokes: {poly_ap}"); + let call = all.iter().find(|d| d.name(b"IT") == Some(b"FreeTextCallout")).expect("callout"); + assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice())); + assert!(ap_content(&doc, call).contains("h B"), "diamond callout"); + + assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected"); + assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced"); +} diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml index 7383970b69..74d90c0453 100644 --- a/crates/automation/Cargo.toml +++ b/crates/automation/Cargo.toml @@ -24,5 +24,6 @@ default = ["mcp"] mcp = ["dep:base64"] [dev-dependencies] +pdfcraft-cos = { workspace = true } pdfcraft-xfa = { workspace = true } image = { version = "0.25", default-features = false, features = ["jpeg"] } diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 094bf4984f..ebeab2625c 100644 --- a/crates/automation/src/comments.rs +++ b/crates/automation/src/comments.rs @@ -3,7 +3,9 @@ //! Geometry follows the automation convention: points from the top-left of the displayed page, //! y down. It is converted to PDF user space (crop box, `/Rotate`) here. -use pdfcraft_engine::{Edit, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort}; +use pdfcraft_engine::{ + Edit, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, StampGroup, StampKind, Style, SummarySort, +}; use pdfcraft_render::{Annotation, PageInfo}; use serde_json::{Value, json}; @@ -34,6 +36,33 @@ pub(crate) fn parse_color(s: &str) -> Result { Ok([c(0), c(2), c(4)]) } +const ENDING_NAMES: &str = "None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash"; + +fn line_endings(a: &Args) -> Result>> { + let Some(v) = a.get("endings") else { return Ok(None) }; + let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs("endings must be an array of line-ending names".into()))?; + if arr.is_empty() || arr.len() > 2 { + return Err(ToolError::InvalidArgs("endings takes one name for a callout, or two for a line or polyline".into())); + } + arr.iter() + .map(|item| { + let name = item.as_str().ok_or_else(|| ToolError::InvalidArgs("endings must be line-ending names".into()))?; + LineEnding::parse(name).ok_or_else(|| ToolError::InvalidArgs(format!("unknown line ending {name:?} ({ENDING_NAMES})"))) + }) + .collect::>>() + .map(Some) +} + +/// `[start, end]` for a line, arrow or polyline. An arrow with no `endings` stays `[None, OpenArrow]`. +fn ending_pair(kind: &str, endings: Option<&[LineEnding]>) -> Result<[LineEnding; 2]> { + match endings { + None if kind == "arrow" => Ok([LineEnding::None, LineEnding::OpenArrow]), + None => Ok([LineEnding::None, LineEnding::None]), + Some(v) if v.len() == 2 => Ok([v.first().copied().unwrap_or(LineEnding::None), v.get(1).copied().unwrap_or(LineEnding::None)]), + Some(_) => Err(ToolError::InvalidArgs("a line or polyline needs two endings, the start and the end".into())), + } +} + fn hex(c: [f32; 3]) -> String { let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; format!("#{:02X}{:02X}{:02X}", b(c[0]), b(c[1]), b(c[2])) @@ -181,6 +210,10 @@ impl Automation { let page = self.page(a)?; let info = self.doc(a)?.info.pages[page].clone(); let kind = a.str("type")?; + let endings = line_endings(a)?; + if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") { + return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into())); + } let markup = match kind { "highlight" => Some(Markup::Highlight), "underline" => Some(Markup::Underline), @@ -254,7 +287,8 @@ impl Automation { } "line" | "arrow" => { let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?); - Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), arrow: kind == "arrow" } + let [start, end] = ending_pair(kind, endings.as_deref())?; + Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end } } "ink" => { let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into()); @@ -287,7 +321,10 @@ impl Automation { }) .collect::>>()?; match kind { - "polyline" => Shape::PolyLine { vertices }, + "polyline" => { + let [start, end] = ending_pair(kind, endings.as_deref())?; + Shape::PolyLine { vertices, start, end } + } _ => Shape::Polygon { vertices, cloud: kind == "cloud" }, } } @@ -310,7 +347,17 @@ impl Automation { [(point[0] + side) / 2.0, mid] } }; - Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) } + Shape::Callout { + rect, + knee, + point, + font_size: a.opt_num("font_size")?.unwrap_or(10.0), + ending: match endings.as_deref() { + None => LineEnding::OpenArrow, + Some([ending]) => *ending, + Some(_) => return Err(ToolError::InvalidArgs("a callout needs one ending".into())), + }, + } } "attachment" => { let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?; @@ -406,12 +453,12 @@ impl Automation { let t = format!("{m}/{d}/{yy}"); (text_at(&t), t) } - // A typed signature or initials in the script font, left edge at `at`. + // A typed signature or initials in the script font, left edge at `at`, upright as displayed. kind @ ("signature" | "initials") => { let t = a.str("text")?; let h = if kind == "initials" { 24.0 } else { 32.0 }; - let shape = - pdfcraft_engine::typed_signature_shape(at, t, h).ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?; + let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation)) + .ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?; (shape, String::new()) } kind => { @@ -484,7 +531,7 @@ impl Automation { } let (color, opacity, width) = (a.color("color")?, a.opt_num("opacity")?, a.opt_num("width")?); if color.is_some() || opacity.is_some() || width.is_some() { - edits.push(Edit::StyleAnnotation { page, index, color, opacity, width }); + edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None }); } if let Some(r) = a.nums::<4>("rect")? { edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) }); diff --git a/crates/automation/src/forms.rs b/crates/automation/src/forms.rs index 0203934b7d..eb3f1b381a 100644 --- a/crates/automation/src/forms.rs +++ b/crates/automation/src/forms.rs @@ -189,10 +189,12 @@ impl Automation { "value": match f.kind { FormFieldKind::CheckBox => json!(!f.value.is_empty()), FormFieldKind::List if f.has(field_flags::MULTI_SELECT) => json!(f.value), + FormFieldKind::Radio => json!(f.value.first().map(|v| f.export_for_state(v))), _ => json!(f.value.first()), }, "page": w.and_then(|w| w.page).map(|p| p + 1), "rect": rect, + "rotation": w.map(|w| w.rotation).unwrap_or(0), "read_only": f.read_only(), "required": f.has(field_flags::REQUIRED), }); @@ -225,7 +227,7 @@ impl Automation { } match f.kind { FormFieldKind::Radio => { - o.insert("options".into(), json!(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect::>())); + o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::>())); } FormFieldKind::Combo | FormFieldKind::List => { o.insert("options".into(), f.options.iter().map(|(e, d)| json!({ "value": e, "label": d })).collect()); @@ -393,6 +395,11 @@ impl Automation { out } }, + rotation: match a.opt_int("rotation")? { + None => None, + Some(r @ (0 | 90 | 180 | 270)) => Some((0, r)), + Some(r) => return Err(ToolError::InvalidArgs(format!("rotation must be 0, 90, 180 or 270, not {r}"))), + }, actions: None, check_style: match a.opt_str("check_style")? { None => None, diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 8563e94d8a..1444b313e2 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -1690,7 +1690,7 @@ fn info(d: &Document) -> Value { "links": i.links.iter().map(|l| json!({ "page": page1(l.page), "rect": view_rect(i, l.page, l.rect), "target": match &l.target { - pdfcraft_render::LinkTarget::Page(p) => json!({ "page": page1(*p) }), + pdfcraft_render::LinkTarget::Page(p, _) => json!({ "page": page1(*p) }), pdfcraft_render::LinkTarget::Uri(u) => json!({ "uri": u }), pdfcraft_render::LinkTarget::SetLayers { changes, preserve_rb } => json!({ "layers": changes.iter().map(|(op, ocg)| json!({ diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e088a64f1d..18b7c67e69 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -245,7 +245,7 @@ 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 and rect (top-left-origin points), read-only and required flags.") + 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.") .ro() .with(schema(json!({ "doc": doc() }), &["doc"])), t( @@ -298,7 +298,7 @@ pub fn tools() -> Vec { t( "form_set_props", "Field properties", - "Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.", + "Change a field's properties (General and Options tabs): name (renames it), tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), rect (position), rotation (0, 90, 180 or 270 degrees counterclockwise; changing between horizontal and vertical swaps width and height around the center), format, validate, calculate (Acrobat's Format/Validate/Calculate tabs, run natively: values are checked, formatted and recalculated as in Acrobat). Options tab: align (left, center, right), default (the value Reset form restores; for check boxes and radio buttons their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes and radio buttons: check, circle, cross, diamond, square, star). Only the given ones change. Undoable.", ) .with(schema( json!({ @@ -318,6 +318,7 @@ pub fn tools() -> Vec { "flags": { "type": "object", "additionalProperties": { "type": "boolean" } }, "font_size": { "type": "number", "minimum": 0 }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Move/resize: points from the top-left of the displayed page." }, + "rotation": { "type": "integer", "enum": [0, 90, 180, 270], "description": "Widget rotation in degrees, counterclockwise. Changing between horizontal and vertical swaps the rectangle around its center." }, "appearance": { "type": "object", "description": "Appearance tab: border, fill, text_color (#RRGGBB, a name or \"none\"), width (1 thin, 2 medium, 3 thick), style (solid, dashed, beveled, inset, underline), font (helvetica, times, courier)." }, "format": { "description": "Format tab: {\"type\": \"number\", \"decimals\": 2, \"currency\": \"$\", \"separator\": 0-4, \"negative\": 0-3}, {\"type\": \"percent\"}, {\"type\": \"date\"|\"time\", \"pattern\": \"mm/dd/yyyy\"}, {\"type\": \"zip\"|\"zip4\"|\"phone\"|\"ssn\"}, {\"type\": \"mask\", \"mask\": \"AA-9999\"}, or \"none\"." }, "validate": { "description": "Validate tab: {\"min\": 0, \"max\": 100} (either may be left out) or \"none\"." }, @@ -527,7 +528,7 @@ pub fn tools() -> Vec { "Add a comment", "Add a comment as Acrobat's commenting tools do. Geometry is in points with the origin at the top-left of the displayed page, y down (as in page_render images at 72 dpi and text_find rects). \ note: `at` [x, y] (icon top-left). stamp: `at` [x, y] (its centre) and `stamp`: approved, completed, confidential, draft, final, for comment, for public release, information only, not approved, not for public release, preliminary results, void, accepted, initial here, rejected, sign here, witness; dynamic: true for the dynamic approved/confidential/received/reviewed/revised stamps with a By … at … line. highlight/underline/strikeout/squiggly: `find` (text on the page to mark; every match with all: true) or `quads`. \ - rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. ink: `strokes` [[[x, y], …], …]. \ + rectangle/oval/textbox: `rect` [x0, y0, x1, y1]. line/arrow: `from`, `to`. Optional `endings` (two names: start, end) for a line, arrow or polyline, or one name for a callout: None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash. An arrow without endings is None then OpenArrow. ink: `strokes` [[[x, y], …], …]. \ polygon/cloud/polyline (connected lines): `points` [[x, y], …]. callout: `rect` (its text box), `to` (the point the arrow touches), optional `knee`. caret (inserted text): `at`, the insertion point on the baseline. replace (Replace Text): `find` or `quads` like highlight, `contents` the replacement. attachment: `path` (the file), `at`, optional `icon` (PushPin, Paperclip, Graph, Tag). Undoable.", ) .with(schema( @@ -555,6 +556,7 @@ pub fn tools() -> Vec { "fill": color(), "opacity": { "type": "number", "minimum": 0, "maximum": 1 }, "width": { "type": "number", "minimum": 0, "description": "Line width in points." }, + "endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." }, "font_size": { "type": "number", "exclusiveMinimum": 0 }, }), &["doc", "page", "type"], @@ -998,7 +1000,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese, real Mincho/Gothic fallback outlines matching the requested/source family and available weight (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") + t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for Japanese and other text no standard font can show (Cyrillic, Greek), real Mincho/Gothic fallback outlines matching the requested/source family and available weight, from a face that has every character (the result shows the font used). Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. Undoable.") .cmd("edit.edit_text") .with(schema( json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 }, diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index b935a6f547..f5e3abbfb9 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1698,6 +1698,34 @@ fn preparing_a_form_through_tools() { assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_)))); } +#[test] +fn rotating_a_field_through_tools() { + let dir = workdir("rotate-field"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" })); + let rect_of = |a: &mut Automation| { + let f = &ok(a, "form_fields", json!({ "doc": doc }))["fields"][0]; + (f["rotation"].as_i64().unwrap(), f["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::>()) + }; + let (rot, before) = rect_of(&mut a); + assert_eq!(rot, 0); + assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "City", "rotation": 45 })), Err(ToolError::InvalidArgs(_)))); + assert_eq!(rect_of(&mut a).1, before, "a rejected rotation changes nothing"); + ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 })); + let (rot, turned) = rect_of(&mut a); + assert_eq!(rot, 90); + let (bw, bh) = (before[2] - before[0], before[3] - before[1]); + let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]); + assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}"); + let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0); + let (bc, tc) = (center(&before), center(&turned)); + assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}"); + assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties"); + let (rot, back) = rect_of(&mut a); + assert_eq!((rot, back), (0, before)); +} + #[test] fn redacting_through_tools() { let dir = workdir("redact"); @@ -2132,6 +2160,62 @@ fn image_signatures_stay_upright_on_rotated_pages() { check(&mut a, reopened); } +/// Issue #299: a typed signature and initials read across, as displayed, on every `/Rotate`, +/// anchored at the displayed point, and look the same as on an unturned page. +#[test] +fn typed_signatures_stay_upright_on_rotated_pages() { + let dir = workdir("typed-upright"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap(); + for (page, degrees) in [(2, 90), (3, 180), (4, 270)] { + ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees })); + } + for page in 1..=4 { + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [30, 60], "text": "Ada Lovelace" })); + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "initials", "at": [30, 140], "text": "AL" })); + } + let check = |a: &mut Automation, doc: u64| { + let mut upright = Vec::new(); + for page in 1..=4 { + let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap(); + let Content::Png { data, .. } = &output[0] else { panic!() }; + let pixels = image::load_from_memory(data).unwrap().to_rgba8(); + assert_eq!(pixels.dimensions(), if page % 2 == 1 { (200, 300) } else { (300, 200) }, "page {page}"); + let dark: Vec = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect(); + for y in [60, 140] { + let mut b = [u32::MAX, u32::MAX, 0, 0]; + for (x, py, p) in pixels.enumerate_pixels() { + if py.abs_diff(y) < 40 && p.0[..3].iter().all(|v| *v < 128) { + b = [b[0].min(x), b[1].min(py), b[2].max(x), b[3].max(py)]; + } + } + assert!(b[0] <= b[2], "page {page}: no ink near y = {y}"); + assert!(b[2] - b[0] > b[3] - b[1], "page {page}: the name reads across: {b:?}"); + assert!(b[0].abs_diff(30) <= 2 && b[1] < y && b[3] > y, "page {page}: anchored at (30, {y}): {b:?}"); + } + if page == 1 { + upright = dark; + continue; + } + // Same pixels as the unturned page where both pages are (pages 2 and 4 are wider). + let width = pixels.width(); + let (mut differ, mut ink) = (0, 0); + for y in 0..200 { + for x in 0..200 { + let (want, got) = (upright[(y * 200 + x) as usize], dark[(y * width + x) as usize]); + ink += usize::from(want); + differ += usize::from(want != got); + } + } + assert!(differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page"); + } + }; + check(&mut a, doc); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" })); + let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap(); + check(&mut a, reopened); +} + #[test] fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { let dir = workdir("comment-polish"); @@ -2163,6 +2247,72 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() { assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_)))); } +#[test] +fn line_endings_through_the_tool() { + let dir = workdir("endings"); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["None", "Diamond"] })); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "polyline", "points": [[20, 120], [60, 100], [100, 140]], "endings": ["Circle", "Slash"] }), + ); + ok( + &mut a, + "comment_add", + json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look", "endings": ["Square"] }), + ); + assert!(matches!( + a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["Sparkle", "None"] })), + Err(ToolError::InvalidArgs(_)) + )); + assert!(matches!( + a.call( + "comment_add", + &json!({ "doc": doc, "page": 1, "type": "callout", "rect": [10, 10, 80, 40], "to": [90, 80], "endings": ["None", "None"] }) + ), + Err(ToolError::InvalidArgs(_)) + )); + assert!(matches!( + a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 40, 40], "endings": ["Circle"] })), + Err(ToolError::InvalidArgs(_)) + )); + ok(&mut a, "doc_save", json!({ "doc": doc, "path": "ended.pdf" })); + let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("ended.pdf")).unwrap())).unwrap(); + for name in [b"Diamond".as_slice(), b"Circle", b"Slash", b"Square"] { + assert!( + saved.object_numbers().iter().any(|n| saved.try_get(*n).ok().is_some_and(|o| object_has_name(&o, name))), + "missing /{}", + String::from_utf8_lossy(name) + ); + } + let drawn: Vec = saved + .object_numbers() + .into_iter() + .filter_map(|n| { + let obj = saved.try_get(n).ok()?; + let pdfcraft_cos::Object::Stream(s) = &*obj else { return None }; + if s.dict.name(b"Subtype") != Some(b"Form") { + return None; + } + s.decoded().ok().map(|b| String::from_utf8_lossy(&b).into_owned()) + }) + .collect(); + assert!(drawn.iter().any(|s| s.contains("h B")), "a filled ending was drawn: {drawn:?}"); + assert!(drawn.iter().any(|s| s.contains(" l S")), "an open ending was drawn: {drawn:?}"); +} + +fn object_has_name(obj: &pdfcraft_cos::Object, name: &[u8]) -> bool { + match obj { + pdfcraft_cos::Object::Name(n) => n.as_slice() == name, + pdfcraft_cos::Object::Array(items) => items.iter().any(|o| object_has_name(o, name)), + pdfcraft_cos::Object::Dict(d) => d.iter().any(|(_, o)| object_has_name(o, name)), + pdfcraft_cos::Object::Stream(s) => s.dict.iter().any(|(_, o)| object_has_name(o, name)), + _ => false, + } +} + #[test] fn drawing_comments_through_tools() { let dir = workdir("drawing"); diff --git a/crates/cos/src/bytes.rs b/crates/cos/src/bytes.rs new file mode 100644 index 0000000000..d2dcf23840 --- /dev/null +++ b/crates/cos/src/bytes.rs @@ -0,0 +1,93 @@ +//! Stream data that can share a larger buffer instead of copying out of it. + +use std::ops::{Deref, Range}; +use std::sync::Arc; + +/// A stream's bytes: a buffer of their own, or a range of a larger shared buffer. Streams read +/// from a file point into the file's bytes, which the document keeps anyway, so caching a +/// parsed stream costs its dictionary and not a second copy of its data. +#[derive(Clone, Default)] +pub struct Bytes { + buf: Arc>, + range: Range, +} + +impl Bytes { + /// `range` of `buf`, without copying; `None` when the range is not within `buf`. + pub(crate) fn view(buf: &Arc>, range: Range) -> Option { + (range.start <= range.end && range.end <= buf.len()).then(|| Self { buf: buf.clone(), range }) + } + + /// Whether these bytes keep alive a larger buffer than themselves that isn't `owner`. + pub(crate) fn borrows_other_than(&self, owner: &Arc>) -> bool { + self.range.len() < self.buf.len() && !Arc::ptr_eq(&self.buf, owner) + } + + /// The same bytes, in a buffer of their own. + pub(crate) fn detached(&self) -> Self { + Self::from(self.to_vec()) + } +} + +impl From> for Bytes { + fn from(v: Vec) -> Self { + let range = 0..v.len(); + Self { buf: Arc::new(v), range } + } +} + +impl From>> for Bytes { + fn from(buf: Arc>) -> Self { + let range = 0..buf.len(); + Self { buf, range } + } +} + +impl Deref for Bytes { + type Target = [u8]; + + fn deref(&self) -> &[u8] { + // `view` and `From` only build in-bounds ranges; an empty slice keeps this panic-free. + self.buf.get(self.range.clone()).unwrap_or_default() + } +} + +impl AsRef<[u8]> for Bytes { + fn as_ref(&self) -> &[u8] { + self + } +} + +impl PartialEq for Bytes { + fn eq(&self, other: &Self) -> bool { + (Arc::ptr_eq(&self.buf, &other.buf) && self.range == other.range) || **self == **other + } +} + +impl Eq for Bytes {} + +impl std::fmt::Debug for Bytes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + (**self).fmt(f) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn views_share_their_buffer_and_compare_by_content() { + let file = Arc::new(b"0123456789".to_vec()); + let view = Bytes::view(&file, 2..5).expect("in range"); + assert_eq!(&*view, b"234"); + assert_eq!(view.as_ptr(), file[2..].as_ptr(), "no copy"); + assert_eq!(view, Bytes::from(b"234".to_vec())); + assert!(Bytes::view(&file, 5..11).is_none() && Bytes::view(&file, Range { start: 6, end: 5 }).is_none()); + let other = Arc::new(Vec::new()); + assert!(view.borrows_other_than(&other) && !view.borrows_other_than(&file)); + let own = view.detached(); + assert!(!own.borrows_other_than(&other) && own == view && own.as_ptr() != view.as_ptr()); + assert!(!Bytes::from(file.clone()).borrows_other_than(&other), "a whole buffer is its own"); + } +} diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs index f44d2d24ca..665bebe5f6 100644 --- a/crates/cos/src/document.rs +++ b/crates/cos/src/document.rs @@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex}; use crate::CosError; use crate::object::{Dict, ObjRef, Object}; -use crate::parser::{Lexer, is_whitespace, parse_indirect}; +use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). @@ -509,7 +509,7 @@ impl Document { length }; let off = *offset as usize; - let at = |off: usize| match parse_indirect(&self.data, off, &resolve) { + let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) { Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)), _ => None, }; @@ -564,7 +564,7 @@ impl Document { if p > 0 && !is_whitespace(data[p - 1]) { continue; } - if let Ok((id, o)) = parse_indirect(data, p, &|_| None) + if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None) && id.num == num { found = Some((id, o)); // keep the last (newest) definition @@ -575,9 +575,22 @@ impl Document { // ── editing ───────────────────────────────────────────────────────────────────────────── + /// Stream data still pointing into another document's file would keep that whole file + /// alive for as long as this edit exists: give it a buffer of its own (just its bytes). + fn adopt(&self, obj: Object) -> Object { + match obj { + Object::Stream(mut s) if s.raw.borrows_other_than(&self.data) => { + s.raw = s.raw.detached(); + Object::Stream(s) + } + other => other, + } + } + /// Replace an object (keeping its generation). pub fn set(&mut self, r: ObjRef, obj: impl Into) { - self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj.into()))); + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::Set(r.generation, Arc::new(obj))); if r.num >= self.next_num { self.next_num = r.num + 1; } @@ -587,7 +600,8 @@ impl Document { pub fn add(&mut self, obj: impl Into) -> ObjRef { let r = ObjRef::new(self.next_num, 0); self.next_num += 1; - self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj.into()))); + let obj = self.adopt(obj.into()); + self.overlay.insert(r.num, Slot::Set(0, Arc::new(obj))); r } @@ -1010,7 +1024,7 @@ mod tests { // Either recovered via the endstream search or reported — but never a stack overflow. let o = doc.get(ObjRef::new(3, 0)); if let Object::Stream(s) = o.as_ref() { - assert_eq!(s.raw.as_slice(), b"abc"); + assert_eq!(&s.raw[..], b"abc"); } } diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs index 4cab44d799..29e906e1b1 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -11,12 +11,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod bytes; mod document; mod object; mod parser; mod security; mod writer; +pub use bytes::Bytes; pub use document::{Document, Revision, XrefEntry}; pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream}; pub use parser::{Lexer, parse_indirect}; diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs index 808fdef160..42659f9f4b 100644 --- a/crates/cos/src/object.rs +++ b/crates/cos/src/object.rs @@ -3,11 +3,9 @@ //! Dictionaries keep their key order so rewritten objects stay recognisable in diffs and //! byte-stable when re-serialized. Streams keep their *encoded* bytes; decoding is on demand. -use std::sync::Arc; - use pdfcraft_filters::{Filter, Params}; -use crate::CosError; +use crate::{Bytes, CosError}; /// An indirect reference: object number and generation. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] @@ -160,7 +158,7 @@ impl FromIterator<(Name, Object)> for Dict { #[derive(Clone, Debug, PartialEq)] pub struct Stream { pub dict: Dict, - pub raw: Arc>, + pub raw: Bytes, } /// Upper bound for a single decoded stream (decompression-bomb defence). @@ -169,14 +167,14 @@ pub const MAX_DECODED: usize = 1 << 30; impl Stream { /// A new stream from already-encoded bytes (`/Length` is set at write time). pub fn from_raw(dict: Dict, raw: Vec) -> Self { - Self { dict, raw: Arc::new(raw) } + Self { dict, raw: raw.into() } } /// A new stream holding `data` compressed with Flate. pub fn flate(mut dict: Dict, data: &[u8]) -> Self { dict.set(b"Filter".to_vec(), Object::Name(b"FlateDecode".to_vec())); dict.remove(b"DecodeParms"); - Self { dict, raw: Arc::new(pdfcraft_filters::encode_flate(data)) } + Self { dict, raw: pdfcraft_filters::encode_flate(data).into() } } /// The filter chain declared in the dictionary. @@ -244,7 +242,7 @@ impl Stream { if self.raw.len() > max { return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); } - Ok(self.raw.as_ref().clone()) + Ok(self.raw.to_vec()) } } diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 8bb71398a5..dcb1c58ac5 100644 --- a/crates/cos/src/parser.rs +++ b/crates/cos/src/parser.rs @@ -4,8 +4,10 @@ //! never panics on malformed input (every index is bounds-checked). Depth is limited to defend //! against pathological nesting. -use crate::CosError; +use std::sync::Arc; + use crate::object::{Dict, ObjRef, Object, PdfString, Stream}; +use crate::{Bytes, CosError}; const MAX_DEPTH: usize = 128; @@ -356,6 +358,25 @@ fn hexval(b: u8) -> Option { /// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`. /// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen). pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> { + parse_indirect_in(data, None, offset, resolve_length) +} + +/// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of +/// being copied out of it. +pub(crate) fn parse_indirect_shared( + data: &Arc>, + offset: usize, + resolve_length: &dyn Fn(ObjRef) -> Option, +) -> Result<(ObjRef, Object), CosError> { + parse_indirect_in(data, Some(data), offset, resolve_length) +} + +fn parse_indirect_in( + data: &[u8], + shared: Option<&Arc>>, + offset: usize, + resolve_length: &dyn Fn(ObjRef) -> Option, +) -> Result<(ObjRef, Object), CosError> { let mut lx = Lexer::new(data, offset); lx.skip_ws(); let num_tok = lx.token(); @@ -407,10 +428,15 @@ pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef // Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery). _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start, }; - let raw = data[start..start + len].to_vec(); + let range = start..start + len; + let raw = match shared { + Some(buf) => Bytes::view(buf, range), + None => data.get(range).map(|r| Bytes::from(r.to_vec())), + } + .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream data past the end of the file".into() })?; let mut dict = dict.clone(); dict.set(b"Length".to_vec(), Object::Int(raw.len() as i64)); - return Ok((id, Object::Stream(Stream::from_raw(dict, raw)))); + return Ok((id, Object::Stream(Stream { dict, raw }))); } Ok((id, obj)) } @@ -506,7 +532,7 @@ mod tests { let (id, o) = parse_indirect(data, 0, &|_| None).unwrap(); assert_eq!(id, ObjRef::new(7, 0)); let Object::Stream(s) = o else { panic!("stream expected") }; - assert_eq!(s.raw.as_slice(), b"HELLO"); + assert_eq!(&s.raw[..], b"HELLO"); assert_eq!(s.dict.int(b"Length"), Some(5)); } @@ -515,7 +541,7 @@ mod tests { let data = b"7 0 obj\n<< /Length 8 0 R >>\nstream\r\nAB\r\nendstream\nendobj\n"; let (_, o) = parse_indirect(data, 0, &|r| (r.num == 8).then_some(2)).unwrap(); let Object::Stream(s) = o else { panic!() }; - assert_eq!(s.raw.as_slice(), b"AB"); + assert_eq!(&s.raw[..], b"AB"); } #[test] diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs index 709176b3eb..252b2b4adc 100644 --- a/crates/cos/src/security.rs +++ b/crates/cos/src/security.rs @@ -108,7 +108,7 @@ fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandl _ => StreamKind::Normal, }; let raw = if decrypt { h.decrypt_stream(num, generation, &s.raw, kind) } else { h.encrypt_stream(num, generation, &s.raw, kind) }; - Object::Stream(Stream { dict, raw: std::sync::Arc::new(raw) }) + Object::Stream(Stream { dict, raw: raw.into() }) } other => other.clone(), } diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs index 73311d2289..418891b3cf 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -335,6 +335,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro write_dict(&trailer, &mut out); let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n"); } + // The size isn't known until the end, and the buffer grew by doubling. The result is often + // kept as a document's working bytes for as long as it's open: drop the unused capacity. + out.shrink_to_fit(); Ok(out) } diff --git a/crates/cos/tests/shared_streams.rs b/crates/cos/tests/shared_streams.rs new file mode 100644 index 0000000000..477da65b72 --- /dev/null +++ b/crates/cos/tests/shared_streams.rs @@ -0,0 +1,78 @@ +//! Stream data read from a file points into the file's bytes instead of being copied: the +//! document's object cache holds parsed dictionaries, not a second copy of every stream. + +use std::sync::Arc; + +use pdfcraft_cos::{Document, ObjRef, Object, SaveOptions, write_full, write_incremental}; + +const CONTENT: &[u8] = b"0 0 1 rg 10 10 30 20 re f"; + +fn fixture() -> Vec { + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 100 50] >>".to_string(), + "<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>".to_string(), + format!("<< /Length {} >>\nstream\n{}\nendstream", CONTENT.len(), String::from_utf8_lossy(CONTENT)), + ]; + let mut out = b"%PDF-1.4\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 +} + +const CONTENTS: ObjRef = ObjRef { num: 4, generation: 0 }; + +fn stream_bytes(doc: &Document, r: ObjRef) -> (Vec, *const u8) { + match &*doc.get(r) { + Object::Stream(s) => (s.raw.to_vec(), s.raw.as_ptr()), + other => panic!("{other:?}"), + } +} + +#[test] +fn cached_streams_are_the_files_own_bytes() { + let file = Arc::new(fixture()); + let doc = Document::open(file.clone()).unwrap(); + let (data, at) = stream_bytes(&doc, CONTENTS); + assert_eq!(data, CONTENT); + assert!(file.as_ptr_range().contains(&at), "not a copy"); + // A full save reads and caches every object; the cache still holds no copy of stream data. + let full = write_full(&doc, &SaveOptions::default()).unwrap(); + assert!(file.as_ptr_range().contains(&stream_bytes(&doc, CONTENTS).1)); + // Saves are unchanged: the stream is written as it was read. + let back = Document::open(Arc::new(full)).unwrap(); + assert_eq!(stream_bytes(&back, ObjRef::new(4, 0)).0, CONTENT); +} + +#[test] +fn edits_within_a_document_keep_pointing_into_its_file() { + let file = Arc::new(fixture()); + let mut doc = Document::open(file.clone()).unwrap(); + doc.update_dict(CONTENTS, |d| d.set(b"Marked".to_vec(), Object::Bool(true))).unwrap(); + assert!(file.as_ptr_range().contains(&stream_bytes(&doc, CONTENTS).1)); + let updated = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let back = Document::open(Arc::new(updated)).unwrap(); + assert_eq!(stream_bytes(&back, CONTENTS).0, CONTENT); +} + +#[test] +fn streams_moved_to_another_document_get_their_own_bytes() { + let file = Arc::new(fixture()); + let source = Document::open(file.clone()).unwrap(); + let moved = (*source.get(CONTENTS)).clone(); + let mut target = Document::new_empty(); + let r = target.add(moved); + drop(source); + // The target keeps just the stream's bytes, not the whole source file. + assert_eq!(Arc::strong_count(&file), 1); + assert_eq!(stream_bytes(&target, r).0, CONTENT); +} diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/writer.rs index 41158f10db..4be398bf2e 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -98,7 +98,7 @@ fn incremental_saves_are_allocated_at_their_exact_size() { for original in [table, stream, no_newline] { let mut doc = Document::open(original.clone()).unwrap(); let data: Vec = (0..100_000u32).map(|i| (i % 251) as u8).collect(); - let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()) })); + let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: data.clone().into() })); let updated = write_incremental(&doc, &SaveOptions::default()).unwrap(); assert_eq!(updated.capacity(), updated.len()); assert!(updated.starts_with(&original), "an incremental save only appends"); @@ -112,6 +112,29 @@ fn incremental_saves_are_allocated_at_their_exact_size() { } } +/// A full save grows its buffer as it writes, so it used to end with up to as much again unused, +/// kept alive with the document's working bytes. It now ends at its exact size. +#[test] +fn full_saves_are_allocated_at_their_exact_size() { + let mut doc = Document::open(Arc::new(fixture())).unwrap(); + let data: Vec = (0..100_000u32).map(|i| (i % 251) as u8).collect(); + let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()).into() })); + let root = doc.root().unwrap(); + doc.update_dict(root, |d| d.set(b"Extra".to_vec(), Object::Ref(added))).unwrap(); + for object_streams in [true, false] { + let full = write_full(&doc, &SaveOptions { object_streams, ..SaveOptions::default() }).unwrap(); + assert_eq!(full.capacity(), full.len(), "object streams: {object_streams}"); + let back = Document::open(Arc::new(full.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + let extra = back.dict(&Object::Ref(back.root().unwrap())).and_then(|d| d.get(b"Extra").cloned()).unwrap(); + match &*back.resolve(&extra) { + Object::Stream(s) => assert_eq!(*s.raw, data), + other => panic!("{other:?}"), + } + assert_eq!(hayro_pages(&full), 4); + } +} + #[test] fn qpdf_accepts_object_stream_output() { let Ok(out) = std::process::Command::new("qpdf").arg("--version").output() else { diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index 6537f92dfd..40a2770e54 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -239,6 +239,34 @@ fn is_arabic(c: char) -> bool { matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC) } +/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn +/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no +/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open. +pub fn first_undrawable(t: &AddedText) -> Option { + // Arabic that nothing can draw: there's no face, or the face lacks it. + if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) { + return Some(c); + } + if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) { + // Drawn line by line in the item's standard font (`\n` splits lines). Without the face + // that is also how `draw` redraws an existing item, so it's checked that way. + return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi); + } + // What `arabic_layout` leaves to the standard font, exactly as `draw_arabic` splits it. + wrapped(t).into_iter().find_map(|(line, rtl)| { + let (pieces, _) = arabic_layout(t, &line, rtl).ok()?; + pieces.iter().find_map(|p| match p { + Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s), + Piece::Arabic(_) => None, + }) + }) +} + +/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped). +fn shaped_arabic(c: char) -> bool { + is_arabic(c) && !is_bidi_control(c) +} + /// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn. fn is_bidi_control(c: char) -> bool { matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') @@ -706,6 +734,17 @@ fn validate(c: &Content) -> Result<(), EditError> { if (r[2] - r[0]).abs() < 1.0 { return Err(EditError::Invalid("the text box is too narrow".into())); } + // Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new + // item is refused with what's missing, and one that already holds Arabic stays movable. + // Everything else in it is drawn in the standard font, so it still has to fit. + let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() { + t.text.split('\n').find_map(|line| line.chars().find(|c| !shaped_arabic(*c) && !pdfcraft_fonts::win_ansi_encodable(*c))) + } else { + first_undrawable(t) + }; + if let Some(c) = undrawable { + return Err(crate::undrawable(c)); + } } Content::Image(_) => { if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 { diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7950184eb9..93df6829f3 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -375,6 +375,24 @@ fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String { const END: &str = "EMC\nQ\n"; +/// Refuses page text the standard-14 fonts (WinAnsiEncoding) can't draw: written anyway, each +/// such character would become a `?` on the page (#125). `lines` must split the text the way the +/// caller draws it, so a `\r` that would be drawn is refused too. +pub(crate) fn drawable<'a>(lines: impl IntoIterator) -> Result<(), EditError> { + match lines.into_iter().find_map(pdfcraft_fonts::first_non_win_ansi) { + Some(c) => Err(undrawable(c)), + None => Ok(()), + } +} + +/// The refusal for page text with `c`, which nothing here can draw. +pub(crate) fn undrawable(c: char) -> EditError { + EditError::Invalid(format!( + "the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", + u32::from(c) + )) +} + fn text_op(x: f64, y: f64, text: &str) -> Vec { let mut v = format!("1 0 0 1 {} {} Tm ", n(x), n(y)).into_bytes(); v.extend(literal(&win_ansi(text))); @@ -390,6 +408,9 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, if hf.text.iter().all(|t| t.trim().is_empty()) { return Err(EditError::Invalid("type the header or footer text first".into())); } + // Tokens only add ASCII (Bates prefixes and suffixes are part of the template), so checking + // the templates, split as `expand`'s output is, covers every page. + hf.text.iter().try_for_each(|t| drawable(t.lines()))?; if !(hf.font_size.is_finite() && hf.font_size > 0.0 && hf.font_size <= 200.0 && hf.margins.iter().all(|m| m.is_finite() && *m >= 0.0)) { return Err(EditError::Invalid("invalid font size or margins".into())); } @@ -452,6 +473,9 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac if text.is_empty() && wm.source.is_none() { return Err(EditError::Invalid("type the watermark text first".into())); } + if wm.source.is_none() { + drawable(text.lines())?; + } if !(wm.opacity.is_finite() && wm.rotation.is_finite() && wm.font_size.is_finite() && wm.font_size >= 0.0) { return Err(EditError::Invalid("invalid watermark settings".into())); } @@ -594,7 +618,7 @@ pub use images::{ImageChange, PageImage, change_image, page_images, rect_to_rect pub mod text; pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, replace_block, replace_line, rewrite_block, text_blocks, text_lines}; pub mod added; -pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, list_added, update_content}; +pub use added::{Added, AddedImage, AddedText, Align, Content, Family, add_content, delete_content, first_undrawable, list_added, update_content}; #[cfg(test)] mod tests; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 6e746ca0bc..f7756941bd 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -336,12 +336,22 @@ fn own(doc: &Document, index: usize) -> Vec { fn odd_arabic_text_never_panics() { let mut doc = fixture(); let long = "بسم الله ".repeat(2_000); - for s in - ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ا\u{2029}ب\rc\u{85}د\n", "ﷺ ١٢٣ 456", long.as_str()] - { + for s in ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ﷺ ١٢٣ 456", long.as_str()] { let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() })); assert_eq!(r.is_ok(), pdfcraft_fonts::document_arabic_font().is_some(), "{:?}: {r:?}", s.chars().take(12).collect::()); } + // U+2029 and U+0085 (paragraph and line separators) have no WinAnsi code, so the standard font + // would draw them as `?`: refused, by name, rather than written (#125). Laying it out still + // mustn't panic. + let r = add_content( + &mut doc, + 0, + &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ا\u{2029}ب\rc\u{85}د\n".into(), ..AddedText::default() }), + ); + assert!(r.is_err(), "{r:?}"); + if pdfcraft_fonts::document_arabic_font().is_some() { + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("U+2029") || m.contains("U+0085")), "{r:?}"); + } // A character the face lacks is an error that names it, not a box or a crash. if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') { let r = add_content( @@ -574,6 +584,74 @@ fn japanese_paragraph_uses_unicode_type3_fallback() { assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement); } +/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a +/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1` +/// has the wrong operand count. +#[test] +fn type3_fallback_glyphs_declare_a_well_formed_d1() { + if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") { + return; + } + // Cyrillic is respaced (shifted by its side bearing), and its box must move with it. + let mut glyphs = Vec::new(); + for replacement in ["見本商会 御中", "София 2027"] { + let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET"); + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + glyphs.extend(fallback_paths(&reopen(&doc))); + } + assert!(!glyphs.is_empty()); + for glyph in glyphs { + let text = String::from_utf8(glyph).unwrap(); + let mut lines = text.lines(); + let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect(); + assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}"); + assert_eq!(d1[6], "d1"); + let n: Vec = d1[..6].iter().map(|v| v.parse().unwrap()).collect(); + assert_eq!(n[1], 0.0, "wy"); + assert!(n[2] <= n[4] && n[3] <= n[5], "box: {n:?}"); + for line in lines.filter(|l| l.ends_with(" m") || l.ends_with(" l")) { + let p: Vec = line.split_whitespace().take(2).map(|v| v.parse().unwrap()).collect(); + assert!(p[0] >= n[2] && p[0] <= n[4] && p[1] >= n[3] && p[1] <= n[5], "{line} outside {n:?}"); + } + } +} + +/// Shippori Mincho, the serif fallback, has no Cyrillic: Bulgarian replacement text in a serif +/// line takes a fallback face that has every letter instead of failing on the first one. +#[test] +fn cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters() { + if without_craft_fonts("cyrillic_replacement_in_a_serif_line_uses_a_face_with_the_letters") { + return; + } + let replacement = "София 2027"; + for paragraph in [false, true] { + let mut doc = styled_text_page("ABCDEF+Garamond"); + if paragraph { + text::replace_block(&mut doc, 0, 0, replacement).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + } + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines[0].text, replacement); + // Letters are spaced by their shape, not one em each (the faces' full-width Cyrillic). + let widths = fallback_widths(&doc); + assert!(widths.iter().all(|w| *w > 300.0 && *w < 900.0), "{widths:?}"); + assert!(widths.windows(2).any(|w| w[0] != w[1]), "proportional: {widths:?}"); + } +} + +/// The `/Widths` of the page's Type 3 fallback font, for its Cyrillic letters. +fn fallback_widths(doc: &Document) -> Vec { + let p = pdfcraft_model::pages(doc).swap_remove(0); + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()); + let font = doc.resolve(fonts.as_dict().unwrap().get(b"PCJp").unwrap()); + let font = font.as_dict().unwrap(); + let widths = doc.resolve(font.get(b"Widths").unwrap()).as_array().unwrap().iter().filter_map(|w| w.as_f64()).collect::>(); + // "София 2027": the first five codes are the letters. + widths[..5].to_vec() +} + #[test] fn japanese_paragraph_keeps_ideographic_spaces() { if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") { @@ -1126,3 +1204,67 @@ fn the_graphics_state_carries_over_between_content_streams() { let l = &text::text_lines(&doc, 0).unwrap()[0]; assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}"); } + +/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`, +/// and leave the document untouched. +#[test] +fn page_text_the_standard_fonts_cant_draw_is_refused() { + let mut doc = fixture(); + let before: Vec> = (0..3).map(|p| streams(&reopen(&doc), p)).collect(); + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "日本語のテキスト".into(), ..AddedText::default() }; + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err().to_string(); + assert!(err.contains("\"日\" (U+65E5)"), "{err}"); + let hf = HeaderFooter { + text: ["Ελληνικά".into(), String::new(), String::new(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + assert!(add_header_footer(&mut doc, &[0, 1], &hf, true, &cx()).unwrap_err().to_string().contains("U+0395")); + let wm = Watermark { text: "机密".into(), ..Watermark::default() }; + assert!(add_watermark(&mut doc, &[0], &wm, true).unwrap_err().to_string().contains("U+673A")); + let after: Vec> = (0..3).map(|p| streams(&reopen(&doc), p)).collect(); + assert_eq!(before, after, "a refused edit changes nothing"); + // Western European text, including what only WinAnsi (not Latin-1) has, still works. + let text = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "Café — 5€ ™".into(), ..AddedText::default() }; + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // Retyping existing text is refused the same way, and the whole document is left as it was. + let saved = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let retyped = AddedText { text: "Café 中".into(), ..text.clone() }; + assert!(update_content(&mut doc, 0, 0, &Content::Text(retyped)).unwrap_err().to_string().contains("U+4E2D")); + assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), saved, "a refused update changes nothing"); + // A carriage return is checked where it would be drawn: added text splits on LF only, so + // CRLF would leave a `?`; headers and watermarks split CRLF into lines, but not a lone CR. + let crlf = AddedText { text: "A\r\nB".into(), ..text }; + assert!(add_content(&mut doc, 0, &Content::Text(crlf)).unwrap_err().to_string().contains("U+000D")); + let hf = |t: &str| HeaderFooter { + text: [t.into(), String::new(), String::new(), String::new(), String::new(), String::new()], + ..HeaderFooter::default() + }; + add_header_footer(&mut doc, &[0], &hf("Left\r\n<>"), true, &cx()).unwrap(); + assert!(add_header_footer(&mut doc, &[0], &hf("A\rB"), true, &cx()).unwrap_err().to_string().contains("U+000D")); + // Arabic is shaped with the craft-fonts Arabic face (#403): drawable when that face is built + // in, refused (never written as `?`) when it isn't. Text left to the standard font still counts. + let arabic = |text: &str| AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: text.into(), ..AddedText::default() }; + // U+061C is in the Arabic block but is a direction mark that's dropped, not shaped: it never + // makes text drawable, nor (with the face) undrawable. + assert!(add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}中"))).is_err()); + if pdfcraft_fonts::arabic_has('م') { + add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap(); + add_content(&mut doc, 0, &Content::Text(arabic("\u{061C}Hello"))).unwrap(); + for text in ["مرحبا 中", "\u{061C}中"] { + let err = add_content(&mut doc, 0, &Content::Text(arabic(text))).unwrap_err().to_string(); + assert!(err.contains("U+4E2D"), "{text:?}: {err}"); + } + } else { + // #403's own refusal says what's missing; the Add-text editor still keeps the draft. + let err = add_content(&mut doc, 0, &Content::Text(arabic("مرحبا Hello"))).unwrap_err().to_string(); + assert!(err.contains("CRAFT_FONTS_DIR"), "{err}"); + assert_eq!(first_undrawable(&arabic("مرحبا Hello")), Some('م')); + // An existing item may still take Arabic (#403 keeps it movable), but nothing else that + // the standard font would write as `?`. + let n = list_added(&doc).iter().filter(|a| a.page == 0).count(); + add_content(&mut doc, 0, &Content::Text(arabic("x"))).unwrap(); + let err = update_content(&mut doc, 0, n, &Content::Text(arabic("ب中"))).unwrap_err().to_string(); + assert!(err.contains("U+4E2D"), "{err}"); + update_content(&mut doc, 0, n, &Content::Text(arabic("ب"))).unwrap(); + } +} diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs index 7eb311451e..2fe05ef647 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -538,21 +538,49 @@ fn pdf_num(v: f64) -> String { fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec, f64), EditError> { let glyph = japanese_glyph_from(face, ch).map_err(|e| match e { GlyphError::NoFont => no_japanese_font(), - GlyphError::Missing => EditError::Invalid(format!("Japanese fallback font has no glyph for U+{:04X}", ch as u32)), + GlyphError::Missing => EditError::Invalid(format!("no fallback font has a glyph for U+{:04X}", ch as u32)), GlyphError::TooComplex => EditError::Invalid(format!("Japanese fallback glyph U+{:04X} is too complex", ch as u32)), })?; + let (dx, width) = proportional(ch, &glyph); let scale = 1000.0; - let mut out = format!("{} 0 0 0 0 1000 1000 d1\n", pdf_num(glyph.width * scale)).into_bytes(); + // d1 is `wx wy llx lly urx ury` (ISO 32000-2 §9.6.4) with a box enclosing the glyph; + // Acrobat draws a bullet in place of a glyph whose d1 is malformed. + let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox }; + let mut out = format!( + "{} 0 {} {} {} {} d1\n", + pdf_num(width * scale), + pdf_num(((b[0] + dx) * scale).floor()), + pdf_num((b[1] * scale).floor()), + pdf_num(((b[2] + dx) * scale).ceil()), + pdf_num((b[3] * scale).ceil()) + ) + .into_bytes(); for contour in glyph.contours { let Some(first) = contour.first() else { continue }; - out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes()); + out.extend_from_slice(format!("{} {} m\n", pdf_num((first[0] + dx) * scale), pdf_num(first[1] * scale)).as_bytes()); for p in contour.iter().skip(1) { - out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes()); + out.extend_from_slice(format!("{} {} l\n", pdf_num((p[0] + dx) * scale), pdf_num(p[1] * scale)).as_bytes()); } out.extend_from_slice(b"h\n"); } out.extend_from_slice(b"f\n"); - Ok((out, glyph.width)) + Ok((out, width)) +} + +/// Side bearing (em) given to a respaced glyph on each side. +const SIDE_BEARING: f64 = 0.05; + +/// Shift and advance for a fallback glyph. Japanese faces draw Cyrillic and Greek full-width +/// (one em each, as in JIS X 0208) and have no proportional (`palt`) metrics for them, so a +/// word set that way reads as letter-spaced. Those letters get their ink width plus a side +/// bearing instead; everything else keeps the face's own advance. +fn proportional(ch: char, glyph: &pdfcraft_fonts::GlyphOutline) -> (f64, f64) { + let respace = matches!(ch, '\u{0370}'..='\u{03FF}' | '\u{0400}'..='\u{052F}' | '\u{1F00}'..='\u{1FFF}'); + let ink = glyph.bbox[2] - glyph.bbox[0]; + if !respace || glyph.contours.is_empty() || !(ink > 0.0 && ink < glyph.width) { + return (0.0, glyph.width); + } + (SIDE_BEARING - glyph.bbox[0], ink + 2.0 * SIDE_BEARING) } fn unicode_hex(ch: char) -> String { diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index eb53cfd22c..35576137e5 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -150,6 +150,8 @@ const HELP: Option<&str> = Some("Help"); pub const COMMANDS: &[CommandSpec] = &[ c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"), c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"), + // Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item. + c("file.clear_recent", "Clear Recent Files", None, None, Nothing, "trash-2"), c("file.pin_folder", "Pin folder to Home…", FILE, None, Nothing, "folder-plus"), c("create.blank", "New blank PDF", FILE, None, Nothing, "file-plus-2"), c("measure.distance", "Measure distance", None, None, Annotate, "ruler"), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fb68ea93bd..9ee20873bf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -44,7 +44,7 @@ pub use pdfcraft_forms::{ }; pub use pdfcraft_a11y as a11y; -pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine}; +pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable}; pub use pdfcraft_measure as measure; pub use pdfcraft_xfa::Report as XfaLayout; @@ -69,25 +69,33 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline}; /// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user /// space, vertically centred) and `height` points tall. `None` for text with no outlines. -pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option { +/// +/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`): +/// the box runs along the displayed axes, like an image signature's +/// ([`SignatureImage::rect`]), and the outlines are turned back so the name reads across. +pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option { let o = script_outline(text); let [left, bottom, right, top] = o.bounds(); let span = (top - bottom).max(0.1); let width = right - left; - if o.contours.is_empty() || o.width <= 0.0 { + if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) { return None; } let k = height / span; - let rect = [at[0], at[1] - height / 2.0, at[0] + width * k, at[1] + height / 2.0]; - let contours = o.contours.iter().map(|c| c.iter().map(|p| [(p[0] - left) / width, (p[1] - bottom) / span]).collect()).collect(); + let rect = signature_image::upright_box(rotation, at, width * k, height)?; + // Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box + // is at this fraction of the user-space box. Exact for an unturned page. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)]; + let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect(); Some(Shape::TypedSignature { rect, contours }) } /// Comment geometry helpers (text-box line breaking) for frontends. pub use pdfcraft_annot::appearance as annot_text; pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle}; pub use pdfcraft_annot::{ - AttachIcon, FillMark, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, rect_quad, + AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, + Shape, StampGroup, StampKind, Style, rect_quad, }; pub use pdfcraft_forms::detect; pub use pdfcraft_optimize as optimize; @@ -844,6 +852,8 @@ pub enum Edit { color: Option, opacity: Option, width: Option, + /// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged. + endings: Option>, }, /// Comment properties ▸ General / note icon. SetAnnotationInfo { @@ -1189,7 +1199,7 @@ fn annotation_noun(s: &Shape) -> &'static str { Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline", Shape::Rectangle { .. } => "rectangle", Shape::Oval { .. } => "oval", - Shape::Line { arrow: true, .. } => "arrow", + Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow", Shape::Line { .. } => "line", Shape::Ink { .. } => "drawing", Shape::TextBox { .. } => "text box", @@ -1486,8 +1496,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?, Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, - Edit::StyleAnnotation { page, index, color, opacity, width } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?; + Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { + pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; } Edit::SetAnnotationInfo { page, index, author, subject, icon } => { pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?; diff --git a/crates/engine/src/signature_image.rs b/crates/engine/src/signature_image.rs index fc4c53ea95..d1e3ad5b0a 100644 --- a/crates/engine/src/signature_image.rs +++ b/crates/engine/src/signature_image.rs @@ -68,6 +68,25 @@ impl Document { } } +/// The user-space box `width` x `height` points as displayed on a page turned by `rotation` (its +/// `/Rotate`), with its left edge at `at` (a user-space point) and vertically centred on it. On a +/// quarter-turned page it is `height` wide and `width` tall in user space. `None` for a non-finite +/// point. +pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> { + if !at.iter().chain(&[width, height]).all(|v| v.is_finite()) { + return None; + } + // Right and up as displayed, as user-space vectors: the page's view matrix without its offset. + let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]); + let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]); + Some([ + at[0] + right[0].min(0.0) - up[0].abs(), + at[1] + right[1].min(0.0) - up[1].abs(), + at[0] + right[0].max(0.0) + up[0].abs(), + at[1] + right[1].max(0.0) + up[1].abs(), + ]) +} + /// Saved signatures live in settings, so both source and normalized PNG are bounded. pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20; const MAX_PIXELS: u64 = 4 << 20; @@ -154,16 +173,7 @@ impl SignatureImage { } let [w, h] = self.size; let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64); - let (width, height) = (w as f64 * scale, h as f64 * scale); - // Right and up as displayed, as user-space vectors: the page's view matrix without its offset. - let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]); - let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]); - Some([ - at[0] + right[0].min(0.0) - up[0].abs(), - at[1] + right[1].min(0.0) - up[1].abs(), - at[0] + right[0].max(0.0) + up[0].abs(), - at[1] + right[1].max(0.0) + up[1].abs(), - ]) + upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale) } pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e885a9bd71..1e4973996a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -79,7 +79,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) { assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}"); } - session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None }).unwrap(); + session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap(); check(&session, points); let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from); session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap(); diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index d5965f8bde..ee94566d02 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -39,15 +39,51 @@ pub fn helvetica_width(s: &str, size: f64) -> f64 { units * size / 1000.0 } +/// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one +/// em, half-width forms half of one, and everything else is measured as Helvetica. +pub fn cjk_width(s: &str, size: f64) -> f64 { + let mut buf = [0u8; 4]; + s.chars() + .map(|c| match c { + '\u{ff61}'..='\u{ffdc}' | '\u{ffe8}'..='\u{ffee}' => size * 0.5, + _ if is_wide(c) => size, + _ => helvetica_width(c.encode_utf8(&mut buf), size), + }) + .sum() +} + +/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block). +fn is_wide(c: char) -> bool { + matches!(c, + '\u{1100}'..='\u{115f}' // Hangul Jamo initials + | '\u{2e80}'..='\u{303e}' // CJK radicals, Kangxi, ideographic description, CJK symbols + | '\u{3041}'..='\u{33ff}' // kana, Bopomofo, Hangul compatibility, Kanbun, CJK compatibility + | '\u{3400}'..='\u{4dbf}' // CJK extension A + | '\u{4e00}'..='\u{9fff}' // CJK unified ideographs + | '\u{a000}'..='\u{a4cf}' // Yi + | '\u{ac00}'..='\u{d7a3}' // Hangul syllables + | '\u{f900}'..='\u{faff}' // CJK compatibility ideographs + | '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms + | '\u{ff00}'..='\u{ff60}' // full-width forms + | '\u{ffe0}'..='\u{ffe6}' + | '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes + ) +} + /// Greedy line breaking within `width` points (paragraphs split on newlines; words longer /// than a line are broken by character). pub fn wrap(text: &str, size: f64, width: f64) -> Vec { + wrap_with(text, size, width, helvetica_width) +} + +/// [`wrap`], measuring with `measure` (for example [`cjk_width`]). +pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec { let mut lines = Vec::new(); for para in text.split(['\n', '\r']) { let mut line = String::new(); for word in para.split(' ') { let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if helvetica_width(&candidate, size) <= width || line.is_empty() && helvetica_width(word, size) <= width { + if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width { line = candidate; continue; } @@ -55,7 +91,7 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { lines.push(std::mem::take(&mut line)); } for ch in word.chars() { - if !line.is_empty() && helvetica_width(&format!("{line}{ch}"), size) > width { + if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width { lines.push(std::mem::take(&mut line)); } line.push(ch); @@ -87,6 +123,69 @@ pub fn win_ansi(s: &str) -> Vec { s.chars().map(|c| if c == '\t' { b' ' } else { win_ansi_byte(c).unwrap_or(b'?') }).collect() } +/// The character encoding of a predefined Unicode CMap (`UniJIS-UTF16-H`, `UniGB-UCS2-H`, …; +/// ISO 32000-2 §9.7.5.2), which maps Unicode text straight to codes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnicodeCMap { + Ucs2, + Utf16, + Utf8, + Utf32, +} + +impl UnicodeCMap { + /// The encoding of the predefined CMap `name`, if it is a horizontal Unicode one. Vertical + /// (`-V`) CMaps are refused: field appearances are laid out horizontally, so their glyphs + /// would be drawn stacked down the field instead of across it. + pub fn from_name(name: &[u8]) -> Option { + let name = std::str::from_utf8(name).ok()?; + if !name.starts_with("Uni") || !name.ends_with("-H") { + return None; + } + name.split('-').find_map(|part| match part { + "UCS2" => Some(Self::Ucs2), + "UTF16" => Some(Self::Utf16), + "UTF8" => Some(Self::Utf8), + "UTF32" => Some(Self::Utf32), + _ => None, + }) + } + + /// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?`. + pub fn encode(self, s: &str) -> Vec { + let mut out = Vec::with_capacity(s.len().saturating_mul(2)); + let mut units = [0u16; 2]; + let mut buf = [0u8; 4]; + for c in s.chars() { + let c = if c == '\t' { ' ' } else { c }; + match self { + Self::Ucs2 => { + let unit = u16::try_from(u32::from(c)).unwrap_or(u16::from(b'?')); + out.extend_from_slice(&unit.to_be_bytes()); + } + Self::Utf16 => { + for unit in c.encode_utf16(&mut units) { + out.extend_from_slice(&unit.to_be_bytes()); + } + } + Self::Utf8 => out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()), + Self::Utf32 => out.extend_from_slice(&u32::from(c).to_be_bytes()), + } + } + out + } +} + +/// Whether [`win_ansi`] keeps `c` rather than writing `?` (a tab becomes a space). +pub fn win_ansi_encodable(c: char) -> bool { + c == '\t' || win_ansi_byte(c).is_some() +} + +/// The first character of `s` that [`win_ansi`] can't encode and would write as `?`. +pub fn first_non_win_ansi(s: &str) -> Option { + s.chars().find(|c| !win_ansi_encodable(*c)) +} + /// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped. pub fn literal(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len() + 2); @@ -107,6 +206,18 @@ pub fn literal(bytes: &[u8]) -> Vec { mod tests { use super::*; + #[test] + fn win_ansi_encodability_matches_the_encoder() { + for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] { + assert!(win_ansi_encodable(c), "{c:?}"); + } + for c in ['中', 'あ', 'Ω', 'č', 'ă', '☃', '😀'] { + assert!(!win_ansi_encodable(c), "{c:?}"); + } + assert_eq!(first_non_win_ansi("Café — 5€"), None); + assert_eq!(first_non_win_ansi("Dvořák"), Some('ř')); + } + #[test] fn widths_wrap_and_encode() { assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0); @@ -136,4 +247,26 @@ mod tests { assert_eq!(win_ansi("čć\u{81}\u{8d}\t"), b"???? "); assert_eq!(win_ansi_byte('\0'), None); } + + #[test] + fn unicode_cmaps_encode_cjk_text() { + assert_eq!(UnicodeCMap::from_name(b"UniJIS-UTF16-H"), Some(UnicodeCMap::Utf16)); + assert_eq!(UnicodeCMap::from_name(b"UniGB-UCS2-V"), None); + assert_eq!(UnicodeCMap::from_name(b"UniJIS-UCS2-HW-H"), Some(UnicodeCMap::Ucs2)); + assert_eq!(UnicodeCMap::from_name(b"UniKS-UTF8-H"), Some(UnicodeCMap::Utf8)); + assert_eq!(UnicodeCMap::from_name(b"UniJIS2004-UTF32-H"), Some(UnicodeCMap::Utf32)); + assert_eq!(UnicodeCMap::from_name(b"Identity-H"), None); + assert_eq!(UnicodeCMap::from_name(b"90ms-RKSJ-H"), None); + assert_eq!(UnicodeCMap::from_name(b"\xff\xfe"), None); + assert_eq!(UnicodeCMap::Utf16.encode("令A"), [0x4e, 0xe4, 0x00, 0x41]); + assert_eq!(UnicodeCMap::Utf16.encode("𠀋"), [0xd8, 0x40, 0xdc, 0x0b]); + assert_eq!(UnicodeCMap::Ucs2.encode("𠀋\t"), [0x00, b'?', 0x00, b' ']); + assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes()); + assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]); + assert_eq!(cjk_width("令和", 10.0), 20.0); + assert_eq!(cjk_width("ア", 10.0), 5.0); + assert_eq!(cjk_width("A", 10.0), helvetica_width("A", 10.0)); + let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width); + assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}"); + } } diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml index 3f085ee6cc..ce6a73a595 100644 --- a/crates/forms/Cargo.toml +++ b/crates/forms/Cargo.toml @@ -17,3 +17,4 @@ thiserror = { workspace = true } [dev-dependencies] hayro-syntax = { workspace = true } +pdfcraft-render = { workspace = true } diff --git a/crates/forms/README.md b/crates/forms/README.md index 27459bab82..0fa6d0de04 100644 --- a/crates/forms/README.md +++ b/crates/forms/README.md @@ -39,5 +39,6 @@ delete_field(&mut doc, "email")?; ## Not yet JavaScript actions (format, keystroke, validate, calculate; M6.4/M6.5), rich-text values, -`/MK /R` rotation, the Appearance (colours, fonts), Actions, Format, Validate and Calculate -property tabs, FDF/XFDF import and export (M6.7). +the Appearance (colours, fonts), Actions, Format, Validate and Calculate property tabs, +FDF/XFDF import and export (M6.7). `/MK /R` rotation (0, 90, 180, 270) is drawn into the +appearance. diff --git a/crates/forms/src/appearance.rs b/crates/forms/src/appearance.rs index f13f53f8c9..ea88f67509 100644 --- a/crates/forms/src/appearance.rs +++ b/crates/forms/src/appearance.rs @@ -5,12 +5,18 @@ //! marked-content block as viewers expect. Supported: single-line, multiline (wrapped), comb, //! password, quadding, auto font size (`0 Tf`), combo boxes and list boxes (selection shown). //! -//! The `/DA` font is used when the form's `/DR` defines it as a simple font; text is encoded in -//! WinAnsi. Otherwise (composite fonts, missing resources) Helvetica is used, so text is always -//! visible. Widths use the approximate Helvetica metrics of `pdfcraft-fonts`. +//! The `/DA` font is used when the form's `/DR` defines it as a simple font (text encoded in +//! WinAnsi) or as a composite font with a predefined Unicode CMap such as `UniJIS-UTF16-H`, as +//! Japanese forms use (text encoded in that CMap). Otherwise (other composite fonts, missing +//! resources) Helvetica is used, so text is always visible. Widths use the approximate metrics +//! of `pdfcraft-fonts` (Helvetica, or one em per full-width character in composite fonts). +//! +//! `/MK /R` (0, 90, 180, 270, counterclockwise) lays the content out in the upright box and sets +//! the appearance `/Matrix` so that box lands on the widget rectangle. Any other value is 0, and +//! rotation 0 leaves `/Matrix` off. use pdfcraft_cos::{Dict, Document, Object, Stream}; -use pdfcraft_fonts::{helvetica_width, literal, win_ansi, wrap}; +use pdfcraft_fonts::{UnicodeCMap, cjk_width, helvetica_width, literal, win_ansi, wrap_with}; use crate::{Field, FieldKind, Widget, acroform, flags}; @@ -26,6 +32,57 @@ pub fn fmt(v: f64) -> String { n(v) } +/// `/MK /R` as a quadrant, or 0 when it is missing or not a multiple of 90. +pub(crate) fn mk_rotation(doc: &Document, wd: &Dict) -> i64 { + let Some(raw) = wd.get(b"MK") else { return 0 }; + let mk_obj = doc.resolve(raw); + let Some(mk) = mk_obj.as_dict() else { return 0 }; + let Some(r_obj) = mk.get(b"R") else { return 0 }; + let resolved = doc.resolve(r_obj); + let Some(r) = degrees(&resolved) else { return 0 }; + match r.rem_euclid(360) { + q @ (0 | 90 | 180 | 270) => q, + _ => 0, + } +} + +fn degrees(o: &Object) -> Option { + if let Some(i) = o.as_int() { + return Some(i); + } + let v = o.as_f64()?; + (v.is_finite() && v.abs() <= 1.0e6).then_some(v.round() as i64) +} + +/// Where an appearance is drawn: the upright layout box, and the `/Matrix` that turns it onto +/// the widget rectangle. Rotation 0 has no matrix. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Placement { + pub layout_w: f64, + pub layout_h: f64, + pub matrix: Option<[f64; 6]>, +} + +pub(crate) fn placement(rotation: i64, page_w: f64, page_h: f64) -> Placement { + let (layout_w, layout_h, matrix) = match rotation { + // (x, y) -> (page_w - y, x): text that ran to the right runs upward. + 90 => (page_h, page_w, Some([0.0, 1.0, -1.0, 0.0, page_w, 0.0])), + 180 => (page_w, page_h, Some([-1.0, 0.0, 0.0, -1.0, page_w, page_h])), + // (x, y) -> (y, page_h - x): text that ran to the right runs downward. + 270 => (page_h, page_w, Some([0.0, -1.0, 1.0, 0.0, 0.0, page_h])), + _ => (page_w, page_h, None), + }; + Placement { layout_w, layout_h, matrix } +} + +pub(crate) fn set_form_box(d: &mut Dict, place: Placement) { + let box_ = [0.0, 0.0, place.layout_w, place.layout_h]; + d.set(b"BBox".to_vec(), Object::Array(box_.iter().map(|v| Object::Real(*v)).collect())); + if let Some(m) = place.matrix { + d.set(b"Matrix".to_vec(), Object::Array(m.iter().map(|v| Object::Real(*v)).collect())); + } +} + /// A parsed default appearance string: font resource name, size (0 = auto) and colour operator. #[derive(Clone, Debug, PartialEq)] pub struct Da { @@ -53,8 +110,32 @@ pub fn parse_da(da: &str) -> Da { out } -/// The font resource for `name` from the form's `/DR`, if it is a simple font we can encode for. -fn dr_font(doc: &Document, name: &str) -> Option { +/// How a field font's text is encoded and measured. +#[derive(Clone, Copy, Debug, PartialEq)] +enum FontText { + WinAnsi, + /// A composite font with a predefined Unicode CMap. + Unicode(UnicodeCMap), +} + +impl FontText { + fn encode(self, s: &str) -> Vec { + match self { + Self::WinAnsi => win_ansi(s), + Self::Unicode(cmap) => cmap.encode(s), + } + } + + fn width(self, s: &str, size: f64) -> f64 { + match self { + Self::WinAnsi => helvetica_width(s, size), + Self::Unicode(_) => cjk_width(s, size), + } + } +} + +/// The font resource for `name` from the form's `/DR`, if it is a font we can encode for. +fn dr_font(doc: &Document, name: &str) -> Option<(Object, FontText)> { let af = acroform(doc)?; let dr = doc.resolve(af.get(b"DR")?); let fonts = doc.resolve(dr.as_dict()?.get(b"Font")?); @@ -63,13 +144,17 @@ fn dr_font(doc: &Document, name: &str) -> Option { let fd = font.as_dict()?; match fd.name(b"Subtype") { Some(b"Type1" | b"TrueType" | b"MMType1") => {} + // Japanese, Chinese and Korean forms: a CID font addressed by Unicode (a non-embedded + // `/HeiseiMin-W3` with `/UniJIS-UCS2-H`, say). Embedded CMap streams and Identity-H need + // the font's own mapping, so those still fall back to Helvetica. + Some(b"Type0") => return Some((entry, FontText::Unicode(UnicodeCMap::from_name(fd.name(b"Encoding")?)?))), _ => return None, } // Symbolic fonts (ZapfDingbats, Symbol) can't show WinAnsi text. if matches!(fd.name(b"BaseFont"), Some(b"ZapfDingbats" | b"Symbol")) { return None; } - Some(entry) + Some((entry, FontText::WinAnsi)) } fn helvetica() -> Object { @@ -192,12 +277,15 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri }; let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); - let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); let da = parse_da(wd.get(b"DA").and_then(|o| doc.resolve(o).as_string().map(|s| s.to_text())).as_deref().unwrap_or(&f.da)); - let (font_name, font_obj) = match dr_font(doc, &da.font) { - Some(o) => (da.font.clone(), o), - None => ("Helv".to_string(), helvetica()), + let (font_name, font_obj, enc) = match dr_font(doc, &da.font) { + Some((o, enc)) => (da.font.clone(), o, enc), + None => ("Helv".to_string(), helvetica(), FontText::WinAnsi), }; + let width_of = |text: &str, size: f64| enc.width(text, size); let (mut c, bw) = frame(doc, &wd, width, height); let pad = 2.0 + bw; let inner_w = (width - 2.0 * pad).max(1.0); @@ -205,11 +293,11 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri let mut body: Vec = Vec::new(); let show = |body: &mut Vec, x: f64, y: f64, text: &str| { body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - body.extend(literal(&win_ansi(text))); + body.extend(literal(&enc.encode(text))); body.extend_from_slice(b" Tj\n"); }; let x_for = |text: &str, size: f64| -> f64 { - let tw = helvetica_width(text, size); + let tw = width_of(text, size); match q { 1 => pad + (inner_w - tw) / 2.0, 2 => width - pad - tw, @@ -249,12 +337,12 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri if size == 0.0 { // Auto size: the largest size (≤ 12) whose wrapped lines fit the height. size = 12.0; - while size > 4.0 && wrap(&text, size, inner_w).len() as f64 * size * 1.15 > height - 2.0 * pad { + while size > 4.0 && wrap_with(&text, size, inner_w, width_of).len() as f64 * size * 1.15 > height - 2.0 * pad { size -= 0.5; } } let mut y = height - pad - size * 0.85; - for line in wrap(&text, size, inner_w) { + for line in wrap_with(&text, size, inner_w, width_of) { if y < -size { break; } @@ -264,7 +352,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri } else { if size == 0.0 { size = ((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0); - let tw = helvetica_width(&text, size); + let tw = width_of(&text, size); if tw > inner_w && !comb { size = (size * inner_w / tw).max(4.0); } @@ -276,7 +364,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri let cell = width / cells as f64; for (i, ch) in text.chars().take(cells).enumerate() { let s = ch.to_string(); - show(&mut body, cell * i as f64 + (cell - helvetica_width(&s, size)) / 2.0, y, &s); + show(&mut body, cell * i as f64 + (cell - width_of(&s, size)) / 2.0, y, &s); } } else { show(&mut body, x_for(&text, size), y, &text); @@ -307,7 +395,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(res)); Stream::flate(d, &content) } @@ -318,20 +406,22 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri pub fn check_box_states(doc: &mut Document, w: &Widget, kind: FieldKind, on_name: &str) -> Dict { let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); - let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); + let place = placement(mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); let (frame_c, _) = frame(doc, &wd, width, height); let style = crate::author::CheckStyle::of_widget(doc, &wd, kind); let mut form = |content: String| -> Object { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + set_form_box(&mut d, place); Object::Ref(doc.add(Object::Stream(Stream::flate(d, content.as_bytes())))) }; let s = width.min(height); let mark = check_mark(style, width / 2.0, height / 2.0, s); let mut nd = Dict::new(); - nd.set(on_name.as_bytes().to_vec(), form(format!("{frame_c}{mark}"))); + nd.set(crate::name_bytes(on_name), form(format!("{frame_c}{mark}"))); nd.set(b"Off".to_vec(), form(frame_c)); let mut ap = Dict::new(); ap.set(b"N".to_vec(), Object::Dict(nd)); diff --git a/crates/forms/src/author.rs b/crates/forms/src/author.rs index d51abda730..edf5294c53 100644 --- a/crates/forms/src/author.rs +++ b/crates/forms/src/author.rs @@ -91,6 +91,10 @@ pub struct FieldProps { pub actions: Option>, /// Options tab: the mark of a check box or radio button (every widget). pub check_style: Option, + /// Position tab: rotate one widget (`/MK /R`) to 0, 90, 180 or 270 degrees counterclockwise. + /// Changing between horizontal and vertical swaps that widget's width and height around its + /// center, unless [`Self::rect`] is also set. + pub rotation: Option<(usize, i64)>, } fn invalid(m: impl Into) -> Result { @@ -524,6 +528,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel tab: usize::MAX, locked: false, hidden: false, + rotation: 0, }; let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export); doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; @@ -574,10 +579,12 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { Ok(()) } -fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) { - let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); +fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) { + let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0)); let wobj = doc.get(w.obj); let wd = wobj.as_dict().cloned().unwrap_or_default(); + let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h); + let (width, height) = (place.layout_w, place.layout_h); let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default(); let col = |k: &[u8]| -> Option { let v: Vec = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect(); @@ -590,14 +597,14 @@ fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) { if let Some(bc) = col(b"BC") { c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0)); } - (c, width, height) + (c, place) } -fn form_stream(width: f64, height: f64, content: Vec, resources: Dict) -> Stream { +fn form_stream(place: crate::appearance::Placement, content: Vec, resources: Dict) -> Stream { let mut d = Dict::new(); d.set(b"Type".to_vec(), Object::name("XObject")); d.set(b"Subtype".to_vec(), Object::name("Form")); - d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect())); + crate::appearance::set_form_box(&mut d, place); d.set(b"Resources".to_vec(), Object::Dict(resources)); Stream::flate(d, &content) } @@ -605,7 +612,8 @@ fn form_stream(width: f64, height: f64, content: Vec, resources: Dict) -> St /// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its /// centred caption (unless the layout is icon only, `/TP 1`). fn button_appearance(doc: &Document, w: &Widget) -> Stream { - let (mut c, width, height) = frame_only(doc, w); + let (mut c, place) = frame_only(doc, w); + let (width, height) = (place.layout_w, place.layout_h); let wobj = doc.get(w.obj); let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default(); @@ -646,7 +654,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream { if !xobjects.is_empty() { res.set(b"XObject".to_vec(), Object::Dict(xobjects)); } - form_stream(width, height, content, res) + form_stream(place, content, res) } /// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels): @@ -662,7 +670,8 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, xo.set(b"Im0".to_vec(), Object::Ref(image)); let mut res = Dict::new(); res.set(b"XObject".to_vec(), Object::Dict(xo)); - let icon = doc.add(Object::Stream(form_stream(w, h, format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); + let icon = + doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res))); for wd in &f.widgets { let mut mk = doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default(); @@ -676,14 +685,43 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32, } fn empty_box(doc: &Document, w: &Widget) -> Stream { - let (c, width, height) = frame_only(doc, w); - form_stream(width, height, c.into_bytes(), Dict::new()) + let (c, place) = frame_only(doc, w); + form_stream(place, c.into_bytes(), Dict::new()) +} + +/// Swap width and height, keeping the center. `None` when that is not a usable widget. +fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { + if !r.iter().all(|v| v.is_finite()) { + return None; + } + let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])); + let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let (nw, nh) = (y1 - y0, x1 - x0); + let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0]; + (out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out) } /// Change a field's properties (General and Options tabs) and redraw it. pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result { let all = fields(doc); let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?.clone(); + // Validated before anything is written, so a refused rotation leaves the document unchanged. + // `Some(Some(rect))`: the widget's axis changes and it gets the swapped rectangle. + let mut rotated_rect: Option> = None; + if let Some((wi, rot)) = props.rotation { + if !matches!(rot, 0 | 90 | 180 | 270) { + return invalid("rotation must be 0, 90, 180 or 270 degrees"); + } + let Some(w) = f.widgets.get(wi) else { + return invalid(format!("{name} has no widget {}", wi.saturating_add(1))); + }; + let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0); + rotated_rect = Some(None); + if axis_change && props.rect.is_none() { + let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") }; + rotated_rect = Some(Some(rect)); + } + } // A locked field only takes unlocking (Acrobat greys its properties out). if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) { return invalid(format!("{name} is locked: unlock it first")); @@ -809,7 +847,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result d.set(b"DV".to_vec(), Object::name(v)), + Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))), Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)), Some(None) => { d.remove(b"DV"); @@ -943,6 +981,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result