mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-03 17:24:42 +00:00
Merge branch 'land/574' into land/567
# Conflicts: # ROADMAP.md # docs/localization.md # parity/acrobat-features.toml
This commit is contained in:
commit
51797a1ffd
306 files changed
+73146
-509
No files matched your search
+3
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (255)
|
||||
## In this repository (257)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -17,8 +17,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
|
||||
+33
-11
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8444a3bd135d61feb6c5b559a9eb0babb50de2ebbd1d86b1e87ccb064e2d3f0b"
|
||||
sha256 = "2d56f8464f3c25efe9737b204db34cef012ea3716f4d78209d9abeaf0ea9393b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
|
||||
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "8771d80405b8fba9da4a909fdd97b760f6b25a822807ca83ff20dc5cec83a73c"
|
||||
sha256 = "b0ca648560c45e554447aae8dba37c1c9c636c86b277d00fceeb2a21b474c8f7"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/cs.tsv"
|
||||
@@ -41,7 +41,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "bf1ab8079af725264f9c4298b9737359ba831170dcba76a6bd39228239c87e83"
|
||||
sha256 = "8bab483a3f5ba3a724365c2a5a892291a10f41fdc443eaceb66f49c8a20cae20"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/pt-br.tsv"
|
||||
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "b23b03cf4ac163e7a63e49a0900a125507c05ad4fc7930468b859acf32b09ecd"
|
||||
sha256 = "2cd5dcc6cf7e94914b955ee0f2f5c3e8951b16e26ab49fcb7111246a6e31ee2a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/es.tsv"
|
||||
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "1b3371f239ca89a658e2c9b80cc070be5cd941dd1ecd3e7a247696716f25721d"
|
||||
sha256 = "e9a9bad1252afdcb42c36b24ca85411f8c63df7debada94d4423bd48761ecb05"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fr.tsv"
|
||||
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "dbf725ffc9177a435e894f4405834e87c70b6fe31a34f1be533c60c356510b97"
|
||||
sha256 = "0351382a91d2df51da3d45e25a09af87a081bc88e3496c3512ad89f5d4460a3a"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/de.tsv"
|
||||
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "3827d04146f65a02e9d5e69336a97efc46862a48b18a16acbbc023ee0c8b7e1b"
|
||||
sha256 = "57f52e3671a322938a160ac11b73b1f55fb6311140f20d89ae12c9a91a0500d6"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/te.tsv"
|
||||
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f7a5ab0bcbdb8641b1bd4ae25db54ef187757dde3e4fe372e433a9e5c0fc4f3a"
|
||||
sha256 = "d3630f989e9725c22444dc27765142674b66f72436502ca969b2851fc38d703d"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ru.tsv"
|
||||
@@ -107,7 +107,18 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "259f795a3cdc032aa670693d2ebff010c5dd3bcf57dda11548d5bd46e78969b8"
|
||||
sha256 = "e505c00bcc8b8af6aec27abf84bebac7a1925c632130411000a6c57241816193"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/hu.tsv"
|
||||
title = "Hungarian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "c198f1de831d7f9c853baa6977894ccc34d94e6b36c772c2e53fc5b0e0fe708b"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/bg.tsv"
|
||||
@@ -118,7 +129,7 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "e71f683e410618200c0abe66a362a720cb8ded5bfd155bf825f92644373c54b5"
|
||||
sha256 = "9682b2c3c28449dc63637cb5962b309df3ad7db7e8d96bf8bb0f0bc386c9c611"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/ja.tsv"
|
||||
@@ -129,7 +140,18 @@ licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "cdcdba828efea9d402cb9ce8be2b52ab1d58c8f7837d75d3b9c86f2a05e7fb2e"
|
||||
sha256 = "319b032d2c50097ec14e2c7eab92e8696d33e198aeac3585ff46e98c61dec90f"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/uk.tsv"
|
||||
title = "Ukrainian interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
sha256 = "f3a03fc466c1f956cbd44a5cad101c53db5ffc80b3b7551a273ac41f43263dd5"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/accessibility.svg"
|
||||
|
||||
Generated
+3
-2
@@ -3487,6 +3487,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-organize",
|
||||
"pdfcraft-platform",
|
||||
@@ -3641,6 +3642,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -3791,6 +3793,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.22.1",
|
||||
"bytemuck",
|
||||
"eframe",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
@@ -6589,8 +6592,6 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||
[[package]]
|
||||
name = "winit"
|
||||
version = "0.30.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"android-activity",
|
||||
|
||||
@@ -98,3 +98,4 @@ hayro = { path = "vendor/hayro" }
|
||||
hayro-syntax = { path = "vendor/hayro-syntax" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
+11
@@ -80,6 +80,7 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P
|
||||
4. **Editing existing content (M7).** Fonts, subsets, reflow and CJK. The most visible gap for Pro users.
|
||||
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
|
||||
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
|
||||
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
|
||||
|
||||
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
|
||||
|
||||
@@ -104,6 +105,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, German, Spanish and French catalogs have ≈1,800 entries each; Simplified Chinese, French and German coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog. Missing: more catalogs filled in, installers, performance budgets |
|
||||
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Ukrainian catalogs have ≈1,800–2,000 entries each; Simplified Chinese, French and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one. Missing: more catalogs filled in, installers, performance budgets |
|
||||
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
|
||||
|
||||
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
|
||||
@@ -125,6 +127,8 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
## Log
|
||||
|
||||
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
|
||||
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
|
||||
|
||||
@@ -139,6 +143,11 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
|
||||
|
||||
Newest first. One line per session: the date, what moved, and the new overall percentage.
|
||||
|
||||
- **2026-10-09 (M14, German interface follow-up):** Follow the merged German catalog with shorter search/action labels, corrected callout/accessibility wording and a distinct certificate Subject context. Localized tool names retain their capitalization and comment composer titles go through the catalog. Search hints reserve shortcut space and Home cards bound text with full hover text; layout, context and German Preferences/search control regression tests cover these changes. Overall effort estimate remains about 30–35%.
|
||||
|
||||
- **2026-10-09 (community, destinations):** Bookmarks and links go to their destination's position, not just its page (ISO 32000-2 §12.3.2.2): `/XYZ` puts (left, top) at the window's top-left and applies a non-zero zoom; `/Fit`, `/FitH` and `/FitV` switch to fit page, width and height with `top` or `left` at the window's edge; `/FitR` zooms to fit the rectangle, centred. Named destinations resolve the same way; crop-box origin, `/Rotate` and view rotation are accounted for; malformed or non-finite operands fall back to the top of the page. Not yet: the `/FitB*` content box (read as the page). Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (community, M14, Hungarian):** Magyar (`hu`) is a complete interface catalog (2,036 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `hu` and `hu_HU`, and from Preferences. Informal "te"; commands are nouns (Mentés, Megnyitás). After a numeral the noun stays singular, so both plural forms match. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover Hungarian diacritics including ő and ű. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (M14, Windows Auto):** Neutral launcher locales (`C`, `C.UTF-8`, `POSIX`) no longer select English ahead of the Windows preferred display-language list. Explicit supported locale overrides retain their priority. Regression tests use existing catalogs and cover neutral/mixed overrides, unsupported first display tags and multiple preferred Windows languages. Catalogs and other platforms are unchanged; overall effort estimate remains about 30–35%.
|
||||
|
||||
- **2026-10-09 (community, M14, German):** Deutsch (`de`) is a complete interface catalog (2,035 entries) covering every command, All tools entry, dialog, panel and notice, selected automatically for `de`, `de_DE`, `de_AT`, `de_CH` and other German locales, and from Preferences. Informal "du", infinitive for commands. Coverage tests check commands, All tools labels and every `tl!` literal; history labels keep captured names; the bundled Latin faces cover umlauts, ß and German quotes. Overall estimate unchanged (about 30–35%).
|
||||
@@ -146,6 +155,8 @@ Newest first. One line per session: the date, what moved, and the new overall pe
|
||||
- **2026-10-09 (M2, predictor hardening):** Empty PNG predictor frames return without row buffers. Packed TIFF Predictor 2 rows reject overflowing dimensions and size per-component scratch to the samples present in the input, aligned with upstream lopdf#589. Synthetic regressions cover malformed dimensions, partial rows, 1/2/4-bit decoding and padding. Overall estimate unchanged (about 30–35%).
|
||||
- **2026-10-09 (community, document inspection):** Annotation dates preserve non-date Unicode text instead of panicking while opening; failed auxiliary structure inspection keeps renderer page geometry and reports a warning. Synthetic UTF16BE annotation, UTF8-boundary and independent panic regressions cover the fix. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (community, M14, #503):** Ukrainian (Українська, `uk`) adds 2,047 original catalog entries covering commands, tools, dialogs, panels, notices, history and contributor controls. Auto resolves `uk`/`uk_UA`; the manual preference persists. Integer plurals distinguish one/few/many, including 11–14 and large counts; fixed-count templates use count-neutral wording. Calendar dates use genitive month names; Home cards fit wrapped text and the search hint keeps clear of its shortcut. Coverage checks scan UI literals, and font checks use bundled Inter and JetBrains Mono, including ґ/є/і/ї. Control tests check translated Preferences, command search, error framing and language switching with unsaved documents. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (community, Home pinned folders):** Home gets a Folders section: File ▸ Pin folder to Home… (or "Pin a folder…" on Home) pins up to 8 folders, and each lists its newest PDFs (name, how long ago, size; 8 at first, up to 100 with Show more), one click to open, Unpin to remove. Folders are read on a worker thread and listed again every few seconds while Home shows, so new scans appear without reopening anything; a folder that can't be read (moved, offline synced drive) stays pinned and says so. Each folder is described by where it is ("iCloud Drive", "OneDrive · Contoso", "Documents › Work") rather than its full path, so two folders called "Scans" can be told apart. Cloud drives work through their local synced folder; nothing is read through a provider's API. Settings keep only absolute paths, without duplicates. Command label translated in every catalog; Home strings in French, Spanish, Japanese and both Chinese catalogs. Not yet: subfolders, search inside a pinned folder, the web build. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
- **2026-10-09 (community, M14, Telugu):** Telugu (తెలుగు, `te`) as a complete catalog of 1,984 entries covering every command, All tools entry, dialog, panel and notice; `te` and `te_IN` locales follow it. Telugu text is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, storytold/craft-fonts#10), kept in the web build too, after the CJK and Arabic faces; egui shapes its conjuncts. Commands use the plain imperative and sentences the polite form. The craft-fonts revision pinned for releases has no Telugu face yet, so release builds show Telugu as replacement boxes until the pin moves. Overall estimate unchanged (about 30–35%).
|
||||
|
||||
@@ -1,23 +1,6 @@
|
||||
//! pdfcraft-cli — headless PdfCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! pdfcraft-cli info <file.pdf> [--password PW] document summary as JSON
|
||||
//! pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
|
||||
//! pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
//! pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||||
//! [--insert-blank 1] [--title T] [--author A] [--full]
|
||||
//! pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||||
//! pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
//! pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
//! pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
//! pdfcraft-cli tools automation tools and their JSON Schemas
|
||||
//! pdfcraft-cli run <tool> [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 <method> [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 <file.pdf> [--password PW] document summary as JSON
|
||||
pdfcraft-cli render <file.pdf> --page N [--dpi 96] --out x.png (.png, .jpg, .tif or .pam)
|
||||
pdfcraft-cli text <file.pdf> [--page N] extracted text (pages separated by form feeds)
|
||||
pdfcraft-cli edit <in.pdf> --out out.pdf [--rotate 1,3:90] [--delete 2,4] [--move 5:1]
|
||||
[--insert-blank 1] [--title T] [--author A] [--full]
|
||||
pdfcraft-cli combine <a.pdf> <b.pdf> … --out combined.pdf
|
||||
pdfcraft-cli extract <in.pdf> --pages 1,3,5 --out out.pdf
|
||||
pdfcraft-cli split <in.pdf> (--every N | --before 3,7) [--out-dir DIR]
|
||||
pdfcraft-cli check <files or dirs…> [--timeout 20] [--dpi 36] [--json out.json]
|
||||
pdfcraft-cli tools automation tools and their JSON Schemas
|
||||
pdfcraft-cli run <tool> [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 <method> [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<String> = 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 <info|render|text|edit|combine|extract|split|check|tools|run|mcp|ui> … (see source header for options)\nhelp and feedback: https://discord.gg/artcraft"
|
||||
.into()),
|
||||
};
|
||||
// `<command> --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,
|
||||
|
||||
@@ -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 <file.pdf> --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 <file.pdf> --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}");
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
+292
-97
@@ -19,6 +19,9 @@
|
||||
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use pdfcraft_ui_egui::PdfCraftApp;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -150,6 +153,85 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
}
|
||||
let integrated = cfg!(target_os = "macos");
|
||||
migrate_legacy_folders();
|
||||
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
|
||||
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
|
||||
// Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
let choice = renderer_choice(std::env::var("PDFCRAFT_RENDERER").ok().as_deref());
|
||||
let launch = Launch { files, options, control_file, create_images, integrated };
|
||||
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
|
||||
// that launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
// Set once the app is created, which is after the renderer has started.
|
||||
let started = Rc::new(Cell::new(false));
|
||||
let first = if choice == RendererChoice::Gl { eframe::Renderer::Glow } else { eframe::Renderer::Wgpu };
|
||||
let result = eframe::run_native(
|
||||
"PdfCraft",
|
||||
native_options(integrated, first),
|
||||
app_creator(
|
||||
launch.clone(),
|
||||
Rc::clone(&started),
|
||||
#[cfg(target_os = "macos")]
|
||||
&apple_events,
|
||||
),
|
||||
);
|
||||
match result {
|
||||
// Only a renderer that couldn't start: without a window or display at all, OpenGL can't
|
||||
// help either, and winit's own error says more.
|
||||
Err(e @ eframe::Error::Wgpu(_)) if retry_with_gl(choice, started.get()) => {
|
||||
// Old or unusual GPUs and drivers (#461, #435, #392) can't give wgpu a device; OpenGL
|
||||
// usually still works there, so that's better than quitting.
|
||||
log::error!("the GPU renderer (wgpu) didn't start: {e}. Starting with OpenGL instead; set PDFCRAFT_RENDERER=gl to skip wgpu.");
|
||||
eframe::run_native(
|
||||
"PdfCraft",
|
||||
native_options(integrated, eframe::Renderer::Glow),
|
||||
app_creator(
|
||||
launch,
|
||||
started,
|
||||
#[cfg(target_os = "macos")]
|
||||
&apple_events,
|
||||
),
|
||||
)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Which renderer to start with, from `PDFCRAFT_RENDERER`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum RendererChoice {
|
||||
/// wgpu, then OpenGL if wgpu can't start (unset, or anything unrecognised).
|
||||
Auto,
|
||||
/// wgpu only (`wgpu`): a failure is reported, not worked around.
|
||||
Wgpu,
|
||||
/// OpenGL only (`gl`, `opengl` or `glow`): for drivers where wgpu starts but misbehaves.
|
||||
Gl,
|
||||
}
|
||||
|
||||
fn renderer_choice(value: Option<&str>) -> RendererChoice {
|
||||
match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
|
||||
Some("wgpu") => RendererChoice::Wgpu,
|
||||
Some("gl" | "opengl" | "glow") => RendererChoice::Gl,
|
||||
_ => RendererChoice::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a failed run should be retried with OpenGL: only when wgpu was tried first by choice of
|
||||
/// nobody, and it failed before the app was created (so while starting the renderer, not later).
|
||||
fn retry_with_gl(choice: RendererChoice, app_started: bool) -> bool {
|
||||
choice == RendererChoice::Auto && !app_started
|
||||
}
|
||||
|
||||
/// The window and renderer settings for one run.
|
||||
fn native_options(integrated: bool, renderer: eframe::Renderer) -> eframe::NativeOptions {
|
||||
let mut viewport = egui::ViewportBuilder::default()
|
||||
.with_title("PdfCraft")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
@@ -165,80 +247,82 @@ fn main() -> eframe::Result {
|
||||
if integrated {
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
}
|
||||
migrate_legacy_folders();
|
||||
// The log file lives in the settings folder; opened after the arguments (so `--version` leaves
|
||||
// no file behind) and after the PrintCraft migration (which a fresh folder would block).
|
||||
// Records logged until now are written to it first.
|
||||
if let (Some(logger), Some(dir)) = (logger, settings_dir()) {
|
||||
match logger.attach_dir(&dir.join("logs")) {
|
||||
Ok(path) => log::info!("PdfCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
|
||||
// Standard error only by now (`attach_dir` gave up on the file); unlike `eprintln!`, never panics.
|
||||
Err(e) => log::warn!("no log file: {e}"),
|
||||
}
|
||||
}
|
||||
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
|
||||
let mut native = eframe::NativeOptions { viewport, persistence_path, ..Default::default() };
|
||||
configure_gpu(&mut native);
|
||||
// Finder, Open With and the Dock deliver files as Apple events, not arguments; catch the one
|
||||
// that launched us as well as later ones. Lives until the event loop returns.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"PdfCraft",
|
||||
native,
|
||||
Box::new(move |cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
app.restore(&json);
|
||||
// Set explicitly, so the renderer never depends on which one eframe defaults to.
|
||||
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
|
||||
if renderer == eframe::Renderer::Wgpu {
|
||||
configure_gpu(&mut native);
|
||||
}
|
||||
native
|
||||
}
|
||||
|
||||
/// What the command line asked for, kept so a second run (with OpenGL) can start the same way.
|
||||
#[derive(Clone)]
|
||||
struct Launch {
|
||||
files: Vec<String>,
|
||||
options: Vec<(String, String)>,
|
||||
control_file: Option<String>,
|
||||
create_images: bool,
|
||||
integrated: bool,
|
||||
}
|
||||
|
||||
fn app_creator<'a>(
|
||||
launch: Launch,
|
||||
started: Rc<Cell<bool>>,
|
||||
#[cfg(target_os = "macos")] apple_events: &'a apple_events::AppleEvents,
|
||||
) -> eframe::AppCreator<'a> {
|
||||
let Launch { files, options, control_file, create_images, integrated } = launch;
|
||||
Box::new(move |cc| {
|
||||
started.set(true);
|
||||
let mut app = PdfCraftApp::new();
|
||||
if let Some(json) = cc.storage.and_then(|s| s.get_string("pdfcraft").or_else(|| s.get_string(LEGACY_STORAGE_KEY))) {
|
||||
app.restore(&json);
|
||||
}
|
||||
app.integrated_titlebar = integrated;
|
||||
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
if let Some(file) = &control_file {
|
||||
let client = app.attach_control(&cc.egui_ctx);
|
||||
match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
|
||||
// Never the token (AGENTS.md §3): it stays in the owner-only file.
|
||||
Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
|
||||
Err(e) => log::error!("--control {file}: {e}"),
|
||||
}
|
||||
app.integrated_titlebar = integrated;
|
||||
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
}
|
||||
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
|
||||
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
|
||||
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
|
||||
}
|
||||
// A portable marker whose data folder can't be written (#157): say where settings went.
|
||||
if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable {
|
||||
app.notify_fmt(
|
||||
"Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.",
|
||||
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
|
||||
);
|
||||
}
|
||||
if create_images {
|
||||
if let Err(e) = app.begin_image_import_paths(&files) {
|
||||
app.notify(e);
|
||||
}
|
||||
if let Some(file) = &control_file {
|
||||
let client = app.attach_control(&cc.egui_ctx);
|
||||
match pdfcraft_ui_egui::control::serve(client).and_then(|ep| write_control_file(file, ep.port, &ep.token).map(|()| ep.port)) {
|
||||
// Never the token (AGENTS.md §3): it stays in the owner-only file.
|
||||
Ok(port) => log::info!("UI control channel on 127.0.0.1:{port} (connection details in {file})"),
|
||||
Err(e) => log::error!("--control {file}: {e}"),
|
||||
}
|
||||
} else {
|
||||
// With the preference on, last session's files come back first; files named on
|
||||
// the command line open after them, in front (#442).
|
||||
app.reopen_last_files(&files);
|
||||
for f in files {
|
||||
app.open_path(&f);
|
||||
}
|
||||
// Autosave unsaved changes; offer to recover documents a crashed session left behind.
|
||||
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
|
||||
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
|
||||
}
|
||||
for (k, v) in options {
|
||||
if let Err(e) = app.set_option(&k, &v) {
|
||||
log::warn!("--{k} {v}: {e}");
|
||||
}
|
||||
// A portable marker whose data folder can't be written (#157): say where settings went.
|
||||
if let Some(w) = &pdfcraft_ui_egui::portable::current().unwritable {
|
||||
app.notify_fmt(
|
||||
"Portable mode is off: {folder} can't be written ({error}). Settings are kept in your user folder instead.",
|
||||
&[("folder", &w.folder.display().to_string()), ("error", &w.error)],
|
||||
);
|
||||
}
|
||||
if create_images {
|
||||
if let Err(e) = app.begin_image_import_paths(&files) {
|
||||
app.notify(e);
|
||||
}
|
||||
} else {
|
||||
// With the preference on, last session's files come back first; files named on
|
||||
// the command line open after them, in front (#442).
|
||||
app.reopen_last_files(&files);
|
||||
for f in files {
|
||||
app.open_path(&f);
|
||||
}
|
||||
}
|
||||
for (k, v) in options {
|
||||
if let Err(e) = app.set_option(&k, &v) {
|
||||
log::warn!("--{k} {v}: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
)
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
})
|
||||
}
|
||||
|
||||
/// Write the control endpoint so that only the current user can read the token.
|
||||
@@ -297,7 +381,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
|
||||
/// It also saves battery. Machines with one GPU are unaffected.
|
||||
/// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang
|
||||
/// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland
|
||||
/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins.
|
||||
/// compositors on NVIDIA. When several GPUs drive a display, the one driving a built-in panel
|
||||
/// wins (a hybrid laptop, issue #8); without a built-in panel (a desktop with a monitor on each
|
||||
/// GPU, issue #445) the discrete one wins, as the integrated one may fail to present there.
|
||||
/// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless
|
||||
/// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver
|
||||
/// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before
|
||||
@@ -333,52 +419,78 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors
|
||||
/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
|
||||
/// A GPU with a connected monitor.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
struct DisplayGpu {
|
||||
/// PCI `(vendor, device)` ids.
|
||||
pci: (u32, u32),
|
||||
/// Whether one of its connected connectors is a built-in panel (`eDP`, `LVDS` or `DSI`).
|
||||
internal_panel: bool,
|
||||
}
|
||||
|
||||
/// Whether a DRM connector name (`eDP-1`, `LVDS-1`, `DSI-1`) is a laptop's built-in panel.
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
fn is_internal_panel(connector: &str) -> bool {
|
||||
["eDP", "LVDS", "DSI"].iter().any(|kind| connector.starts_with(kind))
|
||||
}
|
||||
|
||||
/// The GPUs with a connected monitor, read from the DRM connectors under `drm` (`card1-DP-3/status`
|
||||
/// is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> {
|
||||
fn linux_display_gpus(drm: &std::path::Path) -> Vec<DisplayGpu> {
|
||||
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
|
||||
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<DisplayGpu> = 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<usize> {
|
||||
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option<usize> {
|
||||
use eframe::wgpu::DeviceType;
|
||||
let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci);
|
||||
let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.internal_panel);
|
||||
adapters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.min_by_key(|(_, (vendor, device, kind))| {
|
||||
let drives_display = displays.contains(&(*vendor, *device));
|
||||
let frugality = match kind {
|
||||
DeviceType::IntegratedGpu => 0,
|
||||
DeviceType::DiscreteGpu => 1,
|
||||
DeviceType::Other => 2,
|
||||
DeviceType::VirtualGpu => 3,
|
||||
DeviceType::Cpu => 4,
|
||||
let shown = display((*vendor, *device));
|
||||
let drives_display = shown.is_some();
|
||||
let drives_panel = shown.is_some_and(|g| g.internal_panel);
|
||||
let rank = match kind {
|
||||
// On a desktop with monitors on both, the integrated GPU can accept the window and
|
||||
// still fail to present to it (#445).
|
||||
DeviceType::DiscreteGpu if drives_display && multi_gpu_desktop => 0,
|
||||
DeviceType::IntegratedGpu => 1,
|
||||
DeviceType::DiscreteGpu => 2,
|
||||
DeviceType::Other => 3,
|
||||
DeviceType::VirtualGpu => 4,
|
||||
DeviceType::Cpu => 5,
|
||||
};
|
||||
(!drives_display, frugality)
|
||||
(!drives_display, !drives_panel, rank)
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
}
|
||||
@@ -406,6 +518,26 @@ mod tests {
|
||||
assert_eq!(super::path_from_arg("file://".to_string()), "file://");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opengl_is_the_fallback_unless_a_renderer_was_chosen() {
|
||||
use super::{RendererChoice, native_options, renderer_choice, retry_with_gl};
|
||||
assert_eq!(renderer_choice(None), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some("vulkan")), RendererChoice::Auto);
|
||||
assert_eq!(renderer_choice(Some(" WGPU ")), RendererChoice::Wgpu);
|
||||
for gl in ["gl", "OpenGL", "glow"] {
|
||||
assert_eq!(renderer_choice(Some(gl)), RendererChoice::Gl);
|
||||
}
|
||||
// Retried with OpenGL only when wgpu failed to start by default, never after the app ran.
|
||||
assert!(retry_with_gl(RendererChoice::Auto, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Auto, true));
|
||||
assert!(!retry_with_gl(RendererChoice::Wgpu, false));
|
||||
assert!(!retry_with_gl(RendererChoice::Gl, false));
|
||||
// Each run states its renderer rather than relying on eframe's default.
|
||||
assert_eq!(native_options(false, eframe::Renderer::Wgpu).renderer, eframe::Renderer::Wgpu);
|
||||
assert_eq!(native_options(false, eframe::Renderer::Glow).renderer, eframe::Renderer::Glow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_backends_avoid_vulkan_on_windows_and_prefer_low_power() {
|
||||
let mut native = eframe::NativeOptions::default();
|
||||
@@ -423,6 +555,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winit_carries_the_windows_11_monitor_scale_fix() {
|
||||
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
|
||||
// scale factor back onto the one it is leaving, so on Windows 11 it ends up on the wrong
|
||||
// monitor, at the wrong size and scale. vendor/winit carries the fix from winit master. A
|
||||
// dependency bump that resolves winit from crates.io again, or a re-vendored copy without
|
||||
// the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
|
||||
// winit 0.30 release has the fix (vendor/README.md).
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
|
||||
assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
|
||||
let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
|
||||
let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
|
||||
assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
|
||||
}
|
||||
|
||||
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
|
||||
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
|
||||
|
||||
@@ -431,29 +580,65 @@ mod tests {
|
||||
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
|
||||
}
|
||||
|
||||
const INTEL_IGPU: (u32, u32) = (0x8086, 0xa780);
|
||||
|
||||
fn monitor(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, internal_panel: false }
|
||||
}
|
||||
|
||||
fn panel(pci: (u32, u32)) -> super::DisplayGpu {
|
||||
super::DisplayGpu { pci, internal_panel: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_gpu_driving_the_monitors() {
|
||||
// A desktop whose monitors are all on the discrete GPU: the integrated one shows black.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA]), Some(0));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() {
|
||||
// Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[NVIDIA, AMD_IGPU]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_prefers_the_discrete_gpu_on_desktops_with_a_monitor_on_each() {
|
||||
// Issue #445: one monitor on the Intel iGPU, one on the NVIDIA card, no built-in panel.
|
||||
use eframe::wgpu::DeviceType;
|
||||
let desktop =
|
||||
[(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)];
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1));
|
||||
// A GPU driving no display still loses to one that does.
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_adapter_falls_back_to_low_power_without_a_match() {
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None);
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1));
|
||||
assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_panels_are_edp_lvds_and_dsi_connectors() {
|
||||
for name in ["eDP-1", "LVDS-1", "DSI-1"] {
|
||||
assert!(super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
for name in ["DP-3", "HDMI-A-1", "DVI-D-1", "VGA-1", "Writeback-1", ""] {
|
||||
assert!(!super::is_internal_panel(name), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir.join(name).join("device"))?;
|
||||
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
|
||||
@@ -469,9 +654,19 @@ mod tests {
|
||||
connector("card1-DP-4", "connected")?;
|
||||
connector("card2-HDMI-A-1", "disconnected")?;
|
||||
connector("card2-Writeback-1", "unknown")?;
|
||||
let gpus = super::linux_display_gpus(&dir);
|
||||
let desktop = super::linux_display_gpus(&dir);
|
||||
// Issue #445: a monitor on the integrated GPU too, still no built-in panel.
|
||||
connector("card2-HDMI-A-1", "connected")?;
|
||||
let mut both = super::linux_display_gpus(&dir);
|
||||
// A hybrid laptop: the integrated GPU drives the built-in panel as well.
|
||||
connector("card2-eDP-1", "connected")?;
|
||||
let mut laptop = super::linux_display_gpus(&dir);
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
assert_eq!(gpus, vec![NVIDIA]);
|
||||
assert_eq!(desktop, vec![monitor(NVIDIA)]);
|
||||
both.sort_by_key(|g| g.pci);
|
||||
assert_eq!(both, vec![monitor(AMD_IGPU), monitor(NVIDIA)]);
|
||||
laptop.sort_by_key(|g| g.pci);
|
||||
assert_eq!(laptop, vec![panel(AMD_IGPU), monitor(NVIDIA)]);
|
||||
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+103
-20
@@ -26,23 +26,77 @@ pub fn cloud_radius(w: f64) -> f64 {
|
||||
6.0 + 1.5 * w.max(0.0)
|
||||
}
|
||||
|
||||
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`. Open and closed arrows only.
|
||||
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) {
|
||||
/// One of the ten `/LE` names (ISO 32000-2 Table 217). Unknown names are not drawn.
|
||||
pub(crate) fn known_ending(kind: &[u8]) -> bool {
|
||||
std::str::from_utf8(kind).ok().and_then(crate::LineEnding::parse).is_some()
|
||||
}
|
||||
|
||||
/// Draw a line ending (`/LE`) at `tip`, for a line arriving from `from`.
|
||||
///
|
||||
/// Returns false for an unknown style or a non-finite direction, so the caller keeps the
|
||||
/// previous appearance instead of writing `NaN`. `None` and a zero-length direction draw nothing.
|
||||
fn line_end(c: &mut String, kind: &[u8], tip: (f64, f64), from: (f64, f64), w: f64) -> bool {
|
||||
if kind == b"None" {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if !known_ending(kind) {
|
||||
return false;
|
||||
}
|
||||
let (dx, dy) = (from.0 - tip.0, from.1 - tip.1);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let len = dx.hypot(dy);
|
||||
if !len.is_finite() {
|
||||
return false;
|
||||
}
|
||||
if len == 0.0 {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
let (ux, uy) = (dx / len, dy / len);
|
||||
let s = arrow_size(w);
|
||||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
|
||||
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
|
||||
let op = if kind == b"ClosedArrow" { "h B" } else { "S" };
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
|
||||
if !s.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let (ix, iy) = (dx / len, dy / len);
|
||||
match kind {
|
||||
b"OpenArrow" | b"ClosedArrow" | b"ROpenArrow" | b"RClosedArrow" => {
|
||||
// Open and closed arrows keep their original geometry. A reverse arrow uses the same
|
||||
// head, pointing back along the line.
|
||||
let (ux, uy) = if kind.starts_with(b"R") { (-ix, -iy) } else { (ix, iy) };
|
||||
let (cos, sin) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let a = (tip.0 + s * (ux * cos - uy * sin), tip.1 + s * (ux * sin + uy * cos));
|
||||
let b = (tip.0 + s * (ux * cos + uy * sin), tip.1 + s * (-ux * sin + uy * cos));
|
||||
let op = if kind.ends_with(b"ClosedArrow") { "h B" } else { "S" };
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {op}\n", n(a.0), n(a.1), n(tip.0), n(tip.1), n(b.0), n(b.1)));
|
||||
}
|
||||
b"Square" | b"Circle" | b"Diamond" => {
|
||||
let h = s / 2.0;
|
||||
if kind == b"Square" {
|
||||
c.push_str(&format!("{} {} {} {} re h B\n", n(tip.0 - h), n(tip.1 - h), n(s), n(s)));
|
||||
} else if kind == b"Circle" {
|
||||
c.push_str(&ellipse(tip.0 - h, tip.1 - h, tip.0 + h, tip.1 + h));
|
||||
c.push_str("B\n");
|
||||
} else {
|
||||
let (px, py) = (-iy, ix);
|
||||
let v = |along: f64, across: f64| (tip.0 + ix * along + px * across, tip.1 + iy * along + py * across);
|
||||
let (a, b, d, e) = (v(h, 0.0), v(0.0, h), v(-h, 0.0), v(0.0, -h));
|
||||
c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h B\n", n(a.0), n(a.1), n(b.0), n(b.1), n(d.0), n(d.1), n(e.0), n(e.1)));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Butt is perpendicular to the line. Slash is that stroke turned 45 degrees.
|
||||
let (px, py) = (-iy, ix);
|
||||
let (sx, sy) = if kind == b"Slash" {
|
||||
let q = std::f64::consts::FRAC_1_SQRT_2;
|
||||
(px * q - py * q, px * q + py * q)
|
||||
} else {
|
||||
(px, py)
|
||||
};
|
||||
let h = s / 2.0;
|
||||
c.push_str(&format!("{} {} m {} {} l S\n", n(tip.0 - sx * h), n(tip.1 - sy * h), n(tip.0 + sx * h), n(tip.1 + sy * h)));
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The cubic Bézier segments (first control point, second control point, end) of a smooth curve
|
||||
@@ -340,32 +394,45 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
});
|
||||
}
|
||||
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<Vec<u8>> = 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::<Option<_>>()?,
|
||||
};
|
||||
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<Vec<u8>> =
|
||||
o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::<Option<_>>()?;
|
||||
if names.iter().any(|e| !known_ending(e)) {
|
||||
return None;
|
||||
}
|
||||
Some(names)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(f) = fill {
|
||||
c.push_str(&rg(f));
|
||||
}
|
||||
@@ -389,6 +456,17 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
(false, true) => "S\n",
|
||||
(false, false) => "n\n",
|
||||
});
|
||||
if let Some(ends) = ends {
|
||||
let col = col?;
|
||||
let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None };
|
||||
let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None };
|
||||
let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None };
|
||||
let fill = color(d, b"IC")?.unwrap_or(col);
|
||||
c.push_str(&format!("[] 0 d\n{}", rg(fill)));
|
||||
if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
b"Caret" => {
|
||||
// A filled caret: two curved flanks meeting at the top centre.
|
||||
@@ -553,17 +631,22 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
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])));
|
||||
|
||||
+150
-16
@@ -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> {
|
||||
Self::ALL.into_iter().find(|ending| ending.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry of a new comment, in PDF user space of its page.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Shape {
|
||||
@@ -294,11 +343,12 @@ pub enum Shape {
|
||||
Oval {
|
||||
rect: [f64; 4],
|
||||
},
|
||||
/// A line, with an open arrowhead at `to` when `arrow` is set.
|
||||
/// A line. An open arrow at `to` is `start: None`, `end: OpenArrow` (the Arrow tool).
|
||||
Line {
|
||||
from: [f64; 2],
|
||||
to: [f64; 2],
|
||||
arrow: bool,
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// Freehand strokes (Draw tool).
|
||||
Ink {
|
||||
@@ -357,9 +407,11 @@ pub enum Shape {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
cloud: bool,
|
||||
},
|
||||
/// Connected lines (Polygonal Line tool).
|
||||
/// Connected lines (Polygonal Line tool). `start` is the first vertex, `end` the last.
|
||||
PolyLine {
|
||||
vertices: Vec<[f64; 2]>,
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// A text callout: a text box at `rect` with a leader line from `point` (arrowhead) via
|
||||
/// `knee` to the box (FreeText, `/IT /FreeTextCallout`, `/CL`).
|
||||
@@ -368,6 +420,7 @@ pub enum Shape {
|
||||
knee: [f64; 2],
|
||||
point: [f64; 2],
|
||||
font_size: f64,
|
||||
ending: LineEnding,
|
||||
},
|
||||
/// Insert text: a caret in `rect` (its point at the top centre).
|
||||
Caret {
|
||||
@@ -726,6 +779,14 @@ fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
|
||||
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
|
||||
}
|
||||
|
||||
fn ending_draws(ending: LineEnding) -> bool {
|
||||
ending != LineEnding::None
|
||||
}
|
||||
|
||||
fn ending_pair(start: LineEnding, end: LineEnding) -> Object {
|
||||
Object::Array(vec![Object::name(start.name()), Object::name(end.name())])
|
||||
}
|
||||
|
||||
/// Validate and compute `/Rect` for a new comment.
|
||||
fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
let bad = |what: &str| AnnotError::Invalid(format!("invalid {what}"));
|
||||
@@ -765,11 +826,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
}
|
||||
r
|
||||
}
|
||||
Shape::Line { from, to, arrow } => {
|
||||
Shape::Line { from, to, start, end } => {
|
||||
if !finite(from) || !finite(to) || (from[0] - to[0]).hypot(from[1] - to[1]) < 1.0 {
|
||||
return Err(bad("line (too short)"));
|
||||
}
|
||||
let pad = half + if *arrow { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
@@ -790,10 +851,11 @@ fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotError> {
|
||||
let b = b.ok_or_else(|| bad("polygon (it needs three points)"))?;
|
||||
grow(b, half + 1.0 + if *cloud { 1.5 * appearance::cloud_radius(style.width) } else { 0.0 })
|
||||
}
|
||||
Shape::PolyLine { vertices } => {
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
let b = bounds(vertices.iter().copied())
|
||||
.filter(|b| vertices.len() >= 2 && vertices.iter().all(|p| finite(p)) && (b[2] - b[0]).max(b[3] - b[1]) >= 1.0);
|
||||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0)
|
||||
let extra = if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(b.ok_or_else(|| bad("connected lines (they need two points)"))?, half + 1.0 + extra)
|
||||
}
|
||||
Shape::Callout { rect, knee, point, .. } => {
|
||||
let r = normalize(*rect);
|
||||
@@ -846,7 +908,7 @@ fn subject(shape: &Shape) -> &'static str {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "Squiggly",
|
||||
Shape::Rectangle { .. } => "Rectangle",
|
||||
Shape::Oval { .. } => "Oval",
|
||||
Shape::Line { arrow: true, .. } => "Arrow",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } => "Pencil",
|
||||
Shape::TextBox { .. } => "Text Box",
|
||||
@@ -943,11 +1005,11 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Line { from, to, arrow } => {
|
||||
Shape::Line { from, to, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"L".to_vec(), num_array(&[from[0], from[1], to[0], to[1]]));
|
||||
if *arrow {
|
||||
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name("OpenArrow")]));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
@@ -1008,9 +1070,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::PolyLine { vertices } => {
|
||||
Shape::PolyLine { vertices, start, end } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
d.set(b"Vertices".to_vec(), num_array(&vertices.concat()));
|
||||
if ending_draws(*start) || ending_draws(*end) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Caret { .. } => {
|
||||
@@ -1047,12 +1112,12 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
d.set(b"Contents".to_vec(), PdfString::text(file.trim()));
|
||||
}
|
||||
}
|
||||
Shape::Callout { rect: tb, knee, point, .. } => {
|
||||
Shape::Callout { rect: tb, knee, point, ending, .. } => {
|
||||
let tb = normalize(*tb);
|
||||
let attach = callout_attach(tb, *knee);
|
||||
d.set(b"IT".to_vec(), Object::name("FreeTextCallout"));
|
||||
d.set(b"CL".to_vec(), num_array(&[point[0], point[1], knee[0], knee[1], attach[0], attach[1]]));
|
||||
d.set(b"LE".to_vec(), Object::name("OpenArrow"));
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
// The text box inside /Rect (§12.5.6.6 /RD).
|
||||
d.set(b"RD".to_vec(), num_array(&[tb[0] - rect[0], tb[1] - rect[1], rect[2] - tb[2], rect[3] - tb[3]]));
|
||||
}
|
||||
@@ -1526,7 +1591,8 @@ fn border_width_of(d: &Dict) -> f64 {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0)
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity and/or line width, and redraw it.
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_style(
|
||||
doc: &mut Document,
|
||||
page: usize,
|
||||
@@ -1534,6 +1600,7 @@ pub fn set_style(
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
endings: Option<&[LineEnding]>,
|
||||
meta: &Meta,
|
||||
) -> Result<(), AnnotError> {
|
||||
let (_, r) = annot_ref(doc, page, index)?;
|
||||
@@ -1542,11 +1609,18 @@ pub fn set_style(
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
// Check before changing anything: a stale appearance would contradict the new style.
|
||||
if appearance::build(&d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
return Err(AnnotError::Unsupported(subtype.clone()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
|
||||
return Err(AnnotError::Invalid("invalid style".into()));
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
let callout = subtype == "FreeText" && d.contains(b"CL");
|
||||
let pair = matches!(subtype.as_str(), "Line" | "PolyLine");
|
||||
if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) {
|
||||
return Err(AnnotError::Invalid("this comment has no line endings to change".into()));
|
||||
}
|
||||
}
|
||||
let free_text = subtype == "FreeText";
|
||||
doc.update_dict(r, |d| {
|
||||
if let Some(c) = color {
|
||||
@@ -1573,11 +1647,55 @@ pub fn set_style(
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
d.remove(b"Border");
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
if ends.len() == 1 {
|
||||
if let Some(ending) = ends.first() {
|
||||
d.set(b"LE".to_vec(), Object::name(ending.name()));
|
||||
}
|
||||
} else if let (Some(start), Some(end)) = (ends.first(), ends.get(1)) {
|
||||
d.set(b"LE".to_vec(), ending_pair(*start, *end));
|
||||
}
|
||||
}
|
||||
ensure_ending_room(d);
|
||||
touch(d, meta);
|
||||
})?;
|
||||
set_appearance(doc, r)
|
||||
}
|
||||
|
||||
/// Grow a line or polyline's rectangle so a non-`None` ending is not clipped. Idempotent.
|
||||
fn ensure_ending_room(d: &mut Dict) {
|
||||
let subtype = d.name(b"Subtype").unwrap_or_default();
|
||||
if !matches!(subtype, b"Line" | b"PolyLine") {
|
||||
return;
|
||||
}
|
||||
if !line_endings_of(d).is_some_and(|ends| ends.iter().any(|e| ending_draws(*e))) {
|
||||
return;
|
||||
}
|
||||
let key: &[u8] = if subtype == b"Line" { b"L" } else { b"Vertices" };
|
||||
let Some(pts) = pair_points(d, key) else { return };
|
||||
let Some(bounds) = bounds(pts.iter().copied()) else { return };
|
||||
let w = border_width_of(d);
|
||||
let need = grow(bounds, w / 2.0 + appearance::arrow_size(w) + 1.0);
|
||||
let vals: Vec<f64> = 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<Vec<[f64; 2]>> {
|
||||
let v: Vec<f64> = d.get(key)?.as_array()?.iter().filter_map(|o| o.as_f64()).collect();
|
||||
if v.len() < 4 || !v.len().is_multiple_of(2) || v.iter().any(|x| !x.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
Some(v.as_chunks::<2>().0.to_vec())
|
||||
}
|
||||
|
||||
// ── reading ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One comment as the viewer lists it (the same fields and rules as
|
||||
@@ -1723,6 +1841,8 @@ pub struct Props {
|
||||
pub restylable: bool,
|
||||
/// The Locked flag.
|
||||
pub locked: bool,
|
||||
/// `/LE`: two names for a line or polyline (`None` when unset), one for a callout.
|
||||
pub endings: Option<Vec<LineEnding>>,
|
||||
}
|
||||
|
||||
/// The current properties of the comment at `(page, index)`.
|
||||
@@ -1747,9 +1867,23 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
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<Vec<LineEnding>> {
|
||||
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.
|
||||
|
||||
+130
-13
@@ -84,7 +84,7 @@ fn every_tool_creates_a_drawable_comment() {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, quads: vec![[72.0, 412.0, 200.0, 412.0, 72.0, 400.0, 200.0, 400.0]] },
|
||||
Shape::Rectangle { rect: [300.0, 300.0, 200.0, 200.0] },
|
||||
Shape::Oval { rect: [300.0, 300.0, 400.0, 350.0] },
|
||||
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], arrow: true },
|
||||
Shape::Line { from: [50.0, 50.0], to: [150.0, 100.0], start: LineEnding::None, end: LineEnding::OpenArrow },
|
||||
Shape::Ink { strokes: vec![vec![[10.0, 10.0], [20.0, 30.0], [30.0, 10.0]], vec![[40.0, 40.0]]] },
|
||||
Shape::TextBox { rect: [300.0, 600.0, 500.0, 650.0], font_size: 14.0 },
|
||||
];
|
||||
@@ -179,7 +179,7 @@ fn invalid_geometry_is_rejected() {
|
||||
let bad = [
|
||||
Shape::TextMarkup { kind: Markup::Highlight, quads: vec![] },
|
||||
Shape::Rectangle { rect: [0.0, 0.0, 0.5, 10.0] },
|
||||
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], arrow: false },
|
||||
Shape::Line { from: [1.0, 1.0], to: [1.0, 1.0], start: LineEnding::None, end: LineEnding::None },
|
||||
Shape::Ink { strokes: vec![vec![]] },
|
||||
Shape::Note { at: [f64::NAN, 0.0], icon: NoteIcon::Note },
|
||||
];
|
||||
@@ -261,7 +261,7 @@ fn move_shifts_all_geometry_and_the_popup() {
|
||||
fn restyle_and_resize_redraw_the_appearance() {
|
||||
let mut doc = fixture();
|
||||
let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap();
|
||||
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap();
|
||||
set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let d = &list(&doc2, 0)[r];
|
||||
@@ -271,10 +271,15 @@ fn restyle_and_resize_redraw_the_appearance() {
|
||||
assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5));
|
||||
// A stamp's appearance can't be regenerated, so restyling it changes nothing.
|
||||
let before = list(&doc, 1)[0].clone();
|
||||
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
|
||||
assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into())));
|
||||
assert_eq!(list(&doc, 1)[0], before);
|
||||
// Lines can't be resized as rectangles.
|
||||
let l = add_annotation(&mut doc, &new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], arrow: false }), &meta("l")).unwrap();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [0.0, 0.0], to: [10.0, 10.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(set_rect(&mut doc, 0, l, [0.0, 0.0, 5.0, 5.0], &meta("")), Err(AnnotError::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -283,7 +288,7 @@ fn text_box_text_and_colour_changes_are_drawn() {
|
||||
let mut doc = fixture();
|
||||
let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap();
|
||||
set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, &meta("")).unwrap();
|
||||
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let d = &list(&doc, 0)[t];
|
||||
assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%");
|
||||
@@ -337,7 +342,16 @@ fn editing_a_callout_refits_the_box_and_keeps_the_leader() {
|
||||
let mut doc = fixture();
|
||||
let c = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Callout { rect: [300.0, 600.0, 500.0, 612.0], knee: [260.0, 560.0], point: [200.0, 520.0], font_size: 12.0 }),
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 600.0, 500.0, 612.0],
|
||||
knee: [260.0, 560.0],
|
||||
point: [200.0, 520.0],
|
||||
font_size: 12.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
),
|
||||
&meta("c"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -429,8 +443,8 @@ fn fill_and_sign_items_are_drawn() {
|
||||
assert_eq!(text(&all[s], b"Subj"), "Signature");
|
||||
// Other stamps still can't be restyled; ours can.
|
||||
let mut doc = doc;
|
||||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, &meta("")).is_err());
|
||||
set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap();
|
||||
assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -515,11 +529,24 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
|
||||
let tri = vec![[100.0, 100.0], [200.0, 100.0], [150.0, 180.0]];
|
||||
let i = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: false }), &meta("p")).unwrap();
|
||||
let c = add_annotation(&mut doc, &new(0, Shape::Polygon { vertices: tri.clone(), cloud: true }), &meta("c")).unwrap();
|
||||
let l =
|
||||
add_annotation(&mut doc, &new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]] }), &meta("l")).unwrap();
|
||||
let l = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::PolyLine { vertices: vec![[300.0, 300.0], [350.0, 320.0], [400.0, 300.0]], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("l"),
|
||||
)
|
||||
.unwrap();
|
||||
let co = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Callout { rect: [300.0, 500.0, 420.0, 540.0], knee: [260.0, 520.0], point: [220.0, 460.0], font_size: 10.0 }),
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 500.0, 420.0, 540.0],
|
||||
knee: [260.0, 520.0],
|
||||
point: [220.0, 460.0],
|
||||
font_size: 10.0,
|
||||
ending: LineEnding::OpenArrow,
|
||||
},
|
||||
),
|
||||
&meta("co"),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -576,7 +603,7 @@ fn polygons_clouds_connected_lines_callouts_and_carets_are_drawn() {
|
||||
|
||||
for bad in [
|
||||
Shape::Polygon { vertices: vec![[0.0, 0.0], [10.0, 10.0]], cloud: false },
|
||||
Shape::PolyLine { vertices: vec![[0.0, 0.0]] },
|
||||
Shape::PolyLine { vertices: vec![[0.0, 0.0]], start: LineEnding::None, end: LineEnding::None },
|
||||
Shape::Caret { rect: [0.0, 0.0, 0.0, 10.0] },
|
||||
] {
|
||||
assert!(add_annotation(&mut doc, &new(0, bad), &meta("b")).is_err());
|
||||
@@ -735,3 +762,93 @@ fn comments_without_appearances_get_one_for_display_only() {
|
||||
]);
|
||||
assert!(with_missing_appearances(&complete).is_none());
|
||||
}
|
||||
|
||||
fn line_dict(coords: [f64; 4], ending: &str) -> pdfcraft_cos::Dict {
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Line"));
|
||||
d.set(b"Rect".to_vec(), Object::Array([0.0, 0.0, 300.0, 300.0].into_iter().map(Object::Real).collect()));
|
||||
d.set(b"C".to_vec(), Object::Array([1.0, 0.0, 0.0].into_iter().map(Object::Real).collect()));
|
||||
d.set(b"L".to_vec(), Object::Array(coords.into_iter().map(Object::Real).collect()));
|
||||
d.set(b"LE".to_vec(), Object::Array(vec![Object::name("None"), Object::name(ending)]));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_line_ending_is_drawn() {
|
||||
let filled = [LineEnding::Square, LineEnding::Circle, LineEnding::Diamond, LineEnding::ClosedArrow, LineEnding::RClosedArrow];
|
||||
let (mut open, mut reverse) = (String::new(), String::new());
|
||||
for ending in LineEnding::ALL {
|
||||
let mut doc = fixture();
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [80.0, 100.0], to: [200.0, 160.0], start: LineEnding::None, end: ending }),
|
||||
&meta("ending"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
||||
let content = ap_content(&doc, &line);
|
||||
assert!(!content.is_empty(), "{ending:?}");
|
||||
let paints = content.contains("h B") || content.contains("B\n");
|
||||
assert_eq!(paints, filled.contains(&ending), "{ending:?} fill: {content}");
|
||||
if ending != LineEnding::None {
|
||||
let r = rect(&line);
|
||||
assert!(r[0] < 70.0 && r[2] > 210.0, "{ending:?} rectangle includes the ending: {r:?}");
|
||||
}
|
||||
if ending == LineEnding::OpenArrow {
|
||||
open = content.clone();
|
||||
}
|
||||
if ending == LineEnding::ROpenArrow {
|
||||
reverse = content;
|
||||
}
|
||||
}
|
||||
assert_ne!(open, reverse, "a reverse arrow points the other way");
|
||||
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::Line { from: [40.0, 40.0], to: [140.0, 40.0], start: LineEnding::None, end: LineEnding::None }),
|
||||
&meta("plain"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"));
|
||||
set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line");
|
||||
let after = rect(&line);
|
||||
assert!(after[1] < before[1] - 5.0 && after[3] > before[3] + 5.0, "the square is not clipped: {before:?} → {after:?}");
|
||||
assert!(ap_content(&doc, &line).contains("re h B"), "square ending");
|
||||
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(0, Shape::PolyLine { vertices: vec![[40.0, 200.0], [90.0, 260.0], [150.0, 200.0]], start: LineEnding::Circle, end: LineEnding::Slash }),
|
||||
&meta("poly"),
|
||||
)
|
||||
.unwrap();
|
||||
add_annotation(
|
||||
&mut doc,
|
||||
&new(
|
||||
0,
|
||||
Shape::Callout {
|
||||
rect: [300.0, 500.0, 420.0, 540.0],
|
||||
knee: [260.0, 520.0],
|
||||
point: [220.0, 460.0],
|
||||
font_size: 10.0,
|
||||
ending: LineEnding::Diamond,
|
||||
},
|
||||
),
|
||||
&meta("call"),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = list(&doc, 0);
|
||||
let poly = all.iter().find(|d| d.name(b"Subtype") == Some(b"PolyLine")).expect("polyline");
|
||||
let poly_ap = ap_content(&doc, poly);
|
||||
assert!(poly_ap.contains("B\n") && poly_ap.contains(" l S"), "circle fills and the slash only strokes: {poly_ap}");
|
||||
let call = all.iter().find(|d| d.name(b"IT") == Some(b"FreeTextCallout")).expect("callout");
|
||||
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
|
||||
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
|
||||
|
||||
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
|
||||
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
|
||||
}
|
||||
@@ -24,5 +24,6 @@ default = ["mcp"]
|
||||
mcp = ["dep:base64"]
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg"] }
|
||||
@@ -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<Rgb> {
|
||||
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<Option<Vec<LineEnding>>> {
|
||||
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::<Result<Vec<_>>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// `[start, end]` for a line, arrow or polyline. An arrow with no `endings` stays `[None, OpenArrow]`.
|
||||
fn ending_pair(kind: &str, endings: Option<&[LineEnding]>) -> Result<[LineEnding; 2]> {
|
||||
match endings {
|
||||
None if kind == "arrow" => Ok([LineEnding::None, LineEnding::OpenArrow]),
|
||||
None => Ok([LineEnding::None, LineEnding::None]),
|
||||
Some(v) if v.len() == 2 => Ok([v.first().copied().unwrap_or(LineEnding::None), v.get(1).copied().unwrap_or(LineEnding::None)]),
|
||||
Some(_) => Err(ToolError::InvalidArgs("a line or polyline needs two endings, the start and the end".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn hex(c: [f32; 3]) -> String {
|
||||
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
format!("#{:02X}{:02X}{:02X}", b(c[0]), b(c[1]), b(c[2]))
|
||||
@@ -181,6 +210,10 @@ impl Automation {
|
||||
let page = self.page(a)?;
|
||||
let info = self.doc(a)?.info.pages[page].clone();
|
||||
let kind = a.str("type")?;
|
||||
let endings = line_endings(a)?;
|
||||
if endings.is_some() && !matches!(kind, "line" | "arrow" | "polyline" | "callout") {
|
||||
return Err(ToolError::InvalidArgs("endings apply to a line, arrow, polyline or callout".into()));
|
||||
}
|
||||
let markup = match kind {
|
||||
"highlight" => Some(Markup::Highlight),
|
||||
"underline" => Some(Markup::Underline),
|
||||
@@ -254,7 +287,8 @@ impl Automation {
|
||||
}
|
||||
"line" | "arrow" => {
|
||||
let (f, t) = (a.need::<2>("from", "a line")?, a.need::<2>("to", "a line")?);
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), arrow: kind == "arrow" }
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
|
||||
}
|
||||
"ink" => {
|
||||
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
|
||||
@@ -287,7 +321,10 @@ impl Automation {
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
match kind {
|
||||
"polyline" => Shape::PolyLine { vertices },
|
||||
"polyline" => {
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::PolyLine { vertices, start, end }
|
||||
}
|
||||
_ => Shape::Polygon { vertices, cloud: kind == "cloud" },
|
||||
}
|
||||
}
|
||||
@@ -310,7 +347,17 @@ impl Automation {
|
||||
[(point[0] + side) / 2.0, mid]
|
||||
}
|
||||
};
|
||||
Shape::Callout { rect, knee, point, font_size: a.opt_num("font_size")?.unwrap_or(10.0) }
|
||||
Shape::Callout {
|
||||
rect,
|
||||
knee,
|
||||
point,
|
||||
font_size: a.opt_num("font_size")?.unwrap_or(10.0),
|
||||
ending: match endings.as_deref() {
|
||||
None => LineEnding::OpenArrow,
|
||||
Some([ending]) => *ending,
|
||||
Some(_) => return Err(ToolError::InvalidArgs("a callout needs one ending".into())),
|
||||
},
|
||||
}
|
||||
}
|
||||
"attachment" => {
|
||||
let [x, y] = a.need::<2>("at", "an attachment (its icon's top-left)")?;
|
||||
@@ -406,12 +453,12 @@ impl Automation {
|
||||
let t = format!("{m}/{d}/{yy}");
|
||||
(text_at(&t), t)
|
||||
}
|
||||
// A typed signature or initials in the script font, left edge at `at`.
|
||||
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
|
||||
kind @ ("signature" | "initials") => {
|
||||
let t = a.str("text")?;
|
||||
let h = if kind == "initials" { 24.0 } else { 32.0 };
|
||||
let shape =
|
||||
pdfcraft_engine::typed_signature_shape(at, t, h).ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
|
||||
let shape = pdfcraft_engine::typed_signature_shape(at, t, h, i64::from(info.rotation))
|
||||
.ok_or_else(|| ToolError::InvalidArgs("text has nothing to draw".into()))?;
|
||||
(shape, String::new())
|
||||
}
|
||||
kind => {
|
||||
@@ -484,7 +531,7 @@ impl Automation {
|
||||
}
|
||||
let (color, opacity, width) = (a.color("color")?, a.opt_num("opacity")?, a.opt_num("width")?);
|
||||
if color.is_some() || opacity.is_some() || width.is_some() {
|
||||
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width });
|
||||
edits.push(Edit::StyleAnnotation { page, index, color, opacity, width, endings: None });
|
||||
}
|
||||
if let Some(r) = a.nums::<4>("rect")? {
|
||||
edits.push(Edit::ResizeAnnotation { page, index, rect: rect_to_user(&info, r) });
|
||||
|
||||
@@ -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::<Vec<_>>()));
|
||||
o.insert("options".into(), json!((0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect::<Vec<_>>()));
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -245,7 +245,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["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<ToolDef> {
|
||||
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<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
"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<ToolDef> {
|
||||
"fill": color(),
|
||||
"opacity": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"width": { "type": "number", "minimum": 0, "description": "Line width in points." },
|
||||
"endings": { "type": "array", "items": { "type": "string", "enum": ["None", "Square", "Circle", "Diamond", "OpenArrow", "ClosedArrow", "Butt", "ROpenArrow", "RClosedArrow", "Slash"] }, "minItems": 1, "maxItems": 2, "description": "Line endings. Two names (start, end) for a line, arrow or polyline; one name for a callout." },
|
||||
"font_size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
}),
|
||||
&["doc", "page", "type"],
|
||||
@@ -998,7 +1000,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.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 },
|
||||
|
||||
@@ -1698,6 +1698,34 @@ fn preparing_a_form_through_tools() {
|
||||
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_field_through_tools() {
|
||||
let dir = workdir("rotate-field");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "City" }));
|
||||
let rect_of = |a: &mut Automation| {
|
||||
let f = &ok(a, "form_fields", json!({ "doc": doc }))["fields"][0];
|
||||
(f["rotation"].as_i64().unwrap(), f["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect::<Vec<_>>())
|
||||
};
|
||||
let (rot, before) = rect_of(&mut a);
|
||||
assert_eq!(rot, 0);
|
||||
assert!(matches!(a.call("form_set_props", &json!({ "doc": doc, "field": "City", "rotation": 45 })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(rect_of(&mut a).1, before, "a rejected rotation changes nothing");
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "City", "rotation": 90 }));
|
||||
let (rot, turned) = rect_of(&mut a);
|
||||
assert_eq!(rot, 90);
|
||||
let (bw, bh) = (before[2] - before[0], before[3] - before[1]);
|
||||
let (tw, th) = (turned[2] - turned[0], turned[3] - turned[1]);
|
||||
assert!((tw - bh).abs() < 0.2 && (th - bw).abs() < 0.2, "swapped {before:?} -> {turned:?}");
|
||||
let center = |r: &[f64]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (bc, tc) = (center(&before), center(&turned));
|
||||
assert!((bc.0 - tc.0).abs() < 0.2 && (bc.1 - tc.1).abs() < 0.2, "center moved {bc:?} -> {tc:?}");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Change field properties");
|
||||
let (rot, back) = rect_of(&mut a);
|
||||
assert_eq!((rot, back), (0, before));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacting_through_tools() {
|
||||
let dir = workdir("redact");
|
||||
@@ -2132,6 +2160,62 @@ fn image_signatures_stay_upright_on_rotated_pages() {
|
||||
check(&mut a, reopened);
|
||||
}
|
||||
|
||||
/// Issue #299: a typed signature and initials read across, as displayed, on every `/Rotate`,
|
||||
/// anchored at the displayed point, and look the same as on an unturned page.
|
||||
#[test]
|
||||
fn typed_signatures_stay_upright_on_rotated_pages() {
|
||||
let dir = workdir("typed-upright");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_create", json!({ "from": "blank", "width": 200, "height": 300, "pages": 4 }))["doc"].as_u64().unwrap();
|
||||
for (page, degrees) in [(2, 90), (3, 180), (4, 270)] {
|
||||
ok(&mut a, "page_rotate", json!({ "doc": doc, "pages": [page], "degrees": degrees }));
|
||||
}
|
||||
for page in 1..=4 {
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "signature", "at": [30, 60], "text": "Ada Lovelace" }));
|
||||
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": page, "type": "initials", "at": [30, 140], "text": "AL" }));
|
||||
}
|
||||
let check = |a: &mut Automation, doc: u64| {
|
||||
let mut upright = Vec::new();
|
||||
for page in 1..=4 {
|
||||
let output = a.call("page_render", &json!({ "doc": doc, "page": page, "dpi": 72 })).unwrap();
|
||||
let Content::Png { data, .. } = &output[0] else { panic!() };
|
||||
let pixels = image::load_from_memory(data).unwrap().to_rgba8();
|
||||
assert_eq!(pixels.dimensions(), if page % 2 == 1 { (200, 300) } else { (300, 200) }, "page {page}");
|
||||
let dark: Vec<bool> = pixels.pixels().map(|p| p.0[..3].iter().all(|v| *v < 128)).collect();
|
||||
for y in [60, 140] {
|
||||
let mut b = [u32::MAX, u32::MAX, 0, 0];
|
||||
for (x, py, p) in pixels.enumerate_pixels() {
|
||||
if py.abs_diff(y) < 40 && p.0[..3].iter().all(|v| *v < 128) {
|
||||
b = [b[0].min(x), b[1].min(py), b[2].max(x), b[3].max(py)];
|
||||
}
|
||||
}
|
||||
assert!(b[0] <= b[2], "page {page}: no ink near y = {y}");
|
||||
assert!(b[2] - b[0] > b[3] - b[1], "page {page}: the name reads across: {b:?}");
|
||||
assert!(b[0].abs_diff(30) <= 2 && b[1] < y && b[3] > y, "page {page}: anchored at (30, {y}): {b:?}");
|
||||
}
|
||||
if page == 1 {
|
||||
upright = dark;
|
||||
continue;
|
||||
}
|
||||
// Same pixels as the unturned page where both pages are (pages 2 and 4 are wider).
|
||||
let width = pixels.width();
|
||||
let (mut differ, mut ink) = (0, 0);
|
||||
for y in 0..200 {
|
||||
for x in 0..200 {
|
||||
let (want, got) = (upright[(y * 200 + x) as usize], dark[(y * width + x) as usize]);
|
||||
ink += usize::from(want);
|
||||
differ += usize::from(want != got);
|
||||
}
|
||||
}
|
||||
assert!(differ * 10 < ink, "page {page}: {differ} of {ink} ink pixels differ from the unturned page");
|
||||
}
|
||||
};
|
||||
check(&mut a, doc);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "placed.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "placed.pdf" }))["doc"].as_u64().unwrap();
|
||||
check(&mut a, reopened);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||||
let dir = workdir("comment-polish");
|
||||
@@ -2163,6 +2247,72 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
|
||||
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings_through_the_tool() {
|
||||
let dir = workdir("endings");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["None", "Diamond"] }));
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "polyline", "points": [[20, 120], [60, 100], [100, 140]], "endings": ["Circle", "Slash"] }),
|
||||
);
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "callout", "rect": [110, 200, 190, 240], "to": [40, 160], "contents": "Look", "endings": ["Square"] }),
|
||||
);
|
||||
assert!(matches!(
|
||||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "line", "from": [20, 40], "to": [120, 80], "endings": ["Sparkle", "None"] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
a.call(
|
||||
"comment_add",
|
||||
&json!({ "doc": doc, "page": 1, "type": "callout", "rect": [10, 10, 80, 40], "to": [90, 80], "endings": ["None", "None"] })
|
||||
),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 40, 40], "endings": ["Circle"] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "ended.pdf" }));
|
||||
let saved = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("ended.pdf")).unwrap())).unwrap();
|
||||
for name in [b"Diamond".as_slice(), b"Circle", b"Slash", b"Square"] {
|
||||
assert!(
|
||||
saved.object_numbers().iter().any(|n| saved.try_get(*n).ok().is_some_and(|o| object_has_name(&o, name))),
|
||||
"missing /{}",
|
||||
String::from_utf8_lossy(name)
|
||||
);
|
||||
}
|
||||
let drawn: Vec<String> = 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");
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
range: Range<usize>,
|
||||
}
|
||||
|
||||
impl Bytes {
|
||||
/// `range` of `buf`, without copying; `None` when the range is not within `buf`.
|
||||
pub(crate) fn view(buf: &Arc<Vec<u8>>, range: Range<usize>) -> Option<Self> {
|
||||
(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<Vec<u8>>) -> 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<Vec<u8>> for Bytes {
|
||||
fn from(v: Vec<u8>) -> Self {
|
||||
let range = 0..v.len();
|
||||
Self { buf: Arc::new(v), range }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Arc<Vec<u8>>> for Bytes {
|
||||
fn from(buf: Arc<Vec<u8>>) -> 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");
|
||||
}
|
||||
}
|
||||
@@ -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<Object>) {
|
||||
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<Object>) -> 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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,14 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod bytes;
|
||||
mod document;
|
||||
mod object;
|
||||
mod parser;
|
||||
mod security;
|
||||
mod writer;
|
||||
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, Revision, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use parser::{Lexer, parse_indirect};
|
||||
|
||||
@@ -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<Vec<u8>>,
|
||||
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<u8>) -> Self {
|
||||
Self { dict, raw: Arc::new(raw) }
|
||||
Self { dict, raw: raw.into() }
|
||||
}
|
||||
|
||||
/// A new stream holding `data` compressed with Flate.
|
||||
pub fn flate(mut dict: Dict, data: &[u8]) -> Self {
|
||||
dict.set(b"Filter".to_vec(), Object::Name(b"FlateDecode".to_vec()));
|
||||
dict.remove(b"DecodeParms");
|
||||
Self { dict, raw: Arc::new(pdfcraft_filters::encode_flate(data)) }
|
||||
Self { dict, raw: pdfcraft_filters::encode_flate(data).into() }
|
||||
}
|
||||
|
||||
/// The filter chain declared in the dictionary.
|
||||
@@ -244,7 +242,7 @@ impl Stream {
|
||||
if self.raw.len() > max {
|
||||
return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
|
||||
}
|
||||
Ok(self.raw.as_ref().clone())
|
||||
Ok(self.raw.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
/// Parse an indirect object at `offset`: `N G obj <object> [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<i64>) -> 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<Vec<u8>>,
|
||||
offset: usize,
|
||||
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
|
||||
) -> Result<(ObjRef, Object), CosError> {
|
||||
parse_indirect_in(data, Some(data), offset, resolve_length)
|
||||
}
|
||||
|
||||
fn parse_indirect_in(
|
||||
data: &[u8],
|
||||
shared: Option<&Arc<Vec<u8>>>,
|
||||
offset: usize,
|
||||
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
|
||||
) -> 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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -335,6 +335,9 @@ pub fn write_full(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>, 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8> {
|
||||
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<u8>, *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);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ fn incremental_saves_are_allocated_at_their_exact_size() {
|
||||
for original in [table, stream, no_newline] {
|
||||
let mut doc = Document::open(original.clone()).unwrap();
|
||||
let data: Vec<u8> = (0..100_000u32).map(|i| (i % 251) as u8).collect();
|
||||
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.clone()) }));
|
||||
let added = doc.add(Object::Stream(Stream { dict: Dict::new(), raw: data.clone().into() }));
|
||||
let updated = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
assert_eq!(updated.capacity(), updated.len());
|
||||
assert!(updated.starts_with(&original), "an incremental save only appends");
|
||||
@@ -112,6 +112,29 @@ fn incremental_saves_are_allocated_at_their_exact_size() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A full save grows its buffer as it writes, so it used to end with up to as much again unused,
|
||||
/// kept alive with the document's working bytes. It now ends at its exact size.
|
||||
#[test]
|
||||
fn full_saves_are_allocated_at_their_exact_size() {
|
||||
let mut doc = Document::open(Arc::new(fixture())).unwrap();
|
||||
let data: Vec<u8> = (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 {
|
||||
|
||||
@@ -239,6 +239,34 @@ fn is_arabic(c: char) -> bool {
|
||||
matches!(u32::from(c), 0x0600..=0x06FF | 0x0750..=0x077F | 0x0870..=0x08FF | 0xFB50..=0xFDFF | 0xFE70..=0xFEFC)
|
||||
}
|
||||
|
||||
/// The first character of `t` that would be drawn as `?`: outside WinAnsiEncoding and not drawn
|
||||
/// with the craft-fonts Arabic face (#125), or Arabic that the face can't draw (or that has no
|
||||
/// face to draw it). Page-content tools refuse such text; the Add-text editor keeps it open.
|
||||
pub fn first_undrawable(t: &AddedText) -> Option<char> {
|
||||
// Arabic that nothing can draw: there's no face, or the face lacks it.
|
||||
if let Some(c) = t.text.chars().find(|c| shaped_arabic(*c) && !pdfcraft_fonts::arabic_has(*c)) {
|
||||
return Some(c);
|
||||
}
|
||||
if pdfcraft_fonts::document_arabic_font().is_none() || !t.text.chars().any(is_arabic) {
|
||||
// Drawn line by line in the item's standard font (`\n` splits lines). Without the face
|
||||
// that is also how `draw` redraws an existing item, so it's checked that way.
|
||||
return t.text.split('\n').find_map(pdfcraft_fonts::first_non_win_ansi);
|
||||
}
|
||||
// What `arabic_layout` leaves to the standard font, exactly as `draw_arabic` splits it.
|
||||
wrapped(t).into_iter().find_map(|(line, rtl)| {
|
||||
let (pieces, _) = arabic_layout(t, &line, rtl).ok()?;
|
||||
pieces.iter().find_map(|p| match p {
|
||||
Piece::Latin(s) => pdfcraft_fonts::first_non_win_ansi(s),
|
||||
Piece::Arabic(_) => None,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// An Arabic character the face shapes and draws (U+061C, a direction mark, is dropped).
|
||||
fn shaped_arabic(c: char) -> bool {
|
||||
is_arabic(c) && !is_bidi_control(c)
|
||||
}
|
||||
|
||||
/// Direction marks, embeddings, overrides and isolates: they steer the order and are not drawn.
|
||||
fn is_bidi_control(c: char) -> bool {
|
||||
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
||||
@@ -706,6 +734,17 @@ fn validate(c: &Content) -> Result<(), EditError> {
|
||||
if (r[2] - r[0]).abs() < 1.0 {
|
||||
return Err(EditError::Invalid("the text box is too narrow".into()));
|
||||
}
|
||||
// Without the Arabic face, `draw` decides for the Arabic letters themselves (#403): a new
|
||||
// item is refused with what's missing, and one that already holds Arabic stays movable.
|
||||
// Everything else in it is drawn in the standard font, so it still has to fit.
|
||||
let undrawable = if t.text.chars().any(shaped_arabic) && pdfcraft_fonts::document_arabic_font().is_none() {
|
||||
t.text.split('\n').find_map(|line| line.chars().find(|c| !shaped_arabic(*c) && !pdfcraft_fonts::win_ansi_encodable(*c)))
|
||||
} else {
|
||||
first_undrawable(t)
|
||||
};
|
||||
if let Some(c) = undrawable {
|
||||
return Err(crate::undrawable(c));
|
||||
}
|
||||
}
|
||||
Content::Image(_) => {
|
||||
if (r[2] - r[0]).abs() < 1.0 || (r[3] - r[1]).abs() < 1.0 {
|
||||
|
||||
+25
-1
@@ -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<Item = &'a str>) -> 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<u8> {
|
||||
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;
|
||||
+145
-3
@@ -336,12 +336,22 @@ fn own(doc: &Document, index: usize) -> Vec<String> {
|
||||
fn odd_arabic_text_never_panics() {
|
||||
let mut doc = fixture();
|
||||
let long = "بسم الله ".repeat(2_000);
|
||||
for s in
|
||||
["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ا\u{2029}ب\rc\u{85}د\n", "ﷺ ١٢٣ 456", long.as_str()]
|
||||
{
|
||||
for s in ["\u{202E}ب\u{064B}\u{064B} (]", "\u{064B}", "ا\u{200F}\u{2067}b\u{2069}", "ا\n\nب\tc", "ﷺ ١٢٣ 456", long.as_str()] {
|
||||
let r = add_content(&mut doc, 0, &Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: s.into(), ..AddedText::default() }));
|
||||
assert_eq!(r.is_ok(), pdfcraft_fonts::document_arabic_font().is_some(), "{:?}: {r:?}", s.chars().take(12).collect::<String>());
|
||||
}
|
||||
// U+2029 and U+0085 (paragraph and line separators) have no WinAnsi code, so the standard font
|
||||
// would draw them as `?`: refused, by name, rather than written (#125). Laying it out still
|
||||
// mustn't panic.
|
||||
let r = add_content(
|
||||
&mut doc,
|
||||
0,
|
||||
&Content::Text(AddedText { rect: [0.0, 800.0, 300.0, 780.0], text: "ا\u{2029}ب\rc\u{85}د\n".into(), ..AddedText::default() }),
|
||||
);
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
if pdfcraft_fonts::document_arabic_font().is_some() {
|
||||
assert!(matches!(&r, Err(EditError::Invalid(m)) if m.contains("U+2029") || m.contains("U+0085")), "{r:?}");
|
||||
}
|
||||
// A character the face lacks is an error that names it, not a box or a crash.
|
||||
if pdfcraft_fonts::document_arabic_font().is_some() && !pdfcraft_fonts::arabic_has('\u{FDFD}') {
|
||||
let r = add_content(
|
||||
@@ -574,6 +584,74 @@ fn japanese_paragraph_uses_unicode_type3_fallback() {
|
||||
assert_eq!(text::text_blocks(&reopened, 0).unwrap()[0].text, replacement);
|
||||
}
|
||||
|
||||
/// Every Type 3 fallback glyph starts with a well-formed `d1`: six operands, the second 0, and a
|
||||
/// box that encloses every point of the glyph. Acrobat shows a bullet for a glyph whose `d1`
|
||||
/// has the wrong operand count.
|
||||
#[test]
|
||||
fn type3_fallback_glyphs_declare_a_well_formed_d1() {
|
||||
if without_craft_fonts("type3_fallback_glyphs_declare_a_well_formed_d1") {
|
||||
return;
|
||||
}
|
||||
// Cyrillic is respaced (shifted by its side bearing), and its box must move with it.
|
||||
let mut glyphs = Vec::new();
|
||||
for replacement in ["見本商会 御中", "София 2027"] {
|
||||
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
|
||||
text::replace_line(&mut doc, 0, 0, replacement).unwrap();
|
||||
glyphs.extend(fallback_paths(&reopen(&doc)));
|
||||
}
|
||||
assert!(!glyphs.is_empty());
|
||||
for glyph in glyphs {
|
||||
let text = String::from_utf8(glyph).unwrap();
|
||||
let mut lines = text.lines();
|
||||
let d1: Vec<&str> = lines.next().unwrap().split_whitespace().collect();
|
||||
assert_eq!(d1.len(), 7, "six operands and d1: {d1:?}");
|
||||
assert_eq!(d1[6], "d1");
|
||||
let n: Vec<f64> = 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<f64> = 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<f64> {
|
||||
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::<Vec<_>>();
|
||||
// "София 2027": the first five codes are the letters.
|
||||
widths[..5].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_paragraph_keeps_ideographic_spaces() {
|
||||
if without_craft_fonts("japanese_paragraph_keeps_ideographic_spaces") {
|
||||
@@ -1126,3 +1204,67 @@ fn the_graphics_state_carries_over_between_content_streams() {
|
||||
let l = &text::text_lines(&doc, 0).unwrap()[0];
|
||||
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
|
||||
}
|
||||
|
||||
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
|
||||
/// and leave the document untouched.
|
||||
#[test]
|
||||
fn page_text_the_standard_fonts_cant_draw_is_refused() {
|
||||
let mut doc = fixture();
|
||||
let before: Vec<Vec<String>> = (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<Vec<String>> = (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<<Bates Number#6#1#ACME-#>>"), 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();
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -538,21 +538,49 @@ fn pdf_num(v: f64) -> String {
|
||||
fn type3_path(face: &CraftFont, ch: char) -> Result<(Vec<u8>, 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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+20
-10
@@ -44,7 +44,7 @@ pub use pdfcraft_forms::{
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine};
|
||||
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable};
|
||||
pub use pdfcraft_measure as measure;
|
||||
pub use pdfcraft_xfa::Report as XfaLayout;
|
||||
|
||||
@@ -69,25 +69,33 @@ pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline};
|
||||
|
||||
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
|
||||
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
|
||||
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64) -> Option<Shape> {
|
||||
///
|
||||
/// 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<Shape> {
|
||||
let o = script_outline(text);
|
||||
let [left, bottom, right, top] = o.bounds();
|
||||
let span = (top - bottom).max(0.1);
|
||||
let width = right - left;
|
||||
if o.contours.is_empty() || o.width <= 0.0 {
|
||||
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let k = height / span;
|
||||
let rect = [at[0], at[1] - height / 2.0, at[0] + width * k, at[1] + height / 2.0];
|
||||
let contours = o.contours.iter().map(|c| c.iter().map(|p| [(p[0] - left) / width, (p[1] - bottom) / span]).collect()).collect();
|
||||
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
|
||||
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
|
||||
// is at this fraction of the user-space box. Exact for an unturned page.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
|
||||
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use pdfcraft_annot::{
|
||||
AttachIcon, FillMark, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb, Shape,
|
||||
StampGroup, StampKind, Style, rect_quad,
|
||||
AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb,
|
||||
Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use pdfcraft_forms::detect;
|
||||
pub use pdfcraft_optimize as optimize;
|
||||
@@ -844,6 +852,8 @@ pub enum Edit {
|
||||
color: Option<Rgb>,
|
||||
opacity: Option<f64>,
|
||||
width: Option<f64>,
|
||||
/// Line or polyline: two endings. Callout: one. `None` leaves `/LE` unchanged.
|
||||
endings: Option<Vec<pdfcraft_annot::LineEnding>>,
|
||||
},
|
||||
/// Comment properties ▸ General / note icon.
|
||||
SetAnnotationInfo {
|
||||
@@ -1189,7 +1199,7 @@ fn annotation_noun(s: &Shape) -> &'static str {
|
||||
Shape::TextMarkup { kind: Markup::Squiggly, .. } => "squiggly underline",
|
||||
Shape::Rectangle { .. } => "rectangle",
|
||||
Shape::Oval { .. } => "oval",
|
||||
Shape::Line { arrow: true, .. } => "arrow",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "arrow",
|
||||
Shape::Line { .. } => "line",
|
||||
Shape::Ink { .. } => "drawing",
|
||||
Shape::TextBox { .. } => "text box",
|
||||
@@ -1486,8 +1496,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
Edit::LockAnnotation { page, index, locked } => pdfcraft_annot::set_locked(doc, *page, *index, *locked)?,
|
||||
Edit::MoveAnnotation { page, index, dx, dy } => pdfcraft_annot::move_annotation(doc, *page, *index, *dx, *dy, &cx.meta())?,
|
||||
Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?,
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, &cx.meta())?;
|
||||
Edit::StyleAnnotation { page, index, color, opacity, width, endings } => {
|
||||
pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?;
|
||||
}
|
||||
Edit::SetAnnotationInfo { page, index, author, subject, icon } => {
|
||||
pdfcraft_annot::set_info(doc, *page, *index, author.as_deref(), subject.as_deref(), *icon, &cx.meta())?;
|
||||
|
||||
@@ -68,6 +68,25 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation` (its
|
||||
/// `/Rotate`), with its left edge at `at` (a user-space point) and vertically centred on it. On a
|
||||
/// quarter-turned page it is `height` wide and `width` tall in user space. `None` for a non-finite
|
||||
/// point.
|
||||
pub fn upright_box(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
|
||||
if !at.iter().chain(&[width, height]).all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
|
||||
Some([
|
||||
at[0] + right[0].min(0.0) - up[0].abs(),
|
||||
at[1] + right[1].min(0.0) - up[1].abs(),
|
||||
at[0] + right[0].max(0.0) + up[0].abs(),
|
||||
at[1] + right[1].max(0.0) + up[1].abs(),
|
||||
])
|
||||
}
|
||||
|
||||
/// Saved signatures live in settings, so both source and normalized PNG are bounded.
|
||||
pub const MAX_SIGNATURE_IMAGE_BYTES: usize = 4 << 20;
|
||||
const MAX_PIXELS: u64 = 4 << 20;
|
||||
@@ -154,16 +173,7 @@ impl SignatureImage {
|
||||
}
|
||||
let [w, h] = self.size;
|
||||
let scale = (150.0 / w as f64).min(if initials { 24.0 } else { 32.0 } / h as f64);
|
||||
let (width, height) = (w as f64 * scale, h as f64 * scale);
|
||||
// Right and up as displayed, as user-space vectors: the page's view matrix without its offset.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(i64::from(page.rotation), [0.0; 4]);
|
||||
let (right, up) = ([a * width, b * width], [c * height / 2.0, d * height / 2.0]);
|
||||
Some([
|
||||
at[0] + right[0].min(0.0) - up[0].abs(),
|
||||
at[1] + right[1].min(0.0) - up[1].abs(),
|
||||
at[0] + right[0].max(0.0) + up[0].abs(),
|
||||
at[1] + right[1].max(0.0) + up[1].abs(),
|
||||
])
|
||||
upright_box(i64::from(page.rotation), at, w as f64 * scale, h as f64 * scale)
|
||||
}
|
||||
|
||||
pub fn edit(&self, page: usize, info: &PageInfo, at: [f64; 2], initials: bool, author: &str) -> Option<Edit> {
|
||||
|
||||
@@ -79,7 +79,7 @@ fn custom_image_stamp_keeps_displayed_orientation_on_rotated_pages() {
|
||||
for (actual, expected) in bounds.into_iter().zip([60.0, 80.0, 140.0, 120.0]) {
|
||||
assert!((actual - expected).abs() < 0.0001, "displayed bounds {bounds:?}");
|
||||
}
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None }).unwrap();
|
||||
session.apply(id, Edit::StyleAnnotation { page: 0, index: 0, color: None, opacity: Some(1.0), width: None, endings: None }).unwrap();
|
||||
check(&session, points);
|
||||
let resized = info.view_rect_to_user([40.0, 70.0, 160.0, 130.0]).map(f64::from);
|
||||
session.apply(id, Edit::ResizeAnnotation { page: 0, index: 0, rect: resized }).unwrap();
|
||||
|
||||
+135
-2
@@ -39,15 +39,51 @@ pub fn helvetica_width(s: &str, size: f64) -> f64 {
|
||||
units * size / 1000.0
|
||||
}
|
||||
|
||||
/// Approximate advance of `s` at `size` points in a CJK font: full-width characters take one
|
||||
/// em, half-width forms half of one, and everything else is measured as Helvetica.
|
||||
pub fn cjk_width(s: &str, size: f64) -> f64 {
|
||||
let mut buf = [0u8; 4];
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
'\u{ff61}'..='\u{ffdc}' | '\u{ffe8}'..='\u{ffee}' => size * 0.5,
|
||||
_ if is_wide(c) => size,
|
||||
_ => helvetica_width(c.encode_utf8(&mut buf), size),
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// East Asian wide and full-width characters (Unicode UAX #11 W and F, by block).
|
||||
fn is_wide(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{1100}'..='\u{115f}' // Hangul Jamo initials
|
||||
| '\u{2e80}'..='\u{303e}' // CJK radicals, Kangxi, ideographic description, CJK symbols
|
||||
| '\u{3041}'..='\u{33ff}' // kana, Bopomofo, Hangul compatibility, Kanbun, CJK compatibility
|
||||
| '\u{3400}'..='\u{4dbf}' // CJK extension A
|
||||
| '\u{4e00}'..='\u{9fff}' // CJK unified ideographs
|
||||
| '\u{a000}'..='\u{a4cf}' // Yi
|
||||
| '\u{ac00}'..='\u{d7a3}' // Hangul syllables
|
||||
| '\u{f900}'..='\u{faff}' // CJK compatibility ideographs
|
||||
| '\u{fe30}'..='\u{fe4f}' // CJK compatibility forms
|
||||
| '\u{ff00}'..='\u{ff60}' // full-width forms
|
||||
| '\u{ffe0}'..='\u{ffe6}'
|
||||
| '\u{20000}'..='\u{3fffd}' // supplementary ideographic planes
|
||||
)
|
||||
}
|
||||
|
||||
/// Greedy line breaking within `width` points (paragraphs split on newlines; words longer
|
||||
/// than a line are broken by character).
|
||||
pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
|
||||
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<String> {
|
||||
let mut lines = Vec::new();
|
||||
for para in text.split(['\n', '\r']) {
|
||||
let mut line = String::new();
|
||||
for word in para.split(' ') {
|
||||
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if helvetica_width(&candidate, size) <= width || line.is_empty() && helvetica_width(word, size) <= width {
|
||||
if measure(&candidate, size) <= width || line.is_empty() && measure(word, size) <= width {
|
||||
line = candidate;
|
||||
continue;
|
||||
}
|
||||
@@ -55,7 +91,7 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec<String> {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
for ch in word.chars() {
|
||||
if !line.is_empty() && helvetica_width(&format!("{line}{ch}"), size) > width {
|
||||
if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
}
|
||||
line.push(ch);
|
||||
@@ -87,6 +123,69 @@ pub fn win_ansi(s: &str) -> Vec<u8> {
|
||||
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<Self> {
|
||||
let name = std::str::from_utf8(name).ok()?;
|
||||
if !name.starts_with("Uni") || !name.ends_with("-H") {
|
||||
return None;
|
||||
}
|
||||
name.split('-').find_map(|part| match part {
|
||||
"UCS2" => Some(Self::Ucs2),
|
||||
"UTF16" => Some(Self::Utf16),
|
||||
"UTF8" => Some(Self::Utf8),
|
||||
"UTF32" => Some(Self::Utf32),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?`.
|
||||
pub fn encode(self, s: &str) -> Vec<u8> {
|
||||
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<char> {
|
||||
s.chars().find(|c| !win_ansi_encodable(*c))
|
||||
}
|
||||
|
||||
/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped.
|
||||
pub fn literal(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(bytes.len() + 2);
|
||||
@@ -107,6 +206,18 @@ pub fn literal(bytes: &[u8]) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn win_ansi_encodability_matches_the_encoder() {
|
||||
for c in ['a', 'é', 'ÿ', '€', '—', '™', '\t', '?'] {
|
||||
assert!(win_ansi_encodable(c), "{c:?}");
|
||||
}
|
||||
for c in ['中', 'あ', 'Ω', 'č', 'ă', '☃', '😀'] {
|
||||
assert!(!win_ansi_encodable(c), "{c:?}");
|
||||
}
|
||||
assert_eq!(first_non_win_ansi("Café — 5€"), None);
|
||||
assert_eq!(first_non_win_ansi("Dvořák"), Some('ř'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn widths_wrap_and_encode() {
|
||||
assert!(helvetica_width("MMMM", 10.0) > helvetica_width("iiii", 10.0) * 2.0);
|
||||
@@ -136,4 +247,26 @@ mod tests {
|
||||
assert_eq!(win_ansi("čć\u{81}\u{8d}\t"), b"???? ");
|
||||
assert_eq!(win_ansi_byte('\0'), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_cmaps_encode_cjk_text() {
|
||||
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UTF16-H"), Some(UnicodeCMap::Utf16));
|
||||
assert_eq!(UnicodeCMap::from_name(b"UniGB-UCS2-V"), None);
|
||||
assert_eq!(UnicodeCMap::from_name(b"UniJIS-UCS2-HW-H"), Some(UnicodeCMap::Ucs2));
|
||||
assert_eq!(UnicodeCMap::from_name(b"UniKS-UTF8-H"), Some(UnicodeCMap::Utf8));
|
||||
assert_eq!(UnicodeCMap::from_name(b"UniJIS2004-UTF32-H"), Some(UnicodeCMap::Utf32));
|
||||
assert_eq!(UnicodeCMap::from_name(b"Identity-H"), None);
|
||||
assert_eq!(UnicodeCMap::from_name(b"90ms-RKSJ-H"), None);
|
||||
assert_eq!(UnicodeCMap::from_name(b"\xff\xfe"), None);
|
||||
assert_eq!(UnicodeCMap::Utf16.encode("令A"), [0x4e, 0xe4, 0x00, 0x41]);
|
||||
assert_eq!(UnicodeCMap::Utf16.encode("𠀋"), [0xd8, 0x40, 0xdc, 0x0b]);
|
||||
assert_eq!(UnicodeCMap::Ucs2.encode("𠀋\t"), [0x00, b'?', 0x00, b' ']);
|
||||
assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes());
|
||||
assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]);
|
||||
assert_eq!(cjk_width("令和", 10.0), 20.0);
|
||||
assert_eq!(cjk_width("ア", 10.0), 5.0);
|
||||
assert_eq!(cjk_width("A", 10.0), helvetica_width("A", 10.0));
|
||||
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
|
||||
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
@@ -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.
|
||||
+111
-21
@@ -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<i64> {
|
||||
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<Object> {
|
||||
/// 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<u8> {
|
||||
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<Object> {
|
||||
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<u8> = Vec::new();
|
||||
let show = |body: &mut Vec<u8>, 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));
|
||||
|
||||
+69
-11
@@ -91,6 +91,10 @@ pub struct FieldProps {
|
||||
pub actions: Option<Vec<(crate::Trigger, crate::FieldAction)>>,
|
||||
/// Options tab: the mark of a check box or radio button (every widget).
|
||||
pub check_style: Option<CheckStyle>,
|
||||
/// 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<T>(m: impl Into<String>) -> Result<T, FormError> {
|
||||
@@ -524,6 +528,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
tab: usize::MAX,
|
||||
locked: false,
|
||||
hidden: false,
|
||||
rotation: 0,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export);
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
@@ -574,10 +579,12 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
|
||||
let (width, height) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
fn frame_only(doc: &Document, w: &Widget) -> (String, crate::appearance::Placement) {
|
||||
let (page_w, page_h) = ((w.rect[2] - w.rect[0]).max(1.0), (w.rect[3] - w.rect[1]).max(1.0));
|
||||
let wobj = doc.get(w.obj);
|
||||
let wd = wobj.as_dict().cloned().unwrap_or_default();
|
||||
let place = crate::appearance::placement(crate::appearance::mk_rotation(doc, &wd), page_w, page_h);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let mk = wd.get(b"MK").and_then(|m| m.as_dict()).cloned().unwrap_or_default();
|
||||
let col = |k: &[u8]| -> Option<String> {
|
||||
let v: Vec<f64> = mk.get(k)?.as_array()?.iter().filter_map(|x| x.as_f64()).collect();
|
||||
@@ -590,14 +597,14 @@ fn frame_only(doc: &Document, w: &Widget) -> (String, f64, f64) {
|
||||
if let Some(bc) = col(b"BC") {
|
||||
c.push_str(&format!("{bc} RG 1 w 0.5 0.5 {:.3} {:.3} re S\n", width - 1.0, height - 1.0));
|
||||
}
|
||||
(c, width, height)
|
||||
(c, place)
|
||||
}
|
||||
|
||||
fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> Stream {
|
||||
fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources: Dict) -> Stream {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
d.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, width, height].iter().map(|v| Object::Real(*v)).collect()));
|
||||
crate::appearance::set_form_box(&mut d, place);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
Stream::flate(d, &content)
|
||||
}
|
||||
@@ -605,7 +612,8 @@ fn form_stream(width: f64, height: f64, content: Vec<u8>, resources: Dict) -> St
|
||||
/// A push button: background, border, its icon (`/MK /I`, scaled to fit and centred) and its
|
||||
/// centred caption (unless the layout is icon only, `/TP 1`).
|
||||
fn button_appearance(doc: &Document, w: &Widget) -> Stream {
|
||||
let (mut c, width, height) = frame_only(doc, w);
|
||||
let (mut c, place) = frame_only(doc, w);
|
||||
let (width, height) = (place.layout_w, place.layout_h);
|
||||
let wobj = doc.get(w.obj);
|
||||
let mk = wobj.as_dict().and_then(|d| d.get(b"MK")).map(|m| doc.resolve(m)).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
|
||||
let caption = mk.get(b"CA").and_then(|c| c.as_string()).map(|s| s.to_text()).unwrap_or_default();
|
||||
@@ -646,7 +654,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
|
||||
if !xobjects.is_empty() {
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
}
|
||||
form_stream(width, height, content, res)
|
||||
form_stream(place, content, res)
|
||||
}
|
||||
|
||||
/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels):
|
||||
@@ -662,7 +670,8 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
|
||||
xo.set(b"Im0".to_vec(), Object::Ref(image));
|
||||
let mut res = Dict::new();
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
let icon = doc.add(Object::Stream(form_stream(w, h, format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
|
||||
let icon =
|
||||
doc.add(Object::Stream(form_stream(crate::appearance::placement(0, w, h), format!("q {w} 0 0 {h} 0 0 cm /Im0 Do Q").into_bytes(), res)));
|
||||
for wd in &f.widgets {
|
||||
let mut mk =
|
||||
doc.get(wd.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m))).and_then(|m| m.as_dict().cloned()).unwrap_or_default();
|
||||
@@ -676,14 +685,43 @@ pub fn set_button_icon(doc: &mut Document, name: &str, image: ObjRef, px: (u32,
|
||||
}
|
||||
|
||||
fn empty_box(doc: &Document, w: &Widget) -> Stream {
|
||||
let (c, width, height) = frame_only(doc, w);
|
||||
form_stream(width, height, c.into_bytes(), Dict::new())
|
||||
let (c, place) = frame_only(doc, w);
|
||||
form_stream(place, c.into_bytes(), Dict::new())
|
||||
}
|
||||
|
||||
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
|
||||
fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
if !r.iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (x0, y0, x1, y1) = (r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3]));
|
||||
let (cx, cy) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0);
|
||||
let (nw, nh) = (y1 - y0, x1 - x0);
|
||||
let out = [cx - nw / 2.0, cy - nh / 2.0, cx + nw / 2.0, cy + nh / 2.0];
|
||||
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= 4.0 && out[3] - out[1] >= 4.0).then_some(out)
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
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<Option<[f64; 4]>> = None;
|
||||
if let Some((wi, rot)) = props.rotation {
|
||||
if !matches!(rot, 0 | 90 | 180 | 270) {
|
||||
return invalid("rotation must be 0, 90, 180 or 270 degrees");
|
||||
}
|
||||
let Some(w) = f.widgets.get(wi) else {
|
||||
return invalid(format!("{name} has no widget {}", wi.saturating_add(1)));
|
||||
};
|
||||
let axis_change = (w.rotation % 180 == 0) != (rot % 180 == 0);
|
||||
rotated_rect = Some(None);
|
||||
if axis_change && props.rect.is_none() {
|
||||
let Some(rect) = swapped_around_center(w.rect) else { return invalid("the field is too small") };
|
||||
rotated_rect = Some(Some(rect));
|
||||
}
|
||||
}
|
||||
// A locked field only takes unlocking (Acrobat greys its properties out).
|
||||
if f.locked() && props.locked != Some(false) && *props != (FieldProps { locked: props.locked, ..FieldProps::default() }) {
|
||||
return invalid(format!("{name} is locked: unlock it first"));
|
||||
@@ -809,7 +847,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
match &props.default_value {
|
||||
// Buttons name their default state; text and choices hold a string.
|
||||
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::name(v)),
|
||||
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
|
||||
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
|
||||
Some(None) => {
|
||||
d.remove(b"DV");
|
||||
@@ -943,6 +981,26 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some((wi, rot)), Some(swapped)) = (props.rotation, rotated_rect)
|
||||
&& let Some(w) = f.widgets.get(wi)
|
||||
{
|
||||
if let Some(rect) = swapped {
|
||||
doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(rect.iter().map(|v| Object::Real(*v)).collect())))?;
|
||||
}
|
||||
let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default();
|
||||
if rot == 0 {
|
||||
mk.remove(b"R");
|
||||
} else {
|
||||
mk.set(b"R".to_vec(), Object::Int(rot));
|
||||
}
|
||||
doc.update_dict(w.obj, |d| {
|
||||
if mk.is_empty() {
|
||||
d.remove(b"MK");
|
||||
} else {
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
}
|
||||
})?;
|
||||
}
|
||||
redraw_field(doc, &new_name)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
|
||||
+79
-7
@@ -101,6 +101,8 @@ pub struct Widget {
|
||||
pub locked: bool,
|
||||
/// The widget's Hidden or NoView flag (`/F` bit 2 or 6): it isn't shown and takes no input.
|
||||
pub hidden: bool,
|
||||
/// `/MK /R` as 0, 90, 180 or 270 degrees counterclockwise. Anything else is stored as 0.
|
||||
pub rotation: i64,
|
||||
}
|
||||
|
||||
/// A terminal form field.
|
||||
@@ -144,6 +146,35 @@ impl Field {
|
||||
self.widgets.iter().any(|w| w.locked)
|
||||
}
|
||||
|
||||
/// Check boxes and radio groups with `/Opt` (PDF 1.4): one export value per widget, in widget
|
||||
/// order. Their on states are then often just positions (`/0`, `/1` …), as pdf-lib writes them.
|
||||
fn button_exports(&self) -> Option<&[(String, String)]> {
|
||||
(matches!(self.kind, FieldKind::CheckBox | FieldKind::Radio) && !self.options.is_empty() && self.options.len() == self.widgets.len())
|
||||
.then_some(self.options.as_slice())
|
||||
}
|
||||
|
||||
/// The export value of widget `i` of a check box or radio group: its `/Opt` entry when the
|
||||
/// field has one per widget, otherwise the widget's on-state name.
|
||||
pub fn export_of(&self, i: usize) -> Option<&str> {
|
||||
let on = self.widgets.get(i)?.on_state.as_deref()?;
|
||||
Some(self.button_exports().and_then(|o| o.get(i)).map_or(on, |(e, _)| e.as_str()))
|
||||
}
|
||||
|
||||
/// The on-state name that selects `choice` in a check box or radio group, where `choice` is an
|
||||
/// on-state name or an export value from `/Opt`.
|
||||
pub fn state_for(&self, choice: &str) -> Option<&str> {
|
||||
self.widgets.iter().filter_map(|w| w.on_state.as_deref()).find(|s| *s == choice).or_else(|| {
|
||||
let i = (0..self.widgets.len()).find(|&i| self.export_of(i) == Some(choice))?;
|
||||
self.widgets.get(i)?.on_state.as_deref()
|
||||
})
|
||||
}
|
||||
|
||||
/// The export value for an on-state name such as the field's value; the name itself when the
|
||||
/// field has no `/Opt`.
|
||||
pub fn export_for_state<'a>(&'a self, state: &'a str) -> &'a str {
|
||||
self.widgets.iter().position(|w| w.on_state.as_deref() == Some(state)).and_then(|i| self.export_of(i)).unwrap_or(state)
|
||||
}
|
||||
|
||||
/// The value as one string: text, the state name, or the selected display texts.
|
||||
pub fn display_value(&self) -> String {
|
||||
match self.kind {
|
||||
@@ -175,11 +206,49 @@ pub enum FieldValue {
|
||||
fn text_of(o: &Object) -> Option<String> {
|
||||
match o {
|
||||
Object::String(s) => Some(s.to_text()),
|
||||
Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
|
||||
Object::Name(n) => Some(name_text(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A name object's bytes as text that [`name_bytes`] turns back into the same bytes. UTF-8 names
|
||||
/// read as themselves; other bytes (Shift-JIS check box states such as 「はい」 in Japanese
|
||||
/// forms) and `#` are written `#XX`, as in PDF name syntax.
|
||||
pub fn name_text(bytes: &[u8]) -> String {
|
||||
let escape = |b: u8| format!("#{b:02X}");
|
||||
match std::str::from_utf8(bytes) {
|
||||
Ok(s) => s.replace('#', "#23"),
|
||||
Err(_) => bytes.iter().map(|&b| if (0x21..=0x7e).contains(&b) && b != b'#' { char::from(b).to_string() } else { escape(b) }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes of the name written as `text` by [`name_text`] (`#XX` is the byte XX).
|
||||
pub fn name_bytes(text: &str) -> Vec<u8> {
|
||||
let b = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let hex = |j: usize| b.get(j).and_then(|h| char::from(*h).to_digit(16));
|
||||
match (c, hex(i + 1), hex(i + 2)) {
|
||||
// Both digits are below 16, so the byte fits.
|
||||
(b'#', Some(hi), Some(lo)) => {
|
||||
out.push(u8::try_from(hi * 16 + lo).unwrap_or(b'#'));
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A name object from text written by [`name_text`].
|
||||
fn name_obj(text: &str) -> Object {
|
||||
Object::Name(name_bytes(text))
|
||||
}
|
||||
|
||||
/// At most this many `/State` entries of a set-layer-visibility action are read.
|
||||
const MAX_LAYER_STATE: usize = 1024;
|
||||
|
||||
@@ -625,16 +694,17 @@ fn walk(
|
||||
.map(|ap| doc.resolve(ap))
|
||||
.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| doc.resolve(&n).as_dict().cloned())
|
||||
.and_then(|n| n.iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).find(|k| k != "Off"));
|
||||
.and_then(|n| n.iter().map(|(k, _)| name_text(k)).find(|k| k != "Off"));
|
||||
Some(Widget {
|
||||
obj: w,
|
||||
page: page_of.get(&w).copied(),
|
||||
rect: [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])],
|
||||
on_state: on_state.filter(|_| matches!(kind, FieldKind::CheckBox | FieldKind::Radio)),
|
||||
state: wd.name(b"AS").map(|s| String::from_utf8_lossy(s).into_owned()),
|
||||
state: wd.name(b"AS").map(name_text),
|
||||
tab: usize::MAX,
|
||||
locked: annot_flags & 128 != 0,
|
||||
hidden: annot_flags & (2 | 32) != 0,
|
||||
rotation: appearance::mk_rotation(doc, wd),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -780,7 +850,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
// The on-state name or a yes/no word also work (agents, FDF-style data).
|
||||
FieldValue::Text(t) | FieldValue::Radio(Some(t)) => {
|
||||
let t = t.trim();
|
||||
if t == state || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
|
||||
if t == state || f.state_for(t).is_some() || ["yes", "true", "on", "1", "x", "checked"].contains(&t.to_lowercase().as_str()) {
|
||||
true
|
||||
} else if t.is_empty() || ["no", "false", "off", "0", "unchecked"].contains(&t.to_lowercase().as_str()) {
|
||||
false
|
||||
@@ -801,10 +871,12 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
FieldValue::Check(false) => None,
|
||||
_ => return invalid(format!("{:?} is a radio group: choose one of its options", f.name)),
|
||||
};
|
||||
// An export value from /Opt selects its widget's on state.
|
||||
let choice = choice.map(|c| f.state_for(&c).map(str::to_owned).unwrap_or(c));
|
||||
if let Some(c) = &choice
|
||||
&& !f.widgets.iter().any(|w| w.on_state.as_deref() == Some(c.as_str()))
|
||||
{
|
||||
let opts: Vec<&str> = f.widgets.iter().filter_map(|w| w.on_state.as_deref()).collect();
|
||||
let opts: Vec<&str> = (0..f.widgets.len()).filter_map(|i| f.export_of(i)).collect();
|
||||
return invalid(format!("{:?} has no option {c:?} (options: {})", f.name, opts.join(", ")));
|
||||
}
|
||||
if choice.is_none() && f.has(flags::NO_TOGGLE_TO_OFF) && !f.value.is_empty() {
|
||||
@@ -862,7 +934,7 @@ fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scripts: &mut
|
||||
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
|
||||
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
|
||||
let v = on.unwrap_or("Off");
|
||||
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), Object::name(v)))?;
|
||||
doc.update_dict(f.obj, |d| d.set(b"V".to_vec(), name_obj(v)))?;
|
||||
for w in &f.widgets {
|
||||
let state = match (on, w.on_state.as_deref()) {
|
||||
(Some(c), Some(s)) if c == s => s,
|
||||
@@ -875,7 +947,7 @@ fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), For
|
||||
let ap = appearance::check_box_states(doc, w, f.kind, &on_name);
|
||||
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
}
|
||||
doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), Object::name(state)))?;
|
||||
doc.update_dict(w.obj, |d| d.set(b"AS".to_vec(), name_obj(state)))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ fn fixture() -> Document {
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (go) /Ff 65536 /Rect [300 450 380 470] /P 3 0 R >>".into(), // 25
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (bare) /Rect [400 700 415 715] /P 3 0 R /Foo (kept) >>".into(), // 26 check box without AP
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
/// A PDF of the given objects (numbered from 1).
|
||||
fn document(objs: &[String]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
@@ -90,6 +95,40 @@ fn the_field_tree_is_read_with_inheritance() {
|
||||
assert_eq!(field(&all, "address.city").quadding, 1);
|
||||
}
|
||||
|
||||
/// pdf-lib and other writers give radio groups and check boxes an `/Opt` array and name the on
|
||||
/// states by position (`/0`, `/1`): the export values select them, as in Acrobat.
|
||||
#[test]
|
||||
fn opt_export_values_select_button_states() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R] >>".into(), // 3
|
||||
"<< /Fields [5 0 R 8 0 R] /DA (/Helv 0 Tf 0 g) >>".into(), // 4
|
||||
"<< /FT /Btn /T (ship) /Ff 49152 /Opt [(Post) (Pick-up)] /V /Off /Kids [6 0 R 7 0 R] >>".into(), // 5 radio
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [100 700 115 715] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 6
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /AS /Off /Rect [200 700 215 715] /P 3 0 R /AP << /N << /1 9 0 R /Off 10 0 R >> >> >>".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (terms) /Opt [(Accepted)] /V /Off /AS /Off /Rect [100 650 115 665] /P 3 0 R /AP << /N << /0 9 0 R /Off 10 0 R >> >> >>".into(), // 8 check box
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 9
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 10
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
let ship = field(&all, "ship");
|
||||
assert_eq!((0..2).map(|i| ship.export_of(i)).collect::<Vec<_>>(), [Some("Post"), Some("Pick-up")]);
|
||||
assert_eq!((ship.state_for("Pick-up"), ship.state_for("1"), ship.state_for("Courier")), (Some("1"), Some("1"), None));
|
||||
set_value(&mut doc, "ship", &FieldValue::Radio(Some("Pick-up".into()))).unwrap();
|
||||
set_value(&mut doc, "terms", &FieldValue::Text("Accepted".into())).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let ship = field(&all, "ship");
|
||||
assert_eq!(ship.value, ["1"]);
|
||||
assert_eq!(ship.export_for_state("1"), "Pick-up");
|
||||
assert_eq!(ship.widgets.iter().map(|w| w.state.as_deref()).collect::<Vec<_>>(), [Some("Off"), Some("1")]);
|
||||
assert_eq!(field(&all, "terms").value, ["0"]);
|
||||
let err = set_value(&mut doc, "ship", &FieldValue::Radio(Some("Courier".into()))).unwrap_err();
|
||||
assert!(err.to_string().contains("options: Post, Pick-up"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_fields_get_new_appearances() {
|
||||
let mut doc = fixture();
|
||||
@@ -120,6 +159,55 @@ fn text_fields_get_new_appearances() {
|
||||
assert!(raw.windows(8).any(|x| x == b"(Z\xfcrich)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn japanese_choices_use_the_unicode_cid_font() {
|
||||
// A choice field whose /DA names a non-embedded CID font with a predefined Unicode CMap
|
||||
// (UniJIS-UTF16-H). Selecting 令 must draw it in that font, not "?" in Helvetica.
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [8 0 R 9 0 R 10 0 R] >>".into(),
|
||||
"<< /Fields [8 0 R 9 0 R 10 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /HeiseiMin-W3 6 0 R /Emb 7 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /HeiseiMin-W3 /Encoding /UniJIS-UTF16-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /HeiseiMin-W3 /CIDSystemInfo << /Registry (Adobe) /Ordering (Japan1) /Supplement 6 >> >>] >>".into(),
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /ABCDEF+Subset /Encoding /Identity-H /DescendantFonts [] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (era) /Ff 131072 /DA (/HeiseiMin-W3 10 Tf 0 g) /Q 1 /Opt [<FEFF3000> <FEFF660E> <FEFF4EE4>] /V <FEFF3000> /Rect [50 700 80 715] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (address) /Ff 4096 /DA (/HeiseiMin-W3 10 Tf 0 g) /Rect [50 600 100 680] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (subset) /DA (/Emb 10 Tf 0 g) /Rect [50 500 100 520] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
assert_eq!(field(&fields(&doc), "era").options.get(2), Some(&("令".to_string(), "令".to_string())));
|
||||
set_value(&mut doc, "era", &FieldValue::Text("令".into())).unwrap();
|
||||
set_value(&mut doc, "address", &FieldValue::Text("日本語のテキスト入力欄です".into())).unwrap();
|
||||
set_value(&mut doc, "subset", &FieldValue::Text("令和".into())).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "era").value, ["令"]);
|
||||
let raw = |name: &str| {
|
||||
let w = &field(&all, name).widgets[0];
|
||||
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let Object::Stream(s) = &*doc.get(n) else { panic!() };
|
||||
let fonts = doc.resolve(s.dict.get(b"Resources").unwrap()).as_dict().unwrap().get(b"Font").map(|f| doc.resolve(f)).unwrap();
|
||||
let fonts: Vec<String> = fonts.as_dict().unwrap().iter().map(|(k, _)| String::from_utf8_lossy(k).into_owned()).collect();
|
||||
(s.decoded().unwrap(), fonts)
|
||||
};
|
||||
let utf16 = |t: &str| t.encode_utf16().flat_map(u16::to_be_bytes).collect::<Vec<u8>>();
|
||||
let (era, era_fonts) = raw("era");
|
||||
assert!(era.windows(4).any(|x| x == b"(N\xe4)"), "令 as UTF-16BE: {}", String::from_utf8_lossy(&era));
|
||||
assert!(String::from_utf8_lossy(&era).contains("/HeiseiMin-W3 10 Tf"));
|
||||
assert!(!era.windows(3).any(|x| x == b"(?)"), "no WinAnsi fallback");
|
||||
assert_eq!(era_fonts, ["HeiseiMin-W3"]);
|
||||
// Multiline Japanese wraps by character at about one em each (50 pt wide, 10 pt type).
|
||||
let (addr, _) = raw("address");
|
||||
let addr_text = String::from_utf8_lossy(&addr);
|
||||
assert!(addr_text.matches(" Tj").count() >= 4, "{addr_text}");
|
||||
assert!(addr.windows(4).any(|x| x == utf16("日本").as_slice()));
|
||||
// An Identity-H subset can't be addressed by Unicode: Helvetica, as before.
|
||||
let (sub, sub_fonts) = raw("subset");
|
||||
assert!(String::from_utf8_lossy(&sub).contains("/Helv "), "{}", String::from_utf8_lossy(&sub));
|
||||
assert_eq!(sub_fonts, ["Helv"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_and_radios_switch_states() {
|
||||
let mut doc = fixture();
|
||||
@@ -146,6 +234,46 @@ fn check_boxes_and_radios_switch_states() {
|
||||
assert!(field(&fields(&doc), "agree").value.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_boxes_keep_non_utf8_state_names() {
|
||||
// Japanese forms often name a check box's on state 「はい」 in Shift-JIS: ticking it has to
|
||||
// write those exact bytes to /AS and /V, or no appearance matches and no mark shows.
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /AS /Off /Rect [50 700 56 706] /P 3 0 R /AP << /N << /Off 6 0 R /#82#CD#82#A2 7 0 R >> >> >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
];
|
||||
let sjis_hai: &[u8] = b"\x82\xcd\x82\xa2";
|
||||
let mut doc = document(&objs);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "agree").widgets[0].on_state.as_deref(), Some("#82#CD#82#A2"));
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(true)).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "agree").clone();
|
||||
let wd = doc.get(f.widgets[0].obj).as_dict().cloned().unwrap();
|
||||
assert_eq!(wd.name(b"AS"), Some(sjis_hai));
|
||||
assert_eq!(doc.get(f.obj).as_dict().unwrap().name(b"V"), Some(sjis_hai));
|
||||
assert_eq!(f.value, ["#82#CD#82#A2"]);
|
||||
let mut doc = doc;
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(false)).unwrap();
|
||||
assert_eq!(doc.get(f.widgets[0].obj).as_dict().unwrap().name(b"AS"), Some(&b"Off"[..]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_text_round_trips() {
|
||||
for bytes in [&b"Yes"[..], b"\x82\xcd\x82\xa2", "はい".as_bytes(), b"A#1", b"a b", b"", b"\xff#\x00"] {
|
||||
assert_eq!(name_bytes(&name_text(bytes)), bytes, "{bytes:?}");
|
||||
}
|
||||
assert_eq!(name_text("はい".as_bytes()), "はい");
|
||||
assert_eq!(name_text(b"A#1"), "A#231");
|
||||
// Malformed escapes stay as they are.
|
||||
assert_eq!(name_bytes("#G1#4"), b"#G1#4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn choices_accept_exports_or_display_text() {
|
||||
let mut doc = fixture();
|
||||
@@ -827,3 +955,143 @@ fn page_shapes_reads_boxes_and_rules() {
|
||||
assert_eq!(sh.boxes, [[20.0, 20.0, 32.0, 32.0]]);
|
||||
assert_eq!(sh.rules, [[40.0, 100.0, 240.0, 100.0], [60.0, 600.5, 260.0, 600.5]]);
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 0.05
|
||||
}
|
||||
|
||||
fn nums(o: &Object) -> Vec<f64> {
|
||||
o.as_array().unwrap().iter().filter_map(Object::as_f64).collect()
|
||||
}
|
||||
|
||||
/// Normal appearance `/BBox` and `/Matrix` (absent when the widget is not rotated).
|
||||
fn appearance_box(doc: &Document, w: &Widget) -> (Vec<f64>, Option<Vec<f64>>) {
|
||||
let n = doc.get(w.obj).as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let Object::Stream(s) = &*doc.get(n) else { panic!("appearance") };
|
||||
(nums(s.dict.get(b"BBox").unwrap()), s.dict.get(b"Matrix").map(nums))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_fields_draw_in_their_quadrant() {
|
||||
let mut doc = fixture();
|
||||
// No /R: the appearance is the widget rectangle and carries no /Matrix.
|
||||
set_value(&mut doc, "name", &FieldValue::Text("Ada".into())).unwrap();
|
||||
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
|
||||
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
|
||||
assert!(matrix.is_none(), "{matrix:?}");
|
||||
|
||||
let set_r = |doc: &mut Document, degrees: i64| {
|
||||
let obj = field(&fields(doc), "name").widgets[0].obj;
|
||||
doc.update_dict(obj, |d| {
|
||||
let mut mk = d.get(b"MK").and_then(|m| m.as_dict().cloned()).unwrap_or_default();
|
||||
mk.set(b"R".to_vec(), Object::Int(degrees));
|
||||
d.set(b"MK".to_vec(), Object::Dict(mk));
|
||||
})
|
||||
.unwrap();
|
||||
set_value(doc, "name", &FieldValue::Text("Ada".into())).unwrap();
|
||||
};
|
||||
// 90 and 270 lay the text out in the swapped box. 180 keeps the box and turns it over.
|
||||
set_r(&mut doc, 90);
|
||||
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
|
||||
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
|
||||
let m = matrix.unwrap();
|
||||
assert!(close(m[0], 0.0) && close(m[1], 1.0) && close(m[2], -1.0) && close(m[4], 200.0), "{m:?}");
|
||||
set_r(&mut doc, 180);
|
||||
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
|
||||
assert!(close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?}");
|
||||
let m = matrix.unwrap();
|
||||
assert!(close(m[0], -1.0) && close(m[3], -1.0) && close(m[4], 200.0) && close(m[5], 20.0), "{m:?}");
|
||||
set_r(&mut doc, 270);
|
||||
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
|
||||
assert!(close(bbox[2], 20.0) && close(bbox[3], 200.0), "{bbox:?}");
|
||||
let m = matrix.unwrap();
|
||||
assert!(close(m[1], -1.0) && close(m[2], 1.0) && close(m[5], 20.0), "{m:?}");
|
||||
// A non-quadrant /R is drawn upright.
|
||||
set_r(&mut doc, 45);
|
||||
let (bbox, matrix) = appearance_box(&doc, &field(&fields(&doc), "name").widgets[0]);
|
||||
assert!(matrix.is_none() && close(bbox[2], 200.0) && close(bbox[3], 20.0), "{bbox:?} {matrix:?}");
|
||||
assert_eq!(field(&fields(&doc), "name").widgets[0].rotation, 0);
|
||||
|
||||
let before = field(&fields(&doc), "name").widgets[0].rect;
|
||||
assert!(set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 45)), ..FieldProps::default() }).is_err());
|
||||
assert!(field(&fields(&doc), "name").widgets[0].rect.iter().zip(before).all(|(a, b)| close(*a, b)));
|
||||
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
|
||||
let turned = field(&fields(&doc), "name").widgets[0].clone();
|
||||
assert_eq!(turned.rotation, 90);
|
||||
let r = turned.rect;
|
||||
// [50 700 250 720] around its center becomes 20 wide and 200 tall.
|
||||
assert!(close(r[0], 140.0) && close(r[1], 610.0) && close(r[2], 160.0) && close(r[3], 810.0), "{r:?}");
|
||||
let widget = doc.get(turned.obj);
|
||||
let mk = widget.as_dict().unwrap().get(b"MK").unwrap().as_dict().cloned().unwrap();
|
||||
assert!(mk.contains(b"BG") && mk.contains(b"BC") && mk.int(b"R") == Some(90));
|
||||
let (_, matrix) = appearance_box(&doc, &turned);
|
||||
assert!(matrix.is_some());
|
||||
// 270 stays on the vertical axis, so the rectangle is not swapped again.
|
||||
set_props(&mut doc, "name", &FieldProps { rotation: Some((0, 270)), ..FieldProps::default() }).unwrap();
|
||||
let r270 = field(&fields(&doc), "name").widgets[0].rect;
|
||||
assert!(r270.iter().zip(r).all(|(a, b)| close(*a, b)), "{r270:?}");
|
||||
|
||||
// A check box and a push button use the same matrix.
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [20.0, 40.0, 40.0, 60.0], &NewField::CheckBox, Some("agree")).unwrap();
|
||||
add_field(&mut doc, 0, [20.0, 80.0, 120.0, 110.0], &NewField::Button { caption: "Go".into() }, Some("go")).unwrap();
|
||||
set_props(&mut doc, "agree", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
|
||||
set_props(&mut doc, "go", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
|
||||
let agree = field(&fields(&doc), "agree").widgets[0].clone();
|
||||
let widget = doc.get(agree.obj);
|
||||
let n = widget.as_dict().unwrap().get(b"AP").unwrap().as_dict().unwrap().get(b"N").cloned().unwrap();
|
||||
let states = doc.resolve(&n);
|
||||
let yes = states.as_dict().unwrap().reference(b"Yes").unwrap();
|
||||
let Object::Stream(s) = &*doc.get(yes) else { panic!("check") };
|
||||
assert!(s.dict.contains(b"Matrix"), "the check mark is rotated");
|
||||
let go = field(&fields(&doc), "go").widgets[0].clone();
|
||||
let (text, matrix) = (ap(&doc, &go), appearance_box(&doc, &go).1);
|
||||
assert!(text.contains("(Go) Tj") && matrix.is_some(), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotation_refused_as_too_small_changes_nothing_else() {
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("thin")).unwrap();
|
||||
// 200 x 3: turned a quarter it would be 3 wide, too small to use.
|
||||
let obj = field(&fields(&doc), "thin").widgets[0].obj;
|
||||
doc.update_dict(obj, |d| d.set(b"Rect".to_vec(), Object::Array([50.0, 700.0, 250.0, 703.0].iter().map(|v| Object::Real(*v)).collect()))).unwrap();
|
||||
let props = FieldProps { tooltip: Some("changed".into()), rotation: Some((0, 90)), ..FieldProps::default() };
|
||||
assert!(set_props(&mut doc, "thin", &props).is_err());
|
||||
let f = field(&fields(&doc), "thin").clone();
|
||||
assert_eq!(f.tooltip, None, "the tooltip was written before the rotation was refused");
|
||||
assert_eq!(f.widgets[0].rotation, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_field_draws_along_the_vertical_edge() {
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
let mut doc = one_page();
|
||||
add_field(&mut doc, 0, [10.0, 40.0, 90.0, 60.0], &NewField::Text { multiline: false }, Some("wide")).unwrap();
|
||||
set_value(&mut doc, "wide", &FieldValue::Text("MMMMMM".into())).unwrap();
|
||||
set_props(&mut doc, "wide", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
|
||||
let span = |doc: &Document| -> (u32, u32) {
|
||||
let bytes = write_incremental(doc, &SaveOptions::default()).unwrap();
|
||||
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
|
||||
let mut ink = false;
|
||||
for y in 0..p.height {
|
||||
for x in 0..p.width {
|
||||
let i = ((y * p.width + x) * 4) as usize;
|
||||
if p.rgba[i] < 200 {
|
||||
ink = true;
|
||||
(x0, y0, x1, y1) = (x0.min(x), y0.min(y), x1.max(x), y1.max(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(ink, "the field drew no text");
|
||||
(x1 - x0, y1 - y0)
|
||||
};
|
||||
let (wide, tall) = span(&doc);
|
||||
assert!(wide > tall, "upright text is a horizontal run: {wide}x{tall}");
|
||||
set_props(&mut doc, "wide", &FieldProps { rotation: Some((0, 90)), ..FieldProps::default() }).unwrap();
|
||||
let (wide, tall) = span(&doc);
|
||||
assert!(tall > wide, "rotated text is a vertical run: {wide}x{tall}");
|
||||
}
|
||||
@@ -336,9 +336,12 @@ pub fn add(doc: &mut Document, new: &NewMeasurement, meta: &Meta) -> Result<usiz
|
||||
Kind::Distance => Shape::Line {
|
||||
from: new.points.first().copied().ok_or_else(|| invalid("missing start"))?,
|
||||
to: new.points.last().copied().ok_or_else(|| invalid("missing end"))?,
|
||||
arrow: true,
|
||||
start: pdfcraft_annot::LineEnding::None,
|
||||
end: pdfcraft_annot::LineEnding::OpenArrow,
|
||||
},
|
||||
Kind::Perimeter => Shape::PolyLine { vertices: new.points.clone() },
|
||||
Kind::Perimeter => {
|
||||
Shape::PolyLine { vertices: new.points.clone(), start: pdfcraft_annot::LineEnding::None, end: pdfcraft_annot::LineEnding::None }
|
||||
}
|
||||
Kind::Area => Shape::Polygon { vertices: new.points.clone(), cloud: false },
|
||||
};
|
||||
let index = pdfcraft_annot::add_annotation(
|
||||
|
||||
@@ -92,7 +92,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() {
|
||||
let (r, dict) = annot(&d, 0, 0).unwrap();
|
||||
assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..]));
|
||||
d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap();
|
||||
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), &meta).unwrap();
|
||||
pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap();
|
||||
let (_, dict) = annot(&d, 0, 0).unwrap();
|
||||
assert_eq!(dict.int(b"VendorKey"), Some(42));
|
||||
let ap = d.resolve(dict.get(b"AP").unwrap());
|
||||
|
||||
@@ -112,7 +112,7 @@ fn hash_obj(o: &Object, map: &HashMap<ObjRef, ObjRef>, h: &mut impl Hasher) {
|
||||
Object::Stream(s) => {
|
||||
8u8.hash(h);
|
||||
hash_obj(&Object::Dict(s.dict.clone()), map, h);
|
||||
s.raw.as_slice().hash(h);
|
||||
s.raw[..].hash(h);
|
||||
}
|
||||
Object::Ref(r) => {
|
||||
let c = canon(map, *r);
|
||||
@@ -125,9 +125,7 @@ fn eq_obj(a: &Object, b: &Object, map: &HashMap<ObjRef, ObjRef>) -> bool {
|
||||
match (a, b) {
|
||||
(Object::Array(x), Object::Array(y)) => x.len() == y.len() && x.iter().zip(y).all(|(p, q)| eq_obj(p, q, map)),
|
||||
(Object::Dict(x), Object::Dict(y)) => x.len() == y.len() && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| eq_obj(v, w, map))),
|
||||
(Object::Stream(x), Object::Stream(y)) => {
|
||||
(std::sync::Arc::ptr_eq(&x.raw, &y.raw) || x.raw == y.raw) && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map)
|
||||
}
|
||||
(Object::Stream(x), Object::Stream(y)) => x.raw == y.raw && eq_obj(&Object::Dict(x.dict.clone()), &Object::Dict(y.dict.clone()), map),
|
||||
(Object::Ref(x), Object::Ref(y)) => canon(map, *x) == canon(map, *y),
|
||||
(Object::Real(x), Object::Real(y)) => x.to_bits() == y.to_bits(),
|
||||
(Object::String(x), Object::String(y)) => x.bytes == y.bytes,
|
||||
@@ -241,10 +239,9 @@ pub fn dedupe_resources(doc: &mut Document, candidates: &[ObjRef], index_existin
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pdfcraft_cos::{Dict, Stream};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn stream(data: &[u8]) -> Object {
|
||||
Object::Stream(Stream { dict: Dict::new(), raw: Arc::new(data.to_vec()) })
|
||||
Object::Stream(Stream { dict: Dict::new(), raw: data.to_vec().into() })
|
||||
}
|
||||
|
||||
fn font(doc: &mut Document, file: ObjRef, name: &str) -> ObjRef {
|
||||
|
||||
@@ -49,7 +49,7 @@ impl PrintStandard {
|
||||
let id = d.and_then(|d| d.get(b"OutputConditionIdentifier")).and_then(|v| doc.resolve(v).as_string().map(|s| s.bytes.clone()));
|
||||
let profile = d.and_then(|d| d.get(b"DestOutputProfile")).map(|p| doc.resolve(p)).and_then(|p| match &*p {
|
||||
// Undecodable: compare the bytes as stored.
|
||||
Object::Stream(s) => Some(s.decoded_within(MAX_DECODED).unwrap_or_else(|_| s.raw.as_ref().clone())),
|
||||
Object::Stream(s) => Some(s.decoded_within(MAX_DECODED).unwrap_or_else(|_| s.raw.to_vec())),
|
||||
_ => None,
|
||||
});
|
||||
(kind, id.unwrap_or_default(), profile.unwrap_or_default())
|
||||
|
||||
@@ -16,8 +16,8 @@ lopdf = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Moves rendered pixels out of the renderer's pixmap without copying; already in the tree through hayro.
|
||||
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
|
||||
# Reads rendered pixel words as bytes; already in the tree through hayro.
|
||||
bytemuck = { version = "1" }
|
||||
|
||||
# lopdf (bootstrap) draws random bytes for encryption; on the web they come from the browser.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
|
||||
+204
-11
@@ -112,6 +112,27 @@ impl PageInfo {
|
||||
[a * self.width, b * self.height]
|
||||
}
|
||||
|
||||
/// A destination's user-space point (each coordinate possibly unspecified) → the same point
|
||||
/// as a fraction of the displayed page `[x, y]` (0..1 from its top-left corner, y down),
|
||||
/// after `/Rotate` and a further clockwise `view_rotation` (0, 90, 180 or 270). On a page
|
||||
/// turned a quarter, the user `x` decides the displayed `y` and vice versa, so each output
|
||||
/// is `None` when the input it comes from is (or is not finite). Points off the page are
|
||||
/// clamped to its edge.
|
||||
pub fn dest_fraction(&self, x: Option<f32>, y: Option<f32>, view_rotation: u16) -> [Option<f32>; 2] {
|
||||
let [cx0, cy0, cx1, cy1] = self.crop;
|
||||
let frac = |v: f32| if v.is_finite() { Some(v.clamp(0.0, 1.0)) } else { None };
|
||||
// Unrotated fractions across (u, from the crop's left) and down (v, from its top).
|
||||
let u = x.and_then(|x| frac((x - cx0) / (cx1 - cx0).max(1e-3)));
|
||||
let v = y.and_then(|y| frac((cy1 - y) / (cy1 - cy0).max(1e-3)));
|
||||
let flip = |f: Option<f32>| f.map(|f| 1.0 - f);
|
||||
match (u32::from(self.rotation) + u32::from(view_rotation)) % 360 {
|
||||
90 => [flip(v), u],
|
||||
180 => [flip(u), flip(v)],
|
||||
270 => [v, flip(u)],
|
||||
_ => [u, v],
|
||||
}
|
||||
}
|
||||
|
||||
/// A view-space rectangle → the normalized user-space rectangle covering the same area of the
|
||||
/// page (`[x0, y0, x1, y1]`, `x0 <= x1`, `y0 <= y1`). Under `/Rotate` the corners swap roles,
|
||||
/// so a view rectangle's top-left is not in general the user rectangle's `[x0, y1]`.
|
||||
@@ -143,7 +164,8 @@ pub struct Link {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum LinkTarget {
|
||||
Page(usize),
|
||||
/// A destination in this document: the 0-based page and where on it the view goes.
|
||||
Page(usize, DestView),
|
||||
Uri(String),
|
||||
/// A set-layer-visibility action (`SetOCGState`, ISO 32000-2 §12.6.4.13): each change in
|
||||
/// order, naming the layer by its optional content group. With `preserve_rb`, a layer turned
|
||||
@@ -163,10 +185,55 @@ pub enum LayerOp {
|
||||
Toggle,
|
||||
}
|
||||
|
||||
/// Where an explicit destination (ISO 32000-2 §12.3.2.2, Table 149) places its page in the
|
||||
/// window. Coordinates are PDF user space; `None` is a `null` (or unusable) operand, which keeps
|
||||
/// that part of the view as it is.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub enum DestView {
|
||||
/// The top of the page at the current zoom: a destination with no position, or one whose
|
||||
/// type is missing or unknown.
|
||||
#[default]
|
||||
Top,
|
||||
/// `/XYZ left top zoom`: the point (left, top) at the window's upper-left corner, at `zoom`
|
||||
/// (1.0 = 100%; 0 or null keeps the current zoom).
|
||||
Xyz { left: Option<f32>, top: Option<f32>, zoom: Option<f32> },
|
||||
/// `/Fit`, and `/FitB` (fit the page's content box; read as the whole page).
|
||||
Fit,
|
||||
/// `/FitH top`, and `/FitBH`: fit the width, with `top` at the window's top edge.
|
||||
FitH { top: Option<f32> },
|
||||
/// `/FitV left`, and `/FitBV`: fit the height, with `left` at the window's left edge.
|
||||
FitV { left: Option<f32> },
|
||||
/// `/FitR left bottom right top`: fit the rectangle `[x0, y0, x1, y1]` (normalized).
|
||||
FitR { rect: [f32; 4] },
|
||||
}
|
||||
|
||||
impl DestView {
|
||||
/// The view an explicit destination array asks for, from its elements after the page
|
||||
/// (`/XYZ left top zoom`, `/FitH top`, …). Hostile input never fails: a missing or unknown
|
||||
/// type, or a `/FitR` without four usable numbers, is [`DestView::Top`]; a non-numeric,
|
||||
/// non-finite or missing operand is `None`; a zoom that is not positive is `None`.
|
||||
pub fn from_operands(kind: Option<&[u8]>, operands: &[Option<f32>]) -> Self {
|
||||
let n = |i: usize| operands.get(i).copied().flatten().filter(|v| v.is_finite());
|
||||
match kind {
|
||||
Some(b"XYZ") => Self::Xyz { left: n(0), top: n(1), zoom: n(2).filter(|z| *z > 0.0) },
|
||||
Some(b"Fit" | b"FitB") => Self::Fit,
|
||||
Some(b"FitH" | b"FitBH") => Self::FitH { top: n(0) },
|
||||
Some(b"FitV" | b"FitBV") => Self::FitV { left: n(0) },
|
||||
Some(b"FitR") => match (n(0), n(1), n(2), n(3)) {
|
||||
(Some(x0), Some(y0), Some(x1), Some(y1)) => Self::FitR { rect: [x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)] },
|
||||
_ => Self::Top,
|
||||
},
|
||||
_ => Self::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OutlineItem {
|
||||
pub title: String,
|
||||
pub page: Option<usize>,
|
||||
/// Where on `page` the bookmark goes ([`DestView::Top`] for a bookmark without a page).
|
||||
pub view: DestView,
|
||||
pub children: Vec<OutlineItem>,
|
||||
pub open: bool,
|
||||
}
|
||||
@@ -449,14 +516,21 @@ impl<'a> Inspector<'a> {
|
||||
|
||||
// ── destinations ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn dest_page(&self, dest: &Object, depth: u32) -> Option<usize> {
|
||||
/// The page and view of a destination: an explicit array (`[page /XYZ left top zoom]`, …),
|
||||
/// a dictionary with `/D`, or a named destination that resolves to one.
|
||||
fn dest(&self, dest: &Object, depth: u32) -> Option<(usize, DestView)> {
|
||||
if depth > 8 {
|
||||
return None;
|
||||
}
|
||||
match self.resolve(dest) {
|
||||
Object::Array(a) => a.first().and_then(|p| self.page_of(p)),
|
||||
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest_page(d, depth + 1)),
|
||||
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest_page(d, depth + 1)),
|
||||
Object::Array(a) => {
|
||||
let page = a.first().and_then(|p| self.page_of(p))?;
|
||||
let kind = a.get(1).map(|k| self.resolve(k)).and_then(|k| k.as_name().ok());
|
||||
let operands: Vec<Option<f32>> = a.iter().skip(2).take(4).map(|o| self.resolve(o).as_float().ok()).collect();
|
||||
Some((page, DestView::from_operands(kind, &operands)))
|
||||
}
|
||||
Object::Dictionary(d) => d.get(b"D").ok().and_then(|d| self.dest(d, depth + 1)),
|
||||
Object::String(key, _) | Object::Name(key) => self.named_dest(key).and_then(|d| self.dest(d, depth + 1)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -531,17 +605,18 @@ impl<'a> Inspector<'a> {
|
||||
break; // cycle
|
||||
}
|
||||
let Some(d) = self.dict(o) else { break };
|
||||
let page = d
|
||||
let dest = d
|
||||
.get(b"Dest")
|
||||
.ok()
|
||||
.and_then(|dest| self.dest_page(dest, 0))
|
||||
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest_page(dest, 0)));
|
||||
.and_then(|dest| self.dest(dest, 0))
|
||||
.or_else(|| d.get(b"A").ok().and_then(|a| self.dict(a)).and_then(|a| a.get(b"D").ok()).and_then(|dest| self.dest(dest, 0)));
|
||||
let (page, view) = dest.map_or((None, DestView::Top), |(p, v)| (Some(p), v));
|
||||
let children = match (d.get(b"First").ok(), depth < 32) {
|
||||
(Some(f), true) => self.outline_siblings(f, seen, depth + 1),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let open = d.get(b"Count").ok().and_then(|c| c.as_i64().ok()).is_some_and(|c| c > 0);
|
||||
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, children, open });
|
||||
items.push(OutlineItem { title: self.text(d, b"Title").unwrap_or_default(), page, view, children, open });
|
||||
cur = d.get(b"Next").ok();
|
||||
if items.len() > 100_000 {
|
||||
break;
|
||||
@@ -672,11 +747,15 @@ impl<'a> Inspector<'a> {
|
||||
fn link(&self, page: usize, d: &Dictionary) -> Option<Link> {
|
||||
let rect = rect4(self.resolve(d.get(b"Rect").ok()?));
|
||||
let target = if let Ok(dest) = d.get(b"Dest") {
|
||||
LinkTarget::Page(self.dest_page(dest, 0)?)
|
||||
let (page, view) = self.dest(dest, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
} else {
|
||||
let a = d.get(b"A").ok().and_then(|a| self.dict(a))?;
|
||||
match self.name(a, b"S").as_deref() {
|
||||
Some("GoTo") => LinkTarget::Page(self.dest_page(a.get(b"D").ok()?, 0)?),
|
||||
Some("GoTo") => {
|
||||
let (page, view) = self.dest(a.get(b"D").ok()?, 0)?;
|
||||
LinkTarget::Page(page, view)
|
||||
}
|
||||
Some("URI") => {
|
||||
LinkTarget::Uri(a.get(b"URI").ok().and_then(|u| self.resolve(u).as_str().ok()).map(|b| String::from_utf8_lossy(b).into_owned())?)
|
||||
}
|
||||
@@ -1171,6 +1250,120 @@ trailer << /Root 1 0 R >>
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_operands_become_views() {
|
||||
use DestView::*;
|
||||
let v = |kind: &[u8], ops: &[Option<f32>]| DestView::from_operands(Some(kind), ops);
|
||||
// ISO 32000-2 Table 149, each type.
|
||||
assert_eq!(v(b"XYZ", &[Some(0.0), Some(420.0), Some(0.0)]), Xyz { left: Some(0.0), top: Some(420.0), zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[Some(72.0), Some(500.0), Some(1.5)]), Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) });
|
||||
assert_eq!(v(b"Fit", &[]), Fit);
|
||||
assert_eq!(v(b"FitB", &[]), Fit);
|
||||
assert_eq!(v(b"FitH", &[Some(250.0)]), FitH { top: Some(250.0) });
|
||||
assert_eq!(v(b"FitBH", &[None]), FitH { top: None });
|
||||
assert_eq!(v(b"FitV", &[Some(120.0)]), FitV { left: Some(120.0) });
|
||||
assert_eq!(v(b"FitBV", &[Some(5.0)]), FitV { left: Some(5.0) });
|
||||
// FitR's corners are normalized.
|
||||
assert_eq!(v(b"FitR", &[Some(110.0), Some(220.0), Some(10.0), Some(20.0)]), FitR { rect: [10.0, 20.0, 110.0, 220.0] });
|
||||
// Missing operands are null; so are non-finite ones, and a zoom that isn't positive.
|
||||
assert_eq!(v(b"XYZ", &[]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[Some(f32::NAN), Some(f32::INFINITY), Some(f32::NEG_INFINITY)]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[None, Some(-5.0), Some(-1.0)]), Xyz { left: None, top: Some(-5.0), zoom: None });
|
||||
assert_eq!(v(b"XYZ", &[None, None, Some(f32::NAN)]), Xyz { left: None, top: None, zoom: None });
|
||||
assert_eq!(v(b"FitH", &[Some(f32::NAN)]), FitH { top: None });
|
||||
// A FitR without four usable numbers, an unknown type or no type: the top of the page.
|
||||
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0)]), Top);
|
||||
assert_eq!(v(b"FitR", &[Some(1.0), Some(2.0), Some(f32::INFINITY), Some(4.0)]), Top);
|
||||
assert_eq!(v(b"Zoom", &[Some(3.0)]), Top);
|
||||
assert_eq!(v(b"xyz", &[Some(3.0)]), Top);
|
||||
assert_eq!(DestView::from_operands(None, &[Some(1.0)]), Top);
|
||||
}
|
||||
|
||||
/// Two 300×400 pages. Bookmarks and links on page 1 go to page 2 in every destination form,
|
||||
/// named (`/Names /Dests` and the PDF 1.1 `/Dests`) and malformed.
|
||||
const DESTS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R /Names << /Dests 40 0 R >> /Dests << /old << /D [4 0 R /FitV 120] >> >> >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [30 0 R 31 0 R 32 0 R 33 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
10 0 obj << /Type /Outlines /First 11 0 R /Last 20 0 R /Count 10 >> endobj
|
||||
11 0 obj << /Title (xyz) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
|
||||
12 0 obj << /Title (fith) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /FitH 250] >> endobj
|
||||
13 0 obj << /Title (named) /Parent 10 0 R /Next 14 0 R /Dest (sec) >> endobj
|
||||
14 0 obj << /Title (old named) /Parent 10 0 R /Next 15 0 R /Dest /old >> endobj
|
||||
15 0 obj << /Title (action) /Parent 10 0 R /Next 16 0 R /A << /S /GoTo /D [4 0 R /FitR 110 220 10 20] >> >> endobj
|
||||
16 0 obj << /Title (wrong types) /Parent 10 0 R /Next 17 0 R /Dest [4 0 R /XYZ (left) /Top true] >> endobj
|
||||
17 0 obj << /Title (short) /Parent 10 0 R /Next 18 0 R /Dest [4 0 R /FitR 1 2] >> endobj
|
||||
18 0 obj << /Title (no type) /Parent 10 0 R /Next 19 0 R /Dest [4 0 R] >> endobj
|
||||
19 0 obj << /Title (huge) /Parent 10 0 R /Next 20 0 R /Dest [4 0 R /XYZ 1000000000000 -5 -1] >> endobj
|
||||
20 0 obj << /Title (no page) /Parent 10 0 R /Dest [/XYZ 0 420 0] >> endobj
|
||||
30 0 obj << /Type /Annot /Subtype /Link /Rect [0 0 10 10] /Dest [4 0 R /XYZ 72 500 1.5] >> endobj
|
||||
31 0 obj << /Type /Annot /Subtype /Link /Rect [0 20 10 30] /A << /S /GoTo /D (sec) >> >> endobj
|
||||
32 0 obj << /Type /Annot /Subtype /Link /Rect [0 40 10 50] /Dest [4 0 R /FitBH 100] >> endobj
|
||||
33 0 obj << /Type /Annot /Subtype /Link /Rect [0 60 10 70] /Dest [4 0 R /Fit] >> endobj
|
||||
40 0 obj << /Names [(sec) << /D [4 0 R /XYZ null 300 2] >>] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
#[test]
|
||||
fn bookmarks_and_links_keep_their_destination_view() {
|
||||
use DestView::*;
|
||||
let info = inspect(Arc::new(DESTS.to_vec()), None).expect("opens");
|
||||
let outline: Vec<_> = info.outline.iter().map(|o| (o.title.as_str(), o.page, o.view)).collect();
|
||||
assert_eq!(
|
||||
outline,
|
||||
[
|
||||
("xyz", Some(1), Xyz { left: Some(0.0), top: Some(420.0), zoom: None }),
|
||||
("fith", Some(1), FitH { top: Some(250.0) }),
|
||||
("named", Some(1), Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
|
||||
("old named", Some(1), FitV { left: Some(120.0) }),
|
||||
("action", Some(1), FitR { rect: [10.0, 20.0, 110.0, 220.0] }),
|
||||
// Hostile operands fall back without failing the bookmark.
|
||||
("wrong types", Some(1), Xyz { left: None, top: None, zoom: None }),
|
||||
("short", Some(1), Top),
|
||||
("no type", Some(1), Top),
|
||||
("huge", Some(1), Xyz { left: Some(1e12), top: Some(-5.0), zoom: None }),
|
||||
("no page", None, Top),
|
||||
]
|
||||
);
|
||||
let links: Vec<_> = info.links.iter().map(|l| l.target.clone()).collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
[
|
||||
LinkTarget::Page(1, Xyz { left: Some(72.0), top: Some(500.0), zoom: Some(1.5) }),
|
||||
LinkTarget::Page(1, Xyz { left: None, top: Some(300.0), zoom: Some(2.0) }),
|
||||
LinkTarget::Page(1, FitH { top: Some(100.0) }),
|
||||
LinkTarget::Page(1, Fit),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_points_map_to_the_displayed_page() {
|
||||
// A crop box away from the origin: x 100..400, y 50..450.
|
||||
let mut p = PageInfo { width: 300.0, height: 400.0, label: "1".into(), crop: [100.0, 50.0, 400.0, 450.0], rotation: 0 };
|
||||
assert_eq!(p.dest_fraction(Some(100.0), Some(450.0), 0), [Some(0.0), Some(0.0)]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.25)]);
|
||||
// Unspecified stays unspecified; off-page and huge values are clamped to the page.
|
||||
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [None, Some(0.25)]);
|
||||
assert_eq!(p.dest_fraction(Some(-1e30), Some(1e30), 0), [Some(0.0), Some(0.0)]);
|
||||
assert_eq!(p.dest_fraction(Some(f32::INFINITY), Some(f32::NAN), 0), [None, None]);
|
||||
// /Rotate 90 (clockwise): user y runs left to right across the displayed page, user x
|
||||
// top to bottom. A view rotation adds to it.
|
||||
p.rotation = 90;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.75), Some(0.5)]);
|
||||
assert_eq!(p.dest_fraction(None, Some(350.0), 0), [Some(0.75), None]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.75)]);
|
||||
p.rotation = 180;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.5), Some(0.75)]);
|
||||
p.rotation = 270;
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 0), [Some(0.25), Some(0.5)]);
|
||||
assert_eq!(p.dest_fraction(Some(250.0), Some(350.0), 90), [Some(0.5), Some(0.25)]);
|
||||
// A degenerate crop box never divides by zero.
|
||||
let flat = PageInfo { crop: [0.0, 0.0, 0.0, 0.0], rotation: 0, ..p };
|
||||
assert!(flat.dest_fraction(Some(5.0), Some(-5.0), 0).iter().all(|f| f.is_some_and(|f| (0.0..=1.0).contains(&f))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structure_failure_preserves_renderer_pages() {
|
||||
for crash in [true, false] {
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod inspect;
|
||||
mod pixels;
|
||||
mod raster;
|
||||
pub mod text;
|
||||
|
||||
pub use inspect::{
|
||||
Annotation, Attachment, AttachmentSource, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo, Xfa,
|
||||
attachment_data, inspect, pretty_date,
|
||||
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
|
||||
Xfa, attachment_data, inspect, pretty_date,
|
||||
};
|
||||
pub use pixels::Pixels;
|
||||
pub use raster::{
|
||||
MAX_PIXELS, MAX_SIDE, PageRenderer, RenderConfig, RenderPool, RenderRequest, RenderedPage, RequestKind, Tile, device_pixels, effective_scale,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Raster pixels in a buffer a GUI can take over as texture data.
|
||||
|
||||
use std::ops::Deref;
|
||||
|
||||
/// A raster's premultiplied RGBA8 bytes, row-major, kept in 4-byte words: one word per pixel,
|
||||
/// with the bytes in R, G, B, A order in memory. The words make the buffer aligned to whole
|
||||
/// pixels, so it can become a GUI's pixel array (egui's `Color32`) without copying. Read it as
|
||||
/// bytes through `Deref`.
|
||||
#[derive(Clone, Default, PartialEq, Eq)]
|
||||
pub struct Pixels(Vec<u32>);
|
||||
|
||||
impl Pixels {
|
||||
/// Zeroed pixels for a `width` × `height` raster; `None` when the size overflows.
|
||||
pub(crate) fn zeroed(width: usize, height: usize) -> Option<Self> {
|
||||
Some(Self(vec![0; width.checked_mul(height)?]))
|
||||
}
|
||||
|
||||
/// The bytes, for the rasterizer to write into.
|
||||
pub(crate) fn bytes_mut(&mut self) -> &mut [u8] {
|
||||
bytemuck::cast_slice_mut(&mut self.0)
|
||||
}
|
||||
|
||||
/// The buffer itself: one word per pixel, its bytes in R, G, B, A memory order.
|
||||
pub fn into_words(self) -> Vec<u32> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for Pixels {
|
||||
type Target = [u8];
|
||||
|
||||
fn deref(&self) -> &[u8] {
|
||||
bytemuck::cast_slice(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for Pixels {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Pixels {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Pixels({} bytes)", self.len())
|
||||
}
|
||||
}
|
||||
+103
-45
@@ -17,9 +17,10 @@ use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_interpret::font::{FontData, FontQuery};
|
||||
use hayro::hayro_interpret::hayro_cmap::CidFamily;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::Pixmap;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
use hayro::{RenderCache, RenderSettings, render};
|
||||
use hayro::{RenderCache, RenderSettings, render_into, render_size};
|
||||
|
||||
use crate::Pixels;
|
||||
|
||||
/// Monotonic-ish timer that is safe on wasm32 (where `std::time::Instant` panics).
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -152,7 +153,7 @@ pub struct RenderedPage {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Premultiplied RGBA8, row-major. Empty when `error` is set.
|
||||
pub rgba: Vec<u8>,
|
||||
pub rgba: Pixels,
|
||||
/// Why the page could not be rendered (renderer panic, empty page box, …).
|
||||
pub error: Option<String>,
|
||||
/// For `RequestKind::Text`.
|
||||
@@ -182,12 +183,12 @@ pub fn device_pixels(pt: f32, scale: f32) -> u32 {
|
||||
|
||||
/// Render one page with a caller-owned parser and cache. Panics inside the renderer are caught
|
||||
/// and reported as `Err((message, panicked))`.
|
||||
type Output = (u32, u32, Vec<u8>, Option<Arc<crate::text::PageText>>);
|
||||
type Output = (u32, u32, Pixels, Option<Arc<crate::text::PageText>>);
|
||||
|
||||
fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &InterpreterSettings, req: RenderRequest) -> Result<Output, (String, bool)> {
|
||||
if req.kind == RequestKind::Text {
|
||||
return match catch_unwind(AssertUnwindSafe(|| crate::text::extract_page(pdf, req.page, settings))) {
|
||||
Ok(Some(t)) => Ok((0, 0, Vec::new(), Some(Arc::new(t)))),
|
||||
Ok(Some(t)) => Ok((0, 0, Pixels::default(), Some(Arc::new(t)))),
|
||||
Ok(None) => Err((format!("page {} does not exist", req.page + 1), false)),
|
||||
Err(panic) => Err((format!("text extraction crashed on page {}: {}", req.page + 1, panic_message(&panic)), true)),
|
||||
};
|
||||
@@ -222,8 +223,11 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
|
||||
RenderSettings { x_scale: scale, y_scale: scale, width: Some(side(w)), height: Some(side(h)), bg_color: WHITE, ..Default::default() }
|
||||
}
|
||||
};
|
||||
let pixmap = render(page, cache, settings, &rs);
|
||||
Ok((pixmap.width() as u32, pixmap.height() as u32, into_bytes(pixmap), None))
|
||||
// Rendered straight into a buffer of whole pixels, which the GUI takes over as is.
|
||||
let (w, h) = render_size(page, &rs);
|
||||
let mut pixels = Pixels::zeroed(w.into(), h.into()).ok_or_else(|| format!("page {} is too large to render", req.page + 1))?;
|
||||
render_into(page, cache, settings, &rs, pixels.bytes_mut());
|
||||
Ok((w.into(), h.into(), pixels, None))
|
||||
}));
|
||||
match result {
|
||||
Ok(Ok(v)) => Ok(v),
|
||||
@@ -232,18 +236,11 @@ fn render_page<'a>(pdf: &'a Pdf, cache: &RenderCache<'a>, settings: &Interpreter
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixmap's premultiplied RGBA bytes, moved out rather than copied (a page raster can be
|
||||
/// hundreds of megabytes). Four one-byte channels always recast to bytes in place; the copy is
|
||||
/// only there because the cast's signature allows failure.
|
||||
fn into_bytes(pixmap: Pixmap) -> Vec<u8> {
|
||||
bytemuck::allocation::try_cast_vec(pixmap.take()).unwrap_or_else(|(_, pixels)| pixels.iter().flat_map(|p| [p.r, p.g, p.b, p.a]).collect())
|
||||
}
|
||||
|
||||
fn finish(req: RenderRequest, start: Stopwatch, r: Result<Output, (String, bool)>) -> RenderedPage {
|
||||
let millis = start.millis();
|
||||
match r {
|
||||
Ok((width, height, rgba, text)) => RenderedPage { request: req, width, height, rgba, error: None, text, millis },
|
||||
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(e), text: None, millis },
|
||||
Err((e, _)) => RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(e), text: None, millis },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,9 +303,10 @@ struct Shared {
|
||||
/// Pages (and request kinds) the watchdog gave up on: answered with an error at once, so a
|
||||
/// pathological page cannot trap every worker in turn.
|
||||
stuck: Mutex<std::collections::HashSet<(usize, RequestKind)>>,
|
||||
/// Set when the pool is dropped: the workers' renders stop at their next content operator,
|
||||
/// since nobody can receive their answers any more.
|
||||
dropped: Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Per worker id: set to stop that worker's render at its next content operator once nobody
|
||||
/// can receive its answer (the pool was dropped, or the watchdog gave up on the render).
|
||||
/// Lock `busy` first when holding both.
|
||||
stop: Mutex<Vec<Arc<std::sync::atomic::AtomicBool>>>,
|
||||
/// Test hook: make one page slow.
|
||||
#[cfg(test)]
|
||||
slow_page: Mutex<Option<(usize, std::time::Duration)>>,
|
||||
@@ -342,9 +340,11 @@ fn take_next(shared: &Shared) -> Option<RenderRequest> {
|
||||
///
|
||||
/// **Watchdog:** a render running longer than `stuck_after` (default [`STUCK_AFTER`]) is reported
|
||||
/// as an error for that page, the page is not attempted again, and a replacement worker takes
|
||||
/// the stuck one's place (threads cannot be killed; the stuck one exits when its render
|
||||
/// finally returns, and its late result is dropped). At most `threads` replacements are started
|
||||
/// per pool, so a document full of pathological pages cannot spawn threads without bound.
|
||||
/// the stuck one's place (threads cannot be killed: the stuck one is told to stop at its next
|
||||
/// content operator, exits when its render returns, and its late result is dropped; a single
|
||||
/// long operator, such as decoding a huge image, still runs to its end). At most `threads`
|
||||
/// replacements are started per pool, so a document full of pathological pages cannot spawn
|
||||
/// threads without bound.
|
||||
pub struct RenderPool {
|
||||
shared: Arc<Shared>,
|
||||
wake: Mutex<Vec<Sender<()>>>,
|
||||
@@ -394,14 +394,16 @@ impl RenderPool {
|
||||
return false;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let id = {
|
||||
let (id, stop) = {
|
||||
let mut busy = lock(&self.shared.busy);
|
||||
busy.push(None);
|
||||
busy.len() - 1
|
||||
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
lock(&self.shared.stop).push(stop.clone());
|
||||
(busy.len() - 1, stop)
|
||||
};
|
||||
let (wtx, wrx) = channel::<()>();
|
||||
let (shared, out, bytes, config) = (self.shared.clone(), self.results_tx.clone(), self.bytes.clone(), self.config.clone());
|
||||
match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out)) {
|
||||
match std::thread::Builder::new().name(format!("pdfcraft-render-{id}")).spawn(move || worker(id, bytes, config, shared, wrx, out, stop)) {
|
||||
Ok(h) => {
|
||||
lock(&self.wake).push(wtx);
|
||||
lock(&self._workers).push(h);
|
||||
@@ -473,11 +475,15 @@ impl RenderPool {
|
||||
let mut gave_up = Vec::new();
|
||||
{
|
||||
let mut busy = lock(&self.shared.busy);
|
||||
for slot in busy.iter_mut() {
|
||||
let stop = lock(&self.shared.stop);
|
||||
for (id, slot) in busy.iter_mut().enumerate() {
|
||||
if let Some((req, since)) = *slot
|
||||
&& since.elapsed() > self.stuck_after
|
||||
{
|
||||
*slot = None; // the worker sees this and exits when it returns
|
||||
if let Some(s) = stop.get(id) {
|
||||
s.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
gave_up.push(req);
|
||||
}
|
||||
}
|
||||
@@ -494,7 +500,7 @@ impl RenderPool {
|
||||
request: req,
|
||||
width: 0,
|
||||
height: 0,
|
||||
rgba: Vec::new(),
|
||||
rgba: Pixels::default(),
|
||||
error: Some(error),
|
||||
text: None,
|
||||
millis: 0,
|
||||
@@ -510,19 +516,29 @@ impl RenderPool {
|
||||
|
||||
impl Drop for RenderPool {
|
||||
fn drop(&mut self) {
|
||||
self.shared.dropped.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
for s in lock(&self.shared.stop).iter() {
|
||||
s.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A worker's interpreter settings: the document's, stopping once the pool is dropped.
|
||||
/// A worker's interpreter settings: the document's, stopping once `stop` is set.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn worker_settings(config: &RenderConfig, shared: &Shared) -> InterpreterSettings {
|
||||
InterpreterSettings { cancelled: Some(shared.dropped.clone()), ..config.settings() }
|
||||
fn worker_settings(config: &RenderConfig, stop: &Arc<std::sync::atomic::AtomicBool>) -> InterpreterSettings {
|
||||
InterpreterSettings { cancelled: Some(stop.clone()), ..config.settings() }
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn worker(id: usize, bytes: Arc<Vec<u8>>, config: RenderConfig, shared: Arc<Shared>, wake: Receiver<()>, out: Sender<RenderedPage>) {
|
||||
let settings = worker_settings(&config, &shared);
|
||||
fn worker(
|
||||
id: usize,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
config: RenderConfig,
|
||||
shared: Arc<Shared>,
|
||||
wake: Receiver<()>,
|
||||
out: Sender<RenderedPage>,
|
||||
stop: Arc<std::sync::atomic::AtomicBool>,
|
||||
) {
|
||||
let settings = worker_settings(&config, &stop);
|
||||
// Outer loop: (re)build parser + cache; rebuilt after a renderer panic.
|
||||
loop {
|
||||
let pdf = parse(&bytes, config.password.as_deref());
|
||||
@@ -537,7 +553,7 @@ fn worker(id: usize, bytes: Arc<Vec<u8>>, config: RenderConfig, shared: Arc<Shar
|
||||
};
|
||||
if lock(&shared.stuck).contains(&(req.page, req.kind)) {
|
||||
let error = format!("page {} was skipped earlier because it took too long to render", req.page + 1);
|
||||
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Vec::new(), error: Some(error), text: None, millis: 0 };
|
||||
let page = RenderedPage { request: req, width: 0, height: 0, rgba: Pixels::default(), error: Some(error), text: None, millis: 0 };
|
||||
if out.send(page).is_err() {
|
||||
return;
|
||||
}
|
||||
@@ -741,15 +757,37 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_pixels_are_moved_out_of_the_pixmap() {
|
||||
let mut pixmap = Pixmap::new(3, 2);
|
||||
for (i, b) in pixmap.data_as_u8_slice_mut().iter_mut().enumerate() {
|
||||
*b = i as u8;
|
||||
fn pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over() {
|
||||
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
|
||||
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
// The same bytes as hayro's own render to a pixmap, with the same settings.
|
||||
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
|
||||
let pages = pdf.pages();
|
||||
let page = pages.first().expect("a page");
|
||||
let rs = RenderSettings { x_scale: 2.0, y_scale: 2.0, width: Some(200), height: Some(100), bg_color: WHITE, ..Default::default() };
|
||||
let plain = hayro::render(page, &RenderCache::new(), &RenderConfig::default().settings(), &rs);
|
||||
assert_eq!((out.width, out.height), (200, 100));
|
||||
assert_eq!(&*out.rgba, plain.data_as_u8_slice());
|
||||
// Whole pixels, aligned, handed over without a copy.
|
||||
let at = out.rgba.as_ptr();
|
||||
assert_eq!(at as usize % 4, 0);
|
||||
let words = out.rgba.into_words();
|
||||
assert_eq!(words.as_ptr().cast::<u8>(), at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_into_a_buffer_of_the_wrong_length_leaves_it_untouched() {
|
||||
let pdf = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
|
||||
let pages = pdf.pages();
|
||||
let page = pages.first().expect("a page");
|
||||
let rs = RenderSettings { width: Some(10), height: Some(10), bg_color: WHITE, ..Default::default() };
|
||||
let settings = RenderConfig::default().settings();
|
||||
for len in [0, 399, 401, 4000] {
|
||||
let mut buf = vec![7u8; len];
|
||||
render_into(page, &RenderCache::new(), &settings, &rs, &mut buf);
|
||||
assert!(buf.iter().all(|&b| b == 7), "a {len}-byte buffer was written");
|
||||
}
|
||||
let (at, expected) = (pixmap.data_as_u8_slice().as_ptr(), pixmap.data_as_u8_slice().to_vec());
|
||||
let bytes = into_bytes(pixmap);
|
||||
assert_eq!(bytes, expected);
|
||||
assert_eq!(bytes.as_ptr(), at, "the pixmap's own buffer, not a copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1154,16 +1192,16 @@ endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
|
||||
let settings = worker_settings(&pool.config, &pool.shared);
|
||||
let flag = settings.cancelled.clone().expect("workers can be cancelled");
|
||||
assert!(Arc::ptr_eq(&flag, &pool.shared.dropped));
|
||||
let flag = lock(&pool.shared.stop).first().cloned().expect("one worker");
|
||||
let settings = worker_settings(&pool.config, &flag);
|
||||
assert!(settings.cancelled.as_ref().is_some_and(|c| Arc::ptr_eq(c, &flag)));
|
||||
let shapes = Pdf::new(Arc::new(ONE_PAGE.to_vec())).expect("parses");
|
||||
let text = Pdf::new(Arc::new(TEXT.to_vec())).expect("parses");
|
||||
let pages = shapes.pages();
|
||||
let page = pages.first().expect("a page");
|
||||
let rs = RenderSettings { bg_color: WHITE, ..Default::default() };
|
||||
let blue = |s: &InterpreterSettings| {
|
||||
let p = render(page, &RenderCache::new(), s, &rs).sample(20, 30);
|
||||
let p = hayro::render(page, &RenderCache::new(), s, &rs).sample(20, 30);
|
||||
[p.r, p.g, p.b, p.a] == [0, 0, 255, 255]
|
||||
};
|
||||
let glyphs = |s: &InterpreterSettings| crate::text::extract_page(&text, 0, s).map_or(0, |t| t.glyphs.len());
|
||||
@@ -1173,6 +1211,26 @@ trailer << /Root 1 0 R >>
|
||||
assert!(!blue(&settings) && glyphs(&settings) == 0, "once the pool is gone, interpretation stops before drawing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_watchdog_stops_the_render_it_gives_up_on() {
|
||||
let mut pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 1, RenderConfig::default());
|
||||
pool.set_stuck_after(std::time::Duration::from_millis(100));
|
||||
*lock(&pool.shared.slow_page) = Some((0, std::time::Duration::from_millis(1500)));
|
||||
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }]);
|
||||
let t = std::time::Instant::now();
|
||||
let first = loop {
|
||||
if let Some(p) = pool.try_recv() {
|
||||
break p;
|
||||
}
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(8), "no answer");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
};
|
||||
assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error);
|
||||
// The abandoned worker is told to stop at its next operator; its replacement isn't.
|
||||
let stop: Vec<bool> = lock(&pool.shared.stop).iter().map(|s| s.load(std::sync::atomic::Ordering::Relaxed)).collect();
|
||||
assert_eq!(stop, vec![true, false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_text_with_positions() {
|
||||
let pdf = b"%PDF-1.7
|
||||
|
||||
@@ -14,12 +14,14 @@ pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["bytemuck"] }
|
||||
egui_extras = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu.
|
||||
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
|
||||
|
||||
rfd = { workspace = true }
|
||||
png = "0.18"
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHandle, TextureOptions, Vec2, pos2, vec2};
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
use pdfcraft_render::{DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
@@ -200,6 +200,10 @@ pub struct DocView {
|
||||
pub forward: Vec<usize>,
|
||||
/// Pending navigation: page and fraction down the page to align with the viewport top.
|
||||
pub goto: Option<(usize, f32)>,
|
||||
/// Pending position within that page from a destination ([`Self::go_to_dest`]): the point
|
||||
/// (fractions of the displayed page; `None` leaves that axis to `goto`) to put `rel` points
|
||||
/// from the viewport's top-left corner.
|
||||
goto_point: Option<(usize, Option<f32>, Option<f32>, Vec2)>,
|
||||
/// Pending keyboard scrolling, in points down (negative: up): ↓ / ↑, and Page Down /
|
||||
/// Page Up where the pages scroll.
|
||||
pub key_scroll: f32,
|
||||
@@ -240,6 +244,8 @@ pub struct DocView {
|
||||
viewport_screen: Rect,
|
||||
/// Pending zoom anchor: page, position within it (0..1), and offset from the viewport corner.
|
||||
zoom_anchor: Option<(usize, f32, f32, Vec2)>,
|
||||
/// The page view has been shown, so its scroll offset in egui's memory is this document's.
|
||||
shown: bool,
|
||||
/// Turns wheel input into page turns in single-page view.
|
||||
wheel: crate::wheel_pager::WheelPager,
|
||||
pub(crate) auto_scroll: crate::autoscroll::AutoScroll,
|
||||
@@ -355,6 +361,7 @@ impl DocView {
|
||||
back: Vec::new(),
|
||||
forward: Vec::new(),
|
||||
goto: None,
|
||||
goto_point: None,
|
||||
key_scroll: 0.0,
|
||||
single_edges: None,
|
||||
flash: None,
|
||||
@@ -379,6 +386,7 @@ impl DocView {
|
||||
screen_xforms: Vec::new(),
|
||||
viewport_screen: Rect::NOTHING,
|
||||
zoom_anchor: None,
|
||||
shown: false,
|
||||
wheel: Default::default(),
|
||||
auto_scroll: Default::default(),
|
||||
selected: BTreeSet::new(),
|
||||
@@ -421,10 +429,15 @@ impl DocView {
|
||||
self.current = self.current.min(last);
|
||||
self.page_input = (self.current + 1).to_string();
|
||||
self.selected.retain(|p| *p <= last);
|
||||
// Text being typed on a page that no longer exists goes with the page.
|
||||
if self.content.draft.as_ref().is_some_and(|d| d.page > last || self.page_count == 0) {
|
||||
self.content.draft = None;
|
||||
}
|
||||
if self.select_anchor.is_some_and(|a| a > last) {
|
||||
self.select_anchor = None;
|
||||
}
|
||||
self.goto = None;
|
||||
self.goto_point = None;
|
||||
self.zoom_anchor = None;
|
||||
self.flash = None;
|
||||
}
|
||||
@@ -672,6 +685,67 @@ impl DocView {
|
||||
self.page_input = (page + 1).to_string();
|
||||
}
|
||||
|
||||
/// Go to a destination, a bookmark's or a link's (ISO 32000-2 §12.3.2.2): its page, scrolled
|
||||
/// and zoomed as `dest` asks, as Acrobat does. `/XYZ` puts (left, top) at the window's
|
||||
/// top-left at its zoom (null or 0 keeps the zoom); `/Fit`, `/FitH` and `/FitV` switch to fit
|
||||
/// page, width and height with `top` or `left` at the window's edge; `/FitR` zooms so the
|
||||
/// rectangle fits, centred. The `B` (content box) forms are read as the page. A null
|
||||
/// coordinate keeps that scroll position (null `top` on a new page: its top), and a point
|
||||
/// past the end of the document scrolls as far as it can.
|
||||
pub fn go_to_dest(&mut self, page: usize, dest: DestView, info: &DocInfo) {
|
||||
self.go_to_page(page);
|
||||
let page = self.current;
|
||||
self.goto_point = None;
|
||||
let Some(p) = info.pages.get(page) else { return };
|
||||
let point = |x: Option<f32>, y: Option<f32>| p.dest_fraction(x, y, self.rotation);
|
||||
// The window's top-left as far as pages go: its left edge is the gutter that keeps them
|
||||
// clear of the floating quick-action bar, so `left` 0 shows the page's edge beside it.
|
||||
let corner = vec2(SIDE, 0.0);
|
||||
let (fx, fy, rel) = match dest {
|
||||
DestView::Top => return,
|
||||
DestView::Xyz { left, top, zoom } => {
|
||||
if let Some(z) = zoom {
|
||||
self.zoom = z.clamp(0.08, 64.0);
|
||||
self.fit = Fit::None;
|
||||
self.zoom_anchor = None;
|
||||
}
|
||||
let [fx, fy] = point(left, top);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::Fit => {
|
||||
self.fit = Fit::Page;
|
||||
return;
|
||||
}
|
||||
DestView::FitH { top } => {
|
||||
self.fit = Fit::Width;
|
||||
let [fx, fy] = point(None, top);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::FitV { left } => {
|
||||
self.fit = Fit::Height;
|
||||
let [fx, fy] = point(left, None);
|
||||
(fx, fy, corner)
|
||||
}
|
||||
DestView::FitR { rect } => {
|
||||
let ([Some(ax), Some(ay)], [Some(bx), Some(by)]) = (point(Some(rect[0]), Some(rect[3])), point(Some(rect[2]), Some(rect[1]))) else {
|
||||
return;
|
||||
};
|
||||
// The rectangle as displayed, in points (it can be turned a quarter).
|
||||
let (dw, dh) = self.display_size(p);
|
||||
let (w, h) = ((ax - bx).abs() * dw, (ay - by).abs() * dh);
|
||||
if w >= 1.0 && h >= 1.0 && self.viewport_w >= 1.0 && self.viewport_h >= 1.0 {
|
||||
self.zoom = (self.viewport_w / (w * PT)).min(self.viewport_h / (h * PT)).clamp(0.08, 64.0);
|
||||
self.fit = Fit::None;
|
||||
self.zoom_anchor = None;
|
||||
}
|
||||
(Some((ax + bx) / 2.0), Some((ay + by) / 2.0), vec2(self.viewport_w, self.viewport_h) / 2.0)
|
||||
}
|
||||
};
|
||||
if fx.is_some() || fy.is_some() {
|
||||
self.goto_point = Some((page, fx, fy, rel));
|
||||
}
|
||||
}
|
||||
|
||||
/// Next page (`true`) or previous page. In two-page view this moves a whole spread: the other
|
||||
/// page of the spread is already on screen, so stepping to it wouldn't move the view (#70).
|
||||
pub fn step_page(&mut self, forward: bool) {
|
||||
@@ -876,7 +950,7 @@ impl DocView {
|
||||
self.errors.insert(r.request.page, e);
|
||||
continue;
|
||||
}
|
||||
let img = egui::ColorImage::from_rgba_premultiplied([r.width as usize, r.height as usize], &r.rgba);
|
||||
let img = texture_image([r.width as usize, r.height as usize], r.rgba);
|
||||
let page = r.request.page;
|
||||
if let Some(t) = r.request.tile {
|
||||
let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
|
||||
@@ -1326,6 +1400,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
});
|
||||
// Each document keeps its own scroll position when switching tabs (#189).
|
||||
scroll = scroll.id_salt(("page-view", view.id));
|
||||
// A newly opened document starts at the top. eframe saves egui's memory with the settings
|
||||
// and document ids start again at 1 every launch, so the offset stored under this id can be
|
||||
// where an earlier session left another document.
|
||||
if !std::mem::replace(&mut view.shown, true) {
|
||||
scroll = scroll.scroll_offset(Vec2::ZERO);
|
||||
}
|
||||
if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() {
|
||||
let r = rects[page.min(rects.len() - 1)];
|
||||
let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height());
|
||||
@@ -1400,6 +1480,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.content.selected = None;
|
||||
}
|
||||
|
||||
// A destination's position (bookmarks, links) refines `goto`: its point at the window's
|
||||
// top-left, or the centre of a /FitR rectangle at the window's centre.
|
||||
if let Some((page, fx, fy, rel)) = view.goto_point.take() {
|
||||
let r = rects[page.min(rects.len() - 1)];
|
||||
// Within the content, so an unreachable point never shows a frame scrolled past the end.
|
||||
let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0));
|
||||
if let Some(fx) = fx {
|
||||
scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x));
|
||||
}
|
||||
if let Some(fy) = fy {
|
||||
scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y));
|
||||
}
|
||||
}
|
||||
let out = scroll.show_viewport(ui, |ui, viewport| {
|
||||
// egui's drag responses accept every pointer button. Keep a wheel gesture from also
|
||||
// selecting text, drawing a mark, or panning with the Hand tool.
|
||||
@@ -1478,10 +1571,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK);
|
||||
} else {
|
||||
let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0));
|
||||
let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD;
|
||||
// A GPU may take smaller textures than these (OpenGL drivers report as little as
|
||||
// 2048 pixels, and uploading a bigger one aborts), so both stay within its limit.
|
||||
let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32;
|
||||
let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side);
|
||||
// Whole-page raster: sharp when small, a low-res backdrop when tiled.
|
||||
let (want_scale, want_tag) = if tiled {
|
||||
let bs = BASE_SIDE / pw_pt.max(ph_pt);
|
||||
let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt);
|
||||
(bs, scale_tag(bs))
|
||||
} else {
|
||||
(scale, tag)
|
||||
@@ -1758,7 +1854,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
view.selection = Some(Selection { page: i, anchor: first, head: last });
|
||||
}
|
||||
} else if resp.clicked() && !over_link {
|
||||
view.selection = None;
|
||||
// ⇧-click extends a selection on this page to the click, keeping its
|
||||
// anchor (#527). Otherwise (no shift, or no selection on this page; a
|
||||
// selection cannot yet span pages) a click clears the selection.
|
||||
let shift = ui.input(|inp| inp.modifiers.shift);
|
||||
match (view.selection.as_mut().filter(|s| shift && s.page == i), text.nearest(vx, vy)) {
|
||||
(Some(sel), Some(h)) => sel.head = h,
|
||||
_ => view.selection = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1815,7 +1918,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if sr.contains(p) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
let label = match &l.target {
|
||||
LinkTarget::Page(n) => {
|
||||
LinkTarget::Page(n, _) => {
|
||||
crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))])
|
||||
}
|
||||
LinkTarget::Uri(u) => u.clone(),
|
||||
@@ -2049,7 +2152,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
None => {}
|
||||
}
|
||||
match clicked_link {
|
||||
Some(LinkTarget::Page(p)) => view.go_to_page(p),
|
||||
Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info),
|
||||
Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link),
|
||||
Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb),
|
||||
Some(LinkTarget::Other(s)) => app.notify_fmt("{s} actions run in the JavaScript engine (M6)", &[("s", &s)]),
|
||||
@@ -2932,10 +3035,42 @@ fn organize_grid(
|
||||
}
|
||||
}
|
||||
|
||||
/// A rendered raster as texture data. Its words are premultiplied RGBA bytes, exactly
|
||||
/// `Color32`s, so the renderer's buffer becomes the image without a copy on the UI thread.
|
||||
fn texture_image(size: [usize; 2], pixels: pdfcraft_render::Pixels) -> egui::ColorImage {
|
||||
match bytemuck::allocation::try_cast_vec::<u32, Color32>(pixels.into_words()) {
|
||||
Ok(px) if px.len() == size[0].saturating_mul(size[1]) => egui::ColorImage::new(size, px),
|
||||
Ok(px) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&px)),
|
||||
Err((_, words)) => egui::ColorImage::from_rgba_premultiplied(size, bytemuck::cast_slice(&words)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rendered_rasters_become_texture_images_without_a_copy() {
|
||||
let pdf = b"%PDF-1.4
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 50] /Contents 4 0 R >> endobj
|
||||
4 0 obj << /Length 35 >> stream
|
||||
0 0 1 rg 10 10 30 20 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
let mut r = pdfcraft_render::PageRenderer::new(Arc::new(pdf.to_vec()), Default::default());
|
||||
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0 });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let size = [out.width as usize, out.height as usize];
|
||||
let copied = egui::ColorImage::from_rgba_premultiplied(size, &out.rgba);
|
||||
let at = out.rgba.as_ptr();
|
||||
let img = texture_image(size, out.rgba);
|
||||
assert_eq!(img, copied);
|
||||
assert_eq!(img.pixels.as_ptr().cast::<u8>(), at, "the renderer's own buffer");
|
||||
}
|
||||
|
||||
fn view(pages: usize, layout: PageLayout) -> DocView {
|
||||
let info = pdfcraft_render::DocInfo {
|
||||
pages: (0..pages)
|
||||
@@ -3027,4 +3162,47 @@ mod tests {
|
||||
assert_eq!(PageLayout::try_parse(" Two-Up "), Some(PageLayout::TwoUp));
|
||||
assert_eq!(PageLayout::try_parse("facing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destinations_set_the_zoom_mode_and_the_point_to_show() {
|
||||
let info = pdfcraft_render::DocInfo {
|
||||
pages: (0..3)
|
||||
.map(|_| pdfcraft_render::PageInfo { width: 300.0, height: 400.0, label: String::new(), crop: [0.0, 0.0, 300.0, 400.0], rotation: 0 })
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut v = view(3, PageLayout::Continuous);
|
||||
v.fit = Fit::Width;
|
||||
// No position (or a malformed one): the top of the page, zoom as it was.
|
||||
v.go_to_dest(1, DestView::Top, &info);
|
||||
assert_eq!((v.current, v.goto, v.goto_point, v.fit), (1, Some((1, 0.0)), None, Fit::Width));
|
||||
// /XYZ with a null zoom keeps the zoom mode; its point is a quarter down page 3.
|
||||
v.go_to_dest(2, DestView::Xyz { left: None, top: Some(300.0), zoom: None }, &info);
|
||||
assert_eq!((v.current, v.fit, v.goto_point), (2, Fit::Width, Some((2, None, Some(0.25), vec2(SIDE, 0.0)))));
|
||||
v.go_to_dest(0, DestView::Xyz { left: Some(150.0), top: None, zoom: Some(2.5) }, &info);
|
||||
assert_eq!((v.fit, v.zoom, v.goto_point), (Fit::None, 2.5, Some((0, Some(0.5), None, vec2(SIDE, 0.0)))));
|
||||
// A zoom beyond the view's range is clamped to it.
|
||||
v.go_to_dest(0, DestView::Xyz { left: None, top: None, zoom: Some(1e30) }, &info);
|
||||
assert_eq!((v.zoom, v.goto_point), (64.0, None));
|
||||
v.go_to_dest(0, DestView::Fit, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Page, None));
|
||||
v.go_to_dest(0, DestView::FitH { top: Some(100.0) }, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Width, Some((0, None, Some(0.75), vec2(SIDE, 0.0)))));
|
||||
v.go_to_dest(0, DestView::FitV { left: Some(75.0) }, &info);
|
||||
assert_eq!((v.fit, v.goto_point), (Fit::Height, Some((0, Some(0.25), None, vec2(SIDE, 0.0)))));
|
||||
// /FitR: the zoom that fits a 100×50 pt rectangle in an 800×600 window, centred.
|
||||
(v.viewport_w, v.viewport_h) = (800.0, 600.0);
|
||||
v.go_to_dest(0, DestView::FitR { rect: [0.0, 300.0, 100.0, 350.0] }, &info);
|
||||
assert_eq!(v.fit, Fit::None);
|
||||
assert!((v.zoom - 8.0 / PT).abs() < 1e-4, "{}", v.zoom);
|
||||
let (page, fx, fy, rel) = v.goto_point.expect("a point to show");
|
||||
assert_eq!((page, rel), (0, vec2(400.0, 300.0)));
|
||||
assert!((fx.unwrap_or(-1.0) - 50.0 / 300.0).abs() < 1e-4 && (fy.unwrap_or(-1.0) - 75.0 / 400.0).abs() < 1e-4, "{fx:?} {fy:?}");
|
||||
// A page past the end goes to the last page; a page the document info lacks, to its top.
|
||||
v.go_to_dest(usize::MAX, DestView::FitH { top: Some(100.0) }, &info);
|
||||
assert_eq!((v.current, v.goto_point.map(|g| g.0)), (2, Some(2)));
|
||||
let empty = pdfcraft_render::DocInfo::default();
|
||||
v.go_to_dest(1, DestView::FitH { top: Some(100.0) }, &empty);
|
||||
assert_eq!((v.current, v.goto_point), (1, None));
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,8 @@ impl PdfCraftApp {
|
||||
Some(p) => self.open_recent(&p),
|
||||
None => self.notify_tr("No recent files"),
|
||||
},
|
||||
"file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"),
|
||||
"file.clear_recent" => self.recent.clear(),
|
||||
"file.pin_folder" => self.pin_folder_dialog(),
|
||||
"page.combine" => self.open_combine_tab(),
|
||||
"file.save" => {
|
||||
@@ -464,7 +466,10 @@ impl PdfCraftApp {
|
||||
if let Some(i) = active {
|
||||
self.views[i].forms.focus = None;
|
||||
}
|
||||
self.notify_fmt("Click on the page to add a {tool}, or drag to set its size", &[("tool", &tl!(tool.label()).to_lowercase())]);
|
||||
self.notify_fmt(
|
||||
"Click on the page to add a {tool}, or drag to set its size",
|
||||
&[("tool", &crate::i18n::in_sentence(tl!(tool.label())))],
|
||||
);
|
||||
}
|
||||
"sign.fill.signature.remove" => self.signature = None,
|
||||
"sign.fill.initials.remove" => self.initials = None,
|
||||
@@ -570,13 +575,15 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
|
||||
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
|
||||
let label = crate::i18n::menu_label(spec.id, &label);
|
||||
// Open Recent is a submenu of the live recent list, not one action: disabled while the
|
||||
// list is empty, otherwise each entry opens its file (or focuses the tab showing it).
|
||||
// list is empty, otherwise each entry opens its file (or focuses the tab showing it), and
|
||||
// Clear Recent Files at the foot empties the list (#430).
|
||||
if spec.id == "file.open_recent" {
|
||||
if app.recent.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new(label));
|
||||
continue;
|
||||
}
|
||||
let mut open: Option<String> = None;
|
||||
let mut clear = false;
|
||||
ui.menu_button(label, |ui| {
|
||||
for r in &app.recent {
|
||||
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
|
||||
@@ -584,11 +591,20 @@ pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Clear Recent Files")).clicked() {
|
||||
clear = true;
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
if let Some(p) = open {
|
||||
app.open_recent(&p);
|
||||
ui.close();
|
||||
}
|
||||
if clear {
|
||||
app.execute("file.clear_recent");
|
||||
ui.close();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shortcut = spec.shortcut.map(|s| s.label(mac)).unwrap_or_default();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! subject, modified) and Review History (status changes), with Acrobat's Locked box.
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use pdfcraft_engine::{CommentProps, Edit, NoteIcon};
|
||||
use pdfcraft_engine::{CommentProps, Edit, LineEnding, NoteIcon};
|
||||
|
||||
use crate::comments::swatch_grid;
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -85,7 +85,7 @@ impl PdfCraftApp {
|
||||
style.width = w;
|
||||
}
|
||||
self.comment_prefs.set_style(tool, style);
|
||||
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &tl!(tool.label()).to_lowercase())]);
|
||||
self.notify_fmt("New {tool} comments will look like this one", &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))]);
|
||||
}
|
||||
|
||||
/// Open Comment Properties for the comment at `(page, index)` of the active document.
|
||||
@@ -108,8 +108,9 @@ pub fn edits(d: &PropsDraft) -> Vec<Edit> {
|
||||
let color = (e.color != o.color).then_some(e.color).flatten();
|
||||
let opacity = ((e.opacity - o.opacity).abs() > 1e-6).then_some(e.opacity);
|
||||
let width = (e.width != o.width).then_some(e.width).flatten();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width });
|
||||
let endings = (e.endings != o.endings).then(|| e.endings.clone()).flatten();
|
||||
if e.restylable && (color.is_some() || opacity.is_some() || width.is_some() || endings.is_some()) {
|
||||
out.push(Edit::StyleAnnotation { page: d.page, index: d.index, color, opacity, width, endings });
|
||||
}
|
||||
let author = (e.author != o.author).then(|| e.author.clone());
|
||||
let subject = (e.subject != o.subject).then(|| e.subject.clone());
|
||||
@@ -188,6 +189,19 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
ui.add(egui::Slider::new(w, 0.5..=12.0).step_by(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(ends) = e.endings.as_mut() {
|
||||
ui.label(tl!("Line ending"));
|
||||
ui.horizontal(|ui| {
|
||||
for (i, ending) in ends.iter_mut().enumerate() {
|
||||
egui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending.name()).width(130.0).show_ui(ui, |ui| {
|
||||
for style in LineEnding::ALL {
|
||||
ui.selectable_value(ending, style, style.name());
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
//! applied by the app, so each change is one undo step.
|
||||
|
||||
use egui::{Color32, CornerRadius, Pos2, Rect, Sense, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::{Edit, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||||
use pdfcraft_engine::{Edit, LineEnding, Markup, NewAnnotation, NoteIcon, ReviewState, Rgb, Shape, Style};
|
||||
use pdfcraft_render::{Annotation, DocInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
@@ -200,14 +200,14 @@ impl CommentTool {
|
||||
Shape::TextMarkup { kind: self.markup().unwrap_or(Markup::Highlight), quads: Vec::new() }
|
||||
}
|
||||
Self::Ink => Shape::Ink { strokes: Vec::new() },
|
||||
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: false },
|
||||
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], arrow: true },
|
||||
Self::Line => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::None },
|
||||
Self::Arrow => Shape::Line { from: [0.0; 2], to: [0.0; 2], start: LineEnding::None, end: LineEnding::OpenArrow },
|
||||
Self::Rectangle => Shape::Rectangle { rect: [0.0; 4] },
|
||||
Self::Oval => Shape::Oval { rect: [0.0; 4] },
|
||||
Self::Polygon => Shape::Polygon { vertices: Vec::new(), cloud: false },
|
||||
Self::Cloud => Shape::Polygon { vertices: Vec::new(), cloud: true },
|
||||
Self::PolyLine => Shape::PolyLine { vertices: Vec::new() },
|
||||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0 },
|
||||
Self::PolyLine => Shape::PolyLine { vertices: Vec::new(), start: LineEnding::None, end: LineEnding::None },
|
||||
Self::Callout => Shape::Callout { rect: [0.0; 4], knee: [0.0; 2], point: [0.0; 2], font_size: 10.0, ending: LineEnding::OpenArrow },
|
||||
Self::Caret => Shape::Caret { rect: [0.0; 4] },
|
||||
Self::ReplaceText => Shape::TextMarkup { kind: Markup::StrikeOut, quads: Vec::new() },
|
||||
Self::Eraser => Shape::Ink { strokes: Vec::new() },
|
||||
@@ -1008,13 +1008,20 @@ fn drawn_shape(tool: CommentTool, points: &[[f64; 2]]) -> Option<Shape> {
|
||||
let big = rect[2] - rect[0] >= 2.0 && rect[3] - rect[1] >= 2.0;
|
||||
match tool {
|
||||
CommentTool::Ink if points.len() >= 2 => Some(Shape::Ink { strokes: vec![points.to_vec()] }),
|
||||
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line { from: first, to: last, arrow: tool == CommentTool::Arrow }),
|
||||
CommentTool::Line | CommentTool::Arrow if far => Some(Shape::Line {
|
||||
from: first,
|
||||
to: last,
|
||||
start: LineEnding::None,
|
||||
end: if tool == CommentTool::Arrow { LineEnding::OpenArrow } else { LineEnding::None },
|
||||
}),
|
||||
CommentTool::Rectangle if big => Some(Shape::Rectangle { rect }),
|
||||
CommentTool::Oval if big => Some(Shape::Oval { rect }),
|
||||
CommentTool::Polygon | CommentTool::Cloud if points.len() >= 3 => {
|
||||
Some(Shape::Polygon { vertices: points.to_vec(), cloud: tool == CommentTool::Cloud })
|
||||
}
|
||||
CommentTool::PolyLine if points.len() >= 2 => Some(Shape::PolyLine { vertices: points.to_vec() }),
|
||||
CommentTool::PolyLine if points.len() >= 2 => {
|
||||
Some(Shape::PolyLine { vertices: points.to_vec(), start: LineEnding::None, end: LineEnding::None })
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1074,7 +1081,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(&prefs.author).font(theme::semibold(13.0)));
|
||||
ui.label(egui::RichText::new(title).font(theme::regular(11.5)).color(t.text_faint));
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let hint = match c.kind {
|
||||
@@ -1144,7 +1151,7 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
(rect[0] + rect[2]) / 2.0
|
||||
};
|
||||
let knee = [(point[0] + side) / 2.0, (rect[1] + rect[3]) / 2.0];
|
||||
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0 }, text))
|
||||
Some(new_comment(&cx, CommentTool::Callout, Shape::Callout { rect, knee, point, font_size: 10.0, ending: LineEnding::OpenArrow }, text))
|
||||
}
|
||||
ComposerKind::Replace => {
|
||||
view.comments.tool_done = true;
|
||||
@@ -1236,8 +1243,14 @@ pub(crate) fn context_menu(ui: &mut egui::Ui, view: &mut DocView, info: &DocInfo
|
||||
});
|
||||
ui.menu_button(tl!("Colour"), |ui| {
|
||||
if let Some(c) = swatch_grid(ui, a.color.map(|c| c.map(f64::from))) {
|
||||
action =
|
||||
Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation { page, index, color: Some(c), opacity: None, width: None })));
|
||||
action = Some(CanvasAction::Edit(Box::new(Edit::StyleAnnotation {
|
||||
page,
|
||||
index,
|
||||
color: Some(c),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
})));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
@@ -1404,7 +1417,10 @@ mod tests {
|
||||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[0.0, 0.0], [1.0, 1.0]]), None);
|
||||
assert_eq!(drawn_shape(CommentTool::Rectangle, &[[10.0, 0.0], [0.0, 10.0]]), Some(Shape::Rectangle { rect: [0.0, 0.0, 10.0, 10.0] }));
|
||||
assert_eq!(drawn_shape(CommentTool::Ink, &[[0.0, 0.0]]), None);
|
||||
assert!(matches!(drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]), Some(Shape::Line { arrow: true, .. })));
|
||||
assert!(matches!(
|
||||
drawn_shape(CommentTool::Arrow, &[[0.0, 0.0], [5.0, 0.0]]),
|
||||
Some(Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -11,6 +11,37 @@ use crate::canvas::{DocView, PageXform};
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// The colour of the warning under text the standard fonts can't draw.
|
||||
const UNDRAWABLE: Color32 = Color32::from_rgb(0xD7, 0x37, 0x3F);
|
||||
|
||||
/// The first character of a draft that can't be drawn, as the engine decides it (it refuses
|
||||
/// such text, #125): outside the standard fonts and not shaped with the Arabic face.
|
||||
pub(crate) fn undrawable(d: &TextDraft, memo: &UndrawableMemo) -> Option<char> {
|
||||
// Checked as `finish` will commit it: without trailing whitespace.
|
||||
let t = AddedText { text: d.text.trim_end().to_string(), rect: d.rect, ..d.style.clone() };
|
||||
if let Some((key, c)) = memo.borrow().as_ref()
|
||||
&& *key == t
|
||||
{
|
||||
return *c;
|
||||
}
|
||||
let c = pdfcraft_engine::first_undrawable(&t);
|
||||
*memo.borrow_mut() = Some((t, c));
|
||||
c
|
||||
}
|
||||
|
||||
/// The last draft [`undrawable`] checked and its answer: the check lays the text out (and shapes
|
||||
/// Arabic), too slow to repeat every frame for long text, so it reruns only when the text or
|
||||
/// anything that changes its layout does.
|
||||
pub(crate) type UndrawableMemo = std::cell::RefCell<Option<(AddedText, Option<char>)>>;
|
||||
|
||||
/// The warning shown for text with `c` in it.
|
||||
pub(crate) fn undrawable_message(c: char) -> String {
|
||||
crate::i18n::fmt(
|
||||
tl!("The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now."),
|
||||
&[("c", &c.to_string()), ("code", &format!("U+{:04X}", u32::from(c)))],
|
||||
)
|
||||
}
|
||||
|
||||
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
|
||||
/// The text being typed or retyped in place.
|
||||
@@ -41,6 +72,24 @@ pub struct ContentView {
|
||||
/// Select the item an add creates (the page's item count before it).
|
||||
pub select_added: Option<(usize, usize)>,
|
||||
grab: Option<(usize, Grab, Pos2)>,
|
||||
undrawable: UndrawableMemo,
|
||||
}
|
||||
|
||||
impl ContentView {
|
||||
/// The draft's first character the standard fonts can't draw. Such a draft is kept open, and
|
||||
/// counts as unsaved work, until it is corrected or discarded.
|
||||
pub(crate) fn blocked(&self) -> Option<char> {
|
||||
self.draft.as_ref().and_then(|d| undrawable(d, &self.undrawable))
|
||||
}
|
||||
|
||||
/// Keeps a blocked draft open and focused instead of finishing or replacing it.
|
||||
pub(crate) fn hold_blocked(&mut self) -> bool {
|
||||
let blocked = self.blocked().is_some();
|
||||
if let Some(d) = self.draft.as_mut().filter(|_| blocked) {
|
||||
d.focus = true;
|
||||
}
|
||||
blocked
|
||||
}
|
||||
}
|
||||
|
||||
/// Text style for new text (the panel's Format controls set it).
|
||||
@@ -151,6 +200,10 @@ pub(crate) fn page_input(
|
||||
if adding_text && hit.is_none() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
if resp.clicked() {
|
||||
// Text that can't be drawn stays where it is being typed (#125).
|
||||
if cv.hold_blocked() {
|
||||
return true;
|
||||
}
|
||||
// A click elsewhere keeps the text being typed and starts a new box (#74). The click
|
||||
// can reach the page before the editor sees it lose focus, so finish it here.
|
||||
let typed = cv.draft.take().and_then(|d| finish(cv, d, added));
|
||||
@@ -195,6 +248,9 @@ pub(crate) fn page_input(
|
||||
&& let Some((i, a)) = hit
|
||||
&& let AddedContent::Text(t) = &a.content
|
||||
{
|
||||
if cv.hold_blocked() {
|
||||
return true;
|
||||
}
|
||||
cv.draft = Some(TextDraft { page, index: Some(i), rect: t.rect, text: t.text.clone(), style: t.clone(), focus: true });
|
||||
cv.selected = Some((page, i));
|
||||
consumed = true;
|
||||
@@ -277,6 +333,8 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
let zoom = xf.rect.width() / xf.pw.max(1.0);
|
||||
let tokens = Tokens::get(ctx);
|
||||
let (mut commit, mut cancel, mut done) = (false, false, false);
|
||||
let mut undrawable = None;
|
||||
let memo = &view.content.undrawable;
|
||||
egui::Area::new(egui::Id::new(("added-text", view.id.0))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| {
|
||||
let Some(t) = view.content.draft.as_mut() else { return };
|
||||
let [cr, cg, cb] = t.style.color.map(|v| (v.clamp(0.0, 1.0) * 255.0) as u8);
|
||||
@@ -315,12 +373,18 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
cancel |= ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
commit = done || resp.lost_focus() && !on_buttons;
|
||||
});
|
||||
// Page text is drawn with the standard fonts; anything else would be saved as `?` (#125).
|
||||
undrawable = self::undrawable(t, memo);
|
||||
if let Some(c) = undrawable {
|
||||
ui.label(egui::RichText::new(undrawable_message(c)).small().color(UNDRAWABLE));
|
||||
}
|
||||
});
|
||||
if cancel {
|
||||
view.content.draft = None;
|
||||
return (None, false);
|
||||
}
|
||||
if !commit {
|
||||
// Text that can't be drawn stays in the editor, with the warning, rather than being lost.
|
||||
if !commit || undrawable.is_some() {
|
||||
return (None, false);
|
||||
}
|
||||
let edit = view.content.draft.take().and_then(|d| finish(&mut view.content, d, added));
|
||||
|
||||
@@ -267,6 +267,21 @@ impl PdfCraftApp {
|
||||
/// brought forward first, so the commit and any field scripts act on that document.
|
||||
pub(crate) fn commit_typing_in(&mut self, id: pdfcraft_engine::DocId) -> bool {
|
||||
let Some(i) = self.views.iter().position(|v| v.id == id) else { return true };
|
||||
// Added text the standard fonts can't draw can't be saved: show it rather than drop it.
|
||||
// Its page is brought into view, so the text, the warning and Discard are on screen.
|
||||
let blocked = self.views.get_mut(i).and_then(|v| {
|
||||
let c = v.content.blocked()?;
|
||||
v.content.hold_blocked();
|
||||
if let Some(page) = v.content.draft.as_ref().map(|d| d.page) {
|
||||
v.go_to_page(page);
|
||||
}
|
||||
Some(c)
|
||||
});
|
||||
if let Some(c) = blocked {
|
||||
self.active = Some(i);
|
||||
self.notify(crate::content_ui::undrawable_message(c));
|
||||
return false;
|
||||
}
|
||||
if self.active != Some(i) {
|
||||
let typing = self.views.get(i).is_some_and(|v| v.pending_edit.is_some() || v.forms.focus.is_some());
|
||||
if !typing {
|
||||
@@ -281,7 +296,10 @@ impl PdfCraftApp {
|
||||
pub(crate) fn has_unsaved_work(&self, index: usize) -> bool {
|
||||
let Some(v) = self.views.get(index) else { return false };
|
||||
let Some(doc) = self.session.get(v.id) else { return false };
|
||||
doc.dirty || v.pending_edit.is_some() || v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|
||||
doc.dirty
|
||||
|| v.pending_edit.is_some()
|
||||
|| v.content.blocked().is_some()
|
||||
|| v.forms.focus.as_ref().is_some_and(|f| crate::forms_ui::draft_edit(f, &doc.form).is_some())
|
||||
}
|
||||
|
||||
/// Save the active document. Returns `true` if it was written.
|
||||
|
||||
@@ -190,38 +190,50 @@ pub fn typed(page: usize, at: [f64; 2], text: &str, author: &str) -> Edit {
|
||||
}
|
||||
|
||||
/// Place a saved signature (strokes normalised to a 0–1 box, y up) with its left edge at `at`,
|
||||
/// 150 pt wide.
|
||||
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], author: &str) -> Option<Edit> {
|
||||
/// 150 pt wide. Left, centred and upright are as displayed on a page turned by `rotation` (its
|
||||
/// `/Rotate`), so the strokes are turned back into user space.
|
||||
pub fn signature_at(page: usize, at: [f64; 2], strokes: &[Vec<[f32; 2]>], rotation: i64, author: &str) -> Option<Edit> {
|
||||
let w = 150.0;
|
||||
let (min_y, max_y) = strokes.iter().flatten().fold((f32::MAX, f32::MIN), |(a, b), p| (a.min(p[1]), b.max(p[1])));
|
||||
if !min_y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let h = f64::from(max_y - min_y).max(0.05) * w;
|
||||
// Displayed right and up as user-space unit vectors (the identity on an unturned page).
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
let strokes: Vec<Vec<[f64; 2]>> = strokes
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.iter().map(|p| [at[0] + f64::from(p[0]) * w, at[1] - h / 2.0 + f64::from(p[1] - min_y) * w]).collect())
|
||||
.map(|s| {
|
||||
s.iter()
|
||||
.map(|p| {
|
||||
let (dx, dy) = (f64::from(p[0]) * w, f64::from(p[1] - min_y) * w - h / 2.0);
|
||||
[at[0] + a * dx + c * dy, at[1] + b * dx + d * dy]
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
(!strokes.is_empty()).then(|| new(page, Shape::Signature { strokes }, String::new(), author))
|
||||
}
|
||||
|
||||
/// Place typed text in the script font with its left edge at `at`, `height` points tall.
|
||||
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, author: &str) -> Option<Edit> {
|
||||
pdfcraft_engine::typed_signature_shape(at, text, height).map(|shape| new(page, shape, String::new(), author))
|
||||
/// Place typed text in the script font with its left edge at `at`, `height` points tall, upright
|
||||
/// as displayed on a page turned by `rotation`.
|
||||
pub fn typed_signature_at(page: usize, at: [f64; 2], text: &str, height: f64, rotation: i64, author: &str) -> Option<Edit> {
|
||||
pdfcraft_engine::typed_signature_shape(at, text, height, rotation).map(|shape| new(page, shape, String::new(), author))
|
||||
}
|
||||
|
||||
/// Place a saved signature or initials.
|
||||
/// Place a saved signature or initials, upright as `info`'s page is displayed.
|
||||
pub fn place(page: usize, info: &PageInfo, at: [f64; 2], sig: &SavedSig, initials: bool, author: &str) -> Option<Edit> {
|
||||
let rotation = i64::from(info.rotation);
|
||||
match sig {
|
||||
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, author),
|
||||
SavedSig::Drawn(strokes) => signature_at(page, at, strokes, rotation, author),
|
||||
SavedSig::Image(image) => image.edit(page, info, at, initials, author),
|
||||
SavedSig::Typed(text) => {
|
||||
let [left, bottom, right, top] = pdfcraft_engine::script_outline(text).bounds();
|
||||
let height = if initials { 24.0_f64 } else { 32.0_f64 };
|
||||
// Keep long names within the same placement width as drawn signatures.
|
||||
let height = height.min(150.0 * (top - bottom).max(0.1) / (right - left).max(0.01));
|
||||
typed_signature_at(page, at, text, height, author)
|
||||
typed_signature_at(page, at, text, height, rotation, author)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,11 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
|
||||
.selectable_label(selected, text)
|
||||
.on_hover_text(crate::i18n::fmt(
|
||||
tl!("{month} {day}, {y}"),
|
||||
&[("month", tl!(MONTHS[(m - 1) as usize])), ("day", &day.to_string()), ("y", &y.to_string())],
|
||||
&[
|
||||
("month", tl_ctx!("calendar date", MONTHS[(m - 1) as usize])),
|
||||
("day", &day.to_string()),
|
||||
("y", &y.to_string()),
|
||||
],
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
|
||||
@@ -60,13 +60,22 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
ui.painter().text(rect.min + vec2(44.0, 25.0), Align2::LEFT_CENTER, tl!(g.label), theme::semibold(13.5), t.text);
|
||||
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
|
||||
title_job.wrap =
|
||||
egui::text::TextWrapping { max_width: rect.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let title = ui.fonts_mut(|f| f.layout_job(title_job));
|
||||
let title_elided = title.elided;
|
||||
ui.painter().galley(rect.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
|
||||
let blurb = g
|
||||
.sections
|
||||
.first()
|
||||
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
|
||||
.unwrap_or_default();
|
||||
let galley = ui.fonts_mut(|f| f.layout(blurb, theme::regular(11.5), t.text_muted, rect.width() - 28.0));
|
||||
// Fixed-height cards must leave room for their action in every language.
|
||||
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, rect.width() - 28.0);
|
||||
job.wrap.max_rows = 2;
|
||||
let galley = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let blurb_elided = galley.elided;
|
||||
ui.painter().galley(rect.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(
|
||||
rect.left_bottom() + vec2(14.0, -14.0),
|
||||
@@ -75,6 +84,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
theme::medium(12.0),
|
||||
t.accent_text,
|
||||
);
|
||||
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
|
||||
if resp.clicked() {
|
||||
app.left = LeftPanel::Tool(g.id);
|
||||
app.left_open = true;
|
||||
@@ -104,17 +114,42 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
|
||||
ui.add_space(26.0);
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
let mut clear = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if app.recent.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
|
||||
}
|
||||
let mut open = None;
|
||||
let mut remove = None;
|
||||
for r in &app.recent {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name));
|
||||
if resp.hovered() {
|
||||
// The row, not its response: the pointer stays "in" the row over its remove button.
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
if hovered {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
|
||||
// Remove just this file from the list (#430); the file itself is untouched.
|
||||
let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0));
|
||||
let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click());
|
||||
let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]);
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed);
|
||||
}
|
||||
icons::paint(ui, x_rect.shrink(6.0), "x", 16.0, t.icon);
|
||||
if x.on_hover_text(tl!("Remove from Recent")).clicked() {
|
||||
remove = Some(r.path.clone());
|
||||
}
|
||||
}
|
||||
icons::paint(
|
||||
ui,
|
||||
@@ -123,8 +158,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
22.0,
|
||||
egui::Color32::from_rgb(0xE0, 0x3E, 0x3E),
|
||||
);
|
||||
// Room on the right for the remove button.
|
||||
let detail = ui.painter().text(
|
||||
rect.right_center() - vec2(12.0, 0.0),
|
||||
rect.right_center() - vec2(48.0, 0.0),
|
||||
Align2::RIGHT_CENTER,
|
||||
format!("{} {} · {}", r.pages, tl!("pages"), human_size(r.size)),
|
||||
theme::regular(12.0),
|
||||
@@ -141,6 +177,12 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if let Some(p) = open {
|
||||
app.open_recent(&p);
|
||||
}
|
||||
if let Some(p) = remove {
|
||||
app.recent.retain(|r| r.path != p);
|
||||
}
|
||||
if clear {
|
||||
app.execute("file.clear_recent");
|
||||
}
|
||||
ui.add_space(20.0);
|
||||
widgets::section_title(ui, tl!("Privacy"));
|
||||
ui.label(
|
||||
@@ -150,3 +192,46 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn translated_card_description_leaves_room_for_the_action() {
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
crate::i18n::set_current(de);
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let mut app = PdfCraftApp::new();
|
||||
let blurb = catalog::group("form")
|
||||
.unwrap()
|
||||
.sections
|
||||
.first()
|
||||
.unwrap()
|
||||
.items
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|item| crate::i18n::tr(de, item.label))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · ");
|
||||
let output = ctx
|
||||
.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }, |ui| {
|
||||
show(&mut app, ui)
|
||||
});
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let text_shapes: Vec<_> =
|
||||
output.shapes.iter().filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some(text) } else { None }).collect();
|
||||
let description = text_shapes.iter().find(|text| text.galley.job.text == blurb).unwrap();
|
||||
let action = text_shapes
|
||||
.iter()
|
||||
.find(|text| text.galley.job.text == crate::i18n::tr(de, "Use now") && (text.pos.x - description.pos.x).abs() < 0.1)
|
||||
.unwrap();
|
||||
assert!(description.galley.rows.len() <= 2);
|
||||
assert!(
|
||||
description.pos.y + description.galley.size().y + 4.0 <= action.pos.y,
|
||||
"the translated description must leave a visible gap before its action"
|
||||
);
|
||||
output.drop_without_applying_deltas();
|
||||
}
|
||||
}
|
||||
@@ -290,6 +290,7 @@
|
||||
Bottom: Долу:
|
||||
Width: Ширина:
|
||||
Height: Височина:
|
||||
Rotation: Завъртане:
|
||||
Points from the page's bottom-left corner. Точки от долния ляв ъгъл на страницата.
|
||||
Alignment: Подравняване:
|
||||
Default Value: Стойност по подразбиране:
|
||||
@@ -607,6 +608,7 @@
|
||||
This comment's appearance can't be changed yet. Външният вид на този коментар все още не може да се променя.
|
||||
Icon Икона
|
||||
Thickness Дебелина
|
||||
Line ending Край на линията
|
||||
Modified Променено
|
||||
No status has been set. Не е зададено състояние.
|
||||
Compare Files Сравняване на файлове
|
||||
@@ -2043,3 +2045,7 @@ signature pad Type Въвеждане
|
||||
action wizard Start Старт
|
||||
Margins (in) Полета (in)
|
||||
Reopen the files that were open when PdfCraft last closed Отваряй отново файловете, които бяха отворени при последното затваряне на PdfCraft
|
||||
Clear Recent Files Изчисти последно отваряните файлове
|
||||
Remove from Recent Премахни от последно отваряните
|
||||
Remove {name} from Recent Премахни {name} от последно отваряните
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартните шрифтове не могат да покажат „{c}“ ({code}). Засега като текст могат да се добавят само западноевропейски символи.
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -10,6 +10,7 @@
|
||||
|
||||
Menu Nabídka
|
||||
File Soubor
|
||||
Rotation: Otočení:
|
||||
Edit Úpravy
|
||||
Pages Stránky
|
||||
View Zobrazení
|
||||
@@ -29,6 +30,10 @@
|
||||
OK OK
|
||||
Open… Otevřít…
|
||||
Open Recent Otevřít nedávné soubory
|
||||
Clear Recent Files Vymazat nedávné soubory
|
||||
Remove from Recent Odebrat z nedávných souborů
|
||||
Remove {name} from Recent Odebrat {name} z nedávných souborů
|
||||
Clear Vymazat
|
||||
Pin folder to Home… Připnout složku na úvodní stránku…
|
||||
No recent files Žádné nedávné soubory
|
||||
New blank PDF Nový prázdný dokument PDF
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 81.
|
@@ -40,7 +40,7 @@
|
||||
Advanced search… Erweiterte Suche…
|
||||
Copy pages Seiten kopieren
|
||||
Cut pages Seiten ausschneiden
|
||||
Paste pages Seiten einfügen
|
||||
Paste pages Kopierte Seiten einfügen
|
||||
Fit visible Sichtbaren Inhalt einpassen
|
||||
Marquee zoom Auswahlrahmen-Zoom
|
||||
Take a snapshot Schnappschuss erstellen
|
||||
@@ -49,8 +49,8 @@
|
||||
Switch light / dark theme Helles / dunkles Design umschalten
|
||||
Comments panel Kommentarbereich
|
||||
Form fields panel Formularfeldbereich
|
||||
Clear form Formular leeren
|
||||
Find tools and commands… Werkzeuge und Befehle suchen…
|
||||
Clear form Formular zurücksetzen
|
||||
Find tools and commands… Tools und Befehle suchen…
|
||||
Zoom Zoom
|
||||
Actual size Originalgröße
|
||||
Zoom to page level Ganze Seite
|
||||
@@ -84,7 +84,7 @@
|
||||
Print (⌘P) Drucken (⌘P)
|
||||
Save (M4) Speichern (M4)
|
||||
Document properties (⌘D) Dokumenteigenschaften (⌘D)
|
||||
Find tools and commands Werkzeuge und Befehle suchen
|
||||
Find tools and commands Tools und Befehle suchen
|
||||
Fit to height Seitenhöhe
|
||||
Zoom in Vergrößern
|
||||
Zoom out Verkleinern
|
||||
@@ -94,7 +94,7 @@
|
||||
Previous view Vorherige Ansicht
|
||||
Next view Nächste Ansicht
|
||||
Page display Seitenanzeige
|
||||
Continuous scrolling Fortlaufend blättern
|
||||
Continuous scrolling Fortlaufendes Scrollen
|
||||
Two-page view Doppelseitenansicht
|
||||
Single page Einzelseite
|
||||
Show cover page in two-page view Deckblatt in Doppelseitenansicht anzeigen
|
||||
@@ -137,12 +137,12 @@
|
||||
View more Mehr anzeigen
|
||||
Available Verfügbar
|
||||
Ready Bereit
|
||||
Search tools and commands… Werkzeuge und Befehle suchen…
|
||||
Search tools and commands… Tools und Befehle suchen…
|
||||
No matching tools Keine passenden Werkzeuge
|
||||
Tool Werkzeug
|
||||
Command Befehl
|
||||
Add a sticky note Notiz hinzufügen
|
||||
Add a text box Textfeld hinzufügen
|
||||
Add a text box Textbox hinzufügen
|
||||
Highlight text Text hervorheben
|
||||
Underline text Text unterstreichen
|
||||
Strikethrough text Text durchstreichen
|
||||
@@ -155,7 +155,7 @@
|
||||
Draw a polygon Polygon zeichnen
|
||||
Draw connected lines Verbundene Linien zeichnen
|
||||
Draw a cloud Wolke zeichnen
|
||||
Add a callout Legende hinzufügen
|
||||
Add a callout Text mit Hinweislinie hinzufügen
|
||||
Insert text Text einfügen
|
||||
Replace text Text ersetzen
|
||||
Attach a file Datei anhängen
|
||||
@@ -324,13 +324,13 @@
|
||||
Set page boxes Seitenrahmen festlegen
|
||||
Page labels Seitenbeschriftungen
|
||||
Page transitions Seitenübergänge
|
||||
Add comments Kommentare hinzufügen
|
||||
Add comments Kommentieren
|
||||
Markup Markierungen
|
||||
Sticky note Notiz
|
||||
Underline Unterstreichen
|
||||
Strikethrough Durchstreichen
|
||||
Squiggly underline Schlangenlinie
|
||||
Text box Textfeld
|
||||
Text box Textbox
|
||||
Drawing Zeichnen
|
||||
Rectangle Rechteck
|
||||
Oval Ellipse
|
||||
@@ -550,7 +550,7 @@
|
||||
Text Field Properties Textfeld-Eigenschaften
|
||||
Check Box Properties Kontrollkästchen-Eigenschaften
|
||||
Radio Button Properties Optionsfeld-Eigenschaften
|
||||
Dropdown Properties Dropdown-Liste-Eigenschaften
|
||||
Dropdown Properties Dropdown-Listen-Eigenschaften
|
||||
List Box Properties Listenfeld-Eigenschaften
|
||||
Button Properties Schaltflächen-Eigenschaften
|
||||
Digital Signature Properties Eigenschaften der digitalen Signatur
|
||||
@@ -576,6 +576,7 @@
|
||||
Bottom: Unten:
|
||||
Width: Breite:
|
||||
Height: Höhe:
|
||||
Rotation: Drehung:
|
||||
Points from the page's bottom-left corner. Punkte ab der unteren linken Ecke der Seite.
|
||||
Alignment: Ausrichtung:
|
||||
Default Value: Standardwert:
|
||||
@@ -589,8 +590,8 @@
|
||||
Comb of Kammfeld mit
|
||||
Export Value: {on} Exportwert: {on}
|
||||
Check box is checked by default Kontrollkästchen ist standardmäßig aktiviert
|
||||
Button is checked by default Schaltfläche ist standardmäßig aktiviert
|
||||
Buttons with the same name and value are selected in unison Schaltflächen mit gleichem Namen und Wert werden gemeinsam ausgewählt
|
||||
Button is checked by default Optionsfeld ist standardmäßig ausgewählt
|
||||
Buttons with the same name and value are selected in unison Optionsfelder mit gleichem Namen und Wert werden gemeinsam ausgewählt
|
||||
Item: Eintrag:
|
||||
Item List Eintragsliste
|
||||
Up Nach oben
|
||||
@@ -629,8 +630,8 @@
|
||||
Select Trigger: Auslöser auswählen:
|
||||
Mouse Up Maustaste loslassen
|
||||
Mouse Down Maustaste drücken
|
||||
Mouse Enter Maus hinein
|
||||
Mouse Exit Maus heraus
|
||||
Mouse Enter Mauszeiger ins Feld bewegen
|
||||
Mouse Exit Mauszeiger aus dem Feld bewegen
|
||||
On Focus Fokus erhalten
|
||||
On Blur Fokus verlieren
|
||||
Select Action: Aktion auswählen:
|
||||
@@ -755,7 +756,7 @@
|
||||
Preview: page {p} of {n} Vorschau: Seite {p} von {n}
|
||||
Applies to 1 page Gilt für 1 Seite
|
||||
Applies to {n} pages Gilt für {n} Seiten
|
||||
Replaces the existing one on those pages. Ersetzt das vorhandene auf diesen Seiten.
|
||||
Replaces the existing one on those pages. Ersetzt das Vorhandene auf diesen Seiten.
|
||||
PDF or image PDF oder Bild
|
||||
No file chosen Keine Datei ausgewählt
|
||||
Page number Seitenzahl
|
||||
@@ -788,10 +789,10 @@
|
||||
Couldn't open the signed version: {e} Die signierte Version konnte nicht geöffnet werden: {e}
|
||||
Sign with a Digital ID Mit digitaler ID signieren
|
||||
Choose the digital ID that you want to use for signing: Wähle die digitale ID aus, mit der du signieren möchtest:
|
||||
Keychain · Schlüsselbund ·
|
||||
Windows store · Windows-Zertifikatspeicher ·
|
||||
Issued by: Ausgestellt von:
|
||||
, Expires: , läuft ab:
|
||||
Keychain · Schlüsselbund ·
|
||||
Windows store · Windows-Zertifikatspeicher ·
|
||||
Issued by: Ausgestellt von:
|
||||
, Expires: , läuft ab:
|
||||
No digital IDs yet. Noch keine digitalen IDs.
|
||||
Configure New Digital ID Neue digitale ID einrichten
|
||||
Continue Weiter
|
||||
@@ -840,7 +841,7 @@
|
||||
View signed version Signierte Version anzeigen
|
||||
Show certificate… Zertifikat anzeigen…
|
||||
Export certificate… Zertifikat exportieren…
|
||||
Certificate Viewer Zertifikatanzeige
|
||||
Certificate Viewer Zertifikatsanzeige
|
||||
This dialog shows the details of a certificate and its chain. Dieser Dialog zeigt die Details eines Zertifikats und seiner Kette.
|
||||
Trust Vertrauen
|
||||
Issued to Ausgestellt für
|
||||
@@ -879,11 +880,12 @@
|
||||
Add Page Numbers Seitenzahlen hinzufügen
|
||||
Number every page in the footer. Jede Seite in der Fußzeile nummerieren.
|
||||
Mark as Confidential Als vertraulich kennzeichnen
|
||||
A diagonal CONFIDENTIAL watermark on every page. Ein diagonales Wasserzeichen „VERTRAULICH“ auf jeder Seite.
|
||||
A diagonal CONFIDENTIAL watermark on every page. Ein diagonales Wasserzeichen „CONFIDENTIAL“ auf jeder Seite.
|
||||
Sanitize Documents Dokumente bereinigen
|
||||
Remove hidden information, metadata, scripts and embedded content, and rewrite the files. Ausgeblendete Informationen, Metadaten, Skripte und eingebettete Inhalte entfernen und die Dateien neu schreiben.
|
||||
Type text Text eingeben
|
||||
Text to insert Einzufügender Text
|
||||
Inserted text Eingefügter Text
|
||||
Replacement text Ersatztext
|
||||
Edit text… Text bearbeiten…
|
||||
Add a sticky note here Hier eine Notiz hinzufügen
|
||||
@@ -894,6 +896,7 @@
|
||||
This comment's appearance can't be changed yet. Die Darstellung dieses Kommentars kann noch nicht geändert werden.
|
||||
Icon Symbol
|
||||
Thickness Stärke
|
||||
Line ending Linienende
|
||||
Modified Geändert
|
||||
No status has been set. Es wurde kein Status festgelegt.
|
||||
Compare Files Dateien vergleichen
|
||||
@@ -924,7 +927,7 @@
|
||||
Save & Close Speichern und schließen
|
||||
Accessibility Checker Options Optionen der Barrierefreiheitsprüfung
|
||||
Report Options Berichtsoptionen
|
||||
Create accessibility report Bericht zur Barrierefreiheit erstellen
|
||||
Create accessibility report Barrierefreiheitsbericht erstellen
|
||||
Page Range Seitenbereich
|
||||
All pages in document Alle Seiten im Dokument
|
||||
Checking Options ({on} of 32 in all categories) Prüfoptionen ({on} von 32 in allen Kategorien)
|
||||
@@ -940,7 +943,7 @@
|
||||
{label} ({n} issues) {label} ({n} Probleme)
|
||||
Check again Erneut prüfen
|
||||
Report Bericht
|
||||
Show the accessibility report Bericht zur Barrierefreiheit anzeigen
|
||||
Show the accessibility report Barrierefreiheitsbericht anzeigen
|
||||
Collapse Einklappen
|
||||
Expand Ausklappen
|
||||
{rule} - {status} {rule} – {status}
|
||||
@@ -963,7 +966,7 @@
|
||||
Headings Überschriften
|
||||
Accessibility permission flag Berechtigung für Barrierefreiheit
|
||||
Accessibility permission flag is set Berechtigung für Barrierefreiheit ist gesetzt
|
||||
Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Die Sicherheitseinstellungen müssen Hilfstechnologien das Lesen des Inhalts erlauben („Textzugriff für Screenreader aktivieren“). Ändere die Sicherheit des Dokuments, um dies zu erlauben.
|
||||
Security settings must allow assistive technology to read the content ("Enable text access for screen reader devices"). Change the document's security to allow it. Die Sicherheitseinstellungen müssen Hilfstechnologien das Lesen des Inhalts erlauben („Textzugriff für Screenreader für Sehbehinderte erlauben“). Ändere die Sicherheit des Dokuments, um dies zu erlauben.
|
||||
Image-only PDF Reines Bild-PDF
|
||||
Document is not image-only PDF Dokument ist kein reines Bild-PDF
|
||||
Pages that are only pictures of text can't be read aloud or searched. Recognize text (OCR) to add a text layer. Seiten, die nur aus Bildern von Text bestehen, können weder vorgelesen noch durchsucht werden. Führe Text erkennen (OCR) aus, um eine Textebene hinzuzufügen.
|
||||
@@ -1002,8 +1005,8 @@
|
||||
Animation that flashes can cause seizures. Check scripts and media for flicker. Blinkende Animationen können Anfälle auslösen. Prüfe Skripte und Medien auf Flackern.
|
||||
Scripts Skripte
|
||||
No inaccessible scripts Keine nicht barrierefreien Skripte
|
||||
Timed responses Zeitgesteuerte Antworten
|
||||
Page does not require timed responses Seite erfordert keine zeitgesteuerten Antworten
|
||||
Timed responses Zeitbegrenzte Antworten
|
||||
Page does not require timed responses Die Seite erfordert keine zeitbegrenzten Antworten
|
||||
People may need more time to respond. Check that nothing has a time limit. Manche Menschen brauchen mehr Zeit zum Reagieren. Prüfe, dass nichts ein Zeitlimit hat.
|
||||
Navigation links Navigationslinks
|
||||
Navigation links are not repetitive Navigationslinks wiederholen sich nicht
|
||||
@@ -1059,7 +1062,7 @@
|
||||
Phone Numbers Telefonnummern
|
||||
Email Addresses E-Mail-Adressen
|
||||
Credit Cards Kreditkarten
|
||||
Social Security Numbers Sozialversicherungsnummern
|
||||
Social Security Numbers US-Sozialversicherungsnummern
|
||||
Dates Datumsangaben
|
||||
Text in images isn't found; recognise text (OCR) first. Text in Bildern wird nicht gefunden; führe zuerst Text erkennen (OCR) aus.
|
||||
No matches. Keine Treffer.
|
||||
@@ -1179,12 +1182,12 @@
|
||||
{n} marks {n} Markierungen
|
||||
Dynamic Dynamisch
|
||||
Sign Here Hier unterschreiben
|
||||
Standard Business Standard-Geschäftsstempel
|
||||
Standard Business Geschäftlich
|
||||
Choose a stamp, then click on the page to place it. Wähle einen Stempel und klicke dann auf die Seite, um ihn zu platzieren.
|
||||
Note Notiz
|
||||
Polyline Polylinie
|
||||
Attachment Anhang
|
||||
Callout Legende
|
||||
Callout Text mit Hinweislinie
|
||||
Typewriter Schreibmaschine
|
||||
Checked Abgehakt
|
||||
Unchecked Nicht abgehakt
|
||||
@@ -1203,11 +1206,11 @@
|
||||
Add a reply Antwort hinzufügen
|
||||
Post Senden
|
||||
Page {label} Seite {label}
|
||||
{kind} — click to show it on the page {kind} – klicken, um es auf der Seite anzuzeigen
|
||||
{kind} — click to show it on the page {kind} – zum Anzeigen auf der Seite klicken
|
||||
Set status Status festlegen
|
||||
Copy text Text kopieren
|
||||
Properties… Eigenschaften…
|
||||
Make Current Properties Default Aktuelle Eigenschaften als Standard festlegen
|
||||
Make Current Properties Default Als Standard festlegen
|
||||
More options Weitere Optionen
|
||||
Sort by Sortieren nach
|
||||
Author Verfasser
|
||||
@@ -1326,7 +1329,7 @@
|
||||
Choose a layout: Layout wählen:
|
||||
Comments only Nur Kommentare
|
||||
Sort comments by: Kommentare sortieren nach:
|
||||
Create PDF Comment Summary PDF-Kommentarzusammenfassung erstellen
|
||||
Create PDF Comment Summary Kommentarübersicht erstellen
|
||||
Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards. Zur zuletzt gespeicherten Version von „{name}“ zurückkehren? Seitdem vorgenommene Änderungen lassen sich danach nicht wiederherstellen.
|
||||
Number pages Seiten nummerieren
|
||||
Style Stil
|
||||
@@ -1736,7 +1739,7 @@
|
||||
Images and text (converted to PDF) Bilder und Text (in PDF konvertiert)
|
||||
Images and text Bilder und Text
|
||||
Check Box Style: Stil des Kontrollkästchens:
|
||||
Button Style: Stil der Schaltfläche:
|
||||
Button Style: Stil des Optionsfelds:
|
||||
Check Häkchen
|
||||
Circle Kreis
|
||||
Diamond Raute
|
||||
@@ -1803,7 +1806,7 @@
|
||||
Add arrow Pfeil hinzufügen
|
||||
Add line Linie hinzufügen
|
||||
Add drawing Zeichnung hinzufügen
|
||||
Add text box Textfeld hinzufügen
|
||||
Add text box Textbox hinzufügen
|
||||
Add checkmark Häkchen hinzufügen
|
||||
Add cross Kreuz hinzufügen
|
||||
Add dot Punkt hinzufügen
|
||||
@@ -1812,7 +1815,7 @@
|
||||
Add cloud Wolke hinzufügen
|
||||
Add polygon Polygon hinzufügen
|
||||
Add connected lines Verbundene Linien hinzufügen
|
||||
Add callout Legende hinzufügen
|
||||
Add callout Text mit Hinweislinie hinzufügen
|
||||
Add inserted text Eingefügten Text hinzufügen
|
||||
Add file attachment Dateianhang hinzufügen
|
||||
Set status Accepted Status „Angenommen“ festlegen
|
||||
@@ -1847,7 +1850,7 @@
|
||||
Click vertices. Enter or double-click finishes a perimeter or area. Click the first point to close an area. Klicke Eckpunkte an. Enter oder Doppelklick schließt einen Umfang oder eine Fläche ab. Klicke auf den ersten Punkt, um eine Fläche zu schließen.
|
||||
Shift constrains to 45 degrees. Backspace removes a vertex. Escape cancels. Shift beschränkt auf 45 Grad. Rücktaste entfernt einen Eckpunkt. Escape bricht ab.
|
||||
Angle Winkel
|
||||
Click the first point to begin. Klicke auf den ersten Punkt, um zu beginnen.
|
||||
Click the first point to begin. Klicke, um den ersten Punkt zu setzen.
|
||||
Snap to drawing An Zeichnung einrasten
|
||||
Endpoints Endpunkte
|
||||
Midpoints Mittelpunkte
|
||||
@@ -2043,5 +2046,44 @@ comment menu Edit Bearbeiten
|
||||
Undo {action} {action} rückgängig machen
|
||||
Redo {action} {action} wiederholen
|
||||
signature pad Type Tippen
|
||||
certificate Subject Zertifikatsinhaber
|
||||
action wizard Start Starten
|
||||
Reopen the files that were open when PdfCraft last closed Beim Start die Dateien wieder öffnen, die beim letzten Beenden von PdfCraft geöffnet waren
|
||||
|
||||
# Labels that reach tl!() through a variable (Fill & Sign buttons, link prompts, notices, history labels, checker and certificate rows)
|
||||
Add {what} {what} hinzufügen
|
||||
Use {what} {what} verwenden
|
||||
Place saved {what} Gespeicherte {what} platzieren
|
||||
Remove saved {what} Gespeicherte {what} entfernen
|
||||
A link Ein Link
|
||||
A button Eine Schaltfläche
|
||||
A script Ein Skript
|
||||
{who} in this document tried to open an address PdfCraft won't open: {e}. {who} in diesem Dokument wollte eine Adresse öffnen, die PdfCraft nicht öffnet: {e}.
|
||||
{who} in this document tried to open an address PdfCraft won't open: {e}. Only web (http, https) and email (mailto) links open from documents. {who} in diesem Dokument wollte eine Adresse öffnen, die PdfCraft nicht öffnet: {e}. Aus Dokumenten heraus öffnet PdfCraft nur Web-Links (http, https) und E-Mail-Links (mailto).
|
||||
Another file dialog is still open. Finish with it first. Ein anderer Dateidialog ist noch geöffnet. Schließe ihn zuerst.
|
||||
Couldn't show the file picker. Please try again. Die Dateiauswahl konnte nicht angezeigt werden. Versuch es noch einmal.
|
||||
The document changed while you were choosing a file, so nothing was changed. Das Dokument hat sich geändert, während du eine Datei ausgewählt hast; deshalb wurde nichts geändert.
|
||||
The document was closed before it could be saved. Das Dokument wurde geschlossen, bevor es gespeichert werden konnte.
|
||||
The pages were extracted but not deleted, because the document changed while you were choosing a folder. Die Seiten wurden extrahiert, aber nicht gelöscht, weil sich das Dokument geändert hat, während du einen Ordner ausgewählt hast.
|
||||
That didn't work: an internal error stopped it ({m}). Das hat nicht geklappt: Ein interner Fehler ist aufgetreten ({m}).
|
||||
The Windows store's digital IDs couldn't be listed: {e} Die digitalen IDs im Windows-Zertifikatspeicher konnten nicht aufgelistet werden: {e}
|
||||
Align fields Felder ausrichten
|
||||
Distribute fields Felder verteilen
|
||||
Match field sizes Feldgrößen angleichen
|
||||
Mark for redaction Zum Schwärzen markieren
|
||||
Mark pages for redaction Seiten zum Schwärzen markieren
|
||||
Change document properties Dokumenteigenschaften ändern
|
||||
Mark differences Unterschiede markieren
|
||||
Run form script Formularskript ausführen
|
||||
Scripted content must be usable with a keyboard and assistive technology. Check the document's scripts. Skriptgesteuerte Inhalte müssen mit Tastatur und Hilfstechnologien bedienbar sein. Prüfe die Skripte des Dokuments.
|
||||
Table rows belong directly in a table or its head, body or foot. Fix the table's tags. Tabellenzeilen gehören direkt in eine Tabelle oder in deren Kopf-, Haupt- oder Fußbereich. Korrigiere die Tags der Tabelle.
|
||||
Basic constraints Basiseinschränkungen
|
||||
SHA-1 digest SHA-1-Fingerabdruck
|
||||
SHA-256 digest SHA-256-Fingerabdruck
|
||||
Squiggly Schlangenlinie
|
||||
Home / End Pos1 / Ende
|
||||
H / Space (hold) H / Leertaste (halten)
|
||||
Clear Recent Files Liste „Zuletzt geöffnet“ leeren
|
||||
Remove from Recent Aus „Zuletzt geöffnet“ entfernen
|
||||
Remove {name} from Recent {name} aus „Zuletzt geöffnet“ entfernen
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Die Standardschriften können „{c}“ ({code}) nicht darstellen. Derzeit lassen sich nur westeuropäische Zeichen als Text hinzufügen.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 51.
|
@@ -22,6 +22,9 @@
|
||||
Name on new comments Nombre en los comentarios nuevos
|
||||
Open… Abrir…
|
||||
Open Recent Abrir recientes
|
||||
Clear Recent Files Borrar archivos recientes
|
||||
Remove from Recent Quitar de recientes
|
||||
Remove {name} from Recent Quitar {name} de recientes
|
||||
Pin folder to Home… Anclar carpeta en Inicio…
|
||||
Folders Carpetas
|
||||
Pin a folder… Anclar una carpeta…
|
||||
@@ -596,6 +599,7 @@
|
||||
Bottom: Inferior:
|
||||
Width: Ancho:
|
||||
Height: Alto:
|
||||
Rotation: Rotación:
|
||||
Points from the page's bottom-left corner. Puntos desde la esquina inferior izquierda de la página.
|
||||
Alignment: Alineación:
|
||||
Default Value: Valor predeterminado:
|
||||
@@ -2000,3 +2004,4 @@
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Elija una imagen PNG o JPEG (hasta 4 MiB). Se conserva la transparencia.
|
||||
Couldn't import the signature image: {e} No se pudo importar la imagen de la firma: {e}
|
||||
Go to page… Ir a la página…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Las fuentes estándar no pueden mostrar «{c}» ({code}). Por ahora solo se pueden añadir como texto caracteres de Europa occidental.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 60.
|
@@ -576,6 +576,7 @@
|
||||
Bottom: Bas :
|
||||
Width: Largeur :
|
||||
Height: Hauteur :
|
||||
Rotation: Rotation :
|
||||
Points from the page's bottom-left corner. Points depuis le coin inférieur gauche de la page.
|
||||
Alignment: Alignement :
|
||||
Default Value: Valeur par défaut :
|
||||
@@ -894,6 +895,7 @@
|
||||
This comment's appearance can't be changed yet. L'apparence de ce commentaire ne peut pas encore être modifiée.
|
||||
Icon Icône
|
||||
Thickness Épaisseur
|
||||
Line ending Extrémité de ligne
|
||||
Modified Modifié
|
||||
No status has been set. Aucun état n'a été défini.
|
||||
Compare Files Comparer des fichiers
|
||||
@@ -2014,6 +2016,9 @@
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. Choisissez une image PNG ou JPEG (jusqu’à 4 Mio). La transparence est conservée.
|
||||
Couldn't import the signature image: {e} Impossible d’importer l’image de la signature : {e}
|
||||
Open Recent Ouvrir un fichier récent
|
||||
Clear Recent Files Effacer les fichiers récents
|
||||
Remove from Recent Retirer des fichiers récents
|
||||
Remove {name} from Recent Retirer {name} des fichiers récents
|
||||
Pin folder to Home… Épingler un dossier à l'Accueil…
|
||||
Folders Dossiers
|
||||
Pin a folder… Épingler un dossier…
|
||||
@@ -2034,3 +2039,4 @@
|
||||
Pinned {folder} to Home {folder} épinglé à l'Accueil
|
||||
No recent files Aucun fichier récent
|
||||
Go to page… Aller à la page…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Les polices standard ne peuvent pas afficher « {c} » ({code}). Pour l'instant, seuls les caractères d'Europe occidentale peuvent être ajoutés comme texte.
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 39.
|
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,9 @@
|
||||
Name on new comments 新しい注釈の作成者名
|
||||
Open… 開く…
|
||||
Open Recent 最近のファイルを開く
|
||||
Clear Recent Files 最近のファイルをクリア
|
||||
Remove from Recent 最近のファイルから外す
|
||||
Remove {name} from Recent {name} を最近のファイルから外す
|
||||
Pin folder to Home… フォルダーをホームにピン留め…
|
||||
Folders フォルダー
|
||||
Pin a folder… フォルダーをピン留め…
|
||||
@@ -602,6 +605,7 @@
|
||||
Bottom: 下:
|
||||
Width: 幅:
|
||||
Height: 高さ:
|
||||
Rotation: 回転:
|
||||
Points from the page's bottom-left corner. ページの左下隅からの距離(ポイント)。
|
||||
Alignment: 配置:
|
||||
Default Value: 既定値:
|
||||
@@ -1976,3 +1980,4 @@
|
||||
Couldn't import the signature image: {e} 署名画像を読み込めませんでした: {e}
|
||||
Go to page… ページへ移動…
|
||||
Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -86,8 +86,21 @@ fn plural_russian(n: u64) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ukrainian (CLDR `uk`, integer counts): one (1, 21), few (2–4, 22–24), many (0, 5–19).
|
||||
/// Counts ending in 11–14 always take many, regardless of the last digit.
|
||||
fn plural_ukrainian(n: u64) -> usize {
|
||||
match n % 100 {
|
||||
11..=14 => 2,
|
||||
_ => match n % 10 {
|
||||
1 => 0,
|
||||
2..=4 => 1,
|
||||
_ => 2,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry. English first: it is the fallback and the source language.
|
||||
pub static LANGUAGES: [LangInfo; 12] = [
|
||||
pub static LANGUAGES: [LangInfo; 14] = [
|
||||
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
|
||||
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
|
||||
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
|
||||
@@ -110,6 +123,10 @@ pub static LANGUAGES: [LangInfo; 12] = [
|
||||
LangInfo { code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
|
||||
// Telugu; every `te-*` locale (`te-IN`) resolves here.
|
||||
LangInfo { code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
|
||||
// Hungarian; every `hu-*` locale (`hu-HU`) resolves here.
|
||||
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
|
||||
// Ukrainian; every `uk-*` locale (`uk-UA`, `uk_UA.UTF-8`) resolves here.
|
||||
LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_ukrainian, catalog: OnceLock::new() },
|
||||
];
|
||||
|
||||
impl LangInfo {
|
||||
@@ -299,6 +316,12 @@ pub fn set_current(lang: Lang) {
|
||||
}
|
||||
|
||||
/// The language the UI is drawn in.
|
||||
/// `label` as it reads inside a sentence ("add a text box"): lowercased, except in languages
|
||||
/// that capitalise nouns (German), where lowercasing would misspell it.
|
||||
pub fn in_sentence(label: &str) -> std::borrow::Cow<'_, str> {
|
||||
if current().code() == "de" { std::borrow::Cow::Borrowed(label) } else { std::borrow::Cow::Owned(label.to_lowercase()) }
|
||||
}
|
||||
|
||||
pub fn current() -> Lang {
|
||||
CURRENT.get()
|
||||
}
|
||||
@@ -1239,6 +1262,24 @@ mod tests {
|
||||
assert!(missing.is_empty(), "untranslated German UI literals: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_names_inside_sentences_are_lowercased_except_in_german() {
|
||||
set_current(Lang::EN);
|
||||
assert_eq!(in_sentence("Text Box"), "text box");
|
||||
set_current(Lang::from_code("de").expect("de registered"));
|
||||
assert_eq!(in_sentence("Textfeld"), "Textfeld");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_certificate_subject_has_a_distinct_context() {
|
||||
let de = Lang::from_code("de").expect("de registered");
|
||||
assert_eq!(tr(de, "Subject"), "Thema");
|
||||
assert_eq!(tr_ctx(de, "certificate", "Subject"), "Zertifikatsinhaber");
|
||||
assert_eq!(tr_ctx(de, "certificate", "Issuer"), tr(de, "Issuer"));
|
||||
assert_eq!(tr_ctx(Lang::EN, "certificate", "Subject"), "Subject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_history_and_diagnostics_preserve_user_values() {
|
||||
let de = Lang::from_code("de").expect("de registered");
|
||||
@@ -1470,6 +1511,323 @@ mod tests {
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_is_registered() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
assert_eq!(hu.name(), "Magyar");
|
||||
assert_eq!(normalize_pref("HU"), Some("hu"));
|
||||
assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu));
|
||||
assert_eq!(lang_from_tag("hu-HU"), Some(hu));
|
||||
assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu));
|
||||
assert_eq!(tr(hu, "File"), "Fájl");
|
||||
assert_eq!(tr(hu, "Save as…"), "Mentés másként…");
|
||||
assert_eq!(tr(hu, "Bookmarks"), "Könyvjelzők");
|
||||
assert_eq!(tr(hu, "Layers"), "Rétegek");
|
||||
assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf");
|
||||
assert_eq!(tr_ctx(hu, "signature pad", "Type"), "Gépelés");
|
||||
assert_eq!(tr_ctx(hu, "action wizard", "Start"), "Indítás");
|
||||
// one (1), other (0, 2+)
|
||||
assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::<Vec<_>>(), [1, 0, 1, 1]);
|
||||
assert_eq!(trn(hu, 0, "{n} page", "{n} pages"), "0 oldal");
|
||||
assert_eq!(trn(hu, 1, "{n} page", "{n} pages"), "1 oldal");
|
||||
assert_eq!(trn(hu, 2, "{n} page", "{n} pages"), "2 oldal");
|
||||
assert_eq!(trn(hu, 1, "{n} field", "{n} fields"), "1 mező");
|
||||
assert_eq!(trn(hu, 5, "{n} field", "{n} fields"), "5 mező");
|
||||
let mut app = crate::PdfCraftApp::default();
|
||||
app.set_option("language", "hu").unwrap();
|
||||
assert_eq!(app.language, "hu");
|
||||
let mut restored = crate::PdfCraftApp::default();
|
||||
restored.restore(&app.persist());
|
||||
assert_eq!(restored.language, "hu");
|
||||
}
|
||||
|
||||
/// Hungarian translates every registered command and every All tools group, section and item.
|
||||
#[test]
|
||||
fn hungarian_covers_commands_and_catalogue() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
for command in pdfcraft_engine::commands::COMMANDS {
|
||||
assert!(has(hu, command.label), "missing command: {}", command.label);
|
||||
if let Some(menu) = command.menu {
|
||||
assert!(has(hu, menu), "missing menu: {menu}");
|
||||
}
|
||||
}
|
||||
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
assert!(has(hu, group.label), "missing group: {}", group.label);
|
||||
for section in group.sections {
|
||||
assert!(has(hu, section.title), "missing section: {}", section.title);
|
||||
for item in section.items {
|
||||
assert!(has(hu, item.label), "missing item: {}", item.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// New tl!("literal") labels must not silently fall back to English.
|
||||
#[test]
|
||||
fn hungarian_covers_ui_literals() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
let mut literals = std::collections::BTreeSet::new();
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(dir).expect("UI source directory") {
|
||||
let path = entry.expect("UI source entry").path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().is_some_and(|name| name != "i18n") {
|
||||
stack.push(path);
|
||||
}
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n");
|
||||
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
while let Some((_, after)) = rest.split_once("tl!(\"") {
|
||||
let mut escaped = false;
|
||||
let end = after
|
||||
.char_indices()
|
||||
.find_map(|(i, c)| {
|
||||
if c == '"' && !escaped {
|
||||
return Some(i);
|
||||
}
|
||||
escaped = c == '\\' && !escaped;
|
||||
None
|
||||
})
|
||||
.expect("closed tl! literal");
|
||||
let (raw, tail) = after.split_at(end);
|
||||
if tail.starts_with("\")") {
|
||||
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
|
||||
literals.insert(label);
|
||||
}
|
||||
rest = tail.strip_prefix('"').expect("closing quote");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
|
||||
let missing: Vec<_> = literals.iter().filter(|label| !has(hu, label)).collect();
|
||||
assert!(missing.is_empty(), "untranslated Hungarian UI literals: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hungarian_history_and_diagnostics_preserve_user_values() {
|
||||
let hu = Lang::from_code("hu").expect("hu registered");
|
||||
set_current(hu);
|
||||
assert_eq!(command_label("Undo Insert pages from Rapport {n}.pdf"), "Oldalak beszúrása ebből: Rapport {n}.pdf visszavonása");
|
||||
assert_eq!(command_label("Redo Fill in Contact {key}"), "Contact {key} kitöltése megismétlése");
|
||||
assert_eq!(action_label("Change Title"), "Cím módosítása");
|
||||
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
|
||||
assert_eq!(
|
||||
fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]),
|
||||
"Ezt az oldalt nem sikerült megjeleníteni.\nOS error {n}"
|
||||
);
|
||||
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "3 oldal kijelölve");
|
||||
assert_eq!(tr(hu, "CheckBox"), "Jelölőnégyzet");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_is_registered_and_persists() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
assert_eq!(uk.name(), "Українська");
|
||||
assert_eq!(normalize_pref("UK"), Some("uk"));
|
||||
for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA@variant"] {
|
||||
assert_eq!(lang_from_tag(tag), Some(uk), "{tag}");
|
||||
}
|
||||
assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk));
|
||||
assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk));
|
||||
assert_eq!(tr(uk, "File"), "Файл");
|
||||
assert_eq!(tr(uk, "Save as…"), "Зберегти як…");
|
||||
assert_eq!(tr(uk, "Bookmarks"), "Закладки");
|
||||
assert_eq!(tr(uk, "Layers"), "Шари");
|
||||
assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf");
|
||||
assert_eq!(tr_ctx(uk, "comment menu", "Edit"), "Редагувати");
|
||||
assert_eq!(tr_ctx(uk, "signature pad", "Type"), "Ввести");
|
||||
assert_eq!(tr_ctx(uk, "action wizard", "Start"), "Почати");
|
||||
let mut app = crate::PdfCraftApp::default();
|
||||
app.set_option("language", "UK").unwrap();
|
||||
assert_eq!(app.language, "uk");
|
||||
let mut restored = crate::PdfCraftApp::default();
|
||||
restored.restore(&app.persist());
|
||||
assert_eq!(restored.language, "uk");
|
||||
assert!(restored.set_option("language", "xx").is_err());
|
||||
assert_eq!(restored.language, "uk");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_integer_plurals_cover_teens_and_large_counts() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
for (counts, page, field) in [
|
||||
(vec![1, 21, 31, 101, 121, 1001], "сторінка", "поле"),
|
||||
(vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], "сторінки", "поля"),
|
||||
(vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], "сторінок", "полів"),
|
||||
] {
|
||||
for n in counts {
|
||||
assert_eq!(trn(uk, n, "{n} page", "{n} pages"), format!("{n} {page}"));
|
||||
assert_eq!(trn(uk, n, "{n} field", "{n} fields"), format!("{n} {field}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_calendar_uses_month_case_for_dates() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
for (source, standalone, date) in [
|
||||
("January", "Січень", "січня"),
|
||||
("February", "Лютий", "лютого"),
|
||||
("March", "Березень", "березня"),
|
||||
("April", "Квітень", "квітня"),
|
||||
("May", "Травень", "травня"),
|
||||
("June", "Червень", "червня"),
|
||||
("July", "Липень", "липня"),
|
||||
("August", "Серпень", "серпня"),
|
||||
("September", "Вересень", "вересня"),
|
||||
("October", "Жовтень", "жовтня"),
|
||||
("November", "Листопад", "листопада"),
|
||||
("December", "Грудень", "грудня"),
|
||||
] {
|
||||
assert_eq!(tr(uk, source), standalone);
|
||||
let month = tr_ctx(uk, "calendar date", source);
|
||||
assert_eq!(month, date);
|
||||
assert_eq!(fmt(tr(uk, "{month} {day}, {y}"), &[("month", month), ("day", "9"), ("y", "2026")]), format!("9 {date} 2026"));
|
||||
// Catalogs without this context retain their existing month translation.
|
||||
assert_eq!(tr_ctx(Lang::EN, "calendar date", source), source);
|
||||
assert_eq!(tr_ctx(JA(), "calendar date", source), tr(JA(), source));
|
||||
}
|
||||
}
|
||||
|
||||
/// Preserve only product/format names, abbreviations and templates with no English words.
|
||||
#[test]
|
||||
fn ukrainian_catalog_does_not_leave_english_ui_text() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
let (entries, errors) = parse_entries(uk.0.source, uk.0.plural_forms());
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let keep = [
|
||||
"OK",
|
||||
"PDF/A…",
|
||||
"Microsoft Word (.docx)",
|
||||
"PostScript / EPS",
|
||||
// Key-cap names, printed in English on Ukrainian keyboards.
|
||||
"Home / End",
|
||||
"JavaScript",
|
||||
"ZIP",
|
||||
"{n} {kind}",
|
||||
"{rule} - {status}",
|
||||
"{field}: {e}",
|
||||
"`{command}` {when}",
|
||||
"{0}: {1}",
|
||||
"{month} {y}",
|
||||
"+LOC",
|
||||
"−LOC",
|
||||
"ΔLOC",
|
||||
"+Bin",
|
||||
"−Bin",
|
||||
];
|
||||
let unchanged: Vec<_> = entries.iter().filter(|e| e.source == e.translation && !keep.contains(&e.source.as_str())).collect();
|
||||
assert!(unchanged.is_empty(), "untranslated Ukrainian catalog entries: {unchanged:#?}");
|
||||
}
|
||||
|
||||
/// Ukrainian translates every registered command and every All tools label.
|
||||
#[test]
|
||||
fn ukrainian_covers_commands_and_catalogue() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
for command in pdfcraft_engine::commands::COMMANDS {
|
||||
assert!(has(uk, command.label), "missing command: {}", command.label);
|
||||
if let Some(menu) = command.menu {
|
||||
assert!(has(uk, menu), "missing menu: {menu}");
|
||||
}
|
||||
}
|
||||
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
assert!(has(uk, group.label), "missing group: {}", group.label);
|
||||
for section in group.sections {
|
||||
assert!(has(uk, section.title), "missing section: {}", section.title);
|
||||
for item in section.items {
|
||||
assert!(has(uk, item.label), "missing item: {}", item.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls,
|
||||
/// following the complete-catalog check for Simplified Chinese.
|
||||
#[test]
|
||||
fn ukrainian_covers_ui_literals() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
|
||||
let mut literals = std::collections::BTreeSet::new();
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(dir).expect("UI source directory") {
|
||||
let path = entry.expect("UI source entry").path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().is_some_and(|name| name != "i18n") {
|
||||
stack.push(path);
|
||||
}
|
||||
} else if path.extension().is_some_and(|ext| ext == "rs") {
|
||||
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!(");
|
||||
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
while let Some((_, after)) = rest.split_once("tl!(") {
|
||||
let after = after.trim_start();
|
||||
let Some(after) = after.strip_prefix('"') else {
|
||||
rest = after;
|
||||
continue;
|
||||
};
|
||||
let mut escaped = false;
|
||||
let end = after
|
||||
.char_indices()
|
||||
.find_map(|(i, c)| {
|
||||
if c == '"' && !escaped {
|
||||
return Some(i);
|
||||
}
|
||||
escaped = c == '\\' && !escaped;
|
||||
None
|
||||
})
|
||||
.expect("closed tl! literal");
|
||||
let (raw, tail) = after.split_at(end);
|
||||
let closing = tail.strip_prefix('"').expect("closing quote").trim_start();
|
||||
let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start();
|
||||
if closing.starts_with(')') {
|
||||
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
|
||||
literals.insert(label);
|
||||
}
|
||||
rest = tail.strip_prefix('"').expect("closing quote");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
|
||||
let missing: Vec<_> = literals.iter().filter(|label| !has(uk, label)).collect();
|
||||
assert!(missing.is_empty(), "untranslated Ukrainian UI literals: {missing:#?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_history_and_diagnostics_preserve_user_values() {
|
||||
let uk = Lang::from_code("uk").expect("uk registered");
|
||||
set_current(uk);
|
||||
assert_eq!(command_label("Undo Insert pages from Звіт {n}.pdf"), "Скасувати: Вставити сторінки з Звіт {n}.pdf");
|
||||
assert_eq!(command_label("Redo Fill in Контакт {key}"), "Повторити: Заповнити Контакт {key}");
|
||||
assert_eq!(action_label("Change Title"), "Змінити: Заголовок");
|
||||
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
|
||||
assert_eq!(fmt(t("This page couldn't be displayed.\n{e}"), &[("e", "OS error {n}")]), "Не вдалося відобразити цю сторінку.\nOS error {n}");
|
||||
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Вибрано сторінок: 3");
|
||||
assert_eq!(fmt(t("{n} contributors · {c} commits"), &[("n", "2"), ("c", "1,234")]), "Учасників: 2 · Комітів: 1,234");
|
||||
assert_eq!(tr(uk, "CheckBox"), "Прапорець");
|
||||
let contributor = crate::credits::Contributor {
|
||||
login: "reader{n}",
|
||||
display_name: Some("Save"),
|
||||
real_name: None,
|
||||
prs: 2,
|
||||
commits: 3,
|
||||
lines_added: 10,
|
||||
lines_deleted: 4,
|
||||
binary_added: 1,
|
||||
binary_deleted: 0,
|
||||
first_commit: "2026-10-01T00:00:00Z",
|
||||
last_commit: "2026-10-09T00:00:00Z",
|
||||
};
|
||||
assert_eq!(
|
||||
contributor.summary(),
|
||||
"@reader{n}: PR: 2, комітів: 3, +10 / −4 рядків (Δ +6), +1 / −0 бінарних ресурсів, 2026-10-01 – 2026-10-09"
|
||||
);
|
||||
assert_eq!(contributor.name(crate::credits::NameMode::DisplayName), "Save");
|
||||
set_current(Lang::EN);
|
||||
}
|
||||
|
||||
/// Every bundled catalog is well-formed and consistent with its sources.
|
||||
#[test]
|
||||
fn bundled_catalogs_are_consistent() {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
Menu Menu
|
||||
File Arquivo
|
||||
Rotation: Rotação:
|
||||
Edit Editar
|
||||
Pages Páginas
|
||||
View Exibir
|
||||
@@ -21,6 +22,10 @@
|
||||
Name on new comments Nome nos novos comentários
|
||||
Open… Abrir…
|
||||
Open Recent Abrir recentes
|
||||
Clear Recent Files Limpar arquivos recentes
|
||||
Remove from Recent Remover dos recentes
|
||||
Remove {name} from Recent Remover {name} dos recentes
|
||||
Clear Limpar
|
||||
Pin folder to Home… Fixar pasta no Início…
|
||||
No recent files Nenhum arquivo recente
|
||||
New blank PDF Novo PDF em branco
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -21,6 +21,9 @@
|
||||
Name on new comments Имя в новых комментариях
|
||||
Open… Открыть…
|
||||
Open Recent Открыть недавние
|
||||
Clear Recent Files Очистить недавние файлы
|
||||
Remove from Recent Убрать из недавних
|
||||
Remove {name} from Recent Убрать {name} из недавних
|
||||
Pin folder to Home… Закрепить папку на главной…
|
||||
Folders Папки
|
||||
Pin a folder… Закрепить папку…
|
||||
@@ -594,6 +597,7 @@
|
||||
Bottom: Снизу:
|
||||
Width: Ширина:
|
||||
Height: Высота:
|
||||
Rotation: Поворот:
|
||||
Points from the page's bottom-left corner. Точки от левого нижнего угла страницы.
|
||||
Alignment: Выравнивание:
|
||||
Default Value: Значение по умолчанию:
|
||||
@@ -912,6 +916,7 @@
|
||||
This comment's appearance can't be changed yet. Внешний вид этого комментария пока нельзя изменить.
|
||||
Icon Значок
|
||||
Thickness Толщина
|
||||
Line ending Окончание линии
|
||||
Modified Изменено
|
||||
No status has been set. Статус не установлен.
|
||||
Compare Files Сравнить файлы
|
||||
@@ -1995,3 +2000,4 @@
|
||||
Created a PDF from {n} files; left out: {names} PDF создан из файлов: {n}; пропущены: {names}
|
||||
Created a PDF from the first {n} files PDF создан из первых файлов: {n}
|
||||
Go to page… Перейти на страницу…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы.
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -582,6 +582,7 @@ comment menu Edit సవరించు
|
||||
Bottom: కింద:
|
||||
Width: వెడల్పు:
|
||||
Height: ఎత్తు:
|
||||
Rotation: భ్రమణం:
|
||||
Points from the page's bottom-left corner. పేజీ కింది ఎడమ మూల నుండి పాయింట్లలో కొలుస్తారు.
|
||||
Alignment: అమరిక:
|
||||
Default Value: డిఫాల్ట్ విలువ:
|
||||
@@ -2017,6 +2018,9 @@ action wizard Start ప్రారంభించు
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. PNG లేదా JPEG చిత్రాన్ని ఎంచుకోండి (గరిష్ఠంగా 4 MiB). పారదర్శకత అలాగే ఉంటుంది.
|
||||
Couldn't import the signature image: {e} సంతకం చిత్రాన్ని దిగుమతి చేయడం సాధ్యం కాలేదు: {e}
|
||||
Open Recent ఇటీవలివి తెరువు
|
||||
Clear Recent Files ఇటీవలి ఫైళ్లను క్లియర్ చేయి
|
||||
Remove from Recent ఇటీవలి ఫైళ్ల నుండి తీసివేయి
|
||||
Remove {name} from Recent {name}ను ఇటీవలి ఫైళ్ల నుండి తీసివేయి
|
||||
Pin folder to Home… ఫోల్డర్ను హోమ్కు పిన్ చేయి…
|
||||
No recent files ఇటీవలి ఫైళ్లు లేవు
|
||||
Go to page… పేజీకి వెళ్లు…
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 16.
|
File diff suppressed because it is too large.
Load diff
@@ -62,6 +62,9 @@
|
||||
Name on new comments 新注释的作者姓名
|
||||
Open… 打开…
|
||||
Open Recent 打开最近的文件
|
||||
Clear Recent Files 清除最近的文件
|
||||
Remove from Recent 从最近的文件中移除
|
||||
Remove {name} from Recent 从最近的文件中移除{name}
|
||||
Pin folder to Home… 将文件夹固定到主页…
|
||||
Folders 文件夹
|
||||
Pin a folder… 固定文件夹…
|
||||
@@ -293,6 +296,7 @@
|
||||
Bottom: 下:
|
||||
Width: 宽:
|
||||
Height: 高:
|
||||
Rotation: 旋转:
|
||||
Points from the page's bottom-left corner. 从页面左下角算起的点数。
|
||||
Alignment: 对齐:
|
||||
Default Value: 默认值:
|
||||
@@ -611,6 +615,7 @@
|
||||
This comment's appearance can't be changed yet. 此注释的外观尚无法更改。
|
||||
Icon 图标
|
||||
Thickness 粗细
|
||||
Line ending 线条端点
|
||||
Modified 修改时间
|
||||
No status has been set. 尚未设置状态。
|
||||
Compare Files 比较文件
|
||||
@@ -2045,3 +2050,4 @@
|
||||
Choose a PNG or JPEG image (up to 4 MiB). Transparency is preserved. 选择 PNG 或 JPEG 图像(最大 4 MiB)。透明度会保留。
|
||||
Couldn't import the signature image: {e} 无法导入签名图像:{e}
|
||||
Go to page… 转到页面…
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 标准字体无法显示“{c}”({code})。目前只能添加西欧字符作为文本。
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -21,6 +21,9 @@
|
||||
Name on new comments 新註解的作者名稱
|
||||
Open… 開啟…
|
||||
Open Recent 開啟最近的檔案
|
||||
Clear Recent Files 清除最近的檔案
|
||||
Remove from Recent 從最近的檔案中移除
|
||||
Remove {name} from Recent 從最近的檔案中移除{name}
|
||||
Pin folder to Home… 將資料夾釘選到首頁…
|
||||
Folders 資料夾
|
||||
Pin a folder… 釘選資料夾…
|
||||
@@ -251,6 +254,7 @@
|
||||
Bottom: 下:
|
||||
Width: 寬:
|
||||
Height: 高:
|
||||
Rotation: 旋轉:
|
||||
Points from the page's bottom-left corner. 從頁面左下角算起的點數。
|
||||
Alignment: 對齊:
|
||||
Default Value: 預設值:
|
||||
@@ -1969,3 +1973,4 @@
|
||||
Couldn't import the signature image: {e} 無法匯入簽名圖像:{e}
|
||||
Go to page… 轉到頁面…
|
||||
Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案
|
||||
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準字型無法顯示「{c}」({code})。目前只能新增西歐字元作為文字。
|
||||
|
Can't render this file because it contains an unexpected character in line 9 and column 16.
|
@@ -481,7 +481,7 @@ pub struct PdfCraftApp {
|
||||
grid_drop: Option<GridDrop>,
|
||||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||||
/// system clipboard (tests turn that off).
|
||||
pub last_snapshot: Option<(u32, u32, Vec<u8>)>,
|
||||
pub last_snapshot: Option<(u32, u32, pdfcraft_render::Pixels)>,
|
||||
pub system_clipboard: bool,
|
||||
/// Attach file: the file to attach instead of asking (tests, automation).
|
||||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
|
||||
@@ -378,6 +378,8 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
|
||||
pub(crate) enum Nav {
|
||||
Page(usize),
|
||||
/// A bookmark's destination: its page and where on it.
|
||||
Dest(usize, pdfcraft_render::DestView),
|
||||
Flash(usize, [f32; 4]),
|
||||
}
|
||||
|
||||
@@ -673,6 +675,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
match nav {
|
||||
Some(Nav::Page(p)) => app.views[index].go_to_page(p),
|
||||
Some(Nav::Dest(p, dest)) => match app.session.get(id) {
|
||||
Some(doc) => app.views[index].go_to_dest(p, dest, &doc.info),
|
||||
None => app.views[index].go_to_page(p),
|
||||
},
|
||||
Some(Nav::Flash(p, r)) => {
|
||||
let v = &mut app.views[index];
|
||||
v.go_to_page(p);
|
||||
@@ -850,7 +856,7 @@ fn outline_item(ui: &mut egui::Ui, t: &Tokens, info: &DocInfo, item: &OutlineIte
|
||||
if resp.clicked()
|
||||
&& let Some(p) = item.page
|
||||
{
|
||||
*cx.nav = Some(Nav::Page(p));
|
||||
*cx.nav = Some(Nav::Dest(p, item.view));
|
||||
}
|
||||
if resp.double_clicked() && cx.editable {
|
||||
*cx.action = Some(BmAction::StartRename(path.to_vec()));
|
||||
|
||||
@@ -666,6 +666,8 @@ pub struct FieldDraft {
|
||||
pub font_size: f64,
|
||||
/// Left, bottom, width, height in points.
|
||||
pub position: [f64; 4],
|
||||
/// `/MK /R`: 0, 90, 180 or 270 degrees counterclockwise.
|
||||
pub rotation: i64,
|
||||
pub look: Option<FieldLook>,
|
||||
/// Check boxes and radio buttons: the mark when on (Options tab).
|
||||
pub check_style: Option<pdfcraft_engine::CheckStyle>,
|
||||
@@ -709,6 +711,7 @@ impl FieldDraft {
|
||||
on_state: f.widgets.get(widget).and_then(|w| w.on_state.clone()).unwrap_or_default(),
|
||||
font_size: da_size(&f.da),
|
||||
position: [r[0], r[1], r[2] - r[0], r[3] - r[1]],
|
||||
rotation: f.widgets.get(widget).map(|w| w.rotation).unwrap_or(0),
|
||||
look: None,
|
||||
check_style: None,
|
||||
format: f.actions.format.clone(),
|
||||
@@ -764,6 +767,7 @@ impl FieldDraft {
|
||||
let [x, y, w, h] = self.position;
|
||||
(self.widget, [x, y, x + w.max(4.0), y + h.max(4.0)])
|
||||
}),
|
||||
rotation: (self.rotation != o.rotation).then_some((self.widget, self.rotation)),
|
||||
look: (self.look != o.look).then_some(self.look).flatten(),
|
||||
check_style: (self.check_style != o.check_style).then_some(self.check_style).flatten(),
|
||||
format: (self.format != o.format).then(|| self.format.clone()),
|
||||
@@ -929,6 +933,25 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Points from the page's bottom-left corner.")).small().color(t.text_faint));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
let l = ui.label(tl!("Rotation:"));
|
||||
let before = d.rotation;
|
||||
egui::ComboBox::from_id_salt("field-rotation")
|
||||
.selected_text(format!("{}°", d.rotation))
|
||||
.show_ui(ui, |ui| {
|
||||
for r in [0, 90, 180, 270] {
|
||||
if ui.selectable_value(&mut d.rotation, r, format!("{r}°")).changed() && (before % 180 == 0) != (r % 180 == 0) {
|
||||
// Keep the same center. set_props skips its own swap when the rect changed too.
|
||||
let [x, y, w, h] = d.position;
|
||||
let (cx, cy) = (x + w / 2.0, y + h / 2.0);
|
||||
d.position = [cx - h / 2.0, cy - w / 2.0, h, w];
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(l.id);
|
||||
});
|
||||
}
|
||||
FieldTab::Format => format_tab(ui, d, t),
|
||||
FieldTab::Validate => validate_tab(ui, d),
|
||||
|
||||
@@ -1044,7 +1044,7 @@ pub(crate) fn cert_viewer(ui: &mut egui::Ui, v: &mut CertViewer, trusted: &[Cert
|
||||
let grid = |ui: &mut egui::Ui, rows: Vec<(&str, String)>| {
|
||||
egui::Grid::new(("cert-rows", v.tab as u8)).num_columns(2).spacing([12.0, 5.0]).show(ui, |ui| {
|
||||
for (k, val) in rows {
|
||||
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
|
||||
ui.label(egui::RichText::new(tl_ctx!("certificate", k)).color(t.text_muted));
|
||||
ui.add(egui::Label::new(val).wrap());
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
@@ -63,10 +63,22 @@ pub fn search_box(ui: &mut egui::Ui, placeholder: &str, width: f32) -> Response
|
||||
let fill = if resp.hovered() { t.hover } else { t.field };
|
||||
ui.painter().rect(rect, CornerRadius::same(16), fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui, Rect::from_min_size(rect.min + vec2(10.0, 8.0), vec2(16.0, 16.0)), "search", 15.0, t.text_muted);
|
||||
ui.painter().text(rect.left_center() + vec2(34.0, 0.0), Align2::LEFT_CENTER, placeholder, theme::regular(13.0), t.text_faint);
|
||||
let shortcut = ui.ctx().format_shortcut(&egui::KeyboardShortcut::new(egui::Modifiers::COMMAND, egui::Key::K));
|
||||
ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, shortcut, theme::regular(11.5), t.text_faint);
|
||||
resp.on_hover_cursor(egui::CursorIcon::Text)
|
||||
let shortcut = ui.painter().layout_no_wrap(shortcut, theme::regular(11.5), t.text_faint);
|
||||
let shortcut_pos = rect.right_center() - vec2(12.0 + shortcut.size().x, shortcut.size().y * 0.5);
|
||||
// Reserve the measured shortcut width in every language; long translations are elided
|
||||
// within the remaining space rather than painted underneath the shortcut.
|
||||
let text_right = (shortcut_pos.x - 10.0).max(rect.left());
|
||||
let text_left = (rect.left() + 34.0).min(text_right);
|
||||
let text_rect = Rect::from_min_max(egui::pos2(text_left, rect.top()), egui::pos2(text_right, rect.bottom()));
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(placeholder.to_owned(), theme::regular(13.0), t.text_faint);
|
||||
job.wrap = egui::text::TextWrapping { max_width: text_rect.width(), max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let text = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let elided = text.elided;
|
||||
ui.painter().with_clip_rect(text_rect).galley(egui::pos2(text_left, rect.center().y - text.size().y * 0.5), text, t.text_faint);
|
||||
ui.painter().with_clip_rect(rect).galley(shortcut_pos, shortcut, t.text_faint);
|
||||
let resp = resp.on_hover_cursor(egui::CursorIcon::Text);
|
||||
if elided { resp.on_hover_text(placeholder) } else { resp }
|
||||
}
|
||||
|
||||
pub fn menu_item(ui: &mut egui::Ui, label: &str, shortcut: &str) -> Response {
|
||||
@@ -298,6 +310,29 @@ mod tests {
|
||||
use super::*;
|
||||
use egui::Pos2;
|
||||
|
||||
#[test]
|
||||
fn search_placeholder_never_overlaps_the_shortcut() {
|
||||
for width in [260.0, 160.0, 90.0] {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(theme::font_definitions());
|
||||
let placeholder = "Werkzeuge und Befehle suchen";
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
search_box(ui, placeholder, width);
|
||||
});
|
||||
let text_shapes: Vec<_> = output
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| if let egui::Shape::Text(text) = &shape.shape { Some((shape.clip_rect, text)) } else { None })
|
||||
.collect();
|
||||
let (clip, hint) = text_shapes.iter().find(|(_, text)| text.galley.job.text == placeholder).unwrap();
|
||||
let (_, shortcut) = text_shapes.iter().find(|(_, text)| text.galley.job.text.contains('K')).unwrap();
|
||||
assert!(clip.right() <= shortcut.pos.x - 9.0, "hint must leave a gap before the shortcut at width {width}");
|
||||
assert!(hint.galley.elided, "the long original German hint must be elided at width {width}");
|
||||
assert_eq!(hint.galley.rows.len(), 1);
|
||||
output.drop_without_applying_deltas();
|
||||
}
|
||||
}
|
||||
|
||||
fn toast_rect(app: &mut PdfCraftApp, ctx: &egui::Context, width: f32, now: &mut f64) -> Rect {
|
||||
// Let the Area settle after a notice/viewport change, without advancing to
|
||||
// expiry. No native rendering or renderer worker is needed for this layout.
|
||||
|
||||
@@ -243,6 +243,49 @@ fn the_open_recent_menu_is_disabled_while_the_list_is_empty() {
|
||||
);
|
||||
}
|
||||
|
||||
fn recent_file(name: &str) -> pdfcraft_ui_egui::RecentFile {
|
||||
pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_recent_files_at_the_foot_of_open_recent_empties_the_list() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("doc.pdf", None, fixture(3)).unwrap();
|
||||
app.recent.extend([recent_file("first.pdf"), recent_file("second.pdf")]);
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File ⏵").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Open Recent ⏵").hover();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Clear Recent Files").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().recent.is_empty(), "the list is empty");
|
||||
h.get_by_label("Menu").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("File ⏵").hover();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by(|n| n.label().as_deref() == Some("Open Recent") && n.is_disabled()).is_some(), "and Open Recent is disabled");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_recent_files_runs_from_the_palette_and_is_saved() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.recent.extend([recent_file("first.pdf"), recent_file("second.pdf")]);
|
||||
assert!(app.execute("file.clear_recent"));
|
||||
assert!(app.recent.is_empty());
|
||||
let mut restarted = PdfCraftApp::new();
|
||||
restarted.recent.push(recent_file("stale.pdf"));
|
||||
restarted.restore(&app.persist());
|
||||
assert!(restarted.recent.is_empty(), "the empty list is what's saved");
|
||||
app.execute("file.clear_recent");
|
||||
assert!(app.toast.as_ref().is_some_and(|(m, _)| m.contains("No recent files")), "nothing to clear is said, not silent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shortcuts_dialog_lists_the_real_bindings() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -264,6 +264,37 @@ fn the_panel_posts_comments_and_replies() {
|
||||
h.get_by_label_contains("Agreed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_ending_properties_change_and_undo() {
|
||||
let mut h = harness(|app| app.set_option("quick", "line").unwrap());
|
||||
drag_pt(&mut h, (40.0, 100.0), (160.0, 40.0));
|
||||
h.state_mut().open_comment_props(0, 0);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Line ending");
|
||||
{
|
||||
let d = h.state_mut().comment_props.as_mut().expect("open");
|
||||
assert_eq!(d.edited.endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..]));
|
||||
d.edited.endings = Some(vec![pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond]);
|
||||
}
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let endings = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(doc.can_undo(), Some("Change comment properties"));
|
||||
doc.comment_props(0, 0).unwrap().endings
|
||||
};
|
||||
assert_eq!(endings.as_deref(), Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::Diamond][..]));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
assert_eq!(
|
||||
doc.comment_props(0, 0).unwrap().endings.as_deref(),
|
||||
Some(&[pdfcraft_engine::LineEnding::None, pdfcraft_engine::LineEnding::None][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_properties_change_appearance_and_author() {
|
||||
let mut h = harness(|app| app.set_option("quick", "square").unwrap());
|
||||
@@ -453,6 +484,7 @@ fn the_panel_filters_by_colour_and_checkmark() {
|
||||
color: Some([0.0, 0.47, 0.84]),
|
||||
opacity: None,
|
||||
width: None,
|
||||
endings: None,
|
||||
});
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::MarkAnnotation { page: 0, index: 0, marked: true, author: "Tester".into() });
|
||||
h.run_steps(2);
|
||||
@@ -487,6 +519,7 @@ fn make_current_properties_default() {
|
||||
color: Some([0.0, 0.47, 0.84]),
|
||||
opacity: Some(0.5),
|
||||
width: Some(5.0),
|
||||
endings: None,
|
||||
});
|
||||
h.run_steps(2);
|
||||
h.get_by_label("More").click();
|
||||
|
||||
@@ -155,3 +155,59 @@ fn typing_moving_styling_and_deleting_added_content() {
|
||||
let pdfcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() };
|
||||
assert!(img.flip_h);
|
||||
}
|
||||
|
||||
/// #125: text the standard fonts can't draw stays in the editor with a warning instead of being
|
||||
/// written to the page as `?` (or lost).
|
||||
#[test]
|
||||
fn text_the_standard_fonts_cant_draw_stays_in_the_editor() {
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().execute("edit.text"));
|
||||
h.run_steps(2);
|
||||
let p = at(&h, 40.0, 300.0);
|
||||
click(&mut h, p);
|
||||
h.event(egui::Event::Text("Hello 世界".into()));
|
||||
h.run_steps(2);
|
||||
h.get_by_label_contains("can't draw “世” (U+4E16)");
|
||||
if let Ok(dir) = std::env::var("PDFCRAFT_SHOTS") {
|
||||
h.render().unwrap().save(format!("{dir}/undrawable-text.png")).unwrap();
|
||||
}
|
||||
h.get_by_label("Done adding text").click();
|
||||
h.run_steps(3);
|
||||
assert!(texts(&h).is_empty(), "nothing was written to the page");
|
||||
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone());
|
||||
assert_eq!(draft.as_deref(), Some("Hello 世界"), "the typed text is kept");
|
||||
// A click elsewhere on the page doesn't finish it, or replace it with a new box.
|
||||
let elsewhere = at(&h, 40.0, 150.0);
|
||||
click(&mut h, elsewhere);
|
||||
let draft = h.state().views[0].content.draft.as_ref().map(|d| d.text.clone());
|
||||
assert_eq!(draft.as_deref(), Some("Hello 世界"), "clicking elsewhere keeps the typed text");
|
||||
assert!(texts(&h).is_empty());
|
||||
// Once the text can be drawn, Done adds it (Done took the focus, so edit the draft directly).
|
||||
if let Some(d) = h.state_mut().views[0].content.draft.as_mut() {
|
||||
d.text = "Hello world".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Done adding text").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(texts(&h), ["Hello world"]);
|
||||
}
|
||||
|
||||
/// Text kept because it can't be drawn goes with its page when the page is deleted, so nothing
|
||||
/// is left blocking Save out of sight.
|
||||
#[test]
|
||||
fn a_kept_draft_goes_with_its_deleted_page() {
|
||||
let mut h = harness();
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::InsertBlankPage { at: 1, width: 612.0, height: 792.0 }));
|
||||
h.run_steps(2);
|
||||
assert!(h.state_mut().execute("edit.text"));
|
||||
h.state_mut().views[0].go_to_page(1);
|
||||
h.run_steps(4);
|
||||
let p = h.state().views[0].page_screen_rect(1).expect("page 2 on screen").min + egui::vec2(40.0, 40.0);
|
||||
click(&mut h, p);
|
||||
h.event(egui::Event::Text("日本語".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| (d.page, d.text.as_str())), Some((1, "日本語")));
|
||||
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![1] }));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[0].content.draft.is_none());
|
||||
}
|
||||
@@ -132,7 +132,7 @@ fn language_switch_preserves_document_and_command_ids() {
|
||||
let documents = ok(&mut h, &c, "ui.state", json!({}))["documents"].clone();
|
||||
assert_eq!(documents[0]["dirty"], true);
|
||||
let commands = ok(&mut h, &c, "ui.commands", json!({}));
|
||||
for code in ["ja", "zh-hans", "fr", "de", "en"] {
|
||||
for code in ["ja", "zh-hans", "fr", "de", "uk", "en"] {
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": code }));
|
||||
h.run_steps(2);
|
||||
let state = ok(&mut h, &c, "ui.state", json!({}));
|
||||
@@ -502,6 +502,69 @@ fn japanese_controls_and_search_keep_command_ids() {
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_preferences_and_search_keep_command_ids() {
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" }));
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" }));
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
|
||||
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
|
||||
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" }));
|
||||
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
|
||||
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "Deutsch" }));
|
||||
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "English" }));
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "en");
|
||||
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": "English" }));
|
||||
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "Deutsch" }));
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "de");
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
|
||||
for query in ["Dokument teilen", "Split document", "page.split"] {
|
||||
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
|
||||
ok(&mut h, &c, "ui.type", json!({ "text": query }));
|
||||
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "Dokument teilen…" }));
|
||||
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
|
||||
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
|
||||
h.state_mut().palette_query.clear();
|
||||
}
|
||||
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["documents"][0]["name"], "doc.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ukrainian_preferences_and_search_keep_command_ids() {
|
||||
let (mut h, c) = harness();
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "uk" }));
|
||||
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
|
||||
for label in ["Мова інтерфейсу", "Українська"] {
|
||||
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label }));
|
||||
assert!(found["count"].as_u64().unwrap() > 0, "{label}: {found}");
|
||||
}
|
||||
ok(&mut h, &c, "ui.click", json!({ "label": "OK" }));
|
||||
for query in ["Розділити", "Split document", "page.split"] {
|
||||
ok(&mut h, &c, "ui.command", json!({ "id": "view.palette" }));
|
||||
ok(&mut h, &c, "ui.type", json!({ "text": query }));
|
||||
let hits = ok(&mut h, &c, "ui.inspect", json!({ "query": "Розділити документ…" }));
|
||||
assert!(hits["count"].as_u64().unwrap() > 0, "{query}: {hits}");
|
||||
ok(&mut h, &c, "ui.key", json!({ "key": "Escape" }));
|
||||
h.state_mut().palette_query.clear();
|
||||
}
|
||||
for (dialog, label) in [("properties", "Властивості документа"), ("protect", "Захистити паролем"), ("about", "Учасники")]
|
||||
{
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": dialog }));
|
||||
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": label }));
|
||||
assert!(found["count"].as_u64().unwrap() > 0, "{dialog}: {found}");
|
||||
}
|
||||
ok(&mut h, &c, "ui.set", json!({ "key": "dialog", "value": "none" }));
|
||||
assert!(!h.state_mut().apply_edit(pdfcraft_engine::Edit::DeletePages { pages: vec![0, 1, 2, 3, 4] }));
|
||||
let state = ok(&mut h, &c, "ui.state", json!({}));
|
||||
assert_eq!(state["language"], "uk");
|
||||
assert_eq!(state["notice"], "Помилка «Видалити сторінки»: a document must keep at least one page");
|
||||
assert_eq!(state["documents"][0]["name"], "doc.pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
|
||||
let (mut h, c) = harness();
|
||||
@@ -511,7 +574,8 @@ fn preferences_menu_and_shortcut_allow_switching_interface_languages() {
|
||||
let menu = ok(&mut h, &c, "ui.inspect", json!({ "query": "環境設定…" }));
|
||||
let prefs = menu["widgets"].as_array().unwrap().iter().find(|w| w["clickable"] == true).expect("Preferences menu item");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": prefs["id"] }));
|
||||
for (current, next, code) in [("日本語", "English", "en"), ("English", "日本語", "ja")] {
|
||||
for (current, next, code) in [("日本語", "English", "en"), ("English", "Українська", "uk"), ("Українська", "日本語", "ja")]
|
||||
{
|
||||
let selector = ok(&mut h, &c, "ui.inspect", json!({ "query": current }));
|
||||
let combo = selector["widgets"].as_array().unwrap().iter().find(|w| w["role"] == "ComboBox").expect("language selector");
|
||||
ok(&mut h, &c, "ui.click", json!({ "id": combo["id"] }));
|
||||
|
||||
@@ -877,3 +877,83 @@ fn image_import_cancel_clear_and_errors_preserve_saved_signatures() {
|
||||
assert!(again.initials.is_none());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #299: drawn and typed signatures and initials are upright as displayed on pages with
|
||||
/// `/Rotate` 90, 180 and 270, anchored at the displayed point, exactly as on an unturned page.
|
||||
#[test]
|
||||
fn drawn_and_typed_signatures_stay_upright_on_rotated_pages() {
|
||||
// An "L": a long stroke along the bottom and a tick up at its left end, so a turned or
|
||||
// mirrored signature shows.
|
||||
let drawn = SavedSig::Drawn(vec![vec![[0.0, 0.0], [1.0, 0.0]], vec![[0.0, 0.0], [0.0, 0.2]]]);
|
||||
let typed = SavedSig::Typed("Ada Lovelace".into());
|
||||
// (signature, initials, displayed left-centre point)
|
||||
let cases = [(&drawn, false, [40.0_f32, 60.0]), (&typed, false, [40.0, 140.0]), (&typed, true, [40.0, 230.0])];
|
||||
let ink_mask = |rotation: i64| {
|
||||
let mut session = pdfcraft_engine::Session::new();
|
||||
let blank = session.create_blank(300.0, 400.0, 1).unwrap();
|
||||
let id = session.open_new("form.pdf", blank).unwrap();
|
||||
if rotation != 0 {
|
||||
session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: rotation }).unwrap();
|
||||
}
|
||||
let page = session.get(id).unwrap().info.pages[0].clone();
|
||||
assert_eq!(i64::from(page.rotation), rotation);
|
||||
for (sig, initials, shown) in cases {
|
||||
let at = page.view_to_user(shown[0], shown[1]);
|
||||
let edit = pdfcraft_ui_egui::fill_sign::place(0, &page, [f64::from(at[0]), f64::from(at[1])], sig, initials, "Ada").unwrap();
|
||||
session.apply(id, edit).unwrap();
|
||||
}
|
||||
let annotations = &session.get(id).unwrap().info.annotations;
|
||||
assert_eq!(annotations.len(), cases.len());
|
||||
let bytes = session.get(id).unwrap().bytes.clone();
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(bytes, Default::default());
|
||||
let out = renderer.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let shown_size = if rotation % 180 == 0 { (300, 400) } else { (400, 300) };
|
||||
assert_eq!((out.width, out.height), shown_size, "rendered as displayed");
|
||||
let dark: Vec<bool> = out.rgba.as_chunks::<4>().0.iter().map(|p| p[..3].iter().all(|v| *v < 128)).collect();
|
||||
// The displayed ink box of each signature, in the band around its point.
|
||||
let boxes: Vec<[u32; 4]> = cases
|
||||
.iter()
|
||||
.map(|(_, _, shown)| {
|
||||
let (top, bottom) = ((shown[1] - 35.0) as u32, (shown[1] + 35.0) as u32);
|
||||
let mut b = [u32::MAX, u32::MAX, 0, 0];
|
||||
for y in top..bottom {
|
||||
for x in 0..out.width {
|
||||
if dark[(y * out.width + x) as usize] {
|
||||
b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)];
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(b[0] <= b[2], "rotation {rotation}: no ink near {shown:?}");
|
||||
b
|
||||
})
|
||||
.collect();
|
||||
(dark, out.width, boxes)
|
||||
};
|
||||
let (upright, _, upright_boxes) = ink_mask(0);
|
||||
for (b, (sig, _, shown)) in upright_boxes.iter().zip(cases) {
|
||||
assert!(b[2] - b[0] > b[3] - b[1], "{sig:?} reads across: {b:?}");
|
||||
assert!(b[0].abs_diff(shown[0] as u32) <= 2, "{sig:?} starts at the point: {b:?}");
|
||||
assert!(b[1] < shown[1] as u32 && b[3] > shown[1] as u32, "{sig:?} is centred on the point: {b:?}");
|
||||
}
|
||||
// The drawn "L" has its tick at the top left, not mirrored or upside down.
|
||||
let at = |x: u32, y: u32| upright[(y * 300 + x) as usize];
|
||||
let b = upright_boxes[0];
|
||||
assert!(at(b[0] + 1, b[1] + 2) && !at(b[2] - 2, b[1] + 2) && at(b[2] - 2, b[3] - 1), "the drawn L is upright: {b:?}");
|
||||
for rotation in [90, 180, 270] {
|
||||
let (dark, width, boxes) = ink_mask(rotation);
|
||||
for (got, want) in boxes.iter().zip(&upright_boxes) {
|
||||
assert!(got.iter().zip(want).all(|(g, w)| g.abs_diff(*w) <= 1), "rotation {rotation}: displayed box {got:?}, upright {want:?}");
|
||||
}
|
||||
// Compare where both pages are shown (the turned ones are 400 x 300).
|
||||
let (mut differ, mut ink) = (0, 0);
|
||||
for y in 0..300 {
|
||||
for x in 0..300 {
|
||||
let (want, got) = (upright[(y * 300 + x) as usize], dark[(y * width + x) as usize]);
|
||||
ink += usize::from(want);
|
||||
differ += usize::from(want != got);
|
||||
}
|
||||
}
|
||||
assert!(differ * 10 < ink, "rotation {rotation}: {differ} of {ink} ink pixels differ from the unturned page");
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ fn ui_fonts_work_without_craft_fonts() {
|
||||
theme::install_fonts(&ctx);
|
||||
}
|
||||
|
||||
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian) are drawn entirely by the app's own faces
|
||||
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian) are drawn entirely by the app's own faces
|
||||
/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's
|
||||
/// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.)
|
||||
#[test]
|
||||
@@ -177,8 +177,10 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
|
||||
("fr", include_str!("../src/i18n/fr.tsv")),
|
||||
("ru", include_str!("../src/i18n/ru.tsv")),
|
||||
("bg", include_str!("../src/i18n/bg.tsv")),
|
||||
("hu", include_str!("../src/i18n/hu.tsv")),
|
||||
("uk", include_str!("../src/i18n/uk.tsv")),
|
||||
] {
|
||||
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖß„“¿¡«»…");
|
||||
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇ");
|
||||
for line in catalog.lines().filter(|l| !l.starts_with('#')) {
|
||||
if let Some(translation) = line.split('\t').nth(2) {
|
||||
text.extend(translation.chars().filter(|c| !c.is_whitespace()));
|
||||
|
||||
@@ -171,6 +171,39 @@ fn appearance_tab_restyles_the_field() {
|
||||
assert_eq!((l.style, l.font, l.fill), (BorderStyle::Dashed, FieldFont::Courier, Some([1.0, 1.0, 0.8])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_tab_rotates_a_field() {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_field_props("city", 0);
|
||||
h.run_steps(2);
|
||||
let widget = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let s = h.state();
|
||||
let f = s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap();
|
||||
f.widgets[0].clone()
|
||||
};
|
||||
let before = widget(&h);
|
||||
assert_eq!(before.rotation, 0);
|
||||
assert!(h.state().field_props.as_ref().unwrap().props().is_none(), "an unchanged draft is not an edit");
|
||||
h.get_by_label("Position").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Rotation:");
|
||||
h.state_mut().field_props.as_mut().unwrap().rotation = 90;
|
||||
assert!(h.state().field_props.as_ref().unwrap().props().is_some());
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
let turned = widget(&h);
|
||||
assert_eq!(turned.rotation, 90);
|
||||
let [x0, y0, x1, y1] = before.rect;
|
||||
let (cx, cy, w, ht) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0, x1 - x0, y1 - y0);
|
||||
let expect = [cx - ht / 2.0, cy - w / 2.0, cx + ht / 2.0, cy + w / 2.0];
|
||||
assert!(turned.rect.iter().zip(expect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {expect:?}", turned.rect);
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(3);
|
||||
let back = widget(&h);
|
||||
assert_eq!(back.rotation, 0);
|
||||
assert!(back.rect.iter().zip(before.rect).all(|(a, b)| (a - b).abs() < 0.05), "{:?} vs {:?}", back.rect, before.rect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_tab_sets_the_check_box_style() {
|
||||
// #94: Check Box Style on the Options tab.
|
||||
|
||||
@@ -85,3 +85,57 @@ fn quick_clicks_with_the_highlighter_mark_only_the_word() {
|
||||
assert_eq!(marks, [("Highlight", 1)], "one highlight, on the double-clicked word");
|
||||
assert!(app.views[0].selected_text().is_none());
|
||||
}
|
||||
|
||||
/// Press at `from`, move to `to` and release there.
|
||||
fn drag(h: &mut Harness<'static, PdfCraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(from + (to - from) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// Click at `pos` with Shift held.
|
||||
fn shift_click(h: &mut Harness<'static, PdfCraftApp>, pos: Pos2) {
|
||||
h.event(Event::ModifiersChanged(Modifiers::SHIFT));
|
||||
h.hover_at(pos);
|
||||
h.run_steps(1);
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::SHIFT });
|
||||
}
|
||||
h.run_steps(1);
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(1);
|
||||
}
|
||||
|
||||
/// Issue #527: ⇧-click extends the text selection to the click, keeping its anchor.
|
||||
#[test]
|
||||
fn shift_click_extends_the_selection_from_its_anchor() {
|
||||
let (mut h, _) = harness(|_| {});
|
||||
let glyph = |h: &Harness<'static, PdfCraftApp>, g: usize| h.state().views[0].glyph_screen_pos(0, g).expect("glyph on screen");
|
||||
// Drag over "quick" (glyphs 4..=8).
|
||||
let (q, k) = (glyph(&h, 4), glyph(&h, 8));
|
||||
drag(&mut h, q, k);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick"));
|
||||
h.run_steps(60);
|
||||
// ⇧-click on the "n" of "brown": the selection runs from the anchor to it.
|
||||
let n = glyph(&h, 14);
|
||||
shift_click(&mut h, n);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick brown"));
|
||||
h.run_steps(60);
|
||||
// ⇧-click before the anchor: the anchor stays, the selection now runs back to the click.
|
||||
let t = glyph(&h, 0);
|
||||
shift_click(&mut h, t);
|
||||
assert_eq!(h.state().views[0].selected_text().as_deref(), Some("The q"));
|
||||
h.run_steps(60);
|
||||
// A plain click still clears it.
|
||||
click(&mut h, n);
|
||||
assert_eq!(h.state().views[0].selected_text(), None);
|
||||
h.run_steps(60);
|
||||
// ⇧-click with nothing selected selects nothing.
|
||||
shift_click(&mut h, n);
|
||||
assert_eq!(h.state().views[0].selected_text(), None);
|
||||
}
|
||||
@@ -67,6 +67,31 @@ fn long_recent_names_and_paths_are_cut_before_the_page_count() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_removes_one_recent_file_or_clears_them_all() {
|
||||
// Tall enough that the Recent list is on screen without scrolling.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1600.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
for name in ["first.pdf", "second.pdf", "third.pdf"] {
|
||||
app.recent.push(pdfcraft_ui_egui::RecentFile { name: name.into(), path: format!("/nowhere/{name}"), pages: 1, size: 0 });
|
||||
}
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.query_by_label("Remove second.pdf from Recent").is_none(), "the remove button shows only over its row");
|
||||
h.get_by_label("second.pdf").hover();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Remove second.pdf from Recent").click();
|
||||
h.run_steps(2);
|
||||
let names: Vec<String> = h.state().recent.iter().map(|r| r.name.clone()).collect();
|
||||
assert_eq!(names, ["first.pdf", "third.pdf"], "only that file left the list");
|
||||
h.get_by_label("Clear Recent Files").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().recent.is_empty(), "Clear empties the list");
|
||||
h.get_by_label_contains("Files you open in PdfCraft appear here");
|
||||
assert!(h.query_by_label("Clear Recent Files").is_none(), "and goes away with nothing left to clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_catalog_tool_is_listed_after_view_more() {
|
||||
let mut h = harness(|_| {});
|
||||
|
||||
@@ -451,6 +451,105 @@ fn clicking_a_link_goes_to_its_destination() {
|
||||
assert!((target.min.y - vp.min.y).abs() < 40.0, "page 3 is scrolled to the top: {target:?} in {vp:?}");
|
||||
}
|
||||
|
||||
/// Bookmarks and a link with positioned destinations (ISO 32000-2 §12.3.2.2). Page 2's crop box
|
||||
/// is away from the origin (x 50..350, y 100..500), so its user-space y 420 is 20% down the page.
|
||||
const DESTS: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines 10 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Annots [20 0 R] >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 600] /CropBox [50 100 350 500] >> endobj
|
||||
5 0 obj << /Type /Page /Parent 2 0 R >> endobj
|
||||
10 0 obj << /Type /Outlines /First 11 0 R /Last 13 0 R /Count 3 >> endobj
|
||||
11 0 obj << /Title (Section 1.1 Shapes) /Parent 10 0 R /Next 12 0 R /Dest [4 0 R /XYZ 0 420 0] >> endobj
|
||||
12 0 obj << /Title (Zoomed in) /Parent 10 0 R /Next 13 0 R /Dest [4 0 R /XYZ 125 300 3] >> endobj
|
||||
13 0 obj << /Title (Broken) /Parent 10 0 R /Dest [5 0 R /XYZ (left) /top true] >> endobj
|
||||
20 0 obj << /Type /Annot /Subtype /Link /Rect [50 300 250 350] /Border [0 0 0] /Dest [4 0 R /FitH 420] >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF";
|
||||
|
||||
fn dests_harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("dests.pdf", None, DESTS.to_vec()).expect("opens");
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.set_option("panel", "bookmarks").unwrap();
|
||||
app
|
||||
});
|
||||
// The Bookmarks panel widens to fit its titles over many frames; fit-width zoom follows it.
|
||||
// Positions are checked once the window's layout holds still.
|
||||
let mut width = 0.0;
|
||||
for _ in 0..400 {
|
||||
h.run_steps(1);
|
||||
let w = h.state().views[0].viewport_rect().width();
|
||||
if w == width {
|
||||
break;
|
||||
}
|
||||
width = w;
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Where the point a fraction (`fx`, `fy`) across and down `page` is on screen, relative to the
|
||||
/// viewport's top-left corner.
|
||||
fn from_viewport_corner(h: &Harness<'static, PdfCraftApp>, page: usize, fx: f32, fy: f32) -> egui::Vec2 {
|
||||
let (r, vp) = (rect(h, page).expect("page on screen"), h.state().views[0].viewport_rect());
|
||||
egui::pos2(r.min.x + fx * r.width(), r.min.y + fy * r.height()) - vp.min
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_destinations_scroll_to_their_position() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
let mut h = dests_harness();
|
||||
// Fit width: the pages are taller than the window, so any point of them can reach its top.
|
||||
h.get_by_label("Section 1.1 Shapes").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!(v.current, 1, "the bookmark targets page 2");
|
||||
assert_eq!(v.fit, Fit::Width, "/XYZ with zoom 0 keeps the zoom");
|
||||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window, not the page top: {d:?}");
|
||||
|
||||
// /XYZ left top zoom: 300%, with (125, 300), a quarter across and half way down, at the
|
||||
// window's top-left (the page is now wider than the window): its left is the edge of the
|
||||
// gutter that keeps pages clear of the quick-action bar.
|
||||
h.get_by_label("Zoomed in").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.fit, v.zoom), (1, Fit::None, 3.0));
|
||||
let d = from_viewport_corner(&h, 1, 0.25, 0.5);
|
||||
assert!((d.x - 70.0).abs() < 2.0 && d.y.abs() < 2.0, "the destination point is at the window's top-left: {d:?}");
|
||||
|
||||
// Malformed operands fall back to the top of the page at the current zoom.
|
||||
h.get_by_label("Broken").click();
|
||||
h.run_steps(6);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.zoom), (2, 3.0));
|
||||
let d = from_viewport_corner(&h, 2, 0.0, 0.0);
|
||||
assert!(d.y > 0.0 && d.y < 40.0, "page 3 is scrolled to its top: {d:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_destinations_scroll_to_their_position() {
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
let mut h = dests_harness();
|
||||
h.state_mut().set_option("zoom", "100%").unwrap();
|
||||
h.run_steps(4);
|
||||
let r = rect(&h, 0).expect("page 1");
|
||||
// The link spans x 50..250, y 300..350 in PDF space on the 300×400 page 1.
|
||||
let at = egui::pos2(r.min.x + r.width() * (150.0 / 300.0), r.min.y + r.height() * (1.0 - 325.0 / 400.0));
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
h.drag_at(at); // press
|
||||
h.drop_at(at); // release
|
||||
h.run_steps(8);
|
||||
let v = &h.state().views[0];
|
||||
assert_eq!((v.current, v.fit), (1, Fit::Width), "/FitH goes to page 2 and fits its width");
|
||||
let d = from_viewport_corner(&h, 1, 0.0, 0.2);
|
||||
assert!(d.y.abs() < 2.0, "y 420 on page 2 is at the top of the window: {d:?}");
|
||||
}
|
||||
|
||||
/// Two pages with a text field on each and a non-embedded Helvetica.
|
||||
const FORM: &[u8] = b"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R 7 0 R] >> >> endobj
|
||||
|
||||
@@ -427,6 +427,59 @@ fn each_document_keeps_its_own_scroll_position() {
|
||||
assert_eq!(show(&mut h, 1), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_opens_at_the_top_after_a_relaunch() {
|
||||
// eframe saves egui's memory, scroll offsets included, with the settings and restores it at
|
||||
// the next launch, where document ids start again at 1: the first document opened then
|
||||
// started at the offset the previous session's first document was left at.
|
||||
use pdfcraft_ui_egui::canvas::Fit;
|
||||
let mut h = harness();
|
||||
for v in &mut h.state_mut().views {
|
||||
v.fit = Fit::Width;
|
||||
}
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(4);
|
||||
h.state_mut().views[0].goto = Some((3, 0.0));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views[0].current, 3, "a.pdf scrolled to page 4");
|
||||
// Quit: eframe writes egui's memory to app.ron, as below, and reads it back at the next launch.
|
||||
#[derive(Default)]
|
||||
struct Settings(std::collections::HashMap<String, String>);
|
||||
impl eframe::Storage for Settings {
|
||||
fn get_string(&self, key: &str) -> Option<String> {
|
||||
self.0.get(key).cloned()
|
||||
}
|
||||
fn set_string(&mut self, key: &str, value: String) {
|
||||
self.0.insert(key.into(), value);
|
||||
}
|
||||
fn remove_string(&mut self, key: &str) {
|
||||
self.0.remove(key);
|
||||
}
|
||||
fn flush(&mut self) {}
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
h.ctx.memory(|m| eframe::set_value(&mut settings, "egui", m));
|
||||
let memory: egui::Memory = eframe::get_value(&settings, "egui").expect("egui's memory round-trips");
|
||||
// Next launch: a document never seen before, which gets a.pdf's old id.
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|cc| {
|
||||
cc.egui_ctx.memory_mut(|m| *m = memory);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("c.pdf", None, fixture(5)).unwrap();
|
||||
app.views[0].fit = Fit::Width;
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views[0].id, pdfcraft_engine::DocId(1));
|
||||
assert_eq!(h.state().views[0].current, 0, "c.pdf opens on page 1");
|
||||
// It is still a scroll position of its own from then on.
|
||||
h.state_mut().views[0].goto = Some((2, 0.0));
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views[0].current, 2);
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().views[0].current, 2, "c.pdf stays on page 3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_and_page_keys_move_through_a_scrolling_document() {
|
||||
// #185: in the continuous (default) and two-page views the arrow keys and Page Down / Up did
|
||||
|
||||
@@ -152,7 +152,7 @@ fn nums_of(doc: &Document, o: Option<&Object>) -> Vec<f64> {
|
||||
fn text_of(doc: &Document, d: &Dict, k: &[u8]) -> Option<String> {
|
||||
d.get(k).and_then(|o| match &*doc.resolve(o) {
|
||||
Object::String(s) => Some(s.to_text()),
|
||||
Object::Name(n) => Some(String::from_utf8_lossy(n).into_owned()),
|
||||
Object::Name(n) => Some(pdfcraft_forms::name_text(n)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
@@ -370,7 +370,7 @@ pub fn export_fdf(doc: &Document, comments: bool, fields: bool, file: &str) -> V
|
||||
match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => {
|
||||
let v = f.value.first().cloned().unwrap_or_else(|| "Off".into());
|
||||
pdfcraft_cos::serialize(&Object::Name(v.into_bytes()), &mut body);
|
||||
pdfcraft_cos::serialize(&Object::Name(pdfcraft_forms::name_bytes(&v)), &mut body);
|
||||
}
|
||||
FieldKind::List if f.value.len() > 1 => {
|
||||
pdfcraft_cos::serialize(&Object::Array(f.value.iter().map(|v| Object::String(PdfString::text(v))).collect()), &mut body);
|
||||
@@ -759,7 +759,7 @@ fn walk_fdf(fdf: &Document, list: &[Object], prefix: &str, out: &mut Vec<(String
|
||||
if let Some(v) = d.get(b"V").map(|v| fdf.resolve(v)) {
|
||||
let vals: Vec<String> = match &*v {
|
||||
Object::String(s) => vec![s.to_text()],
|
||||
Object::Name(n) => vec![String::from_utf8_lossy(n).into_owned()],
|
||||
Object::Name(n) => vec![pdfcraft_forms::name_text(n)],
|
||||
Object::Array(a) => a.iter().filter_map(|x| fdf.resolve(x).as_string().map(|s| s.to_text())).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
@@ -132,6 +132,49 @@ fn fdf_round_trips_values_and_comments() {
|
||||
assert_eq!(comment_view(&dst), comment_view(&src), "replies keep their thread");
|
||||
}
|
||||
|
||||
/// One page with a check box whose on state is the Shift-JIS name 「はい」 (`/#82#CD#82#A2`).
|
||||
fn shift_jis_check_box() -> Document {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< /Fields [5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [50 700 64 714] /P 3 0 R /AP << /N << /Off 6 0 R /#82#CD#82#A2 6 0 R >> >> >>",
|
||||
"<< /Length 0 >>\nstream\n\nendstream",
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_utf8_check_box_states_round_trip() {
|
||||
let sjis_hai: &[u8] = b"\x82\xcd\x82\xa2";
|
||||
let mut src = shift_jis_check_box();
|
||||
set_value(&mut src, "agree", &FieldValue::Check(true)).unwrap();
|
||||
// FDF writes the state's own bytes, not its #XX text.
|
||||
let fdf = export_fdf(&src, false, true, "form.pdf");
|
||||
let name: &[u8] = b"/#82#CD#82#A2";
|
||||
assert!(fdf.windows(name.len()).any(|w| w == name), "{}", String::from_utf8_lossy(&fdf));
|
||||
for data in [fdf, export_xfdf(&src, false, true, "form.pdf").into_bytes()] {
|
||||
let mut dst = shift_jis_check_box();
|
||||
assert_eq!(import(&mut dst, &data).unwrap().fields, 1);
|
||||
assert_eq!(values(&dst), values(&src));
|
||||
let w = fields(&dst).into_iter().next().unwrap().widgets[0].obj;
|
||||
assert_eq!(dst.get(w).as_dict().unwrap().name(b"AS"), Some(sjis_hai));
|
||||
}
|
||||
}
|
||||
|
||||
/// Who each comment replies to, by name, with its review state.
|
||||
fn threads(doc: &Document) -> Vec<(Option<String>, Option<String>, Option<String>)> {
|
||||
summaries(doc).into_iter().map(|s| (s.name, s.in_reply_to, s.state)).collect()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Interface language
|
||||
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français** or **తెలుగు**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar** or **Українська**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
|
||||
|
||||
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, and Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
|
||||
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
|
||||
|
||||
The control channel exposes the setting through `ui.set`:
|
||||
|
||||
@@ -10,16 +10,20 @@ The control channel exposes the setting through `ui.set`:
|
||||
{"method":"ui.set","params":{"key":"language","value":"ja"}}
|
||||
```
|
||||
|
||||
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr` or `te`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
|
||||
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu` or `uk`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
|
||||
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French and Telugu translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
|
||||
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian and Ukrainian translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
|
||||
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish and French need only the bundled Latin faces (tested in `tests/fonts.rs`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
|
||||
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
|
||||
|
||||
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French and Hungarian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
|
||||
|
||||
There is no right-to-left interface language yet. File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
|
||||
|
||||
Simplified Chinese, French and German coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Simplified Chinese also checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140).
|
||||
|
||||
German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened.
|
||||
|
||||
## How translations work
|
||||
|
||||
PdfCraft uses the same system as PhotoCraft (`crates/ui-egui/src/i18n/`):
|
||||
|
||||
@@ -656,10 +656,10 @@ area = "A"
|
||||
tier = "P1"
|
||||
milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["file.open_recent"]
|
||||
commands = ["file.open_recent", "file.clear_recent"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty"]
|
||||
notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it"
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::persistence_round_trips_and_tolerates_garbage", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_lists_files_and_opens_one", "crates/ui-egui/tests/commands.rs::the_open_recent_menu_is_disabled_while_the_list_is_empty", "crates/ui-egui/tests/commands.rs::clear_recent_files_at_the_foot_of_open_recent_empties_the_list", "crates/ui-egui/tests/commands.rs::clear_recent_files_runs_from_the_palette_and_is_saved", "crates/ui-egui/tests/ui.rs::home_removes_one_recent_file_or_clears_them_all"]
|
||||
notes = "never reads other apps' lists; File ▸ Open Recent lists them (#290), an already-open file focuses its tab instead of reopening it; Clear Recent Files at the foot of Open Recent, on Home and in the palette, and a remove button on each Home row (#430); missing: the row's remove button shows on pointer hover only (keyboard users clear the whole list)"
|
||||
|
||||
[[feature]]
|
||||
id = "core.reopen-last-session"
|
||||
@@ -1154,8 +1154,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination"]
|
||||
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6"
|
||||
evidence = ["crates/ui-egui/tests/view.rs::clicking_a_link_goes_to_its_destination", "crates/ui-egui/tests/view.rs::link_destinations_scroll_to_their_position", "crates/render/src/inspect.rs::bookmarks_and_links_keep_their_destination_view"]
|
||||
notes = "GoTo and URI links; other action types (JavaScript, Launch, named actions) arrive with M6. Destinations keep their position and zoom (/XYZ, /Fit, /FitH, /FitV, /FitR, named); the /FitB* content box is read as the whole page"
|
||||
|
||||
[[feature]]
|
||||
id = "view.annotation-hover"
|
||||
@@ -1178,8 +1178,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_list"]
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position"]
|
||||
notes = "each tab keeps its own scroll position (#189)"
|
||||
evidence = ["crates/ui-egui/tests/editing.rs::combining_files_opens_a_new_unsaved_tab", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/viewer.rs::each_document_keeps_its_own_scroll_position", "crates/ui-egui/tests/viewer.rs::a_new_document_opens_at_the_top_after_a_relaunch"]
|
||||
notes = "each tab keeps its own scroll position (#189), and a newly opened one starts at the top, not where an earlier session left the scroll"
|
||||
|
||||
[[feature]]
|
||||
id = "view.multiple-windows"
|
||||
@@ -1250,8 +1250,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = ["doc_info"]
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options"]
|
||||
notes = ""
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::opening_a_pdf_shows_comments_and_bookmarks", "crates/ui-egui/tests/control.rs::state_and_view_options", "crates/ui-egui/tests/view.rs::bookmark_destinations_scroll_to_their_position"]
|
||||
notes = "A bookmark goes to its destination's position and zoom, not just its page"
|
||||
|
||||
[[feature]]
|
||||
id = "view.highlight-current-bookmark"
|
||||
@@ -1478,8 +1478,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word"]
|
||||
notes = "Drag to select; with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). The markup and Redact tools mark a double-clicked word and stop there. Missing: paragraph selection, and ⌘A across the whole document (it selects the current page)."
|
||||
evidence = ["crates/ui-egui/tests/ui.rs::drag_selects_text_and_copy_returns_it", "crates/ui-egui/tests/ui.rs::selection_works_in_every_view_rotation", "crates/ui-egui/tests/text_select.rs::quick_clicks_select_a_word_then_the_line_then_the_page", "crates/ui-egui/tests/text_select.rs::quick_clicks_with_the_highlighter_mark_only_the_word", "crates/ui-egui/tests/text_select.rs::shift_click_extends_the_selection_from_its_anchor"]
|
||||
notes = "Drag to select; ⇧-click extends the selection to the click, keeping its anchor (same page only); with the Select tool, quick clicks select a word (two), the line (three) or all the text on the page (four). The markup and Redact tools mark a double-clicked word and stop there. Missing: selection across pages (dragging or ⇧-clicking onto another page), paragraph selection, and ⌘A across the whole document (it selects the current page)."
|
||||
|
||||
[[feature]]
|
||||
id = "view.copy-text"
|
||||
@@ -3770,8 +3770,8 @@ milestone = "M5"
|
||||
status = "partial"
|
||||
commands = []
|
||||
tools = ["comment_add"]
|
||||
evidence = ["crates/annot/src/tests.rs::every_tool_creates_a_drawable_comment"]
|
||||
notes = "None, OpenArrow and ClosedArrow are drawn; other endings are kept but not redrawn"
|
||||
evidence = ["crates/annot/src/tests.rs::every_line_ending_is_drawn", "crates/automation/tests/automation.rs::line_endings_through_the_tool", "crates/ui-egui/tests/comments.rs::line_ending_properties_change_and_undo"]
|
||||
notes = "All ten /LE styles (None, Square, Circle, Diamond, OpenArrow, ClosedArrow, Butt, ROpenArrow, RClosedArrow, Slash) are drawn on lines, polylines and callouts. comment_add endings and Comment properties set them. Dashed borders are drawn. Cloudy borders are still polygon-only."
|
||||
|
||||
[[feature]]
|
||||
id = "comment.properties-dialog"
|
||||
@@ -4397,9 +4397,9 @@ tier = "P0"
|
||||
milestone = "M6"
|
||||
status = "partial"
|
||||
commands = ["form.fields"]
|
||||
tools = ["form_fill"]
|
||||
evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text"]
|
||||
notes = "auto-size, comb, multiline, password, quadding, /MK colours; approximate Helvetica metrics, no /MK rotation yet"
|
||||
tools = ["form_fill", "form_set_props"]
|
||||
evidence = ["crates/forms/src/tests.rs::text_fields_get_new_appearances", "crates/forms/src/tests.rs::choices_accept_exports_or_display_text", "crates/forms/src/tests.rs::rotated_fields_draw_in_their_quadrant", "crates/forms/src/tests.rs::a_rotated_field_draws_along_the_vertical_edge", "crates/automation/tests/automation.rs::rotating_a_field_through_tools", "crates/ui-egui/tests/prepare.rs::position_tab_rotates_a_field"]
|
||||
notes = "auto-size, comb, multiline, password, quadding, /MK colours and /MK /R rotation (0, 90, 180, 270); approximate Helvetica metrics"
|
||||
|
||||
[[feature]]
|
||||
id = "form.rich-text-fields"
|
||||
@@ -5226,8 +5226,8 @@ milestone = "M5"
|
||||
status = "shipped"
|
||||
commands = ["sign.fill.signature", "sign.fill.initials", "sign.fill.signature.change", "sign.fill.initials.change", "sign.fill.signature.remove", "sign.fill.initials.remove"]
|
||||
tools = ["fill_sign_add", "comment_edit", "comment_image_preview"]
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
|
||||
evidence = ["crates/ui-egui/tests/fill_sign.rs::drawn_and_typed_signatures_stay_upright_on_rotated_pages", "crates/automation/tests/automation.rs::typed_signatures_stay_upright_on_rotated_pages", "crates/ui-egui/tests/fill_sign.rs::image_signature_corners_restore_original_aspect_after_edge_resize_and_reopen", "crates/ui-egui/src/comments.rs::proportional_corners_keep_the_opposite_anchor_and_edges_stretch_one_axis", "crates/ui-egui/tests/fill_sign.rs::image_signature_live_resize_and_move_preserve_background_and_hide_moving_handles", "crates/ui-egui/tests/fill_sign.rs::embedded_image_signature_live_gestures_follow_document_and_view_rotation", "crates/automation/tests/automation.rs::image_signature_preview_layers_are_read_only_and_survive_encrypted_save", "crates/engine/src/signature_image.rs::embedded_signature_preview_preserves_alpha_without_editing_the_document", "crates/engine/src/signature_image.rs::oversized_embedded_signature_preview_is_rejected_before_decoding", "crates/ui-egui/tests/fill_sign.rs::image_signature_preview_follows_pointer_at_page_size_with_zoom_and_rotation", "crates/ui-egui/tests/fill_sign.rs::image_signature_placement_selects_resize_handles_and_requires_reselecting_to_repeat", "crates/ui-egui/tests/fill_sign.rs::typed_signatures_and_initials", "crates/ui-egui/tests/fill_sign.rs::signing_draws_a_signature_once_and_places_it", "crates/ui-egui/tests/fill_sign.rs::changing_saved_signatures_and_initials_preserves_placed_marks", "crates/ui-egui/tests/fill_sign.rs::changing_drawn_signatures_can_be_cancelled_or_switched_to_type", "crates/ui-egui/tests/fill_sign.rs::saved_signature_cards_remove_and_add_without_changing_the_document", "crates/ui-egui/tests/fill_sign.rs::long_typed_names_fit_the_placed_signature_and_keep_every_outline", "crates/ui-egui/src/fill_sign.rs::long_signature_previews_have_clear_margins", "crates/ui-egui/tests/control.rs::saved_signature_can_be_changed_through_the_control_channel", "crates/fonts/src/script.rs::a_name_becomes_outlines", "crates/fonts/src/script.rs::long_names_keep_every_glyph_with_bounded_outline_work", "crates/automation/tests/automation.rs::fill_and_sign_through_tools", "crates/ui-egui/tests/fill_sign.rs::image_signatures_and_initials_can_be_imported_placed_and_remembered", "crates/ui-egui/tests/fill_sign.rs::image_import_cancel_clear_and_errors_preserve_saved_signatures", "crates/engine/src/signature_image.rs::image_signatures_preserve_alpha_and_fit_the_signature_box", "crates/engine/src/signature_image.rs::invalid_and_oversized_signature_images_are_rejected", "crates/engine/src/signature_image.rs::jpeg_signatures_are_normalized_to_png", "crates/engine/src/signature_image.rs::jpeg_signature_orientation_is_applied_before_preview_and_embedding", "crates/engine/src/signature_image.rs::very_wide_image_signatures_keep_the_requested_rectangle", "crates/automation/tests/automation.rs::image_signatures_through_tools_preserve_transparency_and_survive_save"]
|
||||
notes = "signature and initials, typed (Dancing Script outlines as filled paths, no font embedded), drawn or imported from local PNG/JPEG images; all three are placed upright and anchored as displayed on pages with /Rotate (#299); saved in settings with preview cards and add/change/remove controls; changes leave placed annotations untouched; images preserve alpha, aspect ratio and EXIF orientation, fit within 150 pt by 32 pt (24 pt for initials), and are normalized without source paths or metadata; image previews follow the pointer at placement size with zoom and rotation; signatures/initials place once then select the new annotation; embedded image signatures resize and move live, hiding the frame during movement and restoring it on release; corner handles preserve the original aspect ratio of the embedded image with the opposite corner anchored, while edge midpoint handles stretch width or height independently; cached background layers preserve page content and alpha, including reopened/encrypted documents; Escape cancels a gesture without committing; stamps resize through selection handles or comment_edit with their original appearance; native and web asynchronous pickers; fill_sign_add accepts path instead of text; image imports capped at 4 MiB, 4 megapixels and 4096 pixels per side"
|
||||
|
||||
[[feature]]
|
||||
id = "form.fill-sign-flatten"
|
||||
@@ -9818,8 +9818,8 @@ milestone = "M14"
|
||||
status = "partial"
|
||||
commands = ["app.preferences"]
|
||||
tools = []
|
||||
evidence = ["crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs"]
|
||||
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish and French cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one. Engine/OS error details stay untranslated. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
|
||||
evidence = ["crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values"]
|
||||
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. Engine/OS error details stay untranslated. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
|
||||
|
||||
[[feature]]
|
||||
id = "misc.prefs-reading"
|
||||
|
||||
Vendored
+3
-2
@@ -7,12 +7,13 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
|
||||
|
||||
| Crate | Version | Licence | Patches | Test |
|
||||
|---|---|---|---|---|
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it when dropped, after an edit or on closing, so its renders in progress stop instead of running to the end for answers nobody can receive) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders` |
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn`; (14) `raster::tests::malformed_simple_font_widths_keep_later_widths_and_the_font`, `raster::tests::huge_simple_font_first_char_terminates`; (15) `raster::tests::annotation_appearance_box_maps_onto_rect`; (16) `raster::tests::clipping_path_applies_after_any_painting_operator`; (17) `raster::tests::dropping_the_pool_cancels_its_renders`, `raster::tests::the_watchdog_stops_the_render_it_gives_up_on` |
|
||||
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo) | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid` |
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over` |
|
||||
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` |
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream` |
|
||||
| winit | 0.30.13 | Apache-2.0 | (1) on Windows 11 (build 22000 and later), `WM_DPICHANGED` applies the rect Windows suggests. It used to nudge the window onto the monitor `MonitorFromWindow` names, which during a drag is still the monitor being left; the nudge sent another `WM_DPICHANGED`, so a window dragged to a monitor with another scale factor grew and stayed on the wrong monitor at the wrong scale (#324). Backport of winit master 488c036a ([winit#4341](https://github.com/rust-windowing/winit/pull/4341)), which no 0.30 release has; Windows 10 keeps the old adjustment, as on winit master. The published crate's asset files (`docs/res/*.svg`, `examples/data/*.png`) are left out, so its `window` example doesn't build | (1) `pdfcraft` `tests::winit_carries_the_windows_11_monitor_scale_fix` keeps the patch in the build; the drag itself needs two monitors with different scale factors on Windows 11 |
|
||||
|
||||
Temporary: hayro is the bootstrap renderer and lopdf the bootstrap inspector (ADR-0004); M2.6 replaces them with our own devices and `cos`/`model`.
|
||||
|
||||
|
||||
Vendored
+52
-12
@@ -127,19 +127,56 @@ pub fn render<'a>(
|
||||
interpreter_settings: &InterpreterSettings,
|
||||
render_settings: &RenderSettings,
|
||||
) -> Pixmap {
|
||||
let (x_scale, y_scale) = (render_settings.x_scale, render_settings.y_scale);
|
||||
let (width, height) = page.render_dimensions();
|
||||
let (scaled_width, scaled_height) = ((width * x_scale) as f64, (height * y_scale) as f64);
|
||||
let initial_transform = Affine::translate((-render_settings.x_offset as f64, -render_settings.y_offset as f64))
|
||||
* Affine::scale_non_uniform(x_scale as f64, y_scale as f64)
|
||||
* page.initial_transform(true).to_kurbo();
|
||||
let (pix_width, pix_height) = render_size(page, render_settings);
|
||||
let mut pixmap = Pixmap::new(pix_width, pix_height);
|
||||
render_into(
|
||||
page,
|
||||
cache,
|
||||
interpreter_settings,
|
||||
render_settings,
|
||||
pixmap.data_as_u8_slice_mut(),
|
||||
);
|
||||
pixmap
|
||||
}
|
||||
|
||||
let (pix_width, pix_height) = (
|
||||
/// PdfCraft patch: the size in pixels of what [`render`] produces with these settings.
|
||||
pub fn render_size(page: &Page<'_>, render_settings: &RenderSettings) -> (u16, u16) {
|
||||
let (width, height) = page.render_dimensions();
|
||||
let (scaled_width, scaled_height) = (
|
||||
(width * render_settings.x_scale) as f64,
|
||||
(height * render_settings.y_scale) as f64,
|
||||
);
|
||||
(
|
||||
render_settings.width.unwrap_or(scaled_width.floor() as u16),
|
||||
render_settings
|
||||
.height
|
||||
.unwrap_or(scaled_height.floor() as u16),
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: [`render`] into `buffer`, premultiplied RGBA8 rows of [`render_size`]
|
||||
/// pixels, so the caller chooses the allocation (one aligned to whole pixels can then become
|
||||
/// texture data without a copy). `buffer.len()` must be width × height × 4; any other length
|
||||
/// leaves `buffer` untouched (vello_cpu asserts on the length, and a panic must not be reachable).
|
||||
pub fn render_into<'a>(
|
||||
page: &'a Page<'a>,
|
||||
cache: &RenderCache<'a>,
|
||||
interpreter_settings: &InterpreterSettings,
|
||||
render_settings: &RenderSettings,
|
||||
buffer: &mut [u8],
|
||||
) {
|
||||
let (x_scale, y_scale) = (render_settings.x_scale, render_settings.y_scale);
|
||||
let initial_transform = Affine::translate((-render_settings.x_offset as f64, -render_settings.y_offset as f64))
|
||||
* Affine::scale_non_uniform(x_scale as f64, y_scale as f64)
|
||||
* page.initial_transform(true).to_kurbo();
|
||||
|
||||
let (pix_width, pix_height) = render_size(page, render_settings);
|
||||
let expected = (pix_width as usize)
|
||||
.checked_mul(pix_height as usize)
|
||||
.and_then(|n| n.checked_mul(4));
|
||||
if expected != Some(buffer.len()) {
|
||||
return;
|
||||
}
|
||||
let mut state = Context::new(
|
||||
initial_transform,
|
||||
Rect::new(0.0, 0.0, pix_width as f64, pix_height as f64),
|
||||
@@ -174,11 +211,14 @@ pub fn render<'a>(
|
||||
|
||||
device.pop_clip_path();
|
||||
|
||||
let mut pixmap = Pixmap::new(pix_width, pix_height);
|
||||
let mut resources = vello_cpu::Resources::default();
|
||||
device.ctx.render_to_pixmap(&mut resources, &mut pixmap);
|
||||
|
||||
pixmap
|
||||
device.ctx.render_to_buffer(
|
||||
&mut resources,
|
||||
buffer,
|
||||
pix_width,
|
||||
pix_height,
|
||||
vc_settings.render_mode,
|
||||
);
|
||||
}
|
||||
|
||||
// Just a convenience method for testing.
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "e9809ef54b18499bb4f2cac945719ecc2a61061b"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
+2187
File diff suppressed because it is too large.
Load diff
Loaded 100 of 306 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user