diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f2730a3..821b7de7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,12 +23,14 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + # No macOS on PRs, main or nightly: the org's 5 macOS slots are kept for release builds, + # and release.yml builds macOS (a failing release is fixed and rebuilt). + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 # The optional craft-fonts build input (Japanese fonts), so the Japanese font tests run on - # Linux; macOS and Windows test the build without it. See craftrules standards/fonts.md. + # Linux; Windows tests the build without it. See craftrules standards/fonts.md. - name: Check out craft-fonts (Linux) if: runner.os == 'Linux' uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0f56341b..afe30062 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 (251) +## In this repository (257) | Asset | Title | Author | Licence | Source | Used for | |---|---|---|---|---|---| @@ -14,9 +14,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese 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 | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/es.tsv` | Spanish 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 | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/fr.tsv` | French 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 | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | +| `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 | @@ -259,6 +263,8 @@ Every asset PdfCraft includes, bundles or uses to build its published material, | `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make | | `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) | +| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py | +| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile | ## Compiled in through dependencies (6) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index 3a1801a2..b33510f3 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 = "50ec1133889f3a3948a3901a9fa40b3499f3c549b605e55848138483946d74da" +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 = "3746a7d87fb98c9f3aeb41c63ce50371c42dfebca64b67ccbb599abb36a93ba8" +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 = "2c09a121e493fb123d9f886529f62268119ca580ad5d5300eca61d4a39216827" +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 = "0ccf62e34424981d5980eeaee8799db2653a4c186807df143326ef9881959006" +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 = "0b579afad6e52b98cef5e61e07e238d9cf76cc7f295395f7e1c855400e5fbaf9" +sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05" [[asset]] path = "crates/ui-egui/src/i18n/fr.tsv" @@ -74,7 +74,18 @@ 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 = "956753e05b5a9e1d4c4a63ee57667cc9fa5bb95288c3d46c6b15435b83d6ed8d" +sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a" + +[[asset]] +path = "crates/ui-egui/src/i18n/de.tsv" +title = "German 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 = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6" [[asset]] path = "crates/ui-egui/src/i18n/te.tsv" @@ -85,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 = "e8b96edd718a1cd212b7440c4ab100f2555c97dccb0cda289bdb1775d5f3aa61" +sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d" [[asset]] path = "crates/ui-egui/src/i18n/ru.tsv" @@ -96,7 +107,29 @@ 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 = "136a9ae206c4ee5aad29d248613c568799080b4b3ee9cd197233072ee9a2983a" +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" +title = "Bulgarian 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 = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" +sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611" [[asset]] path = "crates/ui-egui/src/i18n/ja.tsv" @@ -107,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 = "d2e7114c70da3c52200cf68e9023dc32ff462439795784e49c2ae771531240e2" +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" @@ -3105,6 +3149,29 @@ kind = "font" usage = "Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs)" sha256 = "21808625578fe8d8cd10cb684be546dca077b27cd03a53a2f1ec11dc743c924c" +[[asset]] +path = "packaging/macos/dmg/background.svg" +title = "PdfCraft macOS DMG window background" +author = "@XusBadia" +source = "Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied)" +licence = "MIT OR Apache-2.0" +licence_file = "assets/app-icon/LICENSE.txt" +kind = "image" +usage = "Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py" +sha256 = "e8b9ea9d5e64b7b741c977543c1693825e77d5c4c579eeb674bf5dcd3764cd8a" + + +[[asset]] +path = "packaging/macos/dmg/background.tiff" +title = "PdfCraft macOS DMG window background (rendered)" +author = "@XusBadia" +source = "Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py" +licence = "MIT OR Apache-2.0" +licence_file = "assets/app-icon/LICENSE.txt" +kind = "image" +usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile" +sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" + [[fetched]] file = "text-detection.rten" url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" diff --git a/Cargo.lock b/Cargo.lock index d05e13d8..bc613bb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3477,6 +3477,7 @@ dependencies = [ "hayro-syntax", "pdfcraft-cos", "pdfcraft-fonts", + "pdfcraft-model", "thiserror 2.0.21", ] @@ -3569,6 +3570,7 @@ dependencies = [ "pdfcraft-fonts", "pdfcraft-model", "thiserror 2.0.21", + "unicode-bidi", ] [[package]] @@ -3627,6 +3629,7 @@ dependencies = [ name = "pdfcraft-fonts" version = "0.4.0" dependencies = [ + "harfrust", "pdfcraft-cos", "skrifa 0.44.0", ] @@ -3640,6 +3643,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "pdfcraft-model", + "pdfcraft-render", "thiserror 2.0.21", ] @@ -3746,6 +3750,7 @@ dependencies = [ name = "pdfcraft-render" version = "0.4.0" dependencies = [ + "bytemuck", "getrandom 0.4.3", "hayro", "hayro-syntax", @@ -3753,6 +3758,7 @@ dependencies = [ "log", "lopdf", "pdfcraft-fonts", + "skrifa 0.44.0", "thiserror 2.0.21", ] @@ -3788,6 +3794,7 @@ version = "0.4.0" dependencies = [ "arboard", "base64 0.22.1", + "bytemuck", "eframe", "egui", "egui_extras", @@ -6586,8 +6593,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 37f8fe30..e80a0b46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,8 +75,14 @@ unsafe_code = "forbid" dbg_macro = "warn" todo = "warn" +# `"*"` matches every non-workspace package: crates.io dependencies and the +# vendored crates under `vendor/` (excluded from the workspace). Our own crates +# keep full debug info. Dependencies keep only line tables, so backtraces still +# show file and line, but debug info for the whole dependency graph stays out of +# every test binary (each UI test binary had a 500-600 MB PDB on Windows, #454). [profile.dev.package."*"] opt-level = 2 +debug = "line-tables-only" [profile.dev] opt-level = 1 @@ -92,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/README.md b/README.md index 24db83c6..43342478 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Search the whole document as you type, step through matches with ⌘G ## Navigate long documents -Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. +Bookmarks, page thumbnails and the document's own page labels (i, ii, 1, 2…) keep you oriented in long documents. Search bookmark titles in the Bookmarks panel to find nested entries even when their parents are collapsed. Matches keep their ancestors for context; Clear restores the unfiltered tree without changing its expansion state. @@ -172,6 +172,8 @@ Open **Organize pages** to see every page at once: - *Atomic:* the file is written to a temporary copy, then swapped in. - *Verified:* independently checked with qpdf. +When open documents exceed the window width, scroll over the tab strip with the mouse wheel or trackpad, or use its horizontal scrollbar. Opening or switching to a document brings its tab into view. + Unsaved documents carry a dot on their tab, and closing or quitting asks before anything is lost. Changes are autosaved every minute. If PdfCraft ever quits unexpectedly, it offers to recover your work the next time it opens. Encrypted documents stay encrypted on disk. diff --git a/ROADMAP.md b/ROADMAP.md index 9d6d7412..41ace172 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,7 +79,8 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P 3. **Measure fidelity against Acrobat.** Build a side-by-side harness (render, text, form behaviour) over synthetic fixtures (`plan/acrobat/`, clean-room rules apply). Until then, parity numbers count features, not quality. 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, 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, 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. @@ -90,8 +91,8 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M | Milestone | Est. hours | Done | Remaining (h) | Notes | |---|---|---|---|---|---| | M0 | Skeleton: workspace, xtask gates, CI | 15–30 | 92% | 2–4 | GitHub workflow, `deny.toml` (licence audit of every dependency), parity checklist (826 features, `xtask parity`) done. Missing: remaining crate stubs, testkit/oracle crates | -| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | -| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches). Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | +| M2 | Model, render, text | 200–350 | 15% | 170–300 | hayro bootstrap renderer (vendored patches); inspector predictor scratch is bounded by available input. Text extraction reaches word-F1 0.98 against pdftotext. Missing: model crate, fonts, DisplayList, renderer independent of hayro | +| M1 | COS: filters, crypt, parser, xref, writer | 120–200 | 78% | 25–45 | Done:
- filters and crypt: every standard-security revision R2–R6 (RC4, AES-128/256), SASLprep, permissions, creating encryption;
- cos: parse and repair (including invalid xref object-number ranges), decoded-stream limits including unfiltered data, decrypt on load, re-encrypt on save, incremental and full writing.
Corpus: open/edit/save passes on 958 files, and all 7 password-protected files open.
Full saves now pack objects into compressed object streams. Fuzzing runs nightly (`xtask fuzz`). Missing: ≥ 250 tests, own image codecs | | M3 | Viewer app (native + web) | 80–150 | 88% | 10–19 | Acrobat-style shell, find, select, panels, tiles, web build, UI control channel for agents (opt-in). Missing: 60 fps test on a 500-page document, snapshot tests of every panel Done since: fit visible, document title in the window; Linux middle-button scrolling; shared three-state theme preferences in the toolbar, View menu and control channel. | | M4 | Engine, history, save, organize | 100–180 | 95% | 9–17 | Done:
- command registry (menus, shortcuts and palette all use it);
- undo/redo; incremental, atomic and encrypted saves;
- autosave and crash recovery;
- organize, combine, extract, split and insert-from-file, with identical fonts and images stored once;
- CLI `edit/combine/extract/split`.
Done since: Combine files in its own tab (sortable, resizable columns; folders; early warnings; unlocking protected files), bookmark editing, page labels (Number pages), CLI `run`, Set Page Boxes, Crop tool, Duplicate pages, Ctrl+A / Cmd+A to select all pages in Organize. Missing: Replace pages, page transitions, recovery on the web | | M5 | Comments (all annotation types, XFDF) | 120–200 | 88% | 15–25 | Done: notes, highlight/underline/strikeout/squiggly, text boxes, ink, lines, arrows, rectangles, ovals, stamps (dynamic, Sign Here, Standard Business), with appearance streams; replies, status, checkmarks, locking, move/resize/restyle/delete; properties dialog; panel filter and sort; hide all; summaries (comments only); XFDF/FDF import and export; flatten; Fill & Sign; agent tools. Done since: polygons, connected lines, clouds, callouts, inserted text, colour and checkmark filters; image signature pointer previews, selection handles and live image movement/resizing with proportional corners and independent edge handles. Missing: custom stamps, replace-text proposals, summary layouts with the page | @@ -103,7 +104,8 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t | M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 165–290 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA | | 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, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese and French 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, 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,11 @@ 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%). + - **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%). - **2026-10-08 (M5.7, signature resize proportions):** Image signature corner handles preserve the embedded image's original aspect ratio and anchor the opposite corner; edge midpoint handles stretch width or height independently. The live preview and committed edit share the constrained geometry, including page/view rotation and reopened PDFs whose rectangles were stretched previously. Synthetic UI tests cover all corners, edge resizing, undo and redo. Milestone and effort estimates unchanged. @@ -138,6 +145,20 @@ 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%). +- **2026-10-09 (community, M7, Arabic added text):** Add text writes Arabic into the PDF instead of `?`. The text is shaped with the craft-fonts `Arab` face through harfrust (already in the tree via egui): joining forms, ligatures and mark positions. Each line is laid out in display order by the Unicode bidirectional algorithm, so Arabic mixed with Latin words, European or Arabic-Indic digits and brackets reads correctly; justified right-to-left paragraphs end at the right edge. Each cluster (a letter with its dots or marks) becomes one glyph of a Type 3 font built from the face's outlines, with a ToUnicode entry, so `text_extract`, copy and search get every letter once and no font file is embedded. Wrapping measures shaped words. Each item has its own fonts under names free on the page (a full save renumbers objects but not resource names); updating or deleting the item removes them. Without the face, adding Arabic text returns a clear error and changes nothing, while an existing item holding Arabic still moves and retypes, drawn as `?` as before. Tests cover shaping, display order, ToUnicode, font names and clean-up on one page, and odd input (empty lines, direction controls, lone marks, 18,000 characters). Not yet: bold/italic Arabic, Hebrew, right-to-left typing in the box (misc.rtl-ui), editing existing Arabic text (edit.rtl-cjk-editing). The craft-fonts revision pinned for releases predates its Arabic face, so release builds report the missing font until the pin moves. Overall estimate unchanged (about 30–35%). +- **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 3fe28824..1940d63e 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 00000000..9a86150f --- /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 78d9fb5c..2400fe9e 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 e40607a8..5d567b81 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,77 +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 { - 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. @@ -294,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 @@ -330,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) } @@ -403,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(); @@ -420,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); @@ -428,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"))?; @@ -466,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/contributors/people.toml b/contributors/people.toml index 00beb658..ade46dac 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -59,3 +59,8 @@ sync_github_name = true [people.amitabhthakur] sync_github_name = true + +[people.chent11] +real_name = "Tao Chen" +display_name = "Tao Chen" +sync_github_name = false diff --git a/crates/annot/Cargo.toml b/crates/annot/Cargo.toml index 9603e572..8a313814 100644 --- a/crates/annot/Cargo.toml +++ b/crates/annot/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] pdfcraft-cos = { workspace = true } pdfcraft-fonts = { workspace = true } +pdfcraft-model = { workspace = true } thiserror = { workspace = true } [dev-dependencies] diff --git a/crates/annot/README.md b/crates/annot/README.md index c98cb6d2..4d0fa7c9 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -1,6 +1,6 @@ # pdfcraft-annot -Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends only on `pdfcraft-cos`. +Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`, `pdfcraft-fonts` and `pdfcraft-model`. ## What it does @@ -40,6 +40,10 @@ deterministic; the engine supplies them. never paint a second icon. - Resizing a stamp changes its `/Rect` and preserves its original appearance and resources; viewers scale that appearance into the new rectangle. Locked stamps refuse the edit. +- Natural-size image stamps placed by the engine counterrotate their appearance on rotated + pages. Image restyling retains that appearance matrix. Explicit rectangles, image signatures + and PDF-page stamps still use their existing placement behavior; page-rotation support for + those paths is not complete. ## Not yet diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 6045743f..9a026069 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); - let len = dx.hypot(dy); - if len == 0.0 { - return; + 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 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 @@ -200,6 +254,12 @@ fn form(bbox: [f64; 4], content: &[u8], resources: Dict) -> Stream { Stream::flate(d, content) } +/// The page `/Rotate` an image signature's picture is turned back by (`/PCPictureRotate`, written +/// when the signature is added to a turned page): 0, 90, 180 or 270. +pub(crate) fn picture_turn(d: &Dict) -> i64 { + d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90 +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. pub fn build(d: &Dict) -> Option { // Imported measurement appearances may include leaders, captions and formatting that @@ -334,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)); } @@ -383,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. @@ -444,11 +528,14 @@ pub fn build(d: &Dict) -> Option { return Some(form(rect, c.as_bytes(), res)); } // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { let [x0, y0, x1, y1] = rect; let (w, h) = (x1 - x0, y1 - y0); - let place = if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) } else { let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; @@ -458,7 +545,15 @@ pub fn build(d: &Dict) -> Option { let mut xo = Dict::new(); xo.set(b"Pic".to_vec(), Object::Ref(pic)); res.set(b"XObject".to_vec(), Object::Dict(xo)); - return Some(form(rect, c.as_bytes(), res)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); } // Only PdfCraft's own Fill & Sign marks are drawn here. let name = d.name(b"Name")?; @@ -536,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 2abfdce5..9eb34651 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 { @@ -677,6 +730,16 @@ pub fn signature_image(doc: &Document, page: usize, index: usize) -> Result Result { + let p = page_ref(doc, page)?; + let list = annots(doc, p); + let entry = list.get(index).ok_or(AnnotError::NoSuchAnnotation { page, index })?; + Ok(doc.resolve(entry).as_dict().map_or(0, appearance::picture_turn)) +} + // ── building ──────────────────────────────────────────────────────────────────────────────── /// Annotation flags (§12.5.3). @@ -716,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}")); @@ -755,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 } => { @@ -780,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); @@ -836,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", @@ -933,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); } @@ -968,6 +1040,14 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"PCPicture".to_vec(), Object::Ref(*picture)); d.set(b"PCPictureImage".to_vec(), Object::Bool(*image)); d.set(b"PCPictureSize".to_vec(), num_array(&[size.0, size.1])); + // An image signature's rectangle is in user space; on a turned page its appearance is + // turned back (see `appearance::build`), so it reads upright as displayed. + if *image && matches!(clean.as_str(), "Signature" | "Initials") { + let turn = pdfcraft_model::pages(doc).get(new.page).map_or(0, |p| p.rotation(doc)); + if turn != 0 { + d.set(b"PCPictureRotate".to_vec(), Object::Int(turn)); + } + } } Shape::Ink { strokes } | Shape::Signature { strokes } => { d.set(b"C".to_vec(), rgb(style.color)); @@ -990,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 { .. } => { @@ -1029,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]])); } @@ -1078,14 +1161,57 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R Ok(index) } +/// Counterrotate a newly placed image stamp's appearance into displayed-page axes. +/// The normal appearance's transformed bounding box is fitted to `/Rect` by PDF viewers. +pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotation: i64) -> Result<(), AnnotError> { + let matrix = match rotation { + 90 => [0.0, 1.0, -1.0, 0.0, 0.0, 0.0], + 180 => [-1.0, 0.0, 0.0, -1.0, 0.0, 0.0], + 270 => [0.0, -1.0, 1.0, 0.0, 0.0, 0.0], + _ => return Ok(()), + }; + let (_, r) = annot_ref(doc, page, index)?; + let d = annot_dict(doc, r); + if d.name(b"Subtype") != Some(b"Stamp") || !matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { + return Err(AnnotError::Invalid("only an image stamp can be oriented".into())); + } + let normal = d + .get(b"AP") + .map(|ap| doc.resolve(ap)) + .and_then(|ap| ap.as_dict().and_then(|ap| ap.reference(b"N"))) + .ok_or_else(|| AnnotError::Invalid("the stamp has no normal appearance".into()))?; + let Object::Stream(mut stream) = doc.get(normal).as_ref().clone() else { + return Err(AnnotError::Invalid("the stamp's normal appearance is not a stream".into())); + }; + stream.dict.set(b"Matrix".to_vec(), num_array(&matrix)); + doc.set(normal, Object::Stream(stream)); + Ok(()) +} + /// (Re)generate `/AP /N` for the annotation `r` from its dictionary. /// Regenerate an annotation's normal appearance from its dictionary. pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> { let d = annot_dict(doc, r); let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned(); - let Some(stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) }; + let Some(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) }; + // Image stamp restyling must retain the placement's page-axis correction. + if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) { + let matrix = d.get(b"AP").and_then(|ap| { + let ap = doc.resolve(ap); + let normal = doc.resolve(ap.as_dict()?.get(b"N")?); + let Object::Stream(normal) = normal.as_ref() else { return None }; + normal.dict.get(b"Matrix").cloned() + }); + if let Some(matrix) = matrix { + stream.dict.set(b"Matrix".to_vec(), matrix); + } + } let ap = doc.add(Object::Stream(stream)); - let mut apd = Dict::new(); + // A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover + // appearances would show the previous look on press or hover, so they go with the old /N. + let mut apd = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).unwrap_or_default(); + apd.remove(b"D"); + apd.remove(b"R"); apd.set(b"N".to_vec(), Object::Ref(ap)); doc.update_dict(r, |d| { d.set(b"AP".to_vec(), Object::Dict(apd)); @@ -1469,7 +1595,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, @@ -1477,6 +1604,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)?; @@ -1485,11 +1613,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 { @@ -1516,11 +1651,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 @@ -1666,6 +1845,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)`. @@ -1690,9 +1871,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 3664e7ca..5990e0d0 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -60,7 +60,7 @@ fn list(doc: &Document, page: usize) -> Vec { } fn ap_content(doc: &Document, d: &Dict) -> String { - let n = d.get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")).expect("has /AP /N"); + let n = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.reference(b"N"))).expect("has /AP /N"); let obj = doc.get(n); let Object::Stream(s) = &*obj else { panic!("AP is not a stream") }; String::from_utf8_lossy(&s.decoded().unwrap()).into_owned() @@ -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,160 @@ 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"); +} + +#[test] +fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { + let alternates: [(&[u8], &[u8]); 2] = [(b"R", b"0 0 20 10 re f\n"), (b"D", b"1 1 18 8 re S\n")]; + for indirect in [false, true] { + let mut doc = fixture(); + let first = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("first")).unwrap(); + let other = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [120.0, 10.0, 220.0, 60.0] }), &meta("other")).unwrap(); + let (_, first_ref) = annot_ref(&mut doc, 0, first).unwrap(); + let (_, other_ref) = annot_ref(&mut doc, 0, other).unwrap(); + let mut entries = annot_dict(&doc, first_ref).get(b"AP").unwrap().as_dict().unwrap().clone(); + for (key, bytes) in alternates { + 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(), num_array(&[0.0, 0.0, 20.0, 10.0])); + let r = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d, bytes.to_vec()))); + entries.set(key.to_vec(), Object::Ref(r)); + } + entries.set(b"VendorState".to_vec(), Object::name("Retained")); + let shared = if indirect { Object::Ref(doc.add(Object::Dict(entries))) } else { Object::Dict(entries) }; + for r in [first_ref, other_ref] { + doc.update_dict(r, |d| d.set(b"AP".to_vec(), shared.clone())).unwrap(); + } + let mut doc = reopen(&doc); + let other_dict = list(&doc, 0)[other].clone(); + let source = other_dict.get(b"AP").unwrap(); + let before = doc.resolve(source); + set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + assert_eq!(doc.resolve(source), before, "a shared AP must not change"); + for full in [false, true] { + let bytes = if full { + pdfcraft_cos::write_full(&doc, &SaveOptions::default()).unwrap() + } else { + assert!(!doc.revisions().is_empty(), "incremental save needs an existing revision"); + assert!(!doc.encryption_changed() && !doc.full_save_required(), "this edit must not require a full rewrite"); + let original = doc.bytes(); + let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap(); + assert!(bytes.len() > original.len(), "incremental save appends the changed appearance"); + assert!(bytes.starts_with(original.as_slice()), "incremental save preserves the complete original prefix"); + bytes + }; + hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let doc = Document::open(Arc::new(bytes)).unwrap(); + let all = list(&doc, 0); + let first_ap = doc.resolve(all[first].get(b"AP").unwrap()); + let other_ap = doc.resolve(all[other].get(b"AP").unwrap()); + let first_ap = first_ap.as_dict().unwrap(); + let other_ap = other_ap.as_dict().unwrap(); + assert_ne!(first_ap.get(b"N"), other_ap.get(b"N")); + // The restyled annotation keeps unknown entries, but not down/rollover looks in its + // old colour. + assert_eq!(first_ap.len(), 2); + assert_eq!(other_ap.len(), 4); + for (key, bytes) in alternates { + assert!(first_ap.get(key).is_none(), "stale /{}", String::from_utf8_lossy(key)); + let object = doc.resolve(other_ap.get(key).unwrap()); + let Object::Stream(stream) = &*object else { panic!("alternate appearance") }; + assert_eq!(stream.decoded().unwrap(), bytes); + } + assert_eq!(first_ap.name(b"VendorState"), Some(&b"Retained"[..])); + assert_eq!(other_ap.name(b"VendorState"), Some(&b"Retained"[..])); + assert!(ap_content(&doc, &all[first]).contains("1 0 0 RG")); + assert!(ap_content(&doc, &all[other]).contains("0 0.4 1 RG")); + } + } +} diff --git a/crates/automation/README.md b/crates/automation/README.md index 21dbc3f5..29e9eb9e 100644 --- a/crates/automation/README.md +++ b/crates/automation/README.md @@ -23,7 +23,7 @@ mcp::McpServer::new(a).serve(stdin, stdout)? // newline-delimited JS - Pages and positions are **1-based**. Rectangles are PDF points with the origin at the top-left of the displayed page. - Unknown arguments are rejected, so typos fail loudly. - Tools that change a document return its summary (`doc`, `pages`, `dirty`, `undo`, `redo`, …). -- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document. +- `comment_image_preview` returns JSON and a PNG: `layer: "background"` (the default) gives the page without the selected image signature/initials; `layer: "image"` gives its embedded image with alpha. The metadata supplies its displayed rectangle, document rotation, `image_rotation` (how far clockwise the image is shown turned: 0 for one placed by PdfCraft, which keeps it upright as displayed) and annotation opacity. Each result can be saved with the CLI's `--out`. Cache the background and transform the image for live movement/resizing; `comment_edit` commits the final geometry in one undo step. Previewing never changes the document. - For proportional image signature resizing, use the embedded image layer's width/height ratio rather than a previously stretched annotation rectangle. The GUI preserves this ratio at corners with the opposite corner anchored; edge midpoint handles change only their own axis. `comment_edit` accepts the resulting rectangle. - `doc_close` refuses to drop unsaved changes unless `discard_changes: true`. `doc_save` writes atomically, incrementally in place, and in full for a new path. - With a root set, every read and write path must resolve inside it (symlinks and `..` included). Relative paths resolve inside the root, and `..` is resolved by name before the check. Every path outside the root gets the same refusal (` is outside the allowed directory `), whether or not it exists, so a confined agent can't probe the rest of the disk; on Windows another network share or device path (`\\host\share`, `\\?\UNC\…`, `\\.\…`) is refused without being contacted. "Not found" and other filesystem errors are reported only for paths inside the root. Tools that write several files into a folder name them after the document or input file, with separators, colons and control characters replaced by `_`, so a name can't lead them out of the folder. The root itself must be a folder. diff --git a/crates/automation/src/comments.rs b/crates/automation/src/comments.rs index 4d97e321..ebeab262 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])) @@ -109,6 +138,7 @@ impl Automation { }; Ok(vec![ Content::Json(json!({ "page": page + 1, "index": index + 1, "rect": rect_to_view(info, annotation.rect), "rotation": info.rotation, + "image_rotation": (i64::from(info.rotation) - preview.turn).rem_euclid(360), "layer": layer, "opacity": preview.opacity, "dpi": dpi })), image, ]) @@ -180,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), @@ -253,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()); @@ -286,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" }, } } @@ -309,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)")?; @@ -386,7 +434,7 @@ impl Automation { let path = self.resolve(path, false)?; let file = std::fs::File::open(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?; let image = pdfcraft_engine::SignatureImage::read(file).map_err(|e| ToolError::InvalidArgs(e.to_string()))?; - let edit = image.edit(page, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; + let edit = image.edit(page, &info, at, kind == "initials", &author).ok_or_else(|| ToolError::InvalidArgs("at must be finite".into()))?; return self.apply(a, edit); } let size = 10.0; @@ -405,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 => { @@ -483,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 0203934b..eb3f1b38 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 a74e1a09..2c36d07f 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 73b28960..14039804 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"], @@ -642,7 +644,7 @@ pub fn tools() -> Vec { })), &["doc"], )), - t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") + t("comment_image_preview", "Preview image signature layers", "Return a PNG of the page without the selected image signature/initials (layer=background, default), or its embedded image with alpha (layer=image). Includes the displayed rectangle, document rotation, image_rotation (how far clockwise the image is shown turned) and annotation opacity. Cache these layers for live placement/resizing; commit once with comment_edit. Does not change the document or undo history.") .ro().with(schema(comment_ref(json!({ "layer": { "type": "string", "enum": ["background", "image"], "default": "background" }, "dpi": { "type": "number", "minimum": 1, "maximum": 600, "default": 96 } })), &["doc"])), t("comment_delete", "Delete a comment", "Delete a comment with its pop-up and replies. Undoable.").destructive().with(schema(comment_ref(json!({})), &["doc"])), t( @@ -972,7 +974,7 @@ pub fn tools() -> Vec { .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), - t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole.") + t("page_images", "List page images", "The raster images and grouped Form artwork a page draws (Edit a PDF): number, box (top-left-origin points), kind (image/form), pixel size ([0,0] for forms) and resource name. Forms include nested graphics and are edited as a whole. Numbers count forms and images together in drawing order, so a page with forms numbers its images differently than before forms were listed: always take numbers from a fresh page_images call.") .ro() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])), @@ -990,7 +992,7 @@ pub fn tools() -> Vec { }), &["doc", "page", "image", "action"], )), - t("image_save", "Save image as", "Write one of a page's images to path: JPEG images unchanged, others as PNG (the extension is added when missing).") + t("image_save", "Save image as", "Write one of a page's raster images (number from page_images; forms are refused) to path: JPEG images unchanged, others as PNG (the extension is added when missing).") .destructive() .cmd("edit.edit_text") .with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "image": { "type": "integer", "minimum": 1 }, "path": { "type": "string" } }), &["doc", "page", "image", "path"])), @@ -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 73bcb2c7..a8ecbee4 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -1530,6 +1530,7 @@ fn image_signature_preview_layers_are_read_only_and_survive_encrypted_save() { assert_eq!(meta["layer"], "background"); assert_eq!(image_meta["layer"], "image"); assert_eq!(meta["rotation"], 90); + assert_eq!(meta["image_rotation"], 0, "placed upright as displayed on the turned page"); assert_eq!(meta["opacity"], 0.5); assert_eq!((*width, *height), (120, 40)); assert_eq!(image::load_from_memory(background).unwrap().to_rgba8(), expected, "page text and the other signature remain"); @@ -1742,6 +1743,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"); @@ -1927,6 +1956,47 @@ fn comments_and_form_data_travel_as_xfdf_fdf_and_text() { assert_eq!(r["undo"], "Import all.xfdf"); } +#[test] +fn natural_image_stamps_through_tools_on_rotated_pages() { + for degrees in [0, 90, 180, 270] { + let dir = workdir(&format!("natural-stamps-{degrees}")); + let mut bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut bytes, 80, 40); + encoder.set_color(png::ColorType::Rgb); + encoder.set_depth(png::BitDepth::Eight); + let pixels: Vec = (0..40) + .flat_map(|y| { + (0..80).flat_map(move |x| { + [[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]][usize::from(y >= 20) * 2 + usize::from(x >= 40)] + }) + }) + .collect(); + encoder.write_header().unwrap().write_image_data(&pixels).unwrap(); + } + std::fs::write(dir.join("quadrants.png"), bytes).unwrap(); + let mut a = auto(&dir); + let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap(); + ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":degrees})); + ok(&mut a, "stamp_custom", json!({"doc":doc,"page":1,"path":"quadrants.png","at":[100,100]})); + let rendered = a.call("page_render", &json!({"doc":doc,"page":1,"dpi":72})).unwrap(); + let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") }; + let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap(); + let mut pixels = vec![0; reader.output_buffer_size().unwrap()]; + let info = reader.next_frame(&mut pixels).unwrap(); + assert_eq!(info.color_type, png::ColorType::Rgba); + for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([ + [240, 20, 20, 255], + [20, 180, 20, 255], + [20, 20, 240, 255], + [230, 180, 20, 255], + ]) { + let offset = ((y * info.width + x) * 4) as usize; + assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}"); + } + } +} + #[test] fn stamps_through_tools() { let dir = workdir("stamps"); @@ -2085,6 +2155,112 @@ fn note_icons_anchor_at_the_requested_corner_on_rotated_pages() { } } +/// An image signature is drawn upright as displayed, and anchored at the displayed point asked +/// for, on every `/Rotate`: its picture is placed in user space, which the page turns, so the +/// appearance is counter-rotated and its box is found in user space. +#[test] +fn image_signatures_stay_upright_on_rotated_pages() { + let dir = workdir("image-upright"); + // 80 x 40 px, one colour per quadrant: red and green above, blue and yellow below. + let quadrants = image::RgbaImage::from_fn(80, 40, |x, y| match (x < 40, y < 20) { + (true, true) => image::Rgba([255, 0, 0, 255]), + (false, true) => image::Rgba([0, 255, 0, 255]), + (true, false) => image::Rgba([0, 0, 255, 255]), + (false, false) => image::Rgba([255, 255, 0, 255]), + }); + quadrants.save(dir.join("quadrants.png")).unwrap(); + 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 { + // 64 x 32 pt from x = 60 as displayed, centred on y = 140. + ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [60, 140], "path": "quadrants.png" })); + } + let (white, red, green, blue, yellow) = ([255, 255, 255], [255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]); + let check = |a: &mut Automation, doc: u64| { + 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(); + for (x, y, want, what) in [ + (76, 132, red, "signature top-left"), + (108, 132, green, "signature top-right"), + (76, 148, blue, "signature bottom-left"), + (108, 148, yellow, "signature bottom-right"), + (56, 140, white, "left of the signature"), + (128, 140, white, "right of the signature"), + (92, 120, white, "above the signature"), + (92, 160, white, "below the signature"), + ] { + let got = pixels.get_pixel(x, y).0; + assert!(got[..3].iter().zip(want).all(|(g, w)| g.abs_diff(w) < 12), "page {page}, {what} at ({x}, {y}): {got:?}, want {want:?}"); + } + } + }; + 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); +} + +/// 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"); @@ -2116,6 +2292,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/README.md b/crates/cos/README.md new file mode 100644 index 00000000..b3ac964d --- /dev/null +++ b/crates/cos/README.md @@ -0,0 +1,91 @@ +# pdfcraft-cos + +The PDF object layer (ISO 32000-2 §7): the COS object graph, parsed lazily and tolerantly, edited +through a copy-on-write overlay, and written back incrementally or as a full rewrite. + +- **Layer:** L1, standalone. The layering table lets it use only `pdfcraft-filters`, + `pdfcraft-crypt` and `pdfcraft-geom` (`xtask/src/layers.rs::STANDALONE_DEPS`); it currently uses + the first two. That keeps the parser and the crypto independently auditable and reusable. +- `#![forbid(unsafe_code)]`, and the crate denies `unwrap`/`expect`/`panic!`/`todo!`. Builds for + `wasm32-unknown-unknown`. + +**The object graph is the document model.** Nothing is lifted into a tidier representation and +written back out, because that is how data gets silently dropped. Typed views live above, in +`pdfcraft-model`. + +## API + +```rust +let doc = Document::open(bytes)?; // Arc> +let doc = Document::open_with_password(bytes, Some("owner"))?; // encrypted files + +let root = doc.root(); // Option — the catalog +let obj = doc.get(ObjRef { num: 12, gen: 0 }); // load (and cache): Arc +let real = doc.resolve(&obj); // follow one indirect reference +let d = doc.dict(&obj); // Option, resolving first +let data = stream.decoded()?; // filters applied on demand + +doc.set(r, Object::Dict(d)); // edit: lands in the overlay +let r = doc.add(obj); // append a new object +doc.free(r); + +let bytes = write_incremental(&doc, &SaveOptions::default())?; // append a revision +let bytes = write_full(&doc, &SaveOptions::default())?; // garbage-collected rewrite +``` + +Both writers **return the bytes**; the caller decides where they go, which is what lets the engine +produce a working file in memory after every edit and keep saving atomic. + +Useful alongside: `doc.repair_log()` (what was reconstructed), `doc.revisions()` and +`doc.revision_ends()` (the incremental history), `doc.is_edited(num)` and `doc.modified_objects()`, +`doc.permissions()` and `doc.security()`, `doc.set_encryption()` / `doc.remove_encryption()`, and +`doc.require_full_save()` for edits that cannot be expressed incrementally. + +`Document` is **cheap to clone**: the original bytes, the xref index and the parse caches are +shared, so a clone is a snapshot. Undo/redo and background jobs hold clones while the UI edits +another. Edits never touch the original bytes. + +## Reading + +- **Every cross-reference form:** classic tables with their quirky subsections, cross-reference + streams, hybrid-reference files (`/XRefStm`), and object streams. +- **Repair.** A file whose xref is wrong or missing is reconstructed by scanning for `obj` + headers. Repairs are *recorded*, never silent, so the UI can say the file was damaged. +- **Tolerant, never fatal.** The lexer works on a byte slice and a position; it allocates nothing + for skipped data, bounds-checks every index, and caps nesting depth. Malformed input yields + `CosError`, never a panic — this crate is the first thing a hostile file meets. +- **Lazy.** Objects are parsed on first use and cached. (Whole-file buffering is still the case; + `ByteSource` streaming is `core.lazy-loading`, not yet done.) +- **Encryption** is handled on load and save through `pdfcraft-crypt`: every standard-security + revision R2–R6 (RC4, AES-128/256), crypt filters, `/EFF` embedded-file encryption, and + `/EncryptMetadata`. `security.rs` knows what the spec exempts — the `/Encrypt` dictionary, xref + streams, the trailer `/ID`, and objects inside an object stream (the stream is encrypted whole). + +## Writing + +`write_incremental` appends only the edited objects, a cross-reference section **in the same +style as the file's last one**, and a trailer with `/Prev`. The original bytes are never modified, +which is what keeps a signature over an earlier revision valid. + +`write_full` writes only what the trailer can reach, renumbered densely — the Save As and +garbage-collecting path. By default (`SaveOptions::object_streams`) it packs non-stream objects +into compressed object streams with a cross-reference stream (PDF 1.5+); turning that off writes a +classic table any reader accepts. + +Dictionaries keep their key order and streams keep their *encoded* bytes, so rewritten objects stay +recognisable in a diff and byte-stable when re-serialized. + +## Errors + +`CosError`: `NotPdf`, `Syntax { offset, detail }`, `MissingObject`, `NotADictionary`, `Filter`, +`NeedsPassword`, `WrongPassword`, `Security`, `Poisoned`. + +## Not done here + +- Streaming very large files within a memory budget (`ByteSource`, `core.lazy-loading`); files are + read whole today. +- Linearized output, Fast Web View (`core.linearization`, M11). +- Structural validation against the Arlington model (`core.arlington-validation`), which will live + in the reserved `arlington` crate. +- Image codecs. `pdfcraft-filters` carries the non-image filters; DCT, JPX, JBIG2 and CCITT are + still decoded through the bootstrap renderer (`core.image-filters`, M2.6). diff --git a/crates/cos/src/bytes.rs b/crates/cos/src/bytes.rs new file mode 100644 index 00000000..d2dcf238 --- /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 f44d2d24..665bebe5 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 4cab44d7..f2cdc1a5 100644 --- a/crates/cos/src/lib.rs +++ b/crates/cos/src/lib.rs @@ -5,18 +5,22 @@ //! untouched) or as a full, garbage-collected rewrite. The PDF object graph *is* PdfCraft's //! document model (plan/architecture.md §5, ADR-0010). //! -//! Status (M1 in progress): parsing of all xref forms, object streams, repair by scanning, -//! incremental and full writing. Not yet: encryption (M1.6), object-stream / xref-stream output -//! for full saves, linearization (M11). +//! Done: every cross-reference form (tables, streams, hybrid `/XRefStm`), object streams, repair +//! by scanning, encryption on load and save (R2–R6, crypt filters, `/EFF`), incremental writing, +//! and full saves that pack objects into compressed object streams with a cross-reference stream. +//! Not yet: streaming very large files within a memory budget (`ByteSource`, +//! `core.lazy-loading`), and linearized output (`core.linearization`, M11). See the README. #![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 983b5373..42659f9f 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. @@ -223,19 +221,29 @@ impl Stream { pub fn decoded_within(&self, max: usize) -> Result, CosError> { let chain = self.filters(); if chain.is_empty() { - return Ok(self.raw.as_ref().clone()); + return self.raw_within(max); } pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string())) } - /// Decoded data; any corruption is an error. + /// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error. pub fn decoded_strict(&self) -> Result, CosError> { let chain = self.filters(); if chain.is_empty() { - return Ok(self.raw.as_ref().clone()); + return self.raw_within(MAX_DECODED); } pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string())) } + + fn raw_within(&self, max: usize) -> Result, CosError> { + let max = max.min(MAX_DECODED); + // An empty decoder chain still produces an owned output buffer: check its limit + // before cloning, including streams whose /Crypt filter was applied on load. + if self.raw.len() > max { + return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string())); + } + Ok(self.raw.to_vec()) + } } #[derive(Clone, Debug, PartialEq)] @@ -391,4 +399,55 @@ mod tests { assert_eq!(keys, vec![b"B".to_vec(), b"A".to_vec()]); assert_eq!(d.int(b"B"), Some(3)); } + + #[test] + fn raw_streams_refuse_decoding_past_a_caller_limit() { + let stream = Stream::from_raw(Dict::new(), b"abc".to_vec()); + for max in [0, 2] { + assert!(matches!(stream.decoded_within(max), Err(CosError::Filter(_)))); + } + } + + #[test] + fn empty_filter_arrays_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn crypt_only_streams_refuse_decoding_past_a_caller_limit() { + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::name("Crypt")); + let mut parms = Dict::new(); + parms.set(b"Name".to_vec(), Object::name("Identity")); + dict.set(b"DecodeParms".to_vec(), Object::Dict(parms)); + let stream = Stream::from_raw(dict, b"abc".to_vec()); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + } + + #[test] + fn identity_streams_preserve_bytes_within_a_caller_limit() { + let mut array = Dict::new(); + array.set(b"Filter".to_vec(), Object::Array(Vec::new())); + let mut crypt = Dict::new(); + crypt.set(b"Filter".to_vec(), Object::name("Crypt")); + for dict in [Dict::new(), array, crypt] { + let stream = Stream::from_raw(dict.clone(), b"abc".to_vec()); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_within(usize::MAX).unwrap(), b"abc"); + assert_eq!(stream.decoded().unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + assert!(Stream::from_raw(dict, Vec::new()).decoded_within(0).unwrap().is_empty()); + } + } + + #[test] + fn filtered_streams_preserve_the_caller_limit() { + let stream = Stream::flate(Dict::new(), b"abc"); + assert!(matches!(stream.decoded_within(2), Err(CosError::Filter(_)))); + assert_eq!(stream.decoded_within(3).unwrap(), b"abc"); + assert_eq!(stream.decoded_strict().unwrap(), b"abc"); + } } diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs index 8bb71398..dcb1c58a 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 709176b3..252b2b4a 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 858a3067..418891b3 100644 --- a/crates/cos/src/writer.rs +++ b/crates/cos/src/writer.rs @@ -181,16 +181,17 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, let mut doc = doc.clone(); stamp_mod_date(&mut doc, opts); let original = doc.bytes().clone(); - let mut out = Vec::with_capacity(original.len() + 4096); - out.extend_from_slice(&original); - if !matches!(out.last(), Some(b'\n' | b'\r')) { - out.push(b'\n'); - } + // The revision is written on its own and joined to the original at the end, so the result is + // allocated once at its exact size: growing a buffer that holds a large original past a + // guessed capacity would copy the whole file again and keep up to as much again unused. + let newline = !matches!(original.last(), Some(b'\n' | b'\r')); + let base = (original.len() + usize::from(newline)) as u64; + let mut out = Vec::new(); let mut offsets: BTreeMap = BTreeMap::new(); // num → (offset, gen, in use) for (num, generation, obj) in doc.overlay_entries().collect::>() { match obj { Some(o) => { - offsets.insert(num, (out.len() as u64, generation, true)); + offsets.insert(num, (base + out.len() as u64, generation, true)); write_indirect(num, generation, &prepared(&doc, num, num, generation, &o), &mut out); } None => { @@ -210,16 +211,22 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result, let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32); if as_stream { let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect(); - write_xref_stream(&mut out, &mut trailer, rows, size); + write_xref_stream(&mut out, base, &mut trailer, rows, size); } else { trailer.set(b"Size".to_vec(), Object::Int(size as i64)); - let xref_at = out.len(); + let xref_at = base + out.len() as u64; write_xref_table(&mut out, &offsets, prev.is_none()); out.extend_from_slice(b"trailer\n"); write_dict(&trailer, &mut out); let _ = write!(out, "\nstartxref\n{xref_at}\n%%EOF\n"); } - Ok(out) + let mut file = Vec::with_capacity(original.len() + usize::from(newline) + out.len()); + file.extend_from_slice(&original); + if newline { + file.push(b'\n'); + } + file.extend_from_slice(&out); + Ok(file) } /// Write only reachable objects, renumbered from 1, with a classic cross-reference table. @@ -317,7 +324,7 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result, CosErro ensure_id(&mut trailer, opts, doc.bytes()); } if opts.object_streams { - write_xref_stream(&mut out, &mut trailer, rows, next); + write_xref_stream(&mut out, 0, &mut trailer, rows, next); } else { trailer.set(b"Size".to_vec(), Object::Int(next as i64)); let offsets: BTreeMap = @@ -328,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) } @@ -374,9 +384,10 @@ fn write_xref_table(out: &mut Vec, offsets: &BTreeMap } /// A cross-reference stream section (§7.5.8) that takes object number `num`, ending the file. -/// Rows are Flate-compressed with the PNG Up predictor, and field widths fit the largest value. -fn write_xref_stream(out: &mut Vec, trailer: &mut Dict, mut rows: BTreeMap, num: u32) { - let xref_at = out.len() as u64; +/// `out` holds the file from byte offset `base` on. Rows are Flate-compressed with the PNG Up +/// predictor, and field widths fit the largest value. +fn write_xref_stream(out: &mut Vec, base: u64, trailer: &mut Dict, mut rows: BTreeMap, num: u32) { + let xref_at = base + out.len() as u64; rows.insert(num, Row::InFile(xref_at, 0)); if trailer.get(b"Prev").is_none() { rows.entry(0).or_insert(Row::Free(65535)); diff --git a/crates/cos/tests/shared_streams.rs b/crates/cos/tests/shared_streams.rs new file mode 100644 index 00000000..477da65b --- /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 bfe85882..4be398bf 100644 --- a/crates/cos/tests/writer.rs +++ b/crates/cos/tests/writer.rs @@ -3,7 +3,7 @@ use std::sync::Arc; -use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, write_full, write_incremental}; +use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full, write_incremental}; /// Four pages, a shared font, document info with a non-ASCII title. fn fixture() -> Vec { @@ -87,6 +87,54 @@ fn incremental_saves_stack_on_an_xref_stream_file() { assert_eq!(hayro_pages(&updated), 4); } +/// The revision is joined to the original once, at its exact size. A revision of more than a few +/// kilobytes used to outgrow the buffer: the whole original was copied a second time, and the +/// result kept up to as much again unused for as long as it was the document's working bytes. +#[test] +fn incremental_saves_are_allocated_at_their_exact_size() { + let table = Arc::new(fixture()); + let stream = Arc::new(write_full(&Document::open(table.clone()).unwrap(), &SaveOptions::default()).unwrap()); + let no_newline = Arc::new(fixture().trim_ascii_end().to_vec()); + 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: 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"); + let back = Document::open(Arc::new(updated.clone())).unwrap(); + assert!(back.repair_log().is_empty(), "{:?}", back.repair_log()); + match &*back.get(added) { + Object::Stream(s) => assert_eq!(*s.raw, data), + other => panic!("{other:?}"), + } + assert_eq!(hayro_pages(&updated), 4); + } +} + +/// 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/Cargo.toml b/crates/edit/Cargo.toml index 1aadd633..42733780 100644 --- a/crates/edit/Cargo.toml +++ b/crates/edit/Cargo.toml @@ -14,6 +14,8 @@ pdfcraft-content = { workspace = true } pdfcraft-fonts = { workspace = true } pdfcraft-model = { workspace = true } thiserror = { workspace = true } +# Display order of right-to-left lines in added text; already in the tree via the interface. +unicode-bidi = "0.3" [dev-dependencies] hayro-syntax = { workspace = true } diff --git a/crates/edit/src/added.rs b/crates/edit/src/added.rs index d189dedf..40a2770e 100644 --- a/crates/edit/src/added.rs +++ b/crates/edit/src/added.rs @@ -7,8 +7,11 @@ //! content. Boxes are in display space (origin at the bottom-left of the page as shown, after //! `/Rotate`), so items stay upright on rotated pages. +use std::collections::HashMap; + use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream}; -use pdfcraft_fonts::{helvetica_width, literal, win_ansi}; +use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi}; +use unicode_bidi::{Level, ParagraphBidiInfo}; use crate::{EditError, check, contents, n, page_list, place_tagged}; @@ -195,24 +198,167 @@ fn norm(r: [f64; 4]) -> [f64; 4] { /// The lines of a text item after wrapping to its box width. pub fn lines(t: &AddedText) -> Vec { + wrapped(t).into_iter().map(|(line, _)| line).collect() +} + +/// [`lines`], each with whether its paragraph runs right to left (its first strong character +/// does). +fn wrapped(t: &AddedText) -> Vec<(String, bool)> { let width = (t.rect[2] - t.rect[0]).max(t.size); let mut out = Vec::new(); for para in t.text.split('\n') { - let mut line = String::new(); + let arabic = para.chars().any(is_arabic); + let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl; + // Arabic words are shaped one by one (joining stops at spaces), so a line's width is the + // sum of its words' and shaping stays linear in the paragraph's length. The space is the + // paragraph's; a space between Latin words is drawn in the item's font, a few hundredths + // of an em apart. + let measure = |s: &str| if arabic { arabic_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) }; + let space = measure(" "); + let (mut line, mut line_w) = (String::new(), 0.0); for word in para.split(' ') { let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; - if !line.is_empty() && t.family.width(&candidate, t.size, t.bold) > width { - out.push(std::mem::take(&mut line)); + let candidate_w = if arabic { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) }; + if !line.is_empty() && candidate_w > width { + out.push((std::mem::take(&mut line), rtl)); line = word.to_string(); + line_w = measure(word); } else { line = candidate; + line_w = candidate_w; } } - out.push(line); + out.push((line, rtl)); } out } +/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the +/// craft-fonts Arabic face. +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}') +} + +fn mirrored(c: char) -> char { + match c { + '(' => ')', + ')' => '(', + '[' => ']', + ']' => '[', + '{' => '}', + '}' => '{', + '<' => '>', + '>' => '<', + '«' => '»', + '»' => '«', + other => other, + } +} + +/// A piece of a line with Arabic text, in drawing order. +enum Piece { + /// Shown in the item's own standard font. + Latin(String), + Arabic(Vec), +} + +/// A line of an Arabic item in drawing order (the Unicode bidirectional algorithm, UAX #9), and +/// its width at the item's size. `rtl` is its paragraph's direction. +fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec, f64), EditError> { + // unicode-bidi indexes the first level of a line, so an empty one would panic. + if line.is_empty() { + return Ok((Vec::new(), 0.0)); + } + let base = if rtl { Level::rtl() } else { Level::ltr() }; + let info = ParagraphBidiInfo::new(line, Some(base)); + let (levels, runs) = info.visual_runs(0..line.len()); + let mut pieces = Vec::new(); + for run in runs { + let run_rtl = levels.get(run.start).is_some_and(Level::is_rtl); + // Runs lie on character boundaries; a missing slice is skipped, never a panic. + let Some(text) = line.get(run) else { continue }; + // Arabic letters take the Arabic face; so do spaces and punctuation between them, which the + // shaper then mirrors. Latin text (and what the face lacks) keeps the item's font. + let mut segments: Vec<(bool, String)> = Vec::new(); + for c in text.chars().filter(|c| !is_bidi_control(*c)) { + let arabic = is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)); + match segments.last_mut() { + Some((kind, s)) if *kind == arabic => s.push(c), + _ => segments.push((arabic, c.to_string())), + } + } + if run_rtl { + segments.reverse(); + } + for (arabic, s) in segments { + pieces.push(if arabic { + Piece::Arabic(shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?) + } else if run_rtl { + Piece::Latin(s.chars().rev().map(mirrored).collect()) + } else { + Piece::Latin(s) + }); + } + } + let width = pieces + .iter() + .map(|p| match p { + Piece::Latin(s) => t.family.width(s, t.size, t.bold), + Piece::Arabic(clusters) => clusters.iter().map(|c| c.advance).sum::() * t.size, + }) + .sum(); + Ok((pieces, width)) +} + +/// The width of `s` in an Arabic item; the item's font's estimate if it can't be shaped (drawing +/// then reports why). +fn arabic_width(t: &AddedText, s: &str, rtl: bool) -> f64 { + arabic_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold)) +} + +fn arabic_error(e: GlyphError, text: &str) -> EditError { + EditError::Invalid(match e { + GlyphError::NoFont => "Arabic text needs PdfCraft's Arabic font, which this build doesn't include \ + (to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has an Arab face)" + .into(), + GlyphError::Missing => format!("the Arabic font can't show \"{text}\""), + GlyphError::TooComplex => format!("the Arabic font's glyphs for \"{text}\" are too complex"), + }) +} + /// The box a text item occupies (height from its lines). pub fn text_rect(t: &AddedText) -> [f64; 4] { let r = norm(t.rect); @@ -224,8 +370,196 @@ fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) } -/// Content and resources for an item; `view` maps display space to user space. -fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec, Dict), EditError> { +/// Most glyphs in one Type 3 font: its codes are single bytes, 1–240. +const GLYPHS_PER_FONT: usize = 240; +/// Most Type 3 fonts one item may use, which bounds the objects it adds (ordinary text needs one +/// or two). +const MAX_ARABIC_FONTS: usize = 16; + +/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic glyphs +/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded), +/// with a ToUnicode map for copy and search. Other text uses the item's standard font `latin`. +fn draw_arabic(doc: &mut Document, t: &AddedText, latin: &str, taken: &Dict, fonts: &mut Dict, out: &mut Vec) -> Result<(), EditError> { + let r = text_rect(t); + let lines = wrapped(t); + let laid = lines.iter().map(|(line, rtl)| arabic_layout(t, line, *rtl)).collect::, _>>()?; + // Every distinct cluster (a letter with its dots and the text it stands for) is one glyph of + // one of the fonts, so copy and search get each character exactly once. + let mut keys: Vec<&ShapedCluster> = Vec::new(); + let mut index: HashMap = HashMap::new(); + for c in laid.iter().flat_map(|(pieces, _)| pieces).flat_map(|p| match p { + Piece::Arabic(clusters) => clusters.as_slice(), + Piece::Latin(_) => &[], + }) { + index.entry(cluster_key(c)).or_insert_with(|| { + keys.push(c); + keys.len() - 1 + }); + } + if keys.len() > GLYPHS_PER_FONT * MAX_ARABIC_FONTS { + return Err(EditError::Invalid("the text uses too many different Arabic glyphs; split it into several boxes".into())); + } + // Every outline is read before anything is written, so a failure leaves the document as it was. + let mut outlines = HashMap::new(); + for c in &keys { + for (id, _) in &c.glyphs { + if !outlines.contains_key(id) { + outlines.insert(*id, arabic_glyph(*id).map_err(|e| arabic_error(e, &c.text))?); + } + } + } + let mut names = Vec::new(); + for chunk in keys.chunks(GLYPHS_PER_FONT) { + let font = type3_arabic(doc, chunk, &outlines); + // A name no font on the page has, so items never replace each other's fonts (object + // numbers alone don't do: a full save renumbers objects but keeps resource names). + let mut name = format!("PCAr{}", font.num); + let mut suffix = 0usize; + while taken.contains(name.as_bytes()) || fonts.contains(name.as_bytes()) { + suffix = suffix.saturating_add(1); + name = format!("PCAr{}_{suffix}", font.num); + } + fonts.set(name.clone().into_bytes(), Object::Ref(font)); + names.push(name); + } + let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); + out.extend(format!("BT {} {} {} rg\n", n(cr), n(cg), n(cb)).bytes()); + for (i, ((line, rtl), (pieces, w))) in lines.iter().zip(&laid).enumerate() { + let spaces = line.matches(' ').count(); + // Justified: the spaces of every line but the last stretch to fill the box. + let tw = if t.align == Align::Justify && i + 1 < lines.len() && spaces > 0 { ((r[2] - r[0]) - w).max(0.0) / spaces as f64 } else { 0.0 }; + let x = match t.align { + // A right-to-left paragraph's unstretched last line ends at the right edge. + Align::Justify if *rtl => r[2] - w - tw * spaces as f64, + Align::Left | Align::Justify => r[0], + Align::Center => r[0] + ((r[2] - r[0]) - w) / 2.0, + Align::Right => r[2] - w, + }; + let y = r[3] - (i as f64 * 1.2 + 0.95) * t.size; + let mut pen = x; + let mut current: Option<&str> = None; + for piece in pieces { + match piece { + Piece::Latin(s) => { + out.extend(format!("/{latin} {} Tf {} Tw 1 0 0 1 {} {} Tm ", n(t.size), n(tw), n(pen), n(y)).bytes()); + out.extend(literal(&win_ansi(s))); + out.extend_from_slice(b" Tj\n"); + current = Some(latin); + pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64; + } + Piece::Arabic(clusters) => { + for c in clusters { + let Some(&k) = index.get(&cluster_key(c)) else { continue }; + let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue }; + // Codes start at 1; k % 240 + 1 is at most 240. + let code = u8::try_from(k % GLYPHS_PER_FONT + 1).unwrap_or(1); + if current != Some(name.as_str()) { + out.extend(format!("/{name} {} Tf ", n(t.size)).bytes()); + current = Some(name.as_str()); + } + out.extend(format!("1 0 0 1 {} {} Tm ", n(pen), n(y)).bytes()); + out.extend(literal(&[code])); + out.extend_from_slice(b" Tj\n"); + pen += c.advance * t.size + if c.text == " " { tw } else { 0.0 }; + } + } + } + } + } + out.extend_from_slice(b"ET\n"); + Ok(()) +} + +/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit; +/// equal clusters come from the same font units). +type ClusterKey<'a> = (&'a str, Vec<(u32, u64, u64)>, u64); + +fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> { + (c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits()) +} + +/// A Type 3 font with `clusters` as codes 1…, each drawn from the Arabic face's glyph `outlines`. +fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap) -> ObjRef { + let mut charprocs = Dict::new(); + let mut differences = vec![Object::Int(1)]; + let mut widths = Vec::with_capacity(clusters.len()); + let mut bbox = [0.0f64; 4]; + let mut to_unicode = Vec::new(); + for (i, c) in clusters.iter().enumerate() { + let code = i + 1; + // Glyph space is 1000 units per em. + let mut paths = Vec::new(); + let mut ink: Option<[f64; 4]> = None; + for (id, [dx, dy]) in &c.glyphs { + let Some(g) = outlines.get(id) else { continue }; + for contour in &g.contours { + for (j, p) in contour.iter().enumerate() { + paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes()); + } + paths.extend_from_slice(b"h\n"); + let b = [g.bbox[0] + dx, g.bbox[1] + dy, g.bbox[2] + dx, g.bbox[3] + dy]; + ink = Some(ink.map_or(b, |a| [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])])); + } + } + // Rounded outwards so no ink is clipped. + let [x0, y0, x1, y1] = + ink.map_or([0.0; 4], |b| [(b[0] * 1000.0).floor(), (b[1] * 1000.0).floor(), (b[2] * 1000.0).ceil(), (b[3] * 1000.0).ceil()]); + bbox = [bbox[0].min(x0), bbox[1].min(y0), bbox[2].max(x1), bbox[3].max(y1)]; + let mut proc = format!("{} 0 {} {} {} {} d1\n", n(c.advance * 1000.0), n(x0), n(y0), n(x1), n(y1)).into_bytes(); + if !paths.is_empty() { + proc.extend(paths); + proc.extend_from_slice(b"f\n"); + } + let glyph = format!("g{code:02X}"); + charprocs.set(glyph.clone().into_bytes(), Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), &proc))))); + differences.push(Object::name(&glyph)); + widths.push(Object::Real((c.advance * 1000.0).round())); + if !c.text.is_empty() { + let hex: String = c.text.encode_utf16().map(|u| format!("{u:04X}")).collect(); + to_unicode.push(format!("<{code:02X}> <{hex}>\n")); + } + } + let mut cmap = String::from( + "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> \nendcodespacerange\n", + ); + // A CMap block holds at most 100 entries. + for block in to_unicode.chunks(100) { + cmap.push_str(&format!("{} beginbfchar\n{}endbfchar\n", block.len(), block.concat())); + } + cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"); + let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes()))); + let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |f| f.family); + let mut descriptor = Dict::new(); + descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor")); + descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', ""))); + descriptor.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(family.as_bytes().to_vec()))); + descriptor.set(b"Flags".to_vec(), Object::Int(4)); + descriptor.set(b"ItalicAngle".to_vec(), Object::Int(0)); + let mut encoding = Dict::new(); + encoding.set(b"Type".to_vec(), Object::name("Encoding")); + encoding.set(b"Differences".to_vec(), Object::Array(differences)); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type3")); + font.set(b"FontDescriptor".to_vec(), Object::Ref(doc.add(Object::Dict(descriptor)))); + font.set(b"FontBBox".to_vec(), Object::Array(bbox.iter().map(|v| Object::Real(*v)).collect())); + font.set( + b"FontMatrix".to_vec(), + Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]), + ); + font.set(b"FirstChar".to_vec(), Object::Int(1)); + font.set(b"LastChar".to_vec(), Object::Int(clusters.len() as i64)); + font.set(b"Widths".to_vec(), Object::Array(widths)); + font.set(b"Encoding".to_vec(), Object::Dict(encoding)); + font.set(b"CharProcs".to_vec(), Object::Dict(charprocs)); + font.set(b"ToUnicode".to_vec(), Object::Ref(cmap)); + doc.add(Object::Dict(font)) +} + +/// Content and resources for an item; `view` maps display space to user space. `taken` holds the +/// page's font names. `existing`: the item is being replaced, so a build without the Arabic face +/// keeps drawing its Arabic as `?`, as before Arabic was supported, rather than refusing to move it. +fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing: bool) -> Result<(Vec, Dict), EditError> { let mut res = Dict::new(); let mut out = format!("q {} {} {} {} {} {} cm\n", n(view[0]), n(view[1]), n(view[2]), n(view[3]), n(view[4]), n(view[5])).into_bytes(); match c { @@ -241,6 +575,12 @@ fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec, Dict), } let mut fonts = Dict::new(); fonts.set(name.clone().into_bytes(), Object::Dict(font)); + if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) { + draw_arabic(doc, t, &name, taken, &mut fonts, &mut out)?; + res.set(b"Font".to_vec(), Object::Dict(fonts)); + out.extend_from_slice(b"Q\n"); + return Ok((out, res)); + } res.set(b"Font".to_vec(), Object::Dict(fonts)); let r = text_rect(t); let [cr, cg, cb] = t.color.map(|v| v.clamp(0.0, 1.0)); @@ -394,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 { @@ -410,9 +761,15 @@ fn write(doc: &mut Document, page: usize, c: &Content, obj: Option) -> R let all = page_list(doc); check(&[page], all.len())?; let p = &all[page]; - let (content, res) = draw(doc, c, p.view_matrix(doc))?; - // The page gets its own copy of its resources with the item's fonts and images added. + let view = p.view_matrix(doc); + // The page gets its own copy of its resources with the item's fonts and images added, and + // without the Arabic fonts of the version it replaces. let mut pres = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); + if let Some(r) = obj { + drop_fonts(doc, &mut pres, &own_fonts(doc, r)); + } + let taken = pres.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); + let (content, res) = draw(doc, c, view, &taken, obj.is_some())?; for (k, v) in res.iter() { let mut sub = pres.get(k).map(|s| doc.resolve(s)).and_then(|s| s.as_dict().cloned()).unwrap_or_default(); for (n2, o) in v.as_dict().into_iter().flat_map(|d| d.iter()) { @@ -497,6 +854,40 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<( let a = find(doc, page, index)?; let p = page_list(doc).swap_remove(page); let list: Vec = contents(doc, &p)?.into_iter().filter(|o| o.as_ref() != Some(a.obj)).collect(); - doc.update_dict(p.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?; + let fonts = own_fonts(doc, a.obj); + // Only the page's own resources carry an item's fonts (`write` gives the page a copy). + let res = match p.dict.get(b"Resources") { + Some(Object::Dict(d)) if !fonts.is_empty() => Some(d.clone()), + _ => None, + }; + let res = res.map(|mut r| { + drop_fonts(doc, &mut r, &fonts); + r + }); + doc.update_dict(p.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(list)); + if let Some(r) = res { + d.set(b"Resources".to_vec(), Object::Dict(r)); + } + })?; Ok(()) } + +/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_arabic` +/// names them so), so they go when the item is replaced or deleted. +fn own_fonts(doc: &Document, obj: ObjRef) -> Vec> { + let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() }; + let Ok(data) = s.decoded() else { return Vec::new() }; + data.split(u8::is_ascii_whitespace).filter_map(|w| w.strip_prefix(b"/")).filter(|w| w.starts_with(b"PCAr")).map(<[u8]>::to_vec).collect() +} + +fn drop_fonts(doc: &Document, res: &mut Dict, names: &[Vec]) { + if names.is_empty() { + return; + } + let Some(mut fonts) = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()) else { return }; + for name in names { + fonts.remove(name); + } + res.set(b"Font".to_vec(), Object::Dict(fonts)); +} diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 7950184e..93df6829 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 a06781b6..2d7cfd1d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -227,6 +227,142 @@ fn added_text_and_images_are_page_content_that_stays_editable() { assert!(marks_present(&doc).is_empty()); } +#[test] +fn added_arabic_text_is_shaped_in_display_order_and_stays_searchable() { + let mut doc = fixture(); + let text = + AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا World".into(), size: 14.0, align: Align::Right, ..AddedText::default() }; + if pdfcraft_fonts::document_arabic_font().is_none() { + eprintln!("skipping the Arabic drawing checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)"); + let err = add_content(&mut doc, 0, &Content::Text(text)).unwrap_err(); + assert!(matches!(&err, EditError::Invalid(m) if m.contains("CRAFT_FONTS_DIR")), "{err:?}"); + assert!(!doc.is_modified(), "nothing is written when the font is missing"); + return; + } + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + // A second Arabic item on the same page keeps its own fonts. + add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap(); + let doc = reopen(&doc); + let added = list_added(&doc); + let Content::Text(t) = &added[0].content else { panic!() }; + assert_eq!(t.text, "مرحبا World", "the logical text is kept for editing"); + let s = streams(&doc, 0); + let item = s.iter().find(|s| s.contains("(World) Tj")).unwrap(); + // A right-to-left paragraph: "World" is drawn left of the Arabic word. + let x_before = |s: &str, at: usize| -> f64 { + let tm = s[..at].rfind("1 0 0 1 ").unwrap() + 8; + s[tm..].split(' ').next().unwrap().parse().unwrap() + }; + let latin_x = x_before(item, item.find("(World) Tj").unwrap()); + let arabic_at = item.find("/PCAr").unwrap(); + let arabic_x = x_before(item, item[arabic_at..].find(" Tj").unwrap() + arabic_at); + assert!(latin_x < arabic_x, "{item}"); + // The Arabic glyphs come from Type 3 fonts whose ToUnicode maps give back each letter. + let p = &pdfcraft_model::pages(&doc)[0]; + let res = doc.resolve(p.dict.get(b"Resources").unwrap()); + let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap()).as_dict().unwrap().clone(); + let arabic: Vec<_> = fonts.iter().filter(|(k, _)| k.starts_with(b"PCAr")).map(|(_, v)| doc.resolve(v).as_dict().unwrap().clone()).collect(); + assert_eq!(arabic.len(), 2, "one font per item"); + let mut mapped = String::new(); + for f in &arabic { + assert_eq!(f.name(b"Subtype"), Some(&b"Type3"[..])); + mapped += &String::from_utf8_lossy(&stream_bytes(&doc, f.get(b"ToUnicode").unwrap()).unwrap()); + } + for letter in ["0645", "0631", "062D", "0628", "0627", "0634", "0643"] { + assert!(mapped.contains(&format!("<{letter}>")), "{letter} in {mapped}"); + } +} + +/// The `PCAr` font names in page `page`'s resources. +fn arabic_font_names(doc: &Document, page: usize) -> Vec { + let p = &pdfcraft_model::pages(doc)[page]; + let Some(res) = p.dict.get(b"Resources").map(|r| doc.resolve(r)) else { return vec![] }; + let Some(fonts) = res.as_dict().and_then(|r| r.get(b"Font")).map(|f| doc.resolve(f)) else { return vec![] }; + fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).filter(|k| k.starts_with("PCAr")).collect() +} + +#[test] +fn arabic_items_keep_their_own_fonts_through_saves_updates_and_deletes() { + let mut doc = fixture(); + let text = AddedText { rect: [72.0, 600.0, 400.0, 700.0], text: "مرحبا".into(), size: 14.0, ..AddedText::default() }; + if pdfcraft_fonts::document_arabic_font().is_none() { + // An item that already holds Arabic (stored by an older version, drawn as "?") can still + // be moved and retyped without the face. + add_content(&mut doc, 0, &Content::Text(AddedText { text: "x".into(), ..text.clone() })).unwrap(); + update_content(&mut doc, 0, 0, &Content::Text(text)).unwrap(); + assert!(streams(&doc, 0).join("").contains("(?????) Tj")); + return; + } + // A full save renumbers objects but keeps resource names, so a font named after its object + // number may meet a name already on the page: here, every name it could take. + let mut taken = Dict::new(); + for num in 1..2_000 { + taken.set(format!("PCAr{num}").into_bytes(), Object::name("Taken")); + } + let mut res = Dict::new(); + res.set(b"Font".to_vec(), Object::Dict(taken)); + let page = pdfcraft_model::pages(&doc)[0].obj; + doc.update_dict(page, |d| d.set(b"Resources".to_vec(), Object::Dict(res))).unwrap(); + add_content(&mut doc, 0, &Content::Text(text.clone())).unwrap(); + add_content(&mut doc, 0, &Content::Text(AddedText { text: "شكرا".into(), rect: [72.0, 500.0, 400.0, 520.0], ..text.clone() })).unwrap(); + let names = arabic_font_names(&doc, 0); + assert_eq!(names.len(), 1_999 + 2, "nothing replaced"); + let p = &pdfcraft_model::pages(&doc)[0]; + let fonts = doc.resolve(p.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").cloned().unwrap(); + let fonts = doc.resolve(&fonts).as_dict().unwrap().clone(); + assert_eq!(fonts.iter().filter(|(_, v)| v.as_name() == Some(&b"Taken"[..])).count(), 1_999); + let first = own(&doc, 0); + assert!(first.len() == 1 && first[0].contains('_'), "a free name, not a taken one: {first:?}"); + // Updating an item swaps its fonts; deleting it removes them. + update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "أهلا".into(), ..text })).unwrap(); + let names = arabic_font_names(&doc, 0); + assert!(names.len() == 2_001 && !names.contains(&first[0]), "the replaced item's font is gone"); + delete_content(&mut doc, 0, 0).unwrap(); + delete_content(&mut doc, 0, 0).unwrap(); + assert_eq!(arabic_font_names(&doc, 0).len(), 1_999, "only the page's own names are left"); + reopen(&doc); +} + +/// The `PCAr` fonts the stream of added item `index` on page 0 shows text with. +fn own(doc: &Document, index: usize) -> Vec { + let s = String::from_utf8_lossy(&stream_bytes(doc, &Object::Ref(list_added(doc)[index].obj)).unwrap()).into_owned(); + let mut names: Vec = + s.split_whitespace().filter_map(|w| w.strip_prefix('/')).filter(|w| w.starts_with("PCAr")).map(String::from).collect(); + names.dedup(); + names +} + +#[test] +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", "ﷺ ١٢٣ 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( + &mut doc, + 0, + &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ب \u{FDFD}".into(), ..AddedText::default() }), + ); + assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains('\u{FDFD}')), "{r:?}"); + } +} + /// One page with Helvetica (WinAnsi) and a subset font that has only the glyphs it uses. fn text_page(content: &str) -> Document { let objs: Vec = vec![ @@ -414,6 +550,28 @@ fn japanese_fallback_reports_the_face_after_save_and_reopen() { } } +#[test] +fn cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it() { + if without_craft_fonts("cyrillic_in_a_serif_line_falls_back_to_a_face_that_has_it") { + return; + } + let replacement = "Привет, world"; + let has_all = |f: &pdfcraft_fonts::CraftFont| replacement.chars().all(|ch| pdfcraft_fonts::japanese_glyph_from(f, ch).is_ok()); + if !pdfcraft_fonts::CRAFT_FONTS.iter().any(|f| f.covers("Jpan") && has_all(f)) { + eprintln!("skipping: no Japanese fallback face has Cyrillic"); + return; + } + for paragraph in [false, true] { + let mut doc = styled_text_page("Times-Roman"); + if paragraph { + text::replace_block(&mut doc, 0, 0, replacement).unwrap(); + } else { + text::replace_line(&mut doc, 0, 0, replacement).unwrap(); + } + assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, replacement); + } +} + #[test] fn japanese_paragraph_uses_unicode_type3_fallback() { if without_craft_fonts("japanese_paragraph_uses_unicode_type3_fallback") { @@ -426,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") { @@ -570,6 +796,22 @@ fn editing_text_keeps_the_tokens_a_stream_shares_with_its_neighbours() { assert_split_tokens_kept(&reopen(&doc), "Rewrapped"); } +#[test] +fn win_ansi_high_glyphs_use_helvetica_not_the_fallback_font() { + // š ž Š Ž Œ Ÿ ƒ † ‰ (WinAnsi 0x80–0x9F) are standard-font glyphs (#347): no Type3 fallback. + let text = "Šumava žaba œuvre Ÿ ƒ † ‰ €"; + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_line(&mut doc, 0, 0, text).unwrap(); + assert_eq!(r.substituted.as_deref(), Some("Helvetica")); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!((lines[0].text.as_str(), lines[0].base_font.as_str()), (text, "Helvetica")); + let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); + let r = text::replace_block(&mut doc, 0, 0, text).unwrap(); + assert!(r.substituted.as_deref().is_none_or(|s| !s.contains("Type3")), "{:?}", r.substituted); + let lines = text::text_lines(&reopen(&doc), 0).unwrap(); + assert_eq!(lines.iter().map(|l| l.text.as_str()).collect::>().join(" "), text); +} + #[test] fn missing_glyphs_substitute_helvetica_and_impossible_text_is_refused() { let mut doc = text_page("BT /F2 10 Tf 72 600 Td (ab) Tj ET"); @@ -1012,3 +1254,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 d8a2dd93..2fe05ef6 100644 --- a/crates/edit/src/text.rs +++ b/crates/edit/src/text.rs @@ -499,7 +499,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro type Encoder = Box Option>>; fn is_win_ansi_char(c: char) -> bool { - matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™' | '\t') + c == '\t' || pdfcraft_fonts::win_ansi_byte(c).is_some() } fn needs_type3(text: &str) -> bool { @@ -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 { @@ -570,9 +598,13 @@ fn no_japanese_font() -> EditError { ) } +/// The first of `faces` (best match first) that has a glyph for every one of `chars`, else the first. +fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn(&CraftFont, char) -> bool) -> Option<&'a CraftFont> { + faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied()) +} + fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crate::added::Family, bold: bool) -> Result { - let face = pdfcraft_fonts::document_japanese_font_for_style(family == crate::added::Family::Times, bold).ok_or_else(no_japanese_font)?; - let family = face.family; + let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold); let mut chars = Vec::new(); for ch in text.chars() { if !chars.contains(&ch) { @@ -585,6 +617,10 @@ fn type3_font(doc: &mut Document, fonts_res: &mut Dict, text: &str, family: crat if chars.is_empty() { return Err(EditError::Invalid("replacement text is empty".into())); } + // The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every + // character. When none has them all, the best face reports the character it lacks. + let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?; + let family = face.family; let mut codes = Vec::with_capacity(chars.len()); let mut charprocs = Dict::new(); let mut widths = Vec::with_capacity(chars.len()); @@ -1138,4 +1174,20 @@ mod tests { assert_eq!(wrap("A\u{2028}B C", 3.0, width), ["A B", "C"]); assert_eq!(wrap(" \tA B\r\nC ", 3.0, width), ["A B", "C"]); } + + #[test] + fn fallback_face_moves_on_when_the_best_face_lacks_a_character() { + static JPAN: &[&str] = &["Jpan"]; + let mincho = CraftFont { family: "Mincho", style: "Regular", scripts: JPAN, bytes: b"" }; + let gothic = CraftFont { family: "Gothic", style: "Regular", scripts: JPAN, bytes: b"" }; + let faces = [&mincho, &gothic]; + // Only the Gothic face has the Cyrillic letter. + let has = |face: &CraftFont, ch: char| ch.is_ascii() || face.family == "Gothic"; + assert_eq!(fallback_face(&faces, &['a', 'ф'], has).map(|f| f.family), Some("Gothic")); + // Characters the best face has keep it. + assert_eq!(fallback_face(&faces, &['a', 'b'], has).map(|f| f.family), Some("Mincho")); + // No face has them all: the best face stays, and names the missing glyph. + assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho")); + assert!(fallback_face(&[], &['a'], has).is_none()); + } } diff --git a/crates/engine/README.md b/crates/engine/README.md new file mode 100644 index 00000000..c0553769 --- /dev/null +++ b/crates/engine/README.md @@ -0,0 +1,80 @@ +# pdfcraft-engine + +The façade every frontend talks to. It holds the open documents, their edit history and the tool +catalogue; frontends never reach past it into the parsing, rendering or editing crates. + +- **Layer:** L6. It depends on most of L1–L4 and on nothing above itself. **No UI toolkit** — + `egui`, `winit`, `eframe` and `rfd` are forbidden below L7, and `xtask layers` enforces it. +- The crate denies `unwrap`/`expect`/`panic!`/`todo!`. Entry points that run untrusted-document + code keep a last-resort `catch_unwind` guard so an escaped panic becomes an error and the user + keeps their document. + +Two frontends exist on top of it today: `pdfcraft-ui-egui` (desktop and web) and +`pdfcraft-automation` (headless tools, the CLI and MCP). Anything one can do, the other must be +able to do — see AGENTS.md §3. + +## The editing model + +Each open document holds a `pdfcraft_cos::Document`: the object graph plus a copy-on-write overlay. + +1. An edit runs **on a clone**. On success the previous state is pushed onto the undo stack. Clones + share all unchanged data, so a snapshot is cheap — this is what makes 100 levels of undo + affordable on a 200 MB file. +2. After every edit the **working file** is produced by an incremental write: the original bytes + plus one appended revision. +3. The view is refreshed from those bytes, so **what you see is exactly what Save will write**. +4. Saving rebases onto the written bytes, so the next save appends only new edits. + +Consequences worth preserving: the original bytes of an opened file are never rewritten in place; a +full rewrite happens only where it must (redaction, PDF/A conversion, print imposition, a changed +password); and unknown data survives a round trip because nothing is reconstructed that wasn't +understood. + +## API + +```rust +let mut s = Session::new(); +let id = s.open("report.pdf", Some(path), bytes, None)?; // -> DocId + +s.apply(id, Edit::RotatePages { pages: vec![0], degrees: 90 })?; +let label = s.undo(id)?; // "Rotate pages" +s.redo(id)?; + +let doc = s.get(id).ok_or("gone")?; // &Document: queries, never edits +doc.can_undo(); doc.editable(); doc.allows_printing(); +doc.text_blocks(page); doc.page_boxes(page); doc.security_summary(); + +let bytes = s.save_bytes(id)?; // incremental: keeps signatures over earlier revisions valid +let bytes = s.save_full_bytes(id)?; // full rewrite, garbage-collected +s.mark_saved(id, bytes.clone(), path)?; +``` + +`Edit` is one enum of 75 variants — every undoable change in the product, from +`RotatePages` to `AddAnnotation`, `SetFieldValue`, `ApplyRedactions` and `AddWatermark`. Adding a +feature means adding a variant and handling it in `apply`, which is what keeps undo, the dirty +flag, autosave and the automation tools consistent for free. + +`Session` also owns the operations that produce a *new* document rather than editing one: +`create_blank`, `create_from_images`, `create_from_text`, `combine`, `extract`, `split`, +`split_by_size`, `reduced_bytes`, `optimized_bytes`, `print_pdf`, `export_data`, and the signing +family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`). + +## Modules + +| Module | What it holds | +|---|---| +| `commands` | The command registry: id, label, menu, shortcut, and a `Needs` precondition encoding document security. Menus, the ⌘K palette, shortcuts and the control channel all read it, so every surface agrees on what exists and when it is enabled | +| `catalog` | Acrobat's "All tools" information architecture as data — groups, sections, icons, hues, and an `Availability` of `Ready`, `Planned(milestone)` or `Provider`. The UI renders what is *not* built yet, honestly | +| `actions` | PDF action dispatch (go-to, URI, named, set-layer-state, JavaScript) | +| `links` | Link creation, properties, and finding URLs in page text | +| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible | +| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names | + +## Not done here + +- **Rendering is still borrowed.** `pdfcraft-render` wraps `hayro` and inspects through `lopdf` + (ADR-0004). The engine only sees `inspect() -> DocInfo`, `RenderPool` and `text::PageText`, and + that API stays frozen while M2 replaces what is behind it. +- Performance budgets (`misc.performance-budgets`) are not set or enforced. +- Some shipped features are still reachable only from the UI, not through an `automation` tool; + `cargo xtask parity` lists them; closing that gap is tracked in the execution plan (D4). diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index eb53cfd2..35576137 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/compare.rs b/crates/engine/src/compare.rs index bfcf578b..7da14c82 100644 --- a/crates/engine/src/compare.rs +++ b/crates/engine/src/compare.rs @@ -170,6 +170,7 @@ impl crate::Document { size: b.size, bold: f.contains("bold") || f.contains("black") || f.contains("heavy"), italic: f.contains("italic") || f.contains("oblique"), + color: b.color, } }) .collect(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index af13c1d2..600f376b 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod links; pub mod ocr; pub mod optimizer; pub mod signature_image; +mod upright_image; pub mod xfa; pub use signature_image::{ImageSignaturePreview, SignatureImage}; @@ -43,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; @@ -68,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; @@ -846,6 +855,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 { @@ -1191,7 +1202,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", @@ -1453,9 +1464,11 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Edit::AddCustomStamp { page, rect, name, file, author } => { let src = mark_source(doc, file)?; let (sw, sh) = (src.size.0.max(1.0), src.size.1.max(1.0)); - let rect = if rect[2] == rect[0] && rect[3] == rect[1] { + let natural = rect[2] == rect[0] && rect[3] == rect[1]; + let rotation = if natural && src.image { pdfcraft_organize::page_rotation(doc, *page)? } else { 0 }; + let rect = if natural { let k = (200.0 / sw.max(sh)).min(1.0); - let (w, h) = (sw * k, sh * k); + let (w, h) = if matches!(rotation, 90 | 270) { (sh * k, sw * k) } else { (sw * k, sh * k) }; [rect[0] - w / 2.0, rect[1] - h / 2.0, rect[0] + w / 2.0, rect[1] + h / 2.0] } else { *rect @@ -1463,7 +1476,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let shape = Shape::CustomStamp { rect, name: name.clone(), picture: src.xobject, image: src.image, size: (sw, sh) }; let style = pdfcraft_annot::Style::default_for(&shape); let new = NewAnnotation { page: *page, shape, style, contents: name.clone(), author: author.clone() }; - pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + let index = pdfcraft_annot::add_annotation(doc, &new, &cx.meta())?; + pdfcraft_annot::orient_image_stamp(doc, *page, index, rotation)?; } Edit::DeleteAnnotation { page, index } => pdfcraft_annot::delete_annotation(doc, *page, *index)?, Edit::SetAnnotationContents { page, index, text } => pdfcraft_annot::set_contents(doc, *page, *index, text, &cx.meta())?, @@ -1485,8 +1499,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())?; @@ -1508,7 +1522,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> ran.map_err(EditError::Invalid)?; } Edit::SetFieldImage { name, image } => { - let (img, _) = pdfcraft_create::image_xobject(doc, name, image)?; + let (img, _) = embed_image(doc, name, image)?; let px = match &*doc.get(img) { pdfcraft_cos::Object::Stream(s) => (s.dict.int(b"Width").unwrap_or(1) as u32, s.dict.int(b"Height").unwrap_or(1) as u32), _ => (1, 1), @@ -1571,7 +1585,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> "grouped Form artwork cannot be replaced with a bitmap".into(), ))); } - pdfcraft_edit::ImageChange::Replace(pdfcraft_create::image_xobject(doc, name, bytes)?.0) + pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0) } ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete, }; @@ -1612,7 +1626,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> pdfcraft_edit::add_content(doc, *page, &AddedContent::Text(text.clone()))?; } Edit::AddImage { page, rect, name, bytes } => { - let (image, natural) = pdfcraft_create::image_xobject(doc, name, bytes)?; + let (image, natural) = embed_image(doc, name, bytes)?; let rect = match rect { Some(r) => *r, None => { @@ -1632,7 +1646,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> let Some(AddedContent::Image(old)) = item.map(|a| a.content) else { return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("that item is not an image".into()))); }; - let (image, _) = pdfcraft_create::image_xobject(doc, name, bytes)?; + let (image, _) = embed_image(doc, name, bytes)?; pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?; } Edit::ApplyRedactions { pages } => { @@ -2987,6 +3001,15 @@ pub struct MarkFile { pub page: usize, } +/// Embed an image that is placed on a page, turned the way its EXIF Orientation says. +fn embed_image( + doc: &mut pdfcraft_cos::Document, + name: &str, + bytes: &[u8], +) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> { + pdfcraft_create::image_xobject(doc, name, &upright_image::upright(bytes)) +} + /// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image. fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result { let head = &f.bytes[..f.bytes.len().min(1024)]; @@ -2995,7 +3018,7 @@ fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result>, config: pdfcraft_render::RenderConfig, @@ -56,14 +59,34 @@ impl Document { let image = SignatureImage::from_bytes(&bytes).map_err(|e| e.to_string())?; let opacity = pdfcraft_annot::props(&editor.cos, page, index).map(|p| p.opacity).filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0) as f32; + let turn = pdfcraft_annot::picture_rotation(&editor.cos, page, index).map_err(|e| e.to_string())?; let mut cos = editor.cos.clone(); pdfcraft_annot::delete_annotation(&mut cos, page, index).map_err(|e| e.to_string())?; let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?); - Ok(Some(ImageSignaturePreview { image, opacity, page, bytes, config: self.config.clone() })) + Ok(Some(ImageSignaturePreview { image, opacity, turn, page, bytes, config: self.config.clone() })) })? } } +/// 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; @@ -141,19 +164,20 @@ impl SignatureImage { self.size } - /// Left edge at `at`, vertically centered, with the same 150 pt width cap as typed names. - pub fn rect(&self, at: [f64; 2], initials: bool) -> Option<[f64; 4]> { + /// The user-space box for the image with its left edge at `at` (a user-space point on `page`), + /// vertically centered, with the same 150 pt width cap as typed names. Left and centered are + /// as displayed: on a page with `/Rotate` the box runs along the turned page's axes. + pub fn rect(&self, page: &PageInfo, at: [f64; 2], initials: bool) -> Option<[f64; 4]> { if !at.iter().all(|v| v.is_finite()) { return None; } 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); - Some([at[0], at[1] - height / 2.0, at[0] + width, at[1] + height / 2.0]) + upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale) } - pub fn edit(&self, page: usize, at: [f64; 2], initials: bool, author: &str) -> Option { - let rect = self.rect(at, initials)?; + pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option { + let rect = self.rect(info, at, initials)?; let label = if initials { "Add initials" } else { "Add signature" }; Some(Edit::Batch { label: label.into(), @@ -172,6 +196,12 @@ impl SignatureImage { mod tests { use super::*; + /// A 300 x 400 page shown turned `rotation` degrees clockwise. + fn page(rotation: u16) -> PageInfo { + let (width, height) = if rotation.is_multiple_of(180) { (300.0, 400.0) } else { (400.0, 300.0) }; + PageInfo { width, height, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation } + } + fn png(w: u32, h: u32) -> Vec { let image = image::RgbaImage::from_pixel(w, h, image::Rgba([40, 60, 80, 128])); let mut out = Cursor::new(Vec::new()); @@ -184,10 +214,10 @@ mod tests { let image = SignatureImage::from_bytes(&png(400, 40)).unwrap(); assert_eq!(image.size(), [400, 40]); assert_eq!(&image.rgba()[..4], &[40, 60, 80, 128]); - let Edit::Batch { edits, .. } = image.edit(0, [20.0, 100.0], false, "Ada").unwrap() else { panic!() }; + let Edit::Batch { edits, .. } = image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap() else { panic!() }; let Edit::AddCustomStamp { rect, .. } = &edits[0] else { panic!() }; assert_eq!(*rect, [20.0, 92.5, 170.0, 107.5]); - assert!(image.edit(0, [f64::NAN, 0.0], false, "").is_none()); + assert!(image.edit(0, &page(0), [f64::NAN, 0.0], false, "").is_none()); assert_eq!(SignatureImage::from_bytes(image.bytes()).unwrap(), image); } @@ -236,7 +266,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let a = &session.get(id).unwrap().info.annotations[0]; assert_eq!(a.rect, [20.0, 99.75, 170.0, 100.25]); } @@ -247,7 +277,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let doc = session.get(id).unwrap(); let bytes = doc.bytes.clone(); let generation = doc.edit_generation(); @@ -268,7 +298,7 @@ mod tests { let mut session = crate::Session::new(); let blank = session.create_blank(300.0, 400.0, 1).unwrap(); let id = session.open_new("form.pdf", blank).unwrap(); - session.apply(id, image.edit(0, [20.0, 100.0], false, "Ada").unwrap()).unwrap(); + session.apply(id, image.edit(0, &page(0), [20.0, 100.0], false, "Ada").unwrap()).unwrap(); let doc = session.doc_mut(id).unwrap(); let cos = &mut doc.editor.as_mut().unwrap().cos; let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); @@ -278,4 +308,20 @@ mod tests { let error = doc.image_signature_preview(0, 0).err().unwrap(); assert!(error.contains("4 megapixels"), "{error}"); } + + /// "Left edge at the point, centered vertically" is as displayed: on a turned page the box + /// is another one in user space. The image is 64 x 32 pt; the displayed point is (60, 100). + #[test] + fn the_signature_box_is_laid_out_as_displayed_on_turned_pages() { + let image = SignatureImage::from_bytes(&png(80, 40)).unwrap(); + for (rotation, at, want) in [ + (0, [60.0, 300.0], [60.0, 284.0, 124.0, 316.0]), + (90, [100.0, 60.0], [84.0, 60.0, 116.0, 124.0]), + (180, [240.0, 100.0], [176.0, 84.0, 240.0, 116.0]), + (270, [200.0, 340.0], [184.0, 276.0, 216.0, 340.0]), + ] { + let rect = image.rect(&page(rotation), at, false).unwrap(); + assert!(rect.iter().zip(want).all(|(a, b)| (a - b).abs() < 0.01), "{rotation}: {rect:?}, want {want:?}"); + } + } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a5815229..1e497399 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -36,6 +36,70 @@ fn session_with(n: usize) -> (Session, DocId) { (s, id) } +#[test] +fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() { + let colours = [[240, 20, 20, 255], [20, 180, 20, 255], [20, 20, 240, 255], [230, 180, 20, 255]]; + let image = image::RgbaImage::from_fn(80, 40, |x, y| image::Rgba(colours[usize::from(y >= 20) * 2 + usize::from(x >= 40)])); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let bytes = Arc::new(png.into_inner()); + for degrees in [0, 90, 180, 270] { + let (mut session, id) = session_with(1); + session.apply(id, Edit::RotatePages { pages: vec![0], degrees }).unwrap(); + let at = session.get(id).unwrap().info.pages[0].view_to_user(100.0, 100.0).map(f64::from); + session + .apply( + id, + Edit::AddCustomStamp { + page: 0, + rect: [at[0], at[1], at[0], at[1]], + name: "Original quadrants".into(), + file: MarkFile { name: "quadrants.png".into(), bytes: bytes.clone(), page: 0 }, + author: "Test".into(), + }, + ) + .unwrap(); + let check = |session: &Session, points: [(u32, u32); 4]| { + let doc = session.get(id).unwrap(); + let mut renderer = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()); + let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + assert!(rendered.error.is_none(), "{:?}", rendered.error); + for ((x, y), colour) in points.into_iter().zip(colours) { + let offset = ((y * rendered.width + x) * 4) as usize; + assert_eq!(&rendered.rgba[offset..offset + 4], &colour, "page rotation {degrees}; displayed point ({x}, {y})"); + } + }; + let points = [(80, 90), (120, 90), (80, 110), (120, 110)]; + check(&session, points); + let info = session.get(id).unwrap().info.pages[0].clone(); + let rect = session.get(id).unwrap().info.annotations[0].rect; + let a = info.user_to_view(rect[0], rect[1]); + let b = info.user_to_view(rect[2], rect[3]); + let bounds = [a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]; + 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, 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(); + let resized_points = [(70, 85), (130, 85), (70, 115), (130, 115)]; + check(&session, resized_points); + session.undo(id).unwrap(); + check(&session, points); + session.redo(id).unwrap(); + check(&session, resized_points); + let from = info.view_to_user(100.0, 100.0); + let to = info.view_to_user(110.0, 115.0); + session.apply(id, Edit::MoveAnnotation { page: 0, index: 0, dx: f64::from(to[0] - from[0]), dy: f64::from(to[1] - from[1]) }).unwrap(); + check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); + session.undo(id).unwrap(); + check(&session, resized_points); + session.redo(id).unwrap(); + check(&session, [(80, 100), (140, 100), (80, 130), (140, 130)]); + } +} + fn page_texts(s: &Session, id: DocId) -> Vec { let doc = s.get(id).unwrap(); let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() }; @@ -1587,6 +1651,39 @@ fn exporting_office_files_keeps_images() { assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("data:image/png;base64,")); } +#[test] +fn exporting_office_files_keeps_text_colour() { + // #526: a dark green heading came out black in Word. + let content = "BT /F1 24 Tf 0.05 0.23 0.18 rg 72 700 Td (Annual Report) Tj ET \ +BT /F1 11 Tf 0 g 72 650 Td (Black body text that is long enough to be the body size.) Tj ET"; + let objs = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".into(), + ]; + let mut pdf = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (i, o) in objs.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes()); + let mut s = Session::new(); + let id = s.open("c.pdf", None, Arc::new(pdf), None).unwrap(); + let d = s.get(id).unwrap(); + let html = String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap(); + assert!(html.contains("style=\"color:#0D3B2E\">Annual Report"), "{html}"); + let blocks = &d.export_pages()[0].blocks; + assert!(blocks.iter().any(|b| b.text == "Annual Report" && b.color == [0.05, 0.23, 0.18]), "{blocks:?}"); +} + #[test] fn guard_turns_a_panic_into_an_error() { assert_eq!(guard(|| 7), Ok(7)); diff --git a/crates/engine/src/upright_image.rs b/crates/engine/src/upright_image.rs new file mode 100644 index 00000000..dc2b9cdb --- /dev/null +++ b/crates/engine/src/upright_image.rs @@ -0,0 +1,133 @@ +//! Photos from phones are stored sideways with an EXIF Orientation tag. PDF image XObjects have +//! no such tag, so a JPEG embedded as it is would show up turned. Images placed into a page +//! (stamps, added images, replacements) go through here first. + +use std::borrow::Cow; +use std::io::Cursor; + +use image::codecs::jpeg::{JpegEncoder, PixelDensity, PixelDensityUnit}; +use image::metadata::Orientation; +use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader}; + +/// Quality of a re-encoded JPEG (the camera's own is rarely higher). +const QUALITY: u8 = 92; +/// Larger photos are embedded as they are: turning them would take hundreds of MB. +const MAX_PIXELS: u64 = 64 << 20; + +/// A JPEG whose EXIF Orientation is not "as stored" has its pixels turned (and re-encoded); +/// everything else, and any JPEG this can't read, is returned unchanged. +pub(crate) fn upright(bytes: &[u8]) -> Cow<'_, [u8]> { + match turned(bytes) { + Some(out) => Cow::Owned(out), + None => Cow::Borrowed(bytes), + } +} + +fn turned(bytes: &[u8]) -> Option> { + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } + let mut decoder = ImageReader::with_format(Cursor::new(bytes), ImageFormat::Jpeg).into_decoder().ok()?; + let orientation = decoder.orientation().ok()?; + let (w, h) = decoder.dimensions(); + if orientation == Orientation::NoTransforms || u64::from(w) * u64::from(h) > MAX_PIXELS { + return None; + } + let mut image = DynamicImage::from_decoder(decoder).ok()?; + image.apply_orientation(orientation); + let mut out = Vec::new(); + { + let mut encoder = JpegEncoder::new_with_quality(&mut out, QUALITY); + // Keep the resolution, which sets the image's natural size on the page. + if let Some(mut density) = jfif_density(bytes) { + if matches!(orientation, Orientation::Rotate90 | Orientation::Rotate270 | Orientation::Rotate90FlipH | Orientation::Rotate270FlipH) { + density.density = (density.density.1, density.density.0); + } + encoder.set_pixel_density(density); + } + match &image { + DynamicImage::ImageLuma8(gray) => encoder.encode_image(gray).ok()?, + _ => encoder.encode_image(&image.to_rgb8()).ok()?, + } + } + Some(out) +} + +/// The resolution in a JFIF header, if the file has one in inches or centimetres. +fn jfif_density(bytes: &[u8]) -> Option { + let mut i = 2usize; + while let Some(&[0xFF, marker, hi, lo]) = bytes.get(i..i.saturating_add(4)) { + let len = usize::from(u16::from_be_bytes([hi, lo])); + let seg = bytes.get(i + 4..i + 2 + len)?; + if marker == 0xE0 && seg.starts_with(b"JFIF\0") { + let &[unit, x0, x1, y0, y1] = seg.get(7..12)? else { return None }; + let unit = match unit { + 1 => PixelDensityUnit::Inches, + 2 => PixelDensityUnit::Centimeters, + _ => return None, + }; + return Some(PixelDensity { density: (u16::from_be_bytes([x0, x1]), u16::from_be_bytes([y0, y1])), unit }); + } + if marker == 0xDA { + return None; + } + i += 2 + len; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Edit, MarkFile}; + + /// A 60 x 20 JPEG at `dpi` x 72 dpi, tagged with EXIF `orientation` (6: shown turned 90 + /// degrees clockwise). + fn phone_jpeg(orientation: u8, dpi: u16) -> Vec { + let mut jpeg = Vec::new(); + let mut encoder = JpegEncoder::new(&mut jpeg); + encoder.set_pixel_density(PixelDensity { density: (dpi, 72), unit: PixelDensityUnit::Inches }); + encoder.encode_image(&image::RgbImage::from_pixel(60, 20, image::Rgb([20, 20, 20]))).unwrap(); + let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec(); + exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xff, 0xe1]); + out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&exif); + out.extend_from_slice(&jpeg[2..]); + out + } + + #[test] + fn orientation_is_applied_and_plain_images_are_left_alone() { + let photo = phone_jpeg(6, 144); + let turned = upright(&photo); + assert!(matches!(turned, Cow::Owned(_))); + let decoded = image::load_from_memory(&turned).unwrap(); + assert_eq!((decoded.width(), decoded.height()), (20, 60)); + assert_eq!(jfif_density(&turned), Some(PixelDensity { density: (72, 144), unit: PixelDensityUnit::Inches })); + let stored = phone_jpeg(1, 72); + assert!(matches!(upright(&stored), Cow::Borrowed(_))); + assert!(matches!(upright(b"\x89PNG\r\n\x1a\n"), Cow::Borrowed(_))); + assert!(matches!(upright(b"\xff\xd8 not a jpeg"), Cow::Borrowed(_))); + } + + #[test] + fn a_phone_photo_used_as_a_custom_stamp_is_embedded_upright() { + let mut session = crate::Session::new(); + let blank = session.create_blank(300.0, 400.0, 1).unwrap(); + let id = session.open_new("form.pdf", blank).unwrap(); + // A click places the stamp at its natural size: 30 x 20 pt as stored, 20 x 30 pt upright. + // Named "Signature" so `signature_image` finds its picture. + let file = MarkFile { name: "photo.jpg".into(), bytes: std::sync::Arc::new(phone_jpeg(6, 144)), page: 0 }; + let stamp = Edit::AddCustomStamp { page: 0, rect: [100.0, 100.0, 100.0, 100.0], name: "Signature".into(), file, author: "Ada".into() }; + session.apply(id, stamp).unwrap(); + let cos = &session.get(id).unwrap().editor.as_ref().unwrap().cos; + let picture = pdfcraft_annot::signature_image(cos, 0, 0).unwrap().unwrap(); + let obj = cos.get(picture); + let pdfcraft_cos::Object::Stream(s) = &*obj else { panic!() }; + assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(20), Some(60))); + let rect = pdfcraft_annot::summaries(cos)[0].rect; + assert!(((rect[2] - rect[0]) - 20.0).abs() < 0.01 && ((rect[3] - rect[1]) - 30.0).abs() < 0.01, "{rect:?}"); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e92d09ec..5e78640a 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -21,6 +21,8 @@ pub struct Block { pub size: f64, pub bold: bool, pub italic: bool, + /// Text fill colour as sRGB components in 0..=1 (black when unknown). + pub color: [f64; 3], } /// An image of a page. @@ -48,6 +50,8 @@ pub struct Cell { pub size: f64, pub bold: bool, pub italic: bool, + /// Text fill colour as sRGB components in 0..=1. + pub color: [f64; 3], pub span: usize, } @@ -96,10 +100,11 @@ fn level(b: &Block, body: f64) -> u8 { /// How far two cell left edges may drift (points) and still be the same grid column. const COL_TOL: f64 = 4.0; -/// More grid columns than this isn't a table. It also bounds a table's size: every row is -/// materialized to all its columns, so a hostile page laid out as a staircase of blocks would -/// otherwise make (blocks / 2)² cells. -const MAX_COLS: usize = 64; +/// More grid columns than this isn't a table. It is Word's limit: a .docx whose table has 64 or +/// more columns doesn't open. It also bounds a table's size: every row is materialized to all +/// its columns, so a hostile page laid out as a staircase of blocks would otherwise make +/// (blocks / 2)² cells. +const MAX_COLS: usize = 63; /// A cell is short: taller blocks are body text (or a multi-column layout), not table cells. fn is_cell_like(b: &Block) -> bool { (b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty() @@ -198,7 +203,7 @@ fn tables(blocks: &[Block]) -> (Vec
, Vec) { let b = &blocks[bi]; let Some(slot) = edges.iter().position(|e| (*e - b.rect[0]).abs() <= COL_TOL) else { continue }; let span = edges[slot + 1..].iter().filter(|e| **e > b.rect[0] + COL_TOL && **e < b.rect[2] - COL_TOL).count() + 1; - let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, span }; + let cell = Cell { text: b.text.trim().to_string(), size: b.size, bold: b.bold, italic: b.italic, color: b.color, span }; match &mut slots[slot] { Some(c) => { c.text.push(' '); @@ -219,7 +224,7 @@ fn tables(blocks: &[Block]) -> (Vec
, Vec) { } None => { column += 1; - materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, span: 1 }); + materialized.push(Cell { text: String::new(), size: 11.0, bold: false, italic: false, color: [0.0; 3], span: 1 }); } } } @@ -360,9 +365,10 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi if b.bold && lvl == 0 { t = format!("{t}"); } + let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); match lvl { - 0 => s.push_str(&format!("

{t}

\n")), - l => s.push_str(&format!("{t}\n")), + 0 => s.push_str(&format!("{t}

\n")), + l => s.push_str(&format!("{t}\n")), } } Item::Table(t) => { @@ -378,7 +384,8 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi body = format!("{body}"); } let span = if c.span > 1 { format!(" colspan=\"{}\"", c.span) } else { String::new() }; - s.push_str(&format!("{body}")); + let style = hex(c.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\"")); + s.push_str(&format!("{body}")); } s.push_str("\n"); } @@ -395,8 +402,20 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi s } +/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). +fn rgb8(c: [f64; 3]) -> [u8; 3] { + // The value is clamped to 0..=255 before the cast, so it cannot truncate. + c.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } else { 0 }) +} + +/// The colour as `RRGGBB`, or `None` for black (the default every format already uses). +fn hex(c: [f64; 3]) -> Option { + let [r, g, b] = rgb8(c); + (r | g | b != 0).then(|| format!("{r:02X}{g:02X}{b:02X}")) +} + /// A formatted text run (shared by paragraphs and table cells). -fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { +fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) -> String { let mut rpr = String::new(); if bold { rpr.push_str(""); @@ -404,6 +423,9 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool) -> String { if italic { rpr.push_str(""); } + if let Some(h) = hex(color) { + rpr.push_str(&format!("")); + } let half_points = if size.is_finite() { (size * 2.0).round().clamp(2.0, 3276.0) as i64 } else { 24 }; rpr.push_str(&format!("")); format!("{rpr}{}", esc(text)) @@ -439,7 +461,7 @@ fn docx_table(t: &Table) -> String { let w: i64 = widths.iter().take(c.span.min(widths.len())).sum(); s.push_str(&format!( "{span}{}", - run_xml(&c.text, c.size, c.bold, c.italic) + run_xml(&c.text, c.size, c.bold, c.italic, c.color) )); } // Pad the grid so every row covers all columns (Word rejects short rows). @@ -467,7 +489,7 @@ pub fn docx(pages: &[Page], title: &str) -> Vec { let mut media: Vec<(String, &Image)> = Vec::new(); // Word needs a paragraph between adjacent tables and after the last one in the body. let mut after_table = false; - let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic); + let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color); for it in items(pages) { match &it { Item::Para(b, lvl) => { @@ -585,8 +607,36 @@ fn rtf_text(s: &str) -> String { /// Rich Text Format: paragraphs with their sizes and bold/italic, real table rows, page breaks /// between pages (images are left out). pub fn rtf(pages: &[Page]) -> String { + let items = items(pages); + // The colour table: entry 0 is the default (auto) colour, then each non-black colour once. + let mut colors: Vec<[u8; 3]> = Vec::new(); + let mut note = |c: [f64; 3]| { + let c = rgb8(c); + if c != [0, 0, 0] && !colors.contains(&c) { + colors.push(c); + } + }; + for it in &items { + match it { + Item::Para(b, _) => note(b.color), + Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)), + Item::Img(_) | Item::PageBreak => {} + } + } + // `\cfN` for a colour (nothing for black, which keeps the default). + let cf = |c: [f64; 3]| { + let c = rgb8(c); + colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1)) + }; let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n"); - for it in items(pages) { + if !colors.is_empty() { + s.push_str("{\\colortbl;"); + for [r, g, b] in &colors { + s.push_str(&format!("\\red{r}\\green{g}\\blue{b};")); + } + s.push_str("}\n"); + } + for it in items { match it { Item::Para(b, _) => { let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64); @@ -596,6 +646,7 @@ pub fn rtf(pages: &[Page]) -> String { if b.italic { fmt.push_str("\\i"); } + fmt.push_str(&cf(b.color)); s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text))); } Item::Table(t) => { @@ -621,6 +672,7 @@ pub fn rtf(pages: &[Page]) -> String { if c.italic { fmt.push_str("\\i"); } + fmt.push_str(&cf(c.color)); s.push_str(&format!("{{{fmt} {}}}\\cell", rtf_text(&c.text))); } s.push_str("\\row\n"); @@ -639,7 +691,14 @@ mod tests { use super::*; fn page() -> Page { - let b = |t: &str, y: f64, size: f64, bold: bool| Block { text: t.into(), rect: [72.0, y, 500.0, y + size], size, bold, italic: false }; + let b = |t: &str, y: f64, size: f64, bold: bool| Block { + text: t.into(), + rect: [72.0, y, 500.0, y + size], + size, + bold, + italic: false, + color: [0.0; 3], + }; Page { width: 612.0, height: 792.0, @@ -711,6 +770,7 @@ mod tests { size: f64::NAN, bold: false, italic: false, + color: [0.0; 3], }); let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml"); assert!(xml.contains(">Total 4.50\tGBP"), "{xml}"); @@ -724,10 +784,35 @@ mod tests { assert!(xml.contains(""), "{xml}"); + assert!(xml.contains(""), "out-of-range values clamp: {xml}"); + // Black text keeps Word's default colour. + assert_eq!(xml.matches("Annual Report"), "{h}"); + assert!(h.contains("

"), "{h}"); + let r = rtf(&[p]); + assert!(r.contains("{\\colortbl;\\red13\\green59\\blue46;\\red0\\green255\\blue0;}"), "{r}"); + assert!(r.contains("\\cf1 Annual Report"), "{r}"); + // A table cell keeps its colour too. + let mut t = table_page(); + t.blocks[0].color = [1.0, 0.0, 0.0]; + let xml = part(&docx(&[t.clone()], "Grid"), "word/document.xml"); + assert!(xml.contains(""), "{xml}"); + assert!(html(&[t], "Grid").contains("style=\"color:#FF0000\"")); + } + #[test] fn rtf_escapes_and_sizes() { let mut p = page(); - p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true }); + p.blocks.push(Block { text: "Café {x}".into(), rect: [72.0, 100.0, 200.0, 110.0], size: 10.0, bold: false, italic: true, color: [0.0; 3] }); let r = rtf(&[p]); assert!(r.starts_with("{\\rtf1") && r.ends_with('}')); assert!(r.contains("\\fs48\\b Annual Report")); @@ -736,7 +821,14 @@ mod tests { /// A 3-column table with a spanning header row and a hole in the last row. fn table_page() -> Page { - let cell = |t: &str, x: f64, y: f64, w: f64| Block { text: t.into(), rect: [x, y, x + w, y + 12.0], size: 11.0, bold: false, italic: false }; + let cell = |t: &str, x: f64, y: f64, w: f64| Block { + text: t.into(), + rect: [x, y, x + w, y + 12.0], + size: 11.0, + bold: false, + italic: false, + color: [0.0; 3], + }; Page { width: 612.0, height: 792.0, @@ -757,6 +849,7 @@ mod tests { size: 11.0, bold: false, italic: false, + color: [0.0; 3], }, ], images: Vec::new(), @@ -800,7 +893,7 @@ mod tests { .map(|i| { let (row, col) = (i / 2, i / 2 + i % 2); let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0); - Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false } + Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3] } }) .collect(); let started = std::time::Instant::now(); @@ -809,6 +902,28 @@ mod tests { assert!(started.elapsed() < std::time::Duration::from_secs(5), "took {:?}", started.elapsed()); } + #[test] + fn word_opens_tables_up_to_its_column_limit() { + // Rows of short numbers on a regular grid, n columns wide. + let grid = |n: usize| Page { + width: 1500.0, + height: 792.0, + blocks: (0..3) + .flat_map(|row| { + (0..n).map(move |col| { + let (x, y) = (20.0 + col as f64 * 22.0, 700.0 - row as f64 * 14.0); + Block { text: (col + 1).to_string(), rect: [x, y, x + 8.0, y + 8.0], size: 8.0, bold: false, italic: false, color: [0.0; 3] } + }) + }) + .collect(), + images: Vec::new(), + }; + let grid_cols = |p: &Page| part(&docx(std::slice::from_ref(p), "Grid"), "word/document.xml").matches(", + pub advance: f64, + /// The characters it shows, in logical order. + pub text: String, +} + +fn face() -> Option<&'static (FontRef<'static>, ShaperData)> { + static FACE: OnceLock, ShaperData)>> = OnceLock::new(); + FACE.get_or_init(|| { + let font = FontRef::new(crate::document_arabic_font()?.bytes).ok()?; + let data = ShaperData::new(&font); + Some((font, data)) + }) + .as_ref() +} + +/// Whether the Arabic document face has a glyph for `c` (false without the face). +pub fn arabic_has(c: char) -> bool { + face().is_some_and(|(font, _)| font.charmap().map(c).is_some()) +} + +/// Shape one directional run of `text` with the Arabic document face. The clusters come in +/// drawing order, left to right. [`GlyphError::NoFont`] without the face; +/// [`GlyphError::Missing`] when it lacks a character. +pub fn shape_arabic(text: &str, rtl: bool) -> Result, GlyphError> { + let (font, data) = face().ok_or(GlyphError::NoFont)?; + let shaper = data.shaper(font).build(); + let scale = 1.0 / f64::from(shaper.units_per_em().max(1)); + let mut buffer = UnicodeBuffer::new(); + buffer.push_str(text); + buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight }); + buffer.set_script(script::ARABIC); + // One cluster per character, so marks keep their own text for copy and search. + buffer.set_cluster_level(BufferClusterLevel::Characters); + let shaped = shaper.shape(buffer, ShapeOptions::new()); + // Clusters are byte offsets into `text`; a cluster shows the characters up to the next one. + let mut starts: Vec = shaped.glyph_infos().iter().map(|g| g.cluster as usize).collect(); + starts.sort_unstable(); + starts.dedup(); + let mut out: Vec<(usize, ShapedCluster)> = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + if info.glyph_id == 0 { + return Err(GlyphError::Missing); + } + let start = info.cluster as usize; + // A cluster's glyphs are adjacent in the shaped run. + let cluster = match out.last_mut() { + Some((s, c)) if *s == start => c, + _ => { + let end = starts.get(starts.partition_point(|s| *s <= start)).copied().unwrap_or(text.len()); + let text = text.get(start..end).unwrap_or_default().to_string(); + out.push((start, ShapedCluster { glyphs: Vec::new(), advance: 0.0, text })); + // Just pushed. + let Some((_, c)) = out.last_mut() else { continue }; + c + } + }; + let at = [cluster.advance + f64::from(pos.x_offset) * scale, f64::from(pos.y_offset) * scale]; + cluster.glyphs.push((info.glyph_id, at)); + cluster.advance += f64::from(pos.x_advance) * scale; + } + Ok(out.into_iter().map(|(_, c)| c).collect()) +} + +/// Glyph `id` of the Arabic document face, bounded like [`crate::japanese_glyph`]. Glyphs without +/// outlines (spaces) come back empty. +pub fn arabic_glyph(id: u32) -> Result { + let (font, _) = face().ok_or(GlyphError::NoFont)?; + if font.maxp().map_or(true, |m| id >= u32::from(m.num_glyphs())) { + return Err(GlyphError::Missing); + } + let gid = GlyphId::new(id); + let loc = LocationRef::default(); + let scale = 1.0 / font.metrics(Size::unscaled(), loc).units_per_em.max(1) as f64; + let width = font.glyph_metrics(Size::unscaled(), loc).advance_width(gid).unwrap_or(0.0) as f64 * scale; + // Marks have no advance; the widest ligatures (U+FDFD) span a few em. + if !width.is_finite() || !(0.0..=8.0).contains(&width) { + return Err(GlyphError::Missing); + } + if font.outline_glyphs().get(gid).is_none() { + return Ok(GlyphOutline { contours: Vec::new(), width, bbox: [0.0, 0.0, width, 0.0] }); + } + crate::script::bounded_outline(font, gid, width) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn face_or_skip() -> bool { + if crate::document_arabic_font().is_none() { + eprintln!("skipping the Arabic shaping checks: built without a craft-fonts Arab face (set CRAFT_FONTS_DIR)"); + assert_eq!(shape_arabic("ب", true), Err(GlyphError::NoFont)); + assert_eq!(arabic_glyph(1).map(|_| ()), Err(GlyphError::NoFont)); + assert!(!arabic_has('ب')); + return false; + } + true + } + + #[test] + fn letters_take_their_joining_forms() { + if !face_or_skip() { + return; + } + let ids = + |s: &str| shape_arabic(s, true).unwrap().iter().flat_map(|c| c.glyphs.iter().map(|g| g.0)).collect::>(); + let (isolated, joined) = (ids("ب"), ids("ببب")); + // Initial, medial and final forms are glyphs the isolated letter doesn't use. + assert!(joined.difference(&isolated).count() >= 2, "{isolated:?} {joined:?}"); + // One cluster per letter, however many glyphs the face draws it with (Noto Sans Arabic: + // body and dots). + let c = shape_arabic("ببب", true).unwrap(); + assert!(c.len() == 3 && c.iter().all(|c| c.text == "ب" && c.advance > 0.0 && !c.glyphs.is_empty()), "{c:?}"); + } + + #[test] + fn right_to_left_runs_are_drawn_from_their_last_letter() { + if !face_or_skip() { + return; + } + let g = shape_arabic("سلام", true).unwrap(); + let drawn: String = g.iter().map(|g| g.text.as_str()).collect(); + assert_eq!(drawn.chars().rev().collect::(), "سلام"); + assert!(shape_arabic("سلام", false).unwrap().iter().map(|g| g.text.as_str()).collect::() == "سلام"); + } + + #[test] + fn marks_keep_their_own_text_and_no_advance() { + if !face_or_skip() { + return; + } + let c = shape_arabic("بَ", true).unwrap(); + assert_eq!(c.len(), 2, "{c:?}"); + let mark = c.iter().find(|c| c.text == "\u{064E}").unwrap(); + assert_eq!(mark.advance, 0.0); + let outline = arabic_glyph(mark.glyphs[0].0).unwrap(); + assert!(!outline.contours.is_empty() && outline.bbox[1] > 0.0, "the fatha sits above the baseline"); + } + + #[test] + fn spaces_and_missing_characters() { + if !face_or_skip() { + return; + } + let space = shape_arabic(" ", true).unwrap(); + assert!(space[0].advance > 0.0); + assert!(arabic_glyph(space[0].glyphs[0].0).unwrap().contours.is_empty()); + assert!(arabic_has('ب') && !arabic_has('日')); + assert_eq!(shape_arabic("日", true), Err(GlyphError::Missing)); + assert_eq!(arabic_glyph(u32::MAX).map(|_| ()), Err(GlyphError::Missing)); + assert!(shape_arabic("", true).unwrap().is_empty()); + } +} diff --git a/crates/fonts/src/craft.rs b/crates/fonts/src/craft.rs index 322d9b2f..36d45002 100644 --- a/crates/fonts/src/craft.rs +++ b/crates/fonts/src/craft.rs @@ -49,6 +49,12 @@ pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> { arabic(CRAFT_FONTS.iter()) } +/// The face for Arabic text written into PDFs: the first `Arab` face. `None` when built without +/// craft-fonts or when its revision has no Arabic face. +pub fn document_arabic_font() -> Option<&'static CraftFont> { + ui_arabic_fonts().into_iter().next() +} + fn arabic<'a>(faces: impl IntoIterator) -> Vec<&'a CraftFont> { faces.into_iter().filter(|f| f.covers("Arab")).collect() } @@ -96,22 +102,36 @@ pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'sta document_face(CRAFT_FONTS, serif, bold) } +/// Every `Jpan` face that can stand in for the document text, the best match for the style first +/// (the face [`document_japanese_font_for_style`] returns), then the others. Not every face has +/// every glyph (their coverage of e.g. Cyrillic differs), so a caller can move on to the next one. +/// Empty without craft-fonts. +pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'static CraftFont> { + document_faces(CRAFT_FONTS, serif, bold) +} + fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> { + document_faces(faces, serif, bold).into_iter().next() +} + +fn document_faces(faces: &[CraftFont], serif: bool, bold: bool) -> Vec<&CraftFont> { let jpan = || faces.iter().filter(|f| f.covers("Jpan")); + let mut preferred = Vec::new(); if !serif { let style = if bold { "Bold" } else { "Regular" }; - if let Some(face) = jpan() - .find(|f| f.family == "BIZ UDPGothic" && f.style == style) - .or_else(|| jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular")) - { - return Some(face); + preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == style)); + preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular")); + } + for family in ["Shippori Mincho", "BIZ UDMincho"] { + preferred.extend(jpan().find(|f| f.family == family && f.style == "Regular")); + } + let mut out: Vec<&CraftFont> = Vec::new(); + for face in preferred.into_iter().chain(jpan().filter(|f| f.style == "Regular")).chain(jpan()) { + if !out.iter().any(|f| std::ptr::eq(*f, face)) { + out.push(face); } } - ["Shippori Mincho", "BIZ UDMincho"] - .iter() - .find_map(|family| jpan().find(|f| f.family == *family && f.style == "Regular")) - .or_else(|| jpan().find(|f| f.style == "Regular")) - .or_else(|| jpan().next()) + out } const fn str_eq(a: &str, b: &str) -> bool { @@ -191,6 +211,11 @@ mod tests { assert_eq!(document_face(&faces, true, true).unwrap().bytes, b"serif"); assert_eq!(document_face(&faces[..2], false, true).unwrap().bytes, b"sans"); assert_eq!(document_face(&faces[..1], false, true).unwrap().bytes, b"serif"); + // Every Japanese face is a candidate, the style's match first and none twice. + let order: Vec<&[u8]> = document_faces(&faces, false, true).iter().map(|f| f.bytes).collect(); + assert_eq!(order, [b"bold".as_slice(), b"sans", b"serif"]); + let order: Vec<&[u8]> = document_faces(&faces, true, false).iter().map(|f| f.bytes).collect(); + assert_eq!(order, [b"serif".as_slice(), b"sans", b"bold"]); assert!(document_face(&faces[3..], false, true).is_none()); assert!(document_face(&[], false, true).is_none()); } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index 9c3ce6d1..ee94566d 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -5,13 +5,15 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod arabic; mod craft; mod encodings; pub mod pdf; mod script; +pub use arabic::{ShapedCluster, arabic_glyph, arabic_has, shape_arabic}; pub use craft::{ - CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_japanese_font, document_japanese_font_for_style, ui_arabic_fonts, ui_chinese_fonts, - ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts, + CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_japanese_font, document_japanese_font_for_style, + document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts, }; pub use script::{GlyphError, GlyphOutline, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline}; @@ -37,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; } @@ -53,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); @@ -64,28 +102,88 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec { lines } -/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); unmappable characters become `?`. +/// The WinAnsiEncoding (ISO 32000-2 Annex D) code for `c`, if the encoding has one. +/// +/// Codes 0x80–0x9F come from the same table the text extractor decodes with, so encoding and +/// decoding agree (Š š Ž ž Œ œ Ÿ ƒ † ‡ ˆ ˜ ‰ ‹ › as well as quotes, dashes, € and ™). +pub fn win_ansi_byte(c: char) -> Option { + match c { + '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' => u8::try_from(u32::from(c)).ok(), + _ => { + let high = encodings::WIN_ANSI.get(0x80..0xa0)?; + let i = high.iter().position(|&u| u != 0 && u == u32::from(c))?; + u8::try_from(0x80 + i).ok() + } + } +} + +/// Encode text in WinAnsiEncoding (ISO 32000-2 Annex D); tabs become spaces and unmappable +/// characters become `?`. pub fn win_ansi(s: &str) -> Vec { - s.chars() - .map(|c| match c { - '\u{20}'..='\u{7e}' => c as u8, - '\u{a0}'..='\u{ff}' => c as u32 as u8, - '€' => 0x80, - '‚' => 0x82, - '„' => 0x84, - '…' => 0x85, - '‘' => 0x91, - '’' => 0x92, - '“' => 0x93, - '”' => 0x94, - '•' => 0x95, - '–' => 0x96, - '—' => 0x97, - '™' => 0x99, - '\t' => b' ', - _ => b'?', + 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, }) - .collect() + } + + /// 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. @@ -108,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); @@ -120,4 +230,43 @@ mod tests { assert_eq!(win_ansi("Café — 5€ ☃"), b"Caf\xe9 \x97 5\x80 ?"); assert_eq!(literal(b"a(b)\\c"), b"(a\\(b\\)\\\\c)"); } + + #[test] + fn win_ansi_covers_the_0x80_to_0x9f_glyphs() { + // Every character in the WinAnsiEncoding table encodes to its code and back (#347). + for (code, &u) in encodings::WIN_ANSI.iter().enumerate() { + let Some(c) = char::from_u32(u).filter(|_| u != 0) else { continue }; + assert_eq!(win_ansi_byte(c), Some(code as u8), "{c:?} (U+{u:04X})"); + assert_eq!(win_ansi(&c.to_string()), [code as u8]); + } + assert_eq!( + win_ansi("Šta? žaba, œuvre — „ok“ ‰ ƒ † ‡ ˆ ˜ ‹›Ÿ"), + b"\x8ata? \x9eaba, \x9cuvre \x97 \x84ok\x93 \x89 \x83 \x86 \x87 \x88 \x98 \x8b\x9b\x9f" + ); + // Not in WinAnsi: č ć still need the fallback font; undefined codes are never produced. + 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/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index 88b77c06..aeee43e1 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -68,12 +68,125 @@ fn dict(doc: &Document, o: Option<&Object>) -> Option { o.and_then(|o| doc.resolve(o).as_dict().cloned()) } -fn hex_bytes(tok: &str) -> Option> { - let t = tok.trim_start_matches('<').trim_end_matches('>'); - if !t.len().is_multiple_of(2) || t.is_empty() { - return None; +/// Most entries a CMap may add, so a small stream of overlapping `bfrange`s can't take minutes +/// or gigabytes; a full 2-byte code space is 65 536. Entries past it are ignored. +const MAX_CMAP_ENTRIES: usize = 1 << 20; +/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few +/// thousand. +const MAX_CMAP_RANGES: usize = 1 << 16; + +/// A token of a CMap stream (PostScript syntax, ISO 32000-2 §9.10.3). +#[derive(Debug, PartialEq)] +enum Tok<'a> { + /// A `<…>` string; `None` when it isn't an even number of hex digits. + Hex(Option>), + Open, + Close, + /// An operator, number or name (with its `/`), a `(…)` string or a `<<`/`>>`. + Word(&'a [u8]), +} + +/// Splits a CMap into tokens without assuming whitespace between them (`<01><0041>` is two +/// strings), skipping `%` comments. +struct Lexer<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Lexer<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn at(&self, i: usize) -> Option { + self.data.get(i).copied() + } +} + +fn is_cmap_space(b: u8) -> bool { + matches!(b, 0 | b'\t' | b'\n' | 0x0c | b'\r' | b' ') +} + +fn is_cmap_delimiter(b: u8) -> bool { + matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%') +} + +impl<'a> Iterator for Lexer<'a> { + type Item = Tok<'a>; + + fn next(&mut self) -> Option> { + loop { + let b = self.at(self.pos)?; + if is_cmap_space(b) { + self.pos += 1; + } else if b == b'%' { + while self.at(self.pos).is_some_and(|c| c != b'\n' && c != b'\r') { + self.pos += 1; + } + } else { + break; + } + } + let start = self.pos; + let b = self.at(start)?; + self.pos += 1; + match b { + b'[' => Some(Tok::Open), + b']' => Some(Tok::Close), + b'<' | b'>' if self.at(self.pos) == Some(b) => { + self.pos += 1; + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + b'<' => { + let mut digits = Vec::new(); + let mut valid = true; + loop { + match self.at(self.pos) { + // Unterminated: what's left is one bad string. + None => return Some(Tok::Hex(None)), + Some(b'>') => break, + Some(c) if is_cmap_space(c) => {} + Some(c) => match (c as char).to_digit(16) { + // A hex digit is below 16, so it fits a u8. + Some(d) => digits.push(d as u8), + None => valid = false, + }, + } + self.pos += 1; + } + self.pos += 1; + if !valid || digits.is_empty() || !digits.len().is_multiple_of(2) { + return Some(Tok::Hex(None)); + } + // `chunks` never yields an empty slice, so `p[0]` exists. + Some(Tok::Hex(Some(digits.chunks(2).map(|p| p[0] << 4 | p.get(1).copied().unwrap_or(0)).collect()))) + } + b'(' => { + // Literal strings nest and escape their parentheses; only skipping them matters. + let mut depth = 1usize; + while depth > 0 { + match self.at(self.pos) { + None => break, + Some(b'\\') => self.pos += 1, + Some(b'(') => depth += 1, + Some(b')') => depth -= 1, + _ => {} + } + self.pos += 1; + } + Some(Tok::Word(self.data.get(start..self.pos.min(self.data.len()))?)) + } + _ => { + // A name keeps its `/`; other delimiters (`)`, `>`, `{`, `}`) stand alone. + if b == b'/' || !is_cmap_delimiter(b) { + while self.at(self.pos).is_some_and(|c| !is_cmap_space(c) && !is_cmap_delimiter(c)) { + self.pos += 1; + } + } + Some(Tok::Word(self.data.get(start..self.pos)?)) + } + } } - (0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).ok()).collect() } fn be(b: &[u8]) -> u32 { @@ -89,47 +202,64 @@ fn style_from_name(name: &str) -> (bool, bool) { ) } -/// Codespace ranges and CID mappings from an embedded CMap stream. +/// The next entry of a `begin… end…` section: `N` tokens, or `None` at its `end` operator or +/// the end of the stream. +fn entry<'a, const N: usize>(toks: &mut Lexer<'a>, end: &[u8]) -> Option<[Tok<'a>; N]> { + let mut out = Vec::with_capacity(N); + for _ in 0..N { + match toks.next()? { + Tok::Word(w) if w == end => return None, + t => out.push(t), + } + } + out.try_into().ok() +} + +fn number(t: &Tok) -> Option { + match t { + Tok::Word(w) => std::str::from_utf8(w).ok()?.parse().ok(), + _ => None, + } +} + +/// Codespace ranges and CID mappings from an embedded CMap stream, at most +/// [`MAX_CMAP_RANGES`] of each (every glyph searches them). #[allow(clippy::type_complexity)] fn parse_cmap(data: &[u8]) -> (Vec<(usize, Vec, Vec)>, Vec<(u32, u32, u32)>) { - let text = String::from_utf8_lossy(data); - let toks: Vec<&str> = text.split_whitespace().collect(); let (mut spaces, mut cids) = (Vec::new(), Vec::new()); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "begincodespacerange" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcodespacerange" { - if let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"begincodespacerange") => { + while let Some([lo, hi]) = entry(&mut toks, b"endcodespacerange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) && lo.len() == hi.len() + && spaces.len() < MAX_CMAP_RANGES { spaces.push((lo.len(), lo, hi)); } - i += 2; } } - "begincidrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endcidrange" { - if let (Some(lo), Some(hi), Ok(c)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1]), toks[i + 2].parse::()) { - cids.push((be(&lo), be(&hi), c)); + Tok::Word(b"begincidrange") => { + while let Some([lo, hi, c]) = entry(&mut toks, b"endcidrange") { + if let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi)), Some(c)) = (&lo, &hi, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(lo), be(hi), c)); } - i += 3; } } - "begincidchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endcidchar" { - if let (Some(code), Ok(c)) = (hex_bytes(toks[i]), toks[i + 1].parse::()) { - cids.push((be(&code), be(&code), c)); + Tok::Word(b"begincidchar") => { + while let Some([code, c]) = entry(&mut toks, b"endcidchar") { + if let (Tok::Hex(Some(code)), Some(c)) = (&code, number(&c)) + && cids.len() < MAX_CMAP_RANGES + { + cids.push((be(code), be(code), c)); } - i += 2; } } _ => {} } - i += 1; } spaces.sort_by_key(|s| s.0); (spaces, cids) @@ -312,7 +442,7 @@ impl Metrics { if self.cid_map.is_empty() { return code; } - self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c + (code - lo)) + self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo)) } /// The advance of `code` in text space per unit of font size. @@ -392,60 +522,66 @@ fn utf16(b: &[u8]) -> String { String::from_utf16_lossy(&units) } -/// `bfchar`/`bfrange` entries of a ToUnicode CMap. +/// `bfchar`/`bfrange` entries of a ToUnicode CMap, at most [`MAX_CMAP_ENTRIES`] of them. fn parse_to_unicode(data: &[u8], out: &mut HashMap) { - let text = String::from_utf8_lossy(data); - // Tokenise, keeping arrays' brackets as tokens. - let spaced = text.replace('[', " [ ").replace(']', " ] "); - let toks: Vec<&str> = spaced.split_whitespace().collect(); - let mut i = 0; - while i < toks.len() { - match toks[i] { - "beginbfchar" => { - i += 1; - while i + 1 < toks.len() && toks[i] != "endbfchar" { - if let (Some(c), Some(u)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) { + let mut budget = MAX_CMAP_ENTRIES; + let mut toks = Lexer::new(data); + while let Some(t) = toks.next() { + match t { + Tok::Word(b"beginbfchar") => { + while let Some([c, u]) = entry(&mut toks, b"endbfchar") { + if let (Tok::Hex(Some(c)), Tok::Hex(Some(u))) = (c, u) { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(be(&c), utf16(&u)); } - i += 2; } } - "beginbfrange" => { - i += 1; - while i + 2 < toks.len() && toks[i] != "endbfrange" { - let (Some(lo), Some(hi)) = (hex_bytes(toks[i]), hex_bytes(toks[i + 1])) else { break }; + Tok::Word(b"beginbfrange") => { + while let Some([lo, hi, dst]) = entry(&mut toks, b"endbfrange") { + let (Tok::Hex(Some(lo)), Tok::Hex(Some(hi))) = (lo, hi) else { break }; let (lo, hi) = (be(&lo), be(&hi)); - if toks[i + 2] == "[" { - let mut j = i + 3; - let mut code = lo; - while j < toks.len() && toks[j] != "]" { - if let Some(u) = hex_bytes(toks[j]) { - out.insert(code, utf16(&u)); + match dst { + // One destination per code, from `lo` up to `hi`. + Tok::Open => { + let mut code = (hi >= lo).then_some(lo); + for t in toks.by_ref() { + let Some(c) = code else { + if t == Tok::Close { + break; + } + continue; + }; + match t { + Tok::Close => break, + Tok::Hex(Some(u)) => { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; + out.insert(c, utf16(&u)); + } + // A bad entry still stands for its code. + _ => {} + } + code = c.checked_add(1).filter(|c| *c <= hi); } - code += 1; - j += 1; } - i = j + 1; - } else { - if let Some(u) = hex_bytes(toks[i + 2]) - && hi >= lo - && hi - lo < 65536 - { + Tok::Hex(Some(u)) if hi >= lo && hi - lo < 65536 => { let mut units: Vec = u.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect(); for code in lo..=hi { + let Some(left) = budget.checked_sub(1) else { return }; + budget = left; out.insert(code, String::from_utf16_lossy(&units)); if let Some(last) = units.last_mut() { *last = last.wrapping_add(1); } } } - i += 3; + _ => {} } } } _ => {} } - i += 1; } } @@ -484,13 +620,14 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> HashMap code = (*n).max(0) as u32, + // Out-of-range codes map nothing a simple font can show. + Object::Int(n) => code = u32::try_from((*n).max(0)).unwrap_or(u32::MAX), Object::Name(n) => { match glyph_unicode(&String::from_utf8_lossy(n)) { Some(c) => out.insert(code, c.to_string()), None => out.remove(&code), }; - code += 1; + code = code.saturating_add(1); } _ => {} } @@ -637,4 +774,74 @@ mod tests { assert_eq!(glyph_unicode("u1F600"), Some('😀')); assert_eq!(glyph_unicode("nonsense"), None); } + + fn to_unicode(cmap: &[u8]) -> HashMap { + let mut out = HashMap::new(); + parse_to_unicode(cmap, &mut out); + out + } + + #[test] + fn cmap_tokens_need_no_space_between_them() { + // Hex strings, brackets and operators may touch; comments and literal strings are skipped. + let cmap = b"/CIDSystemInfo << /Registry (Adobe (x) \\) beginbfchar) >> def % beginbfchar <0001> <0058>\n\ + 2 beginbfchar<0003><0020><0010><00 E9>endbfchar \ + 1 beginbfrange<0024><0026><0041>endbfrange 1 beginbfrange<0030><0031>[<0048><0069>]endbfrange"; + let map = to_unicode(cmap); + let mut got: Vec<_> = map.iter().map(|(c, u)| (*c, u.as_str())).collect(); + got.sort_unstable(); + assert_eq!(got, [(3, " "), (0x10, "é"), (0x24, "A"), (0x25, "B"), (0x26, "C"), (0x30, "H"), (0x31, "i")]); + let (spaces, cids) = parse_cmap(b"1 begincodespacerange<00>endcodespacerange 1 begincidrange<20><7E>1 endcidrange"); + assert_eq!(spaces, [(1, vec![0], vec![0xff])]); + assert_eq!(cids, [(0x20, 0x7e, 1)]); + } + + #[test] + fn malformed_cmaps_never_panic() { + // Non-ASCII bytes inside hex strings once split a UTF-8 character and panicked. + for cmap in [ + "1 beginbfchar <0é0> <0041> <0042> endbfchar".as_bytes(), + "1 beginbfrange <0é0> <00é> <0041> endbfrange".as_bytes(), + b"1 beginbfchar <0001> <004", + b"1 beginbfchar <123> <0041> <0002> <0003> endbfchar", + b"1 beginbfrange <0001> [<0041>] endbfrange", + b"1 beginbfrange <0001> <0002> [<0041> (", + b"1 beginbfrange <0001>", + b"\xff\xfe beginbfchar \x80<\xc3\xa9> <0041> endbfchar %", + ] { + to_unicode(cmap); + parse_cmap(cmap); + } + // Code counters at the top of the range stop instead of overflowing. + let map = to_unicode(b"1 beginbfrange [<0041> <0042> <0043>] endbfrange"); + assert_eq!(map.len(), 2); + assert_eq!(map.get(&u32::MAX).map(String::as_str), Some("B")); + // A malformed array entry keeps its place, so the next one maps the next code. + let map = to_unicode(b"1 beginbfrange <0001> <0003> [<0041> <0043>] endbfrange"); + assert_eq!((map.get(&2), map.get(&3).map(String::as_str)), (None, Some("C"))); + let mut m = Metrics::fallback(); + (_, m.cid_map) = parse_cmap(b"1 begincidrange <0000> 4294967295 endcidrange"); + assert_eq!(m.cid(5), u32::MAX); + let mut doc = Document::new_empty(); + let mut enc = Dict::new(); + enc.set(b"Differences".to_vec(), Object::Array(vec![Object::Int(i64::MAX), Object::name("A"), Object::name("B")])); + let f = font(&mut doc, vec![("Subtype", Object::name("Type1")), ("Encoding", Object::Dict(enc))]); + assert_eq!(unicode_map(&doc, &f, false).get(&u32::MAX).map(String::as_str), Some("B")); + } + + #[test] + fn a_cmap_adds_at_most_max_entries() { + // 200 ranges of 65 536 codes would be 13 million strings from 4 KB of stream. + let mut cmap = b"200 beginbfrange ".to_vec(); + for k in 0..200u32 { + cmap.extend(format!("<{:08X}> <{:08X}> <0041> ", k << 16, (k << 16) | 0xffff).bytes()); + } + cmap.extend(b"endbfrange"); + assert_eq!(to_unicode(&cmap).len(), MAX_CMAP_ENTRIES); + let mut many = b"begincidrange ".to_vec(); + for k in 0..MAX_CMAP_RANGES + 10 { + many.extend(format!("<{k:08X}> <{k:08X}> 1 ").bytes()); + } + assert_eq!(parse_cmap(&many).1.len(), MAX_CMAP_RANGES); + } } diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs index 1247ca25..04695ebe 100644 --- a/crates/fonts/src/script.rs +++ b/crates/fonts/src/script.rs @@ -2,7 +2,7 @@ use skrifa::instance::{LocationRef, Size}; use skrifa::outline::{DrawSettings, OutlinePen}; -use skrifa::{FontRef, MetadataProvider}; +use skrifa::{FontRef, GlyphId, MetadataProvider}; static FONT: &[u8] = include_bytes!("../../../assets/fonts/DancingScript.ttf"); @@ -145,6 +145,13 @@ pub fn japanese_glyph_from(face: &crate::CraftFont, ch: char) -> Result 2.0 { return Err(GlyphError::Missing); } + bounded_outline(&font, gid, width) +} + +/// Glyph `gid` of `font` in em units with advance `width`, bounded like [`japanese_glyph`]. +pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Result { + let loc = LocationRef::default(); + let scale = 1.0 / font.metrics(Size::unscaled(), loc).units_per_em.max(1) as f64; let Some(glyph) = font.outline_glyphs().get(gid) else { return Err(GlyphError::Missing) }; let mut pen = Flatten::new(scale); let _ = glyph.draw(DrawSettings::unhinted(Size::unscaled(), loc), &mut pen); diff --git a/crates/forms/Cargo.toml b/crates/forms/Cargo.toml index 3f085ee6..ce6a73a5 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 27459bab..0fa6d0de 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 f13f53f8..ea88f675 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 d51abda7..53af944a 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)))?; @@ -569,15 +574,18 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> { d } }; - doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?; + let existing = crate::merged_appearance(doc, w.obj, &ap); + doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?; } 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 +598,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,11 +613,12 @@ 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(); - let icon_only = mk.int(b"TP") == Some(1); + let caption = mk.get(b"CA").map(|c| doc.resolve(c)).and_then(|c| c.as_string().map(|s| s.to_text())).unwrap_or_default(); + let icon_only = mk.get(b"TP").and_then(|t| doc.resolve(t).as_int()) == Some(1); let mut content = std::mem::take(&mut c).into_bytes(); let mut xobjects = Dict::new(); if let Some(icon) = mk.get(b"I").and_then(Object::as_ref) @@ -646,7 +655,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 +671,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 +686,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 +848,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 +982,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result