M10.5: Reconcile hosted CI and keep the Arabic golden in both font builds

This commit is contained in:
skylar taylor-barrick 2026-10-12 00:23:30 +02:00
commit c5f1cb2d3f
107 changed files with 12969 additions and 427 deletions

View File

@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: storytold/craft-fonts
ref: 8dcdacd5153e64560d109541a47d806f26f048c0
ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
path: craft-fonts
- name: Install Linux system dependencies (egui/winit/rfd)

View File

@ -26,4 +26,4 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3

View File

@ -24,7 +24,7 @@ jobs:
with:
app-name: pdfcraft
app-title: PdfCraft
craft-fonts-ref: 8dcdacd5153e64560d109541a47d806f26f048c0
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}

View File

@ -4,10 +4,11 @@
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 (265)
## In this repository (269)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/el.tsv` | Greek interface translation catalog | Camthalion77 and contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels | Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese 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 | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) 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 | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech 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 | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
@ -21,9 +22,11 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `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/kk.tsv` | Kazakh interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources | Kazakh 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 |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic 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 | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish 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 | Turkish 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 |
@ -273,6 +276,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene 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 | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
## Compiled in through dependencies (6)

View File

@ -1,3 +1,14 @@
[[asset]]
path = "crates/ui-egui/src/i18n/el.tsv"
title = "Greek interface translation catalog"
author = "Camthalion77 and contributors"
source = "Contributor-original: clean-room translations of PdfCraft English UI labels"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Greek UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7ed6a7c482dab19fe1b54a7704902610e79e9bc60f19115ce8af7b1ebca504ff"
# ATTRIBUTION.toml: one entry per asset (AGENTS.md §1). The source of truth for provenance and licences.
# ATTRIBUTION.md is generated from this file: `cargo xtask assets --write`. Checked by `cargo xtask assets` (part of `xtask ci`).
#
@ -19,7 +30,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 = "1863437c886142b2bccce3f44a538ae80d0ceea81074b84dfdfe8d9a4a4227ab"
sha256 = "e5141febe07d89c4c4c7b2d2f87abf71ad1ab8dc4a7880d03805db6f0022118b"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@ -30,7 +41,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 = "6c8604d5cd71412c6a673b75001c7ef629727839eca1449147c7d6ec4bf0be99"
sha256 = "6dff4fded1106659f7c0b1c7e2c671c02e59313ab58875fabf591a9356098767"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@ -41,7 +52,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 = "5ae2e315a9b99b4c0dfaf7be019c4d430ace17b4fbbfea5a2779b3e972846a21"
sha256 = "1978cfd55b08fe9905e00ddd16a10d3d0f5371143e2f00a58a77affcc1d874c3"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@ -52,7 +63,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 = "8b9e22fa25339e4aedb0542b8bf6f68dfd97bc3634105199e1951475e30eed53"
sha256 = "928435778bd8c703083087d401851ab01dc038db3ff568e572b6cf646f805b03"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -63,7 +74,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 = "6d81592677f2b4eb3b97ba5810de9752da969545d91897e27033f37310696ffc"
sha256 = "4f95f2dc524177a5a6261f2f6df6def2cd061596bddac20b264cf6051978b3e3"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@ -74,7 +85,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 = "fb9c91a98c6975bc24249e8ae750fd46b9245ac5d2d7381f1e21319b9adbcaac"
sha256 = "7c0df34f12030b1dc266413839dbb507bc890cc8847f6b2eac324ed54ff3fc19"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@ -85,7 +96,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 = "f36aefae1a1ba6d576a4647fbfdd65b9b5086bfb266ea260e513244adc7af05a"
sha256 = "77f4ec8eb9cba7e2f2aad0883a91a62ca871140135c586f32c725f66774a0632"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@ -96,7 +107,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 = "656940b456a6c2401f8bd3107a71cedcae5e52cd446e86e806285b6322d8ed8e"
sha256 = "4e4d4e4e2f9d4d4be91ed2767a07640b626d83733093d12bff99d0a3986aa7d7"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@ -107,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "14884c08a550600d310a794ceb0b2bb864987a074178ee49b657578b66625bef"
sha256 = "3d1af7e703706bf4c814062871b47308b70f358356c3b17782dde3eaaa66abb7"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@ -118,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "be960bdf9b6eeac2d95db91325076ee79c9b2efdcc4e459c7400e7ddb05fab3a"
sha256 = "d03048fe760c7daf08c0d8ed8470633c0eaca8f68c41d1c8ce889813c7f6fab1"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@ -129,7 +140,7 @@ 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 = "bada9b6f0b3775e4dab1213d9759070f7551d8a6142de10159268c88a04d3f0c"
sha256 = "69e07b82ae409de7544f538fedc8e56cdb9f4092be7f45cc7366bddbc03ce93f"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@ -140,7 +151,7 @@ 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 = "efb75d8575df9e1dd4610602f20545b3a14a3a0814060efc27eb7cab591ccccb"
sha256 = "0a71d58a88ea532358d3b133eba3aae217ad4bcbb763b6235b137615ce226d2b"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@ -151,7 +162,18 @@ 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 = "ca305b188abc8d9c261901dde10f7cd8518520983fdf88d2358374b352ad3d5a"
sha256 = "1e5172befbe995c800cd69e9d6abc02c92544b62d8e6dac0f4a0c26cd0b1efbb"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
title = "Kazakh interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of the English UI labels into Kazakh, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Kazakh UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "dd76d58abda6153e5c0b9fcc761d652cf2ed96522fab18c9ce87e9cec594043a"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@ -162,7 +184,7 @@ 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 = "f004b06f4e7eb8f4a99e22e1abfd97c95795b2215080e4008574522e54eda74c"
sha256 = "6c1ca7f6d53454c69b3e362a2a09c5c28d04b615c53936c639e3b43c880552d3"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@ -173,7 +195,7 @@ 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 = "cd9ecaf661533cbd3b055cfd3994602f6739c83061d1e225ba43ee6f229068cf"
sha256 = "9151c1049289e318409965c5ad061448db9f35a7e8f14a754ba83616e3d87930"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@ -184,7 +206,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a8a66390b2d80b4708c4f854318b923eef7f0f5932439fb5ef25f2651980f24e"
sha256 = "096be489498c16dbf85fcd6d2d0d96590736a8b0bbe3b3a5b557e2627c89f9c1"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
title = "Turkish 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 = "Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "e72215bc4bf21f598bf82a6ac55551bdef47d62cceedea239d89c0febd9cb1b7"
[[asset]]
path = "assets/icons/accessibility.svg"
@ -3296,3 +3329,14 @@ licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
title = "Slovene 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 = "Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7cc8bf51e042d07a397dbebf0747b496879e721a6644f1af197cfb3cbcbe7904"

1
Cargo.lock generated
View File

@ -3619,6 +3619,7 @@ dependencies = [
"image",
"pdfcraft-cos",
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-organize",
"pdfcraft-platform",

7
NOTICE
View File

@ -20,12 +20,15 @@ list (author, source, license) is in ATTRIBUTION.md.
- Inter typeface (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
- JetBrains Mono typeface (assets/fonts/JetBrainsMono-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
- Dancing Script typeface (assets/fonts/DancingScript.ttf), Copyright 2016 The Dancing Script Project Authors — SIL Open Font License 1.1 (Reserved Font Name "Dancing Script"). See assets/fonts/OFL-DancingScript.txt.
- Japanese fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
- Fonts from craft-fonts (https://github.com/storytold/craft-fonts), an optional build input
(CRAFT_FONTS_DIR) that is not part of this repository. Builds made with it, which includes every
official release, embed the fonts listed in
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho; the web build only BIZ UDPGothic Regular) under the SIL Open Font License 1.1. Those
packages carry each font's licence as OFL-<family>.txt.
packages carry each font's licence as OFL-<family>.txt. Builds made with the craft-fonts revision
pinned in .github/workflows/ also include Noto Sans Thai (in the web build too), Copyright 2022
The Noto Project Authors (https://github.com/notofonts/thai), under SIL OFL 1.1; its licence is
fonts/noto-sans-thai/OFL.txt in the craft-fonts build input.
- hayro (rendering, via crates.io) — Apache-2.0 OR MIT. Its `embed-fonts` feature bundles the Foxit
standard-14 Type 1 fonts under BSD-3-Clause ("Copyright 2014 PDFium Authors"), and hayro-cmap bundles
Adobe CMap resources under BSD-3-Clause.

View File

@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26% (ready for real work 49% → 75%) and ~400–700 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (shared craft-print spooler with passing Windows CI; architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (shared craft-print spooler with passing Windows CI; architectural paper controls from #599; localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** M14 #307 Plan 3 uses one marker traversal on open and full refresh, and the combined scanner borrows metadata instead of cloning scan-only containers; includes a behavior-preserving `organize` binding for the workspace Clippy gate · earlier: minor (localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (closed issues synced: #774, #756 and the #799–#821 batch; earlier: localized shortcut labels, #496; readiness table with hours per audience; full number as weighted sum 49%; earlier: second look: ready 40% → 47%, mainstream 55% added; core-workflow gate applied → alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@ -139,7 +139,9 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-12 (M10.5, hosted CI):** Integrated current main, including its date-table formatting and static-storage fixes. The Arabic synthetic drawing regression now checks the original serialized-output golden with and without the optional font face: the face-dependent FontName and FontFamily are checked separately and normalized only in the fixture. The hosted FreeBSD fingerprint is reproduced by those two metadata fields alone. Drawing bytes and all other fields retain the original golden; no printing behavior or readiness estimate changes.
- **2026-10-11 (M10.5):** Extracted printer discovery, options and PDF submission into craft-libs/craft-print, shared with DesignCraft. Windows preserves composed sheet size and orientation without fitting into default margins; duplex binding edges are corrected. PdfCraft retains permission checks and imposition. Shared transport and local composition, engine, automation and UI coverage validate the boundary; native Windows CI passes, including paused-queue submission and cleanup; physical printer fidelity remains a hardware check. Rust 1.95 is required by the shared crate. No readiness percentage change.
- **2026-10-10 (community, M7, Thai Add Text):** Optional craft-fonts Thai face (Noto Sans Thai) for the UI and shaped Add Text through the engine, CLI and MCP. Stacked marks and Sara Am are kept through Type 3 clusters with ToUnicode; regular style, and wrapping at cluster boundaries (no dictionary word segmentation). The craft-fonts pin for CI and releases moves to the revision with the Thai face. Forms and Fill & Sign are outside this change. Estimates unchanged.
- **2026-10-11 (#995, #1077, permissions password after opening):** A PDF with only a permissions password opens without asking, restricted, and nothing let the user enter that password afterwards: the notice and the hint pointed at reopening with it. Now the security notice and Properties ▸ Security offer Unlock…, and Change settings / Remove security on a restricted document ask for the permissions password first and then carry on (as Acrobat does). The open password is refused there. `Session::unlock` upgrades the open document in place (owner and user passwords unlock the same file key, so nothing is re-read), unlocks the undo and redo states and reopens saves as owner; it is not an edit and doesn't mark the document unsaved. New command `protect.unlock`; the new strings are in all 15 full catalogs. Tests cover crypt, cos, engine (undo, redo, save, separate open password) and the UI. Still missing: an automation tool for it. Estimates unchanged.
- **2026-10-11 (M14, #307 Plan 3):** Engine open and full refresh use one page/content traversal to populate marks and added content. The independent public scans remain available; the combined scanner borrows direct objects and holds referenced objects through `Arc<Object>` instead of cloning scan-only `/Contents` arrays or `/PCAdded` dictionaries. Differential tests cover an indirect `/PCAdded` dictionary as well as ordering, malformed/direct/indirect/repeated entries and `/UserUnit`. Exact `bbbe600`→final release CLI measurements used 10 alternating pairs: wall-time medians were 183.645→174.440 ms on 10,000 marker-free pages (10/10 wins), 33.587→32.752 ms on 1,500 mixed pages (8/10), and 24.331→20.133 ms on the 100,000-entry array stress fixture (10/10). The stress fixture's maximum-RSS median fell 28,655,616→19,038,208 B; peak-footprint medians were 14,747,136→14,673,408 B. From P0, the follow-up stress run was 20.886→20.201 ms (10/10), with RSS 23,953,408→19,087,360 B and peak footprint 14,706,144→14,697,952 B. `doc_open` stdout hashes matched every pair. The ancillary `organize` binding preserves the prior `prune_legacy` update condition. Estimates unchanged.
- **2026-10-11 (M4, Organize overview):** Opening Organize pages from All tools, Home, the tool palette or `ui.set tool=organize` opens the thumbnail grid directly. Checkboxes toggle individual pages without modifier keys; page controls rotate either way or delete one page, while the toolbar acts on the selection. Portrait and landscape previews retain their aspect ratio. UI and control regressions cover entry, selection, single-page versus batch actions, undo, permissions and retaining the last page. Milestone and effort estimates unchanged.
@ -162,6 +164,8 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-11 (M14, #307, Plan 2):** Forms now return terminal fields and the page-widget adoption count from one enumeration, and engine open reuses that result while retaining XFA script-button marking. The 12,000-page/no-form PDF (1,442,052 bytes) and 2,500-orphan-widget PDF (673,432 bytes) produce byte-identical `doc_open`/`doc_info` outputs against upstream `33c14e07cfd742a82bf03baa6b103c01008fa68c`, including the orphan repair note. After one warm-up per binary and three interleaved base/candidate rounds, median open times were 0.16/0.15 s and 0.08/0.07 s, with median peak RSS 62.69/59.98 MiB and 33.28/32.20 MiB. Workspace tests, Clippy, formatting, focused forms/engine/organize tests, and the WASM check pass. A behavior-preserving local binding in `organize` also lets the workspace Clippy gate pass. No estimate change.
- **2026-10-11 (M14, Traditional Chinese semantics):** Corrected accessibility pass/fail labels, the inverted script-accessibility rule, Taiwan row/column terminology and form choice/read-only labels; added the missing table-row explanation. Catalog regressions cover the rule metadata and preserve field names. Milestone and effort estimates unchanged.
- **2026-10-11 (M14, no silent `?`):** Text PdfCraft's fonts can't draw (Chinese, Greek, Czech's ř in Helvetica) is never written into a PDF as `?` without a word. Form values and button captions, text boxes, callouts, Fill & Sign text, measurement labels and dynamic stamps' by-lines are kept as typed, as Acrobat keeps them: the appearance draws `?` there, the annotation is marked (`PCUndrawn`), and the edit says so (the interface shows a translated notice; tools return `warnings`). A form also sets `/NeedAppearances`, so other viewers redraw the field from its value (not in a PDF/A document, which forbids it); a restyle doesn't replace a comment another program drew with those characters. Where `?` would be permanent, the edit is refused with the character and what to do instead: flattening what PdfCraft drew with `?`, redaction overlay text (also checked before anything is redacted), typed signatures through `fill_sign_add`, visible signature details, and new documents from text (with the line). Reports PdfCraft writes from documents (comment summaries, compare reports) write such characters as `[U+XXXX]` and say so; a dynamic XFA form's own text is laid out and reported. One end-to-end test drives every path through the tools. Not yet: drawing those characters (embedding a font that has them). Overall estimate unchanged.
- **2026-10-10 (M14, interface size):** Preferences ▸ Interface size scales the whole interface (50–250 %, setting `ui_scale`, control option `ui-scale`); 50 % brings an interface the system draws at 200 % back to 100 % (#456). Auto, the default, follows the desktop's text scaling: on Linux and the BSDs the desktop app reads GNOME's `text-scaling-factor` once at launch, so with Large Text (1.25) on a 1× display the interface no longer draws 13 px labels that show their pixels next to the system's text. The size is egui's zoom factor, so `pixels_per_point` grows with it and page rasters stay one texel per screen pixel; egui's own ⌘+/⌘− interface zoom is turned off so those keys stay page zoom. Still missing: KDE's font DPI and Windows' "Make text bigger" aren't read (both scale only text there), and egui's text is still greyscale-antialiased without the system's gamma, so small text looks rougher than GTK's at 100 %.
- **2026-10-10 (M14, #496):** Shortcut displays share a UI formatter across toolbar search, registry menus, command palette, Help and tooltips. Modifier and key names use a dedicated catalog context in every bundled translation; missing entries keep the English key name rather than a command translation. German shows Strg/Umschalt/Entf, macOS retains its symbols, and runtime language changes refresh the display. Regression coverage checks localized layout widths, English/German switching, platform conventions and unchanged command execution. Milestone and effort estimates unchanged.
@ -210,6 +214,7 @@ Newest first: the date, what moved, and the new overall estimate.
- **2026-10-09 (M5.7, #434 partial):** Natural-size image stamps placed at a click or through `stamp_custom` remain upright on rotated pages, with a displayed-space-sized rectangle and a counterrotated standard appearance matrix. Move, resize, restyle and undo/redo preserve that orientation. Native and external Poppler save/reopen rendering cover all four quarter-turns; UI tests verify the displayed bounds. Explicit rectangle placement, image signatures, PDF-page stamps and image-file orientation metadata remain outside this repair. #434 stays open; milestone table and effort estimates remain unchanged.
- **2026-10-09 (M1, raw-stream decoding limits):** COS checks unfiltered decoded data against the caller and per-stream limits before copying it, including empty filter arrays and streams whose Crypt filter was applied on load. Strict decoding uses the same raw-data guard. Tiny synthetic regressions cover exceeded, exact and zero-byte limits while retaining the filtered decoder behavior. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (community, M7, Hebrew in Edit text):** Editing existing Hebrew text showed it reversed (`בדיוק` read back letter-reversed) and wrote replacement boxes on save. Content streams draw right-to-left lines in visual order; Edit text now reads lines and paragraphs in typing order and writes them back in drawing order (`pdfcraft-edit::bidi`, UAX #9 through `unicode-bidi`, with numbers, Latin and mirrored brackets; where visual order allows several readings, the one that draws back exactly and pairs its brackets wins). Right-to-left paragraphs are right-aligned unless an alignment is chosen, and a single line keeps its right edge as it grows. Letters the page's font lacks are drawn with a craft-fonts `Hebr` face (Noto Sans Hebrew, storytold/craft-fonts PR) as Type 3 glyphs with ToUnicode, so the result is searchable; the interface uses that face too, and the web build keeps it. Without it, a clear error and no change. `edit.rtl-cjk-editing` moves to partial. Not yet: niqqud in replacement text (refused, not misdrawn), Arabic in Edit text (shaping; Add text has it since #403), typing right to left in the box (#437), lines drawn word by word from the right; the release craft-fonts pin must move to a revision with the Hebrew face. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, bounded page labels):** Inspector number trees have shared work, depth, and cycle limits; prefixes are bounded before decoding and complete custom labels have per-label and aggregate UTF-8 byte caps. A refused tree leaves physical page numbers intact and records one warning, with custom labels applied only after every range succeeds. Pure COS Roman and repeated-letter formatters are shared with Organize; label queries and edits return errors for unsupported output before changing the stored tree, while ordinary labels and prefix-only ranges remain supported. Synthetic tests cover safe just-over-limit cases, large first numbers, aliases, cycles, wide/deep trees, UTF-16/UTF-8 boundaries, aggregate output, and unchanged refused edits. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M4, page grid zoom):** The Organize page grid zooms from 50% to 300%: toolbar buttons with a percentage that resets, trackpad pinch or Ctrl/⌘ + wheel about the page under the pointer, and ⌘+ / ⌘− / ⌘0. Pages drawn larger than their thumbnails are rendered at the size drawn while in view, so text stays sharp in documents of any length. Not yet: a slider, remembering the size, a size control for the Pages panel. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M7, grouped artwork):** Edit text/images selects page-drawn Form XObjects as whole figures, including nested raster/vector/text content. Their BBox and optional Matrix determine the selection; move/resize/rotate/flip/delete rewrite only the selected page placement, preserving shared Forms and other placements. Engine, automation and UI core regressions cover geometry, rendered movement, save/reopen, undo and selection/drag/Delete. Bitmap replacement/extraction remain raster-only; inline images and individual elements inside a Form are not editable through this tool. Overall estimate unchanged (about 30–35%).

View File

@ -340,7 +340,7 @@ fn text(args: &[String]) -> Result<(), CliError> {
};
let mut failed: Vec<usize> = Vec::new();
for (n, p) in pages.iter().enumerate() {
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: *p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
if let Some(e) = out.error {
let _ = writeln!(std::io::stderr().lock(), "page {}: {e}", p + 1);
failed.push(p + 1);
@ -503,7 +503,7 @@ fn render(args: &[String]) -> Result<(), CliError> {
read(path)?,
RenderConfig { password: flag(args, "--password").map(Arc::from), reject_oversize: true, ..Default::default() },
);
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
let p = r.render(RenderRequest { page: page_index, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = p.error {
return Err(e.into());
}
@ -541,8 +541,8 @@ fn check_one(args: &[String]) -> Result<(), CliError> {
let mut r = PageRenderer::new(bytes, RenderConfig::default());
let mut failed = Vec::new();
for i in 0..info.pages.len().min(500) {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0 });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 }).error {
let p = r.render(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: dpi / 72.0, tag: 0, preview: false });
if let Some(e) = r.render(RenderRequest { page: i, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false }).error {
failed.push(format!("p{} text: {e}", i + 1));
}
if let Some(e) = p.error {

View File

@ -10,7 +10,14 @@ Comments (annotations), ISO 32000-2 §12.5. Layer L3, depends on `pdfcraft-cos`,
- **Appearance streams** (`appearance::build`) generated from the annotation dictionary, so new
and restyled comments look the same in every viewer. Highlights multiply (`/BM /Multiply`).
Note icons are PdfCraft's own drawings. Text boxes use standard Helvetica (WinAnsi), with
line breaking by approximate Helvetica proportions (no font program is bundled).
line breaking by approximate Helvetica proportions (no font program is bundled). Text it can't
draw (in a text box, callout, Fill & Sign text, measurement label or dynamic stamp's by-line;
`appearance::undrawable_text`) is kept as typed and drawn as `?`, and the comment is marked with
the first such character (`pdfcraft_fonts::UNDRAWN_MARK`) so the engine reports the edit and
flattening refuses to make it permanent. Two refusals (`AnnotError::Undrawable`): a redaction
mark's overlay text, which becomes page content when applied; and a restyle or resize of a
comment another program drew with a font that has those characters, whose appearance is kept
rather than redrawn with `?` (new text for it is taken).
- **Edits:** reply (`/IRT`), set review status (a `/State` reply, as Acrobat's "Set status"
does), change text, move, resize, restyle, delete (with pop-up and replies).

View File

@ -11,8 +11,8 @@
//! Helvetica's proportions by character class (no font program or metrics file is bundled).
use pdfcraft_cos::{Dict, Object, PdfString, Stream};
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
use pdfcraft_fonts::{literal, win_ansi};
use crate::{NOTE_SIZE, Rgb, n};
@ -264,7 +264,27 @@ pub(crate) fn picture_turn(d: &Dict) -> i64 {
d.get(b"PCPictureRotate").and_then(Object::as_int).unwrap_or(0).rem_euclid(360) / 90 * 90
}
/// The first text [`build`] would draw in the standard fonts (Helvetica, WinAnsiEncoding) that
/// they can't, which would come out as `?`: what it is (for the message) and the character. The
/// text of a text box, callout or Fill & Sign text, a measurement's label, a dynamic stamp's
/// "By …" line, and a redaction mark's overlay text (drawn when it is applied). `None` when all of
/// it can be drawn.
pub fn undrawable_text(d: &Dict) -> Option<(&'static str, char)> {
let text = |key: &[u8]| d.get(key).and_then(Object::as_string).map(PdfString::to_text);
let (what, text) = match d.name(b"Subtype")? {
b"FreeText" => ("the comment's text", text(b"Contents")?),
b"Line" | b"PolyLine" | b"Polygon" => ("the measurement label", text(b"PCMeasureValue")?),
b"Stamp" if matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) => ("the stamp's \"By\" line", text(b"PCByLine")?),
// Drawn in Helvetica when the redaction is applied: refused when it is marked.
b"Redact" => ("the redaction's overlay text", text(b"OverlayText")?),
_ => return None,
};
Some((what, first_undrawable(&text)?))
}
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
pub fn build(d: &Dict) -> Option<Stream> {
// Imported measurement appearances may include leaders, captions and formatting that
// this builder cannot reproduce. Preserve them rather than silently losing detail.

View File

@ -14,7 +14,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
pub mod appearance;
pub mod links;
@ -34,6 +34,11 @@ pub enum AnnotError {
Invalid(String),
#[error("{0} comments can't be restyled yet (their appearance can't be regenerated)")]
Unsupported(String),
/// Text the standard fonts can't draw where `?` would be permanent or replace a good drawing:
/// a redaction's overlay text, or an edit that would redraw a comment another program drew
/// with a font that has those characters. The message names the character.
#[error("{0}")]
Undrawable(String),
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@ -1202,8 +1207,16 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
// Survives a later subject edit, so flatten-on-save still finds the mark.
d.set(b"PCFillSign".to_vec(), Object::Bool(true));
}
// A redaction's overlay text becomes page content when it is applied, where `?` would be there
// for good: refused before anything is added. A comment's own text is stored as typed and
// drawn with `?` for what the standard fonts can't draw (marked, see `draw_appearance`).
if d.name(b"Subtype") == Some(b"Redact")
&& let Some((what, c)) = appearance::undrawable_text(&d)
{
return Err(AnnotError::Undrawable(pdfcraft_fonts::undrawable_message(what, c)));
}
let r = doc.add(Object::Dict(d.clone()));
set_appearance(doc, r)?;
draw_appearance(doc, r)?;
let mut list = annots(doc, page);
let index = list.len();
list.push(Object::Ref(r));
@ -1250,12 +1263,43 @@ pub fn orient_image_stamp(doc: &mut Document, page: usize, index: usize, rotatio
Ok(())
}
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary.
/// Regenerate an annotation's normal appearance from its dictionary.
/// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its
/// text (a restyle, a new size or colour).
///
/// A comment whose text the standard fonts can't draw, with an appearance another program drew
/// (with a font that has those characters), keeps that appearance: redrawing it would put `?` in
/// their place, so the edit is refused (`AnnotError::Undrawable`). One PdfCraft drew with `?`
/// (marked `pdfcraft_fonts::UNDRAWN_MARK`) is redrawn.
pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let drawn_elsewhere =
d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N"))) && pdfcraft_fonts::undrawn_mark(&d).is_none();
if drawn_elsewhere && let Some((what, c)) = appearance::undrawable_text(&d) {
return Err(AnnotError::Undrawable(format!(
"PdfCraft can't redraw this comment: the standard fonts can't draw \"{}\" (U+{:04X}) in {what}, which would then show as ? (the comment was left as it is)",
c.escape_debug(),
u32::from(c)
)));
}
draw_appearance(doc, r)
}
/// Draw `r`'s normal appearance from its dictionary, whatever its text: characters the standard
/// fonts can't draw come out as `?`, and the annotation is marked (`pdfcraft_fonts::UNDRAWN_MARK`,
/// cleared when all of it can be drawn) so the edit is reported and flattening won't make that
/// `?` permanent. For new text (a new comment, changed contents), as Acrobat keeps text its font
/// can't show, and for display-only copies.
fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
let d = annot_dict(doc, r);
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
let Some(mut stream) = appearance::build(&d) else { return Err(AnnotError::Unsupported(subtype)) };
let Some(stream) = build_appearance(doc, &d) else { return Err(AnnotError::Unsupported(subtype)) };
apply_appearance(doc, r, &d, stream)
}
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
let mut stream = appearance::build(d)?;
// Image stamp restyling must retain the placement's page-axis correction.
if matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))) {
let matrix = d.get(b"AP").and_then(|ap| {
@ -1268,6 +1312,13 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
stream.dict.set(b"Matrix".to_vec(), matrix);
}
}
Some(stream)
}
/// Store a built appearance on the annotation: the cheap half of [`set_appearance`].
fn apply_appearance(doc: &mut Document, r: ObjRef, d: &Dict, stream: Stream) -> Result<(), AnnotError> {
// Characters the standard fonts can't draw came out as `?`: mark the annotation with the first.
let missing = appearance::undrawable_text(d).map(|(_, c)| c);
let ap = doc.add(Object::Stream(stream));
// A copy (a shared /AP is left alone) that keeps unknown entries; the old down and rollover
// appearances would show the previous look on press or hover, so they go with the old /N.
@ -1278,16 +1329,80 @@ pub fn set_appearance(doc: &mut Document, r: ObjRef) -> Result<(), AnnotError> {
doc.update_dict(r, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
Ok(())
}
/// Display appearances built for annotations without any, reused while the annotation
/// dictionaries they were drawn from are unchanged.
///
/// A content edit (moving a paragraph, retyping text, …) leaves every annotation dictionary
/// alone, but the display copy is rebuilt from the edited document on every refresh: without
/// this cache each refresh redrew every appearance (27k on one CAD file, ~0.7 s per edit
/// commit). Entries are keyed by annotation reference; object numbers are never reused within
/// a document lineage, so an entry stays valid until an annotation edit runs. Callers clear
/// the cache on any refresh that may change annotations (comments, structural and full
/// refreshes) and keep it for content-only ones.
#[derive(Clone, Debug, Default)]
pub struct AppearanceCache {
entries: std::collections::HashMap<ObjRef, Stream>,
bytes: usize,
hits: u64,
builds: u64,
}
impl AppearanceCache {
/// Cached appearance data restarts past this instead of growing without bound.
const MAX_BYTES: usize = 64 << 20;
/// Forget every entry (an annotation edit may have changed the dictionaries).
pub fn clear(&mut self) {
*self = Self::default();
}
fn get(&mut self, r: ObjRef) -> Option<Stream> {
let stream = self.entries.get(&r)?.clone();
self.hits += 1;
Some(stream)
}
fn insert(&mut self, r: ObjRef, stream: &Stream) {
self.builds += 1;
if self.entries.len() >= 1_000_000 {
self.clear();
}
if self.bytes.saturating_add(stream.raw.len()) <= Self::MAX_BYTES {
self.bytes += stream.raw.len();
self.entries.insert(r, stream.clone());
}
// Else over budget: keep serving the entries already cached instead of growing.
}
/// Cache hits so far (reused appearances); for tests.
pub fn hits(&self) -> u64 {
self.hits
}
/// Appearances built so far; for tests.
pub fn builds(&self) -> u64 {
self.builds
}
}
/// A copy of `doc` in which every annotation without a normal appearance (`/AP /N`) has the one
/// [`appearance::build`] draws from its dictionary, for displaying the document (`None` when no
/// annotation needs one). Many files carry comments without appearances (FreeText, Ink, notes,
/// stamps, …) that viewers draw from the dictionary. `doc` is not changed: saving writes the file
/// as it was. Links, form fields and pop-ups keep their own handling.
pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
with_missing_appearances_cached(doc, &mut AppearanceCache::default())
}
/// A copy of `doc` with [`AppearanceCache`] reuse across refreshes: annotations whose
/// dictionary is unchanged since the cached build keep their stored appearance instead of
/// redrawing it. See [`AppearanceCache`] for when entries stay valid.
pub fn with_missing_appearances_cached(doc: &Document, cache: &mut AppearanceCache) -> Option<Document> {
let mut copy: Option<Document> = None;
for page in page_refs(doc).ok()? {
for r in annots(doc, page).iter().filter_map(Object::as_ref) {
@ -1296,11 +1411,18 @@ pub fn with_missing_appearances(doc: &Document) -> Option<Document> {
continue;
}
let has_normal = d.get(b"AP").is_some_and(|ap| doc.resolve(ap).as_dict().is_some_and(|ap| ap.contains(b"N")));
if has_normal || appearance::build(&d).is_none() {
if has_normal {
continue;
}
// Fails only when `r` isn't a dictionary, which `build` above has just ruled out.
set_appearance(copy.get_or_insert_with(|| doc.clone()), r).ok();
if let Some(stream) = cache.get(r) {
// Fails only when `r` isn't a dictionary, which the cached build ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
continue;
}
let Some(stream) = build_appearance(doc, &d) else { continue };
cache.insert(r, &stream);
// Fails only when `r` isn't a dictionary, which the build just ruled out.
apply_appearance(copy.get_or_insert_with(|| doc.clone()), r, &d, stream).ok();
}
}
copy
@ -1407,10 +1529,12 @@ pub fn set_contents(doc: &mut Document, page: usize, index: usize, text: &str, m
})?;
if free_text {
// The box grows and shrinks with the text instead of clipping it. A locked box keeps its
// text editable (as in Acrobat), so the re-fit ignores the lock.
// text editable (as in Acrobat), so the re-fit ignores the lock. New text is drawn even
// where the standard fonts can't draw some of it (marked and reported, see
// `draw_appearance`).
match fitted_box(doc, r, text) {
Some(rect) => apply_text_box(doc, r, rect, meta)?,
None => set_appearance(doc, r)?,
Some(rect) => apply_text_box(doc, r, rect, meta, true)?,
None => draw_appearance(doc, r)?,
}
}
Ok(())
@ -1618,7 +1742,7 @@ pub fn set_rect(doc: &mut Document, page: usize, index: usize, rect: [f64; 4], m
if !matches!(subtype.as_str(), "Square" | "Circle" | "FreeText") {
return Err(AnnotError::Invalid(format!("{subtype} comments can't be resized")));
}
apply_text_box(doc, r, rect, meta)
apply_text_box(doc, r, rect, meta, false)
}
/// Scale a drawing's strokes into `rect`. The margin between the strokes and the old `/Rect` (half
@ -1668,8 +1792,9 @@ fn resize_ink(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Res
}
/// Store `rect` as a FreeText annotation's text box (or a square's/oval's rectangle): a callout's
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn.
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) -> Result<(), AnnotError> {
/// leader line re-attaches and `/Rect` grows to hold it, and the appearance is redrawn
/// (`new_text`: for changed contents, see [`set_appearance`] and `draw_appearance`).
fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, new_text: bool) -> Result<(), AnnotError> {
let rect = normalize(rect);
if !finite(&rect) || rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 {
return Err(AnnotError::Invalid("invalid rectangle (too small)".into()));
@ -1700,7 +1825,7 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta) ->
}
touch(d, meta);
})?;
set_appearance(doc, r)
if new_text { draw_appearance(doc, r) } else { set_appearance(doc, r) }
}
fn border_width_of(d: &Dict) -> f64 {

View File

@ -1158,3 +1158,113 @@ fn drawings_resize_by_scaling_their_strokes() {
// Too small to hold the stroke margin.
assert!(matches!(set_rect(&mut doc, 0, i, [100.0, 400.0, 101.0, 401.0], &meta("")), Err(AnnotError::Invalid(_))));
}
/// Comment text the standard fonts can't draw is kept, as Acrobat keeps it: a text box, Fill & Sign
/// text or a dynamic stamp's by-line is added with its text as typed, its appearance draws `?` for
/// those characters, and the comment is marked with the first one (cleared once all of it can be
/// drawn). A comment another program drew with a font that has those characters keeps that
/// appearance: a restyle is refused rather than replace it with `?`, while new text is taken.
#[test]
fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() {
let mut doc = fixture();
let mark = |doc: &Document, i: usize| pdfcraft_fonts::undrawn_mark(&list(doc, 0)[i]);
let mut boxed = new(0, Shape::TextBox { rect: [100.0, 600.0, 300.0, 650.0], font_size: 12.0 });
boxed.contents = "签名 Ada".into();
let t = add_annotation(&mut doc, &boxed, &meta("t")).unwrap();
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名 Ada");
assert!(ap_content(&doc, &list(&doc, 0)[t]).contains("(?? Ada)"), "{}", ap_content(&doc, &list(&doc, 0)[t]));
assert_eq!(mark(&doc, t), Some('签'));
let typed = Shape::Typewriter { rect: [100.0, 700.0, 200.0, 716.0], font_size: 11.0 };
let f = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&typed), shape: typed, contents: "张三".into(), author: "Ada".into() },
&meta("x"),
)
.unwrap();
assert_eq!(mark(&doc, f), Some('张'));
let stamp =
Shape::Stamp { rect: [100.0, 100.0, 260.0, 142.0], stamp: StampKind::DynApproved, by: Some("By 张三 at 2:14 pm, Oct 02, 2026".into()) };
let s = add_annotation(
&mut doc,
&NewAnnotation { page: 0, style: Style::default_for(&stamp), shape: stamp, contents: String::new(), author: "张三".into() },
&meta("s"),
)
.unwrap();
assert_eq!(mark(&doc, s), Some('张'));
// Our own `?` appearance is redrawn on a restyle; Western European text clears the mark.
set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap();
assert_eq!(mark(&doc, t), Some('签'));
set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap();
assert_eq!(mark(&doc, t), None);
set_contents(&mut doc, 0, t, "签名", &meta("")).unwrap();
assert_eq!((text(&list(&doc, 0)[t], b"Contents").as_str(), mark(&doc, t)), ("签名", Some('签')));
// A text box another program drew (with a font that has 欢): a restyle keeps that appearance.
let mut foreign = Dict::new();
foreign.set(b"Type".to_vec(), Object::name("Annot"));
foreign.set(b"Subtype".to_vec(), Object::name("FreeText"));
foreign.set(b"Rect".to_vec(), Object::Array([100.0, 400.0, 300.0, 430.0].iter().map(|v| Object::Real(*v)).collect()));
foreign.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
foreign.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let mut drawn = Dict::new();
drawn.set(b"Type".to_vec(), Object::name("XObject"));
drawn.set(b"Subtype".to_vec(), Object::name("Form"));
drawn.set(b"BBox".to_vec(), Object::Array([0.0, 0.0, 200.0, 30.0].iter().map(|v| Object::Real(*v)).collect()));
let drawn = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(drawn, b"BT /F1 12 Tf (\x01\x02) Tj ET".to_vec())));
let mut ap = Dict::new();
ap.set(b"N".to_vec(), Object::Ref(drawn));
foreign.set(b"AP".to_vec(), Object::Dict(ap));
let r = doc.add(Object::Dict(foreign));
let page = page_ref(&doc, 0).unwrap();
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(r));
set_annots(&mut doc, page, on_page).unwrap();
let k = list(&doc, 0).len() - 1;
let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap_err();
assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}");
assert_eq!(list(&doc, 0)[k].get(b"AP").and_then(|a| a.as_dict()).and_then(|a| a.reference(b"N")), Some(drawn), "its appearance is kept");
// New text from the user is taken (and marked), as for any comment.
set_contents(&mut doc, 0, k, "欢迎光临", &meta("")).unwrap();
assert_eq!(mark(&doc, k), Some('欢'));
// One without an appearance is shown in the display copy only; drawing it marks it.
let mut bare = Dict::new();
bare.set(b"Type".to_vec(), Object::name("Annot"));
bare.set(b"Subtype".to_vec(), Object::name("FreeText"));
bare.set(b"Rect".to_vec(), Object::Array([100.0, 300.0, 300.0, 330.0].iter().map(|v| Object::Real(*v)).collect()));
bare.set(b"DA".to_vec(), PdfString::literal(b"0 g /Helv 12 Tf".to_vec()));
bare.set(b"Contents".to_vec(), PdfString::text("欢迎"));
let b = doc.add(Object::Dict(bare));
let mut on_page = annots(&doc, page);
on_page.push(Object::Ref(b));
set_annots(&mut doc, page, on_page).unwrap();
let shown = with_missing_appearances(&doc).expect("a display copy");
assert!(shown.get(b).as_dict().unwrap().contains(b"AP"), "displayed anyway");
assert!(!doc.get(b).as_dict().unwrap().contains(b"AP"), "the document itself is unchanged");
set_appearance(&mut doc, b).unwrap();
assert_eq!(doc.get(b).as_dict().and_then(pdfcraft_fonts::undrawn_mark), Some('欢'));
// Kept through a save.
let doc = reopen(&doc);
assert_eq!(text(&list(&doc, 0)[t], b"Contents"), "签名");
}
/// Content edits rebuild the display copy on every commit but leave annotations alone: the
/// cached appearance is drawn once and reused after that, byte-identical.
#[test]
fn cached_appearances_are_reused_while_annotations_are_unchanged() {
let mut doc = fixture();
let idx = add_annotation(&mut doc, &new(0, Shape::Note { at: [100.0, 700.0], icon: NoteIcon::Comment }), &meta("cache")).expect("adds");
// Files carry comments without appearances; strip the created one to simulate that.
let page = page_refs(&doc).unwrap()[0];
let r = annots(&doc, page)[idx].as_ref().expect("indirect");
doc.update_dict(r, |d| {
d.remove(b"AP");
})
.expect("strips");
let mut cache = AppearanceCache::default();
let first = with_missing_appearances_cached(&doc, &mut cache).expect("builds");
assert_eq!((cache.builds(), cache.hits()), (1, 0), "only the note needs an appearance");
let second = with_missing_appearances_cached(&doc, &mut cache).expect("reuses");
assert_eq!((cache.builds(), cache.hits()), (1, 1), "nothing is redrawn");
let [a, b] = [&first, &second].map(|copy| ap_content(copy, &list(copy, 0)[idx]));
assert!(!a.is_empty());
assert_eq!(a, b, "the reused appearance is byte-identical");
}

View File

@ -24,6 +24,7 @@ default = ["mcp"]
mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-fonts = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }

View File

@ -501,6 +501,14 @@ impl Automation {
// A typed signature or initials in the script font, left edge at `at`, upright as displayed.
kind @ ("signature" | "initials") => {
let t = a.str("text")?;
// Nobody sees a preview here: refuse what the script font would leave out.
if let Some(c) = pdfcraft_engine::script_missing(t) {
return Err(ToolError::InvalidArgs(format!(
"the signature script font can't draw \"{}\" (U+{:04X}); type the {kind} in Latin, Cyrillic or Greek letters, or pass `path` to a PNG or JPEG image of it instead of `text`",
c.escape_debug(),
u32::from(c)
)));
}
let h = if kind == "initials" { 24.0 } else { 32.0 };
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()))?;

View File

@ -114,7 +114,7 @@ impl Automation {
return Err(failed("page has invalid dimensions"));
}
let scale = (max as f32 / side).min(1.0);
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0 });
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: 0, preview: false });
if let Some(error) = out.error {
return Err(failed(error));
}

View File

@ -165,7 +165,11 @@ impl Automation {
let out = match name {
"doc_open" => self.doc_open(&a)?,
"doc_list" => json!({ "documents": self.session.docs().iter().map(summary).collect::<Vec<_>>() }),
"doc_info" => info(self.doc(&a)?),
"doc_info" => {
let id = self.doc(&a)?.id;
self.session.refresh_fonts(id);
info(self.doc(&a)?)
}
"doc_close" => self.doc_close(&a)?,
"doc_save" => self.doc_save(&a)?,
"doc_set_info" => {
@ -965,7 +969,13 @@ impl Automation {
fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
let id = self.doc(a)?.id;
self.session.apply(id, edit).map_err(failed)?;
Ok(summary(self.doc(a)?))
let doc = self.doc(a)?;
let mut out = summary(doc);
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
if !doc.edit_notices.is_empty() {
out["warnings"] = json!(doc.edit_notices.iter().map(ToString::to_string).collect::<Vec<_>>());
}
Ok(out)
}
fn marks(&mut self, tool: &str, a: &Args) -> Result<Value> {
@ -1132,11 +1142,13 @@ impl Automation {
}
(None, None) => return Err(ToolError::InvalidArgs("text needs `text` or `path`".into())),
};
// The page font is Helvetica/WinAnsi: refuse what it would silently turn into `?`.
if let Some(c) = pdfcraft_engine::first_undrawable_plain(&text) {
return Err(ToolError::InvalidArgs(pdfcraft_engine::undrawable_message(c)));
}
(format!("{title}.pdf"), self.session.create_from_text(&title, &text).map_err(failed)?)
// The page font is Helvetica/WinAnsi: text it would turn into `?` is refused (with
// its line) by `pdfcraft_create::from_text`, as for every other caller.
let bytes = self.session.create_from_text(&title, &text).map_err(|e| match e {
pdfcraft_engine::EditError::Create(pdfcraft_engine::CreateError::Invalid(m)) => ToolError::InvalidArgs(m),
e => failed(e),
})?;
(format!("{title}.pdf"), bytes)
}
other => return Err(ToolError::InvalidArgs(format!("unknown source {other:?}"))),
};
@ -1540,7 +1552,14 @@ impl Automation {
if !(1.0..=MAX_DPI).contains(&dpi) {
return Err(ToolError::InvalidArgs(format!("dpi must be between 1 and {MAX_DPI}")));
}
let out = self.renderer(id)?.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (dpi / 72.0) as f32, tag: 0 });
let out = self.renderer(id)?.render(RenderRequest {
page,
kind: RequestKind::Pixels,
tile: None,
scale: (dpi / 72.0) as f32,
tag: 0,
preview: false,
});
if let Some(e) = out.error {
return Err(failed(format!("page {} could not be rendered: {e}", page + 1)));
}
@ -1990,7 +2009,7 @@ fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>>
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
fn extract_parallel(bytes: &Arc<Vec<u8>>, password: Option<Arc<str>>, pages: &[usize]) -> Vec<(usize, std::result::Result<PageText, String>)> {
let extract = |r: &mut PageRenderer, p: usize| {
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: p, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, preview: false });
match out.error {
Some(e) => Err(e),
None => Ok(out.text.map(|t| (*t).clone()).unwrap_or_default()),

View File

@ -214,6 +214,14 @@ impl Automation {
}
if let Some(text) = o.get("text") {
let text = text.as_str().ok_or_else(|| bad("graphic.text must be a string"))?;
// Nobody sees a preview here: refuse what the script font and its fallback would leave out.
if let Some(c) = pdfcraft_engine::script_missing(text) {
return Err(bad(format!(
"the signature script font can't draw \"{}\" (U+{:04X}) in graphic.text; type it in Latin, Cyrillic or Greek letters, or pass graphic.image instead",
c.escape_debug(),
u32::from(c)
)));
}
return pdfcraft_engine::typed_signature_graphic(text).ok_or_else(|| {
bad(format!(
"graphic.text {text:?} has nothing the script font can draw (at most {} characters)",

View File

@ -303,7 +303,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_fill",
"Fill in form fields",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values.",
"Set several fields at once (one undo step). values maps field names (from form_fields) to: a string for text fields, combo boxes and radio groups (an option), true/false for check boxes, an array of strings for multi-select lists. Appearances are regenerated so every viewer shows the values. A value whose characters the field's font can't draw (Chinese in a Helvetica field) is still saved; `warnings` in the result names the field and character: PdfCraft draws `?` there and asks other viewers to redraw the field from its value.",
)
.with(schema(json!({ "doc": doc(), "values": { "type": "object", "description": "Field name → value." } }), &["doc", "values"])),
t("form_reset", "Clear form", "Reset fields to their default values: all of them, or only those listed. Undoable.")
@ -600,7 +600,7 @@ pub fn tools() -> Vec<ToolDef> {
"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`. 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.",
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. Text a text box, callout or stamp draws in the standard fonts that they can't draw (Chinese, Greek) is saved as typed and drawn as `?`; `warnings` in the result says so.",
)
.with(schema(
json!({
@ -995,7 +995,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"fill_sign_add",
"Fill & Sign: type text or place a mark",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations.",
"Fill in a form that has no fields, as Acrobat's Fill & Sign does: type text (`text`, 10 pt), place a check, cross, dot or line, today's date (in Preferences ▸ Date format, see fill_sign_date_format, or `format`), or a signature or initials (either `text` drawn in a script font, or `path` to a local PNG/JPEG image up to 4 MiB and 32 megapixels, scaled down to 4 megapixels, preserving transparency). `at` [x, y] is in points from the top-left of the page: text's top-left, mark's centre, signature's left edge centred vertically. Image signatures fit within 150 pt wide and 32 pt tall (24 pt for initials). Creates movable, undoable annotations. Typed text the standard fonts can't draw is saved and drawn as `?` (`warnings` in the result says so); a typed signature neither the script font nor its fallback can draw is refused (pass `path` instead).",
)
.cmd("sign.fill.text")
.with(schema(

View File

@ -2399,7 +2399,7 @@ fn faux_redacted_fixture() -> Vec<u8> {
#[test]
fn auditing_faux_redactions_through_tools() {
let dir = workdir("audit-redactions");
let dir = workdir("faux-redaction-audit");
std::fs::write(dir.join("redacted.pdf"), faux_redacted_fixture()).unwrap();
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "redacted.pdf" }))["doc"].as_u64().unwrap();
@ -2921,6 +2921,58 @@ fn comment_checkmarks_locks_hiding_and_summaries_through_tools() {
assert!(matches!(a.call("comments_summarize", &json!({ "doc": doc, "sort": "colour" })), Err(ToolError::InvalidArgs(_))));
}
/// Text the standard fonts can't draw is never written as `?` silently, through the tools. A form
/// value, a button caption, a text box and Fill & Sign text are kept as typed (as Acrobat keeps
/// them) and the result's `warnings` names the character; a form also asks other viewers to
/// redraw it. Where the `?` would be permanent the tool refuses: a typed signature, a redaction's
/// overlay text, a new document from text, and flattening what PdfCraft drew with `?`. A comment
/// summary that quotes such text writes it as [U+XXXX].
#[test]
fn text_the_standard_fonts_cannot_draw_is_never_silent_through_tools() {
let dir = workdir("undrawable");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let refused = |a: &mut Automation, tool: &str, args: Value, ch: &str| match a.call(tool, &args) {
Err(ToolError::Failed(e) | ToolError::InvalidArgs(e)) => assert!(e.contains(ch), "{tool}: {e}"),
other => panic!("{tool}: expected a refusal, got {other:?}"),
};
let warned = |out: &Value, parts: &[&str]| {
let w = out["warnings"].as_array().unwrap_or_else(|| panic!("no warnings: {out}"));
assert!(w.iter().filter_map(Value::as_str).any(|m| parts.iter().all(|p| m.contains(p))), "{parts:?} not in {w:?}");
};
// Forms: a value is kept and the form asks other viewers to redraw it; a button caption too.
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "who" }));
let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "Ada" } }));
assert!(out.get("warnings").is_none(), "{out}");
let out = ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "who": "张三" } }));
warned(&out, &["form field \"who\"", "\"张\" (U+5F20)", "other PDF viewers redraw the field"]);
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
assert_eq!(fields["fields"][0]["value"], "张三", "{fields}");
let out = ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "button", "rect": [20, 60, 120, 82], "caption": "提交" }));
warned(&out, &["\"提\" (U+63D0)"]);
// Comments and Fill & Sign text: kept, and said.
let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "欢迎" }));
warned(&out, &["a comment on page 1", "\"欢\" (U+6B22)", "every viewer"]);
let out = ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "text", "at": [20, 300], "text": "签名" }));
warned(&out, &["U+7B7E"]);
// A sticky note draws an icon, not its text: nothing to say.
let out = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [40, 40], "contents": "签名 OK", "author": "Ada" }));
assert!(out.get("warnings").is_none(), "{out}");
// Permanent `?`: refused.
refused(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 340], "text": "张三" }), "pass `path`");
// Cyrillic comes from the script font's Inter fallback (#963), so it isn't refused.
ok(&mut a, "fill_sign_add", json!({ "doc": doc, "page": 1, "type": "signature", "at": [20, 380], "text": "Саша" }));
refused(&mut a, "redact_mark", json!({ "doc": doc, "page": 1, "rect": [10, 400, 190, 420], "overlay": "机密" }), "\"机\" (U+673A)");
refused(&mut a, "doc_flatten", json!({ "doc": doc }), "nothing was flattened");
refused(&mut a, "doc_create", json!({ "from": "text", "text": "Notes\n第二行" }), "(line 2)");
// A summary quotes the note: escaped, not refused, not `?`.
ok(&mut a, "comments_summarize", json!({ "doc": doc, "out": "summary.pdf" }));
let summary = ok(&mut a, "doc_open", json!({ "path": "summary.pdf" }))["doc"].as_u64().unwrap();
let text = page_text(&mut a, summary).join("\n");
assert!(text.contains("[U+7B7E][U+540D] OK") && text.contains("[U+XXXX]"), "{text}");
assert!(!text.contains("?? OK"), "{text}");
}
#[test]
fn line_endings_through_the_tool() {
let dir = workdir("endings");
@ -3061,6 +3113,7 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
(json!("logo.png"), "not an object"),
(json!({ "image": "broken.png" }), "not an image"),
(json!({ "text": " " }), "nothing to draw"),
(json!({ "text": "张三" }), "a name the script font and its fallback can't draw"),
(json!({ "strokes": [[[0, 0], ["x", 1]]] }), "malformed strokes"),
(json!({ "strokes": [] }), "empty strokes"),
] {
@ -4054,6 +4107,30 @@ fn command_batch_refuses_more_than_a_thousand_steps() {
assert!(err.to_string().contains("at most 1000 steps"), "{err}");
}
#[test]
fn thai_text_through_tools_saves_reopens_extracts_and_renders() {
let dir = workdir("thai-content");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
let text = "สวัสดี น้ำ ผู้ใช้ ปู่ ๑๒๓ Hello 123";
let args = json!({"doc": doc, "page": 1, "text": text, "at": [10, 20], "size": 9});
if pdfcraft_fonts::document_thai_font().is_none() {
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("Thai"));
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
return;
}
ok(&mut a, "page_add_text", args);
assert!(page_text(&mut a, doc)[0].contains(text), "{:?}", page_text(&mut a, doc));
ok(&mut a, "doc_save", json!({"doc": doc, "path": "thai.pdf"}));
let reopened = ok(&mut a, "doc_open", json!({"path": "thai.pdf"}))["doc"].as_u64().unwrap();
assert_eq!(ok(&mut a, "content_list", json!({"doc": reopened}))["items"][0]["text"], text);
assert!(page_text(&mut a, reopened)[0].contains(text));
let rendered = a.call("page_render", &json!({"doc": reopened, "page": 1, "dpi": 144})).unwrap();
assert!(rendered.iter().any(|c| matches!(c, Content::Png { data, width, height } if !data.is_empty() && *width > 0 && *height > 0)));
ok(&mut a, "content_update", json!({"doc": reopened, "page": 1, "index": 1, "text": "แก้ไข น้ำ"}));
assert!(page_text(&mut a, reopened)[0].contains("แก้ไข น้ำ"));
}
#[test]
fn mixed_objects_move_through_tools_with_generation_and_one_undo() {
let dir = workdir("mixed-object-move");

View File

@ -16,6 +16,10 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
resolution (1–1200 dpi), without resampling or recompressing image pixels. Use 72 dpi
for one point per pixel, or `ImageResolution::Embedded` for the same behavior as
`from_images`. The largest page side is still capped at 14,400 points;
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated.
- `from_text(text, …)`: plain text set in Helvetica, wrapped and paginated. Text Helvetica
can't draw is refused, naming the line and the character, never set as `?`.
- `from_text_escaped(text, …)`: the same for reports PdfCraft generates from documents
(comment summaries, compare reports): such characters are written as `[U+XXXX]` and the first
line says so (`ESCAPED_NOTE`).
Every function returns a `pdfcraft_cos::Document`; the caller writes it.

View File

@ -618,8 +618,50 @@ fn turkish_code_page(bytes: &[u8]) -> bool {
&& !bytes.iter().any(|b| matches!(b, 0xC1 | 0xCD | 0xD3 | 0xDA | 0xE1 | 0xED | 0xF3 | 0xFA))
}
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins.
/// Plain text set in Helvetica on pages of `page` size with 1-inch margins. Text Helvetica can't
/// draw (outside Western European) is refused with [`CreateError::Invalid`] naming the line and
/// the character; text PdfCraft generates goes through [`from_text_escaped`] instead.
pub fn from_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
// Helvetica in WinAnsiEncoding: text it can't draw is refused with where it is, not set as `?`.
if let Some((line, c)) = first_undrawable_line(text) {
return Err(CreateError::Invalid(pdfcraft_fonts::undrawable_message(&format!("the text (line {line})"), c)));
}
set_text(title, text, page, font_size)
}
/// The line (1-based) and character of the first one Helvetica can't draw. Line breaks, tabs and
/// form feeds (new pages) are layout, not drawn.
fn first_undrawable_line(text: &str) -> Option<(usize, char)> {
text.split('\n').enumerate().find_map(|(i, l)| pdfcraft_fonts::first_undrawable(&l.replace('\u{c}', "")).map(|c| (i + 1, c)))
}
/// What a PDF made from text PdfCraft generated says, in its first line, about characters it
/// wrote as `[U+XXXX]`.
pub const ESCAPED_NOTE: &str = "Characters Helvetica can't draw are written as [U+XXXX] code points.";
/// [`from_text`] for text PdfCraft generates from documents (a comment summary, a compare
/// report), which may quote any language. Each character Helvetica can't draw is written as
/// `[U+XXXX]` instead of being refused or set as `?`, so nothing is lost, and the first line says
/// so ([`ESCAPED_NOTE`]). Returns the document and how many characters were written that way.
pub fn from_text_escaped(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<(Document, usize), CreateError> {
let mut escaped = 0usize;
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if matches!(c, '\n' | '\r' | '\u{c}') || pdfcraft_fonts::first_undrawable(c.encode_utf8(&mut [0; 4])).is_none() {
out.push(c);
} else {
escaped = escaped.saturating_add(1);
out.push_str(&format!("[U+{:04X}]", u32::from(c)));
}
}
if escaped > 0 {
out = format!("{ESCAPED_NOTE}\n\n{out}");
}
// The title is a text string in /Info, which holds any character.
Ok((set_text(title, &out, page, font_size)?, escaped))
}
fn set_text(title: &str, text: &str, page: (f64, f64), font_size: f64) -> Result<Document, CreateError> {
let size = if font_size.is_finite() && font_size > 0.0 { font_size.clamp(4.0, 72.0) } else { 11.0 };
let (w, h) = page;
let margin = 72.0;

View File

@ -124,6 +124,32 @@ fn text_is_wrapped_and_paginated() {
assert!(content(p.last().unwrap()).contains("(After a form feed) Tj"), "a form feed starts a page");
}
/// Text Helvetica can't draw is refused with its line and character, never set as `?`; text
/// PdfCraft generates writes such characters as [U+XXXX] and says so.
#[test]
fn text_helvetica_cannot_draw_is_refused_or_escaped() {
let err = from_text("notes", "Café —\tok\r\n\u{c}第二页 notes", LETTER, 11.0).unwrap_err();
let CreateError::Invalid(msg) = &err else { panic!("{err:?}") };
assert!(msg.contains("(line 2)") && msg.contains("\"第\" (U+7B2C)") && msg.contains("Western European"), "{msg}");
// Western European text, tabs, CRLF and form feeds included, still converts.
from_text("notes", "Café —\tok\r\n\u{c}Seite zwei: Grüße", LETTER, 11.0).unwrap();
let (doc, escaped) = from_text_escaped("Summary of Comments on 报告.pdf", "Comment: 签名 ok\nPage 2", LETTER, 11.0).unwrap();
assert_eq!(escaped, 2);
let doc = reopen(&doc);
let p = pages(&doc);
let c = doc.resolve(p[0].get(b"Contents").unwrap());
let Object::Stream(s) = &*c else { panic!() };
let shown = String::from_utf8_lossy(&s.decoded().unwrap()).into_owned();
assert!(shown.contains("(Comment: [U+7B7E][U+540D] ok) Tj") && shown.contains(&format!("({ESCAPED_NOTE}) Tj")), "{shown}");
assert!(!shown.contains('?'), "{shown}");
// Nothing to escape: no note.
let (doc, escaped) = from_text_escaped("r", "Plain report", LETTER, 11.0).unwrap();
assert_eq!(escaped, 0);
let p = pages(&doc);
let Object::Stream(s) = &*doc.resolve(p[0].get(b"Contents").unwrap()) else { panic!() };
assert!(!String::from_utf8_lossy(&s.decoded().unwrap()).contains("U+XXXX"));
}
fn image_of(doc: &Document, page: usize) -> Dict {
let p = &pages(doc)[page];
let res = doc.resolve(p.get(b"Resources").unwrap()).as_dict().cloned().unwrap();

View File

@ -15,7 +15,8 @@ pdfcraft-content = { workspace = true }
pdfcraft-fonts = { workspace = true }
pdfcraft-model = { workspace = true }
thiserror = { workspace = true }
# Display order of right-to-left lines in added text; already in the tree via the interface.
# Display order of right-to-left lines in added text, and reading and writing order in Edit text;
# already in the tree via the interface.
unicode-bidi = "0.3"
[dev-dependencies]

View File

@ -11,7 +11,9 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use pdfcraft_fonts::{GlyphError, GlyphOutline, ShapedCluster, arabic_glyph, helvetica_width, literal, shape_arabic, win_ansi};
use pdfcraft_fonts::{
GlyphError, GlyphOutline, ShapedCluster, ShapedScript, helvetica_width, literal, shape_arabic, shape_thai, shaped_glyph, win_ansi,
};
use unicode_bidi::{Level, ParagraphBidiInfo};
use crate::{EditError, MarkKind, check, contents, n, page_list, place_tagged};
@ -225,17 +227,26 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
let mut out = Vec::new();
for para in t.text.split('\n') {
let arabic = para.chars().any(is_arabic);
let thai = para.chars().any(is_thai);
if thai
&& !arabic
&& let Ok(lines) = thai_lines(t, para, width)
{
out.extend(lines.into_iter().map(|line| (line, false)));
continue;
}
let rtl = arabic && unicode_bidi::get_base_direction(para) == unicode_bidi::Direction::Rtl;
// Arabic words are shaped one by one (joining stops at spaces), so a line's width is the
// sum of its words' and shaping stays linear in the paragraph's length. The space is the
// paragraph's; a space between Latin words is drawn in the item's font, a few hundredths
// of an em apart.
let measure = |s: &str| if arabic { arabic_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let measure = |s: &str| if arabic || thai { shaped_width(t, s, rtl) } else { t.family.width(s, t.size, t.bold) };
let space = measure(" ");
let (mut line, mut line_w) = (String::new(), 0.0);
for word in para.split(' ') {
let candidate = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
let candidate_w = if arabic { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
let candidate_w =
if arabic || thai { if line.is_empty() { measure(word) } else { line_w + space + measure(word) } } else { measure(&candidate) };
if !line.is_empty() && candidate_w > width {
out.push((std::mem::take(&mut line), rtl));
line = word.to_string();
@ -250,6 +261,39 @@ fn wrapped(t: &AddedText) -> Vec<(String, bool)> {
out
}
/// Thai wrapping at shaped cluster boundaries never detaches a tone/vowel mark from its base.
/// This is glyph-safe wrapping, not dictionary-based Thai word segmentation.
fn thai_lines(t: &AddedText, para: &str, width: f64) -> Result<Vec<String>, EditError> {
let (pieces, _) = shaped_layout(t, para, false)?;
let mut atoms = Vec::new();
for piece in pieces {
match piece {
Piece::Shaped(clusters) => atoms.extend(clusters.into_iter().map(|c| (c.text, c.advance * t.size))),
Piece::Latin(s) => atoms.extend(s.chars().map(|c| {
let text = c.to_string();
let w = t.family.width(&text, t.size, t.bold);
(text, w)
})),
}
}
let mut lines = Vec::new();
let (mut line, mut used) = (String::new(), 0.0);
for (text, advance) in atoms {
if !line.is_empty() && used + advance > width {
lines.push(std::mem::take(&mut line));
used = 0.0;
}
line.push_str(&text);
used += advance;
}
lines.push(line);
Ok(lines)
}
fn is_thai(c: char) -> bool {
matches!(u32::from(c), 0x0E00..=0x0E7F)
}
/// Arabic letters, marks, digits and punctuation: an item with any of them is shaped with the
/// craft-fonts Arabic face.
fn is_arabic(c: char) -> bool {
@ -260,6 +304,13 @@ fn is_arabic(c: char) -> bool {
/// 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> {
if t.text.chars().any(is_thai) {
if let Some(c) = t.text.chars().find(|c| is_thai(*c) && !pdfcraft_fonts::thai_has(*c)) {
return Some(c);
}
let rest = AddedText { text: t.text.chars().filter(|c| !is_thai(*c)).collect(), ..t.clone() };
return first_undrawable(&rest);
}
// 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);
@ -269,12 +320,12 @@ pub fn first_undrawable(t: &AddedText) -> Option<char> {
// 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.
// What `shaped_layout` leaves to the standard font, exactly as `draw_shaped` splits it.
wrapped(t).into_iter().find_map(|(line, rtl)| {
let (pieces, _) = arabic_layout(t, &line, rtl).ok()?;
let (pieces, _) = shaped_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,
Piece::Shaped(_) => None,
})
})
}
@ -305,16 +356,16 @@ fn mirrored(c: char) -> char {
}
}
/// A piece of a line with Arabic text, in drawing order.
/// A piece of a line with shaped text, in drawing order.
enum Piece {
/// Shown in the item's own standard font.
Latin(String),
Arabic(Vec<ShapedCluster>),
Shaped(Vec<ShapedCluster>),
}
/// A line of an Arabic item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// A line of a shaped-text item in drawing order (the Unicode bidirectional algorithm, UAX #9), and
/// its width at the item's size. `rtl` is its paragraph's direction.
fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
fn shaped_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f64), EditError> {
// unicode-bidi indexes the first level of a line, so an empty one would panic.
if line.is_empty() {
return Ok((Vec::new(), 0.0));
@ -329,20 +380,30 @@ fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
let Some(text) = line.get(run) else { continue };
// Arabic letters take the Arabic face; so do spaces and punctuation between them, which the
// shaper then mirrors. Latin text (and what the face lacks) keeps the item's font.
let mut segments: Vec<(bool, String)> = Vec::new();
let mut segments: Vec<(Option<ShapedScript>, String)> = Vec::new();
for c in text.chars().filter(|c| !is_bidi_control(*c)) {
let arabic = is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c));
let script = if is_thai(c) {
Some(ShapedScript::Thai)
} else if is_arabic(c) || (run_rtl && !c.is_alphanumeric() && pdfcraft_fonts::arabic_has(c)) {
Some(ShapedScript::Arabic)
} else {
None
};
match segments.last_mut() {
Some((kind, s)) if *kind == arabic => s.push(c),
_ => segments.push((arabic, c.to_string())),
Some((kind, s)) if *kind == script => s.push(c),
_ => segments.push((script, c.to_string())),
}
}
if run_rtl {
segments.reverse();
}
for (arabic, s) in segments {
pieces.push(if arabic {
Piece::Arabic(shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?)
for (script, s) in segments {
pieces.push(if let Some(script) = script {
let shaped = match script {
ShapedScript::Arabic => shape_arabic(&s, run_rtl).map_err(|e| arabic_error(e, &s))?,
ShapedScript::Thai => shape_thai(&s).map_err(|e| thai_error(e, &s))?,
};
Piece::Shaped(shaped)
} else if run_rtl {
Piece::Latin(s.chars().rev().map(mirrored).collect())
} else {
@ -354,16 +415,16 @@ fn arabic_layout(t: &AddedText, line: &str, rtl: bool) -> Result<(Vec<Piece>, f6
.iter()
.map(|p| match p {
Piece::Latin(s) => t.family.width(s, t.size, t.bold),
Piece::Arabic(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
Piece::Shaped(clusters) => clusters.iter().map(|c| c.advance).sum::<f64>() * t.size,
})
.sum();
Ok((pieces, width))
}
/// The width of `s` in an Arabic item; the item's font's estimate if it can't be shaped (drawing
/// The width of `s` in a shaped-text item; the item's font's estimate if it can't be shaped (drawing
/// then reports why).
fn arabic_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
arabic_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
fn shaped_width(t: &AddedText, s: &str, rtl: bool) -> f64 {
shaped_layout(t, s, rtl).map(|(_, w)| w).unwrap_or_else(|_| t.family.width(s, t.size, t.bold))
}
fn arabic_error(e: GlyphError, text: &str) -> EditError {
@ -376,6 +437,16 @@ fn arabic_error(e: GlyphError, text: &str) -> EditError {
})
}
fn thai_error(e: GlyphError, text: &str) -> EditError {
EditError::Invalid(match e {
GlyphError::NoFont => {
"Thai text needs a Thai font in this build; rebuild with CRAFT_FONTS_DIR pointing to craft-fonts with a Thai face".into()
}
GlyphError::Missing => format!("the Thai font can't show \"{text}\""),
GlyphError::TooComplex => "the Thai glyphs are too complex; use a shorter text box".into(),
})
}
/// The box a text item occupies (height from its lines).
pub fn text_rect(t: &AddedText) -> [f64; 4] {
let r = norm(t.rect);
@ -391,31 +462,31 @@ fn font_name(base: &str) -> String {
/// per 240 distinct glyphs it needs.
const GLYPHS_PER_FONT: usize = 240;
/// A line of an Arabic item after layout: its text, whether its paragraph runs right to left, its
/// pieces in drawing order and its width at the item's size.
/// A line of a shaped-text item after layout: its text, whether its paragraph runs right to left,
/// its pieces in drawing order and its width at the item's size.
type LaidLine = (String, bool, Vec<Piece>, f64);
/// Draw a text item that has Arabic in it. Each line is laid out in display order; Arabic glyphs
/// Draw a text item that has Arabic or Thai in it. Each line is laid out in display order; Arabic glyphs
/// come from Type 3 fonts drawn from the craft-fonts face's outlines (no font file is embedded),
/// with a ToUnicode map for copy and search. Other text uses the item's standard font.
fn draw_arabic(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
fn draw_shaped(doc: &mut Document, t: &AddedText, taken: &Dict, fonts: &mut Dict, out: &mut Vec<u8>) -> Result<(), EditError> {
let laid = wrapped(t)
.into_iter()
.map(|(line, rtl)| arabic_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.map(|(line, rtl)| shaped_layout(t, &line, rtl).map(|(pieces, w)| (line, rtl, pieces, w)))
.collect::<Result<Vec<_>, _>>()?;
draw_laid_arabic(doc, t, &laid, taken, fonts, out, arabic_glyph)
draw_laid_shaped(doc, t, &laid, taken, fonts, out, shaped_glyph)
}
/// [`draw_arabic`] for lines that are already laid out. `outline` is the source of glyph outlines
/// (the craft-fonts face, or a stand-in in tests).
fn draw_laid_arabic(
/// [`draw_shaped`] for lines that are already laid out. `outline` is the source of glyph outlines
/// for a script's face (the craft-fonts faces, or a stand-in in tests).
fn draw_laid_shaped(
doc: &mut Document,
t: &AddedText,
laid: &[LaidLine],
taken: &Dict,
fonts: &mut Dict,
out: &mut Vec<u8>,
outline: impl Fn(u32) -> Result<GlyphOutline, GlyphError>,
outline: impl Fn(ShapedScript, u32) -> Result<GlyphOutline, GlyphError>,
) -> Result<(), EditError> {
let r = text_rect(t);
let latin = font_name(t.family.base_font(t.bold, t.italic));
@ -424,7 +495,7 @@ fn draw_laid_arabic(
let mut keys: Vec<&ShapedCluster> = Vec::new();
let mut index: HashMap<ClusterKey, usize> = HashMap::new();
for c in laid.iter().flat_map(|(_, _, pieces, _)| pieces).flat_map(|p| match p {
Piece::Arabic(clusters) => clusters.as_slice(),
Piece::Shaped(clusters) => clusters.as_slice(),
Piece::Latin(_) => &[],
}) {
index.entry(cluster_key(c)).or_insert_with(|| {
@ -432,12 +503,16 @@ fn draw_laid_arabic(
keys.len() - 1
});
}
// Every outline is read before anything is written, so a failure leaves the document as it was.
let mut outlines = HashMap::new();
for c in &keys {
for (id, _) in &c.glyphs {
if !outlines.contains_key(id) {
outlines.insert(*id, outline(*id).map_err(|e| arabic_error(e, &c.text))?);
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry((c.script, *id)) {
entry.insert(outline(c.script, *id).map_err(|e| match c.script {
ShapedScript::Arabic => arabic_error(e, &c.text),
ShapedScript::Thai => thai_error(e, &c.text),
})?);
}
}
}
@ -445,7 +520,7 @@ fn draw_laid_arabic(
let mut taken_names: HashSet<Vec<u8>> = taken.iter().chain(fonts.iter()).map(|(name, _)| name.clone()).collect();
let mut names = Vec::new();
for chunk in keys.chunks(GLYPHS_PER_FONT) {
let font = type3_arabic(doc, chunk, &outlines);
let font = type3_shaped(doc, chunk, &outlines);
// A name no font on the page has, so items never replace each other's fonts (object
// numbers alone don't do: a full save renumbers objects but keeps resource names).
let mut name = format!("PCAr{}", font.num);
@ -483,7 +558,7 @@ fn draw_laid_arabic(
current = Some(latin.as_str());
pen += t.family.width(s, t.size, t.bold) + tw * s.matches(' ').count() as f64;
}
Piece::Arabic(clusters) => {
Piece::Shaped(clusters) => {
for c in clusters {
let Some(&k) = index.get(&cluster_key(c)) else { continue };
let Some(name) = names.get(k / GLYPHS_PER_FONT) else { continue };
@ -508,14 +583,14 @@ fn draw_laid_arabic(
/// What makes two clusters the same Type 3 glyph: text, glyphs, offsets and advance (bit for bit;
/// equal clusters come from the same font units).
type ClusterKey<'a> = (&'a str, Vec<(u32, u64, u64)>, u64);
type ClusterKey<'a> = (ShapedScript, &'a str, Vec<(u32, u64, u64)>, u64);
fn cluster_key(c: &ShapedCluster) -> ClusterKey<'_> {
(c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
(c.script, c.text.as_str(), c.glyphs.iter().map(|(id, [x, y])| (*id, x.to_bits(), y.to_bits())).collect(), c.advance.to_bits())
}
/// A Type 3 font with `clusters` as codes 1…, each drawn from the Arabic face's glyph `outlines`.
fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<u32, GlyphOutline>) -> ObjRef {
/// A Type 3 font with `clusters` as codes 1…, each drawn from its script face's glyph `outlines`.
fn type3_shaped(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &HashMap<(ShapedScript, u32), GlyphOutline>) -> ObjRef {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(clusters.len());
@ -527,7 +602,7 @@ fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
let mut paths = Vec::new();
let mut ink: Option<[f64; 4]> = None;
for (id, [dx, dy]) in &c.glyphs {
let Some(g) = outlines.get(id) else { continue };
let Some(g) = outlines.get(&(c.script, *id)) else { continue };
for contour in &g.contours {
for (j, p) in contour.iter().enumerate() {
paths.extend(format!("{} {} {}\n", n((p[0] + dx) * 1000.0), n((p[1] + dy) * 1000.0), if j == 0 { "m" } else { "l" }).bytes());
@ -564,7 +639,18 @@ fn type3_arabic(doc: &mut Document, clusters: &[&ShapedCluster], outlines: &Hash
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let cmap = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |f| f.family);
// The face's family when every glyph comes from one script; without the face, the script's
// name (as Arabic items were always named).
let family = match clusters.first().map(|c| c.script).filter(|s| clusters.iter().all(|c| c.script == *s)) {
Some(script) => script.font().map_or(
match script {
ShapedScript::Arabic => "Arabic",
ShapedScript::Thai => "Thai",
},
|f| f.family,
),
None => "PdfCraft Shaped Text",
};
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&family.replace(' ', "")));
@ -611,8 +697,8 @@ fn draw(doc: &mut Document, c: &Content, view: [f64; 6], taken: &Dict, existing:
}
let mut fonts = Dict::new();
fonts.set(name.clone().into_bytes(), Object::Dict(font));
if t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing) {
draw_arabic(doc, t, taken, &mut fonts, &mut out)?;
if t.text.chars().any(is_thai) || (t.text.chars().any(is_arabic) && (pdfcraft_fonts::document_arabic_font().is_some() || !existing)) {
draw_shaped(doc, t, taken, &mut fonts, &mut out)?;
res.set(b"Font".to_vec(), Object::Dict(fonts));
out.extend_from_slice(b"Q\n");
return Ok((out, res));
@ -840,11 +926,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()));
}
if t.text.chars().any(is_thai) && pdfcraft_fonts::document_thai_font().is_none() {
return Err(thai_error(GlyphError::NoFont, &t.text));
}
// 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)))
t.text.split('\n').find_map(|line| {
line.chars()
.find(|c| !shaped_arabic(*c) && !(is_thai(*c) && pdfcraft_fonts::thai_has(*c)) && !pdfcraft_fonts::win_ansi_encodable(*c))
})
} else {
first_undrawable(t)
};
@ -963,7 +1055,7 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(
Ok(())
}
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_arabic`
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
/// names them so), so they go when the item is replaced or deleted.
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
let Object::Stream(s) = &*doc.get(obj) else { return Vec::new() };

View File

@ -12,14 +12,14 @@ const SMALL_ITEM_FINGERPRINT: u64 = 14_135_892_979_695_233_112;
/// A cluster that shows one character with the single glyph `id`, at `advance` em.
fn cluster(id: u32, advance: f64) -> ShapedCluster {
ShapedCluster { glyphs: vec![(id, [0.0, 0.0])], advance, text: "ب".into() }
ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(id, [0.0, 0.0])], advance, text: "ب".into() }
}
/// One run of `clusters` as a line of an item at `item()`'s size.
fn line(clusters: Vec<ShapedCluster>, rtl: bool) -> LaidLine {
let text = clusters.iter().map(|c| c.text.as_str()).collect();
let width = clusters.iter().map(|c| c.advance).sum::<f64>() * item().size;
(text, rtl, vec![Piece::Arabic(clusters)], width)
(text, rtl, vec![Piece::Shaped(clusters)], width)
}
/// A glyph outline: a box on the baseline, `advance` em wide.
@ -85,7 +85,7 @@ fn an_item_past_the_old_glyph_limit_gets_a_font_per_240_glyphs() {
let laid = vec![line((0..n).map(|i| cluster(i as u32 + 1, 0.5)).collect(), false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let made = fonts_of(&doc, &fonts);
let mut expected = vec![GLYPHS_PER_FONT; 16];
@ -108,7 +108,7 @@ fn an_outline_missing_from_a_later_font_leaves_the_document_unchanged() {
let before = snapshot(&doc);
let missing = n as u32;
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| {
if id == missing { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
});
assert!(matches!(&drawn, Err(EditError::Invalid(msg)) if msg.contains("can't show")), "{drawn:?}");
@ -120,17 +120,32 @@ fn an_outline_missing_from_a_later_font_leaves_the_document_unchanged() {
fn an_item_below_the_old_limit_is_drawn_exactly_as_before() {
// A left-to-right line with a Latin run, a space and a two-glyph cluster, then a right-to-left
// line. Glyphs repeat across the two lines and share one font.
let space = ShapedCluster { glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let space = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(6, [0.0, 0.0])], advance: 0.25, text: " ".into() };
let ligature = ShapedCluster { script: ShapedScript::Arabic, glyphs: vec![(4, [0.0, 0.0]), (5, [-0.1, 0.2])], advance: 0.6, text: "لا".into() };
let mixed: LaidLine =
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Arabic(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
("Hi ب".into(), false, vec![Piece::Latin("Hi ".into()), Piece::Shaped(vec![cluster(1, 0.5), cluster(2, 0.5), space, ligature])], 4.0);
let rtl = line(vec![cluster(2, 0.5), cluster(9, 0.75)], true);
let laid = vec![mixed, rtl];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| Ok(boxed(0.5 + f64::from(id) * 0.01)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| Ok(boxed(0.5 + f64::from(id) * 0.01)));
assert!(drawn.is_ok(), "{drawn:?}");
assert_eq!(fonts.len(), 1);
// The injected outlines are fixed; only the descriptor's family comes from craft-fonts.
// Check that metadata separately, then normalize those two fields to the original golden.
// Every drawing byte and every other object field must still match, in both build modes.
let family = pdfcraft_fonts::document_arabic_font().map_or("Arabic", |face| face.family);
for (_, font) in fonts.iter() {
let descriptor = doc.dict(font).unwrap().get(b"FontDescriptor").and_then(Object::as_ref).unwrap();
let dict = doc.dict(&Object::Ref(descriptor)).unwrap();
assert_eq!(dict.name(b"FontName"), Some(family.replace(' ', "").as_bytes()));
assert_eq!(dict.get(b"FontFamily").and_then(Object::as_string).unwrap().bytes, family.as_bytes());
doc.update_dict(descriptor, |d| {
d.set(b"FontName".to_vec(), Object::name("Arabic"));
d.set(b"FontFamily".to_vec(), Object::String(PdfString::literal(b"Arabic".to_vec())));
})
.unwrap();
}
let mut bytes = out.clone();
pdfcraft_cos::serialize(&Object::Dict(fonts.clone()), &mut bytes);
bytes.extend(snapshot(&doc));
@ -145,7 +160,7 @@ fn long_text_with_few_distinct_glyphs_needs_one_font() {
let reads = Cell::new(0usize);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| {
reads.set(reads.get() + 1);
Ok(boxed(0.5))
});
@ -159,13 +174,18 @@ fn long_text_with_few_distinct_glyphs_needs_one_font() {
fn distinct_clusters_past_the_old_limit_share_their_glyph_outlines() {
// 3,841 different clusters (their advances differ) built from four glyphs: four outlines are read.
let clusters: Vec<ShapedCluster> = (0..3_841u32)
.map(|i| ShapedCluster { glyphs: vec![(1 + i % 4, [0.0, 0.0])], advance: 0.5 + f64::from(i) * 1e-6, text: "ب".into() })
.map(|i| ShapedCluster {
script: ShapedScript::Arabic,
glyphs: vec![(1 + i % 4, [0.0, 0.0])],
advance: 0.5 + f64::from(i) * 1e-6,
text: "ب".into(),
})
.collect();
let laid = vec![line(clusters, false)];
let reads = Cell::new(0usize);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| {
reads.set(reads.get() + 1);
Ok(boxed(0.5))
});
@ -178,18 +198,18 @@ fn distinct_clusters_past_the_old_limit_share_their_glyph_outlines() {
#[test]
fn hostile_clusters_draw_empty_or_fail_without_changing_the_document() {
// A cluster with no glyphs and no text draws nothing, but it is still one glyph of a font.
let empty = ShapedCluster { glyphs: Vec::new(), advance: 0.0, text: String::new() };
let empty = ShapedCluster { script: ShapedScript::Arabic, glyphs: Vec::new(), advance: 0.0, text: String::new() };
let laid = vec![line(vec![empty, cluster(1, 0.5)], false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
assert_eq!(glyphs_shown(&out).len(), 2);
// A glyph id the face doesn't have fails the item before anything is written.
let before = snapshot(&doc);
let laid = vec![line(vec![cluster(1, 0.5), cluster(u32::MAX, 0.5)], false)];
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |id| {
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, id| {
if id == u32::MAX { Err(GlyphError::Missing) } else { Ok(boxed(0.5)) }
});
assert!(drawn.is_err());
@ -202,7 +222,7 @@ fn a_font_name_the_page_already_uses_is_not_reused() {
let laid = vec![line(vec![cluster(1, 0.5)], false)];
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &Dict::new(), &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let first = fonts_of(&doc, &fonts)[0].0.clone();
// The same item on a page whose resources already use that name.
@ -210,7 +230,7 @@ fn a_font_name_the_page_already_uses_is_not_reused() {
taken.set(first.clone(), Object::Null);
let mut doc = Document::new_empty();
let (mut fonts, mut out) = (Dict::new(), Vec::new());
let drawn = draw_laid_arabic(&mut doc, &item(), &laid, &taken, &mut fonts, &mut out, |_| Ok(boxed(0.5)));
let drawn = draw_laid_shaped(&mut doc, &item(), &laid, &taken, &mut fonts, &mut out, |_, _| Ok(boxed(0.5)));
assert!(drawn.is_ok(), "{drawn:?}");
let second = fonts_of(&doc, &fonts)[0].0.clone();
assert_ne!(second, first);

364
crates/edit/src/bidi.rs Normal file
View File

@ -0,0 +1,364 @@
//! Right-to-left text in Edit text (Hebrew, Arabic).
//!
//! A content stream draws glyphs left to right, so a producer writes a right-to-left line in
//! *visual* order: `בדיוק` is drawn, and read back from the stream, as `קוידב`. The
//! editor works on *logical* (typing) order instead: [`to_logical`] turns a line read from the
//! page into the order a person types it, and [`to_visual`] turns a line about to be written back
//! into the order it must be drawn in (UAX #9 through `unicode-bidi`).
//!
//! Visual order doesn't always determine one logical order (`abc 12 םולש` is both `abc שלום 12`
//! and `abc 12 שלום`), so [`to_logical`] tries a few readings and keeps the first that draws back
//! exactly as the page did. Lines without right-to-left characters are returned unchanged.
//!
//! Combining marks (Hebrew points, Arabic harakat) stay after their base character in both
//! orders. Brackets drawn in a right-to-left run are mirrored, as UAX #9 L4 mirrors them on
//! display.
use unicode_bidi::{BidiInfo, Level};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Class {
Rtl,
Ltr,
Number,
Neutral,
}
/// Letters, marks and punctuation of the right-to-left scripts' blocks (digits excepted).
pub(crate) fn is_rtl(c: char) -> bool {
!is_digit(c) && matches!(u32::from(c), 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFC | 0x1_0800..=0x1_0FFF | 0x1_E800..=0x1_EFFF)
}
/// The Hebrew block and the Hebrew presentation forms.
pub(crate) fn is_hebrew(c: char) -> bool {
matches!(u32::from(c), 0x0590..=0x05FF | 0xFB1D..=0xFB4F)
}
/// Hebrew points and cantillation marks (niqqud, te'amim): combining, drawn on their letter.
pub(crate) fn is_hebrew_mark(c: char) -> bool {
matches!(u32::from(c), 0x0591..=0x05BD | 0x05BF | 0x05C1..=0x05C2 | 0x05C4..=0x05C5 | 0x05C7 | 0xFB1E)
}
fn is_digit(c: char) -> bool {
c.is_ascii_digit() || matches!(c, '\u{0660}'..='\u{0669}' | '\u{06F0}'..='\u{06F9}')
}
/// Combining marks that ride on the character before them.
fn is_mark(c: char) -> bool {
is_hebrew_mark(c)
|| matches!(
u32::from(c),
0x0300..=0x036F | 0x0610..=0x061A | 0x064B..=0x065F | 0x0670 | 0x06D6..=0x06DC | 0x06DF..=0x06E4 | 0x06E7..=0x06E8 | 0x06EA..=0x06ED | 0xFE20..=0xFE2F
)
}
/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
fn mirrored(c: char) -> char {
match c {
'(' => ')',
')' => '(',
'[' => ']',
']' => '[',
'{' => '}',
'}' => '{',
'<' => '>',
'>' => '<',
'«' => '»',
'»' => '«',
other => other,
}
}
fn class(c: char) -> Class {
if is_digit(c) {
Class::Number
} else if is_rtl(c) {
Class::Rtl
} else if c.is_alphabetic() {
Class::Ltr
} else {
Class::Neutral
}
}
/// A base character with the combining marks after it.
#[derive(Clone, Debug)]
struct Cluster {
text: String,
class: Class,
}
impl Cluster {
/// The cluster with its base mirrored (marks stay as they are).
fn mirror(&self) -> Cluster {
let mut chars = self.text.chars();
let mut text: String = chars.next().map(mirrored).into_iter().collect();
text.extend(chars);
Cluster { text, class: self.class }
}
}
fn clusters(s: &str) -> Vec<Cluster> {
let mut out: Vec<Cluster> = Vec::new();
for c in s.chars() {
match out.last_mut() {
Some(last) if is_mark(c) => last.text.push(c),
_ => out.push(Cluster { text: c.to_string(), class: class(c) }),
}
}
out
}
/// Whether the line reads right to left as a whole: more right-to-left letters than
/// left-to-right ones. Counting (not the first strong character) gives the same answer for a
/// line in either order, so reading a line and writing it back agree on its direction.
pub(crate) fn rtl_base(text: &str) -> bool {
let (mut r, mut l) = (0usize, 0usize);
for c in text.chars() {
match class(c) {
Class::Rtl if !is_mark(c) => r = r.saturating_add(1),
Class::Ltr if !is_mark(c) => l = l.saturating_add(1),
_ => {}
}
}
r > l
}
/// Maximal ranges that start and end with a `member` and hold only members and `bridge`s.
fn runs(cl: &[Cluster], member: impl Fn(&Cluster) -> bool, bridge: impl Fn(&[Cluster], usize) -> bool) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut i = 0;
while i < cl.len() {
if !cl.get(i).is_some_and(&member) {
i += 1;
continue;
}
let mut end = i;
let mut j = i + 1;
while let Some(c) = cl.get(j) {
if member(c) {
end = j;
} else if !bridge(cl, j) {
break;
}
j += 1;
}
out.push(i..end + 1);
i = end + 1;
}
out
}
/// One separator between two digits (`1,000`, `12:30`, `3.5`, `1/2`, `5-6`) keeps a number whole.
fn number_separator(cl: &[Cluster], j: usize) -> bool {
let digit = |k: Option<usize>| k.and_then(|k| cl.get(k)).is_some_and(|c| c.class == Class::Number);
cl.get(j).is_some_and(|c| matches!(c.text.as_str(), "." | "," | ":" | "/" | "-" | "+")) && digit(j.checked_sub(1)) && digit(Some(j + 1))
}
fn reverse_mirrored(cl: &mut [Cluster]) {
cl.reverse();
for c in cl.iter_mut() {
*c = c.mirror();
}
}
/// One reading of `visual` in logical order. `wide` lets runs of the other direction swallow the
/// spaces and numbers between their words; narrow keeps numbers and words apart.
fn reading(visual: &[Cluster], rtl: bool, wide: bool) -> String {
let mut cl = visual.to_vec();
let number = |c: &Cluster| c.class == Class::Number;
if rtl {
// Everything reads right to left, except left-to-right words and numbers.
reverse_mirrored(&mut cl);
let ltr = |c: &Cluster| matches!(c.class, Class::Ltr | Class::Number);
let bridge =
|cl: &[Cluster], j: usize| cl.get(j).is_some_and(|c| c.class == Class::Neutral) && (wide || !neutral_between_word_and_number(cl, j));
for r in runs(&cl, ltr, bridge) {
if let Some(run) = cl.get_mut(r) {
reverse_mirrored(run);
}
}
} else {
// Left to right, except right-to-left stretches; numbers inside those read left to right.
let rtl_member = |c: &Cluster| c.class == Class::Rtl;
let bridge = |cl: &[Cluster], j: usize| cl.get(j).is_some_and(|c| c.class == Class::Neutral || (wide && c.class == Class::Number));
for r in runs(&cl, rtl_member, bridge) {
if let Some(run) = cl.get_mut(r) {
reverse_mirrored(run);
for n in runs(run, number, number_separator) {
if let Some(num) = run.get_mut(n) {
reverse_mirrored(num);
}
}
}
}
}
cl.iter().map(|c| c.text.as_str()).collect()
}
/// Whether the neutral at `j` sits between a left-to-right word on one side and a number on the
/// other (UAX #9 resolves such a space to the line's direction, splitting the run).
fn neutral_between_word_and_number(cl: &[Cluster], j: usize) -> bool {
let strong = |mut k: usize, step: isize| -> Option<Class> {
loop {
k = k.checked_add_signed(step)?;
match cl.get(k)?.class {
Class::Neutral => continue,
other => return Some(other),
}
}
};
matches!((strong(j, -1), strong(j, 1)), (Some(Class::Ltr), Some(Class::Number)) | (Some(Class::Number), Some(Class::Ltr)))
}
/// A line read from a content stream (drawn left to right), in logical order.
pub fn to_logical(visual: &str) -> String {
if !visual.chars().any(is_rtl) {
return visual.to_owned();
}
let cl = clusters(visual);
let rtl = rtl_base(visual);
let readings = [reading(&cl, rtl, true), reading(&cl, rtl, false)];
// Readings that draw back exactly as the page did; of those, one whose brackets pair up
// (`12 (PDF)`, not `PDF) 12)`), else the first.
let exact: Vec<&String> = readings.iter().filter(|r| to_visual(r) == visual).collect();
let chosen = exact.iter().find(|r| balanced(r)).or(exact.first()).map_or_else(|| readings[0].clone(), |r| (*r).clone());
// Some producers (LibreOffice) map a bracket drawn in a right-to-left run to the character it
// stands for, not the shape drawn, so mirroring it again turns it inside out: `)טיוטה(`.
let swapped: String = chosen.chars().map(mirrored).collect();
if !balanced(&chosen) && balanced(&swapped) { swapped } else { chosen }
}
/// Whether every closing bracket closes an open one.
fn balanced(text: &str) -> bool {
let mut open: Vec<char> = Vec::new();
for c in text.chars() {
match c {
'(' | '[' | '{' => open.push(c),
')' | ']' | '}' if open.pop() != Some(mirrored(c)) => return false,
_ => {}
}
}
open.is_empty()
}
/// A line in logical order, in the order it must be drawn (left to right), without the bidi
/// controls fonts can't draw.
pub fn to_visual(logical: &str) -> String {
let text: String = logical.chars().filter(|c| !is_bidi_control(*c)).collect();
if !text.chars().any(is_rtl) {
return text;
}
let level = if rtl_base(&text) { Level::rtl() } else { Level::ltr() };
let info = BidiInfo::new(&text, Some(level));
let mut out = String::with_capacity(text.len());
for para in &info.paragraphs {
let (levels, runs) = info.visual_runs(para, para.range.clone());
for run in runs {
let rtl = levels.get(run.start).is_some_and(|l| l.is_rtl());
// Runs lie on character boundaries; a missing slice is skipped, never a panic.
let Some(piece) = text.get(run) else { continue };
if rtl {
for c in clusters(piece).iter().rev() {
out.push_str(&c.mirror().text);
}
} else {
out.push_str(piece);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
/// What a producer writes into the content stream for `logical`.
fn drawn(logical: &str) -> String {
to_visual(logical)
}
#[test]
fn the_reported_word_reads_back_in_typing_order() {
// The report: Edit text showed "בדיוק" (exactly) reversed.
assert_eq!(drawn("בדיוק"), "קוידב");
assert_eq!(to_logical("קוידב"), "בדיוק");
}
#[test]
fn hebrew_sentences_numbers_latin_and_brackets_round_trip() {
for logical in [
"שלום עולם",
"עמוד 12 מתוך 40",
"מחיר: 1,250.50 ₪",
"השעה 12:30",
"קובץ PDF חדש",
"שלום (עולם)",
"ראו [1] ו-[2]",
"גרסה 2.0 של PdfCraft נוסחה",
"hello שלום עולם world",
"abc שלום def",
"שלום abc def עולם",
"Report: דוח שנתי 2026",
"שלום עולם, עמוד 12 (PDF)",
"פרק 3 (מבוא)",
"שָׁלוֹם עוֹלָם",
"مرحبا بالعالم",
] {
let visual = drawn(logical);
assert_ne!(visual, logical, "{logical:?} should be reordered");
assert_eq!(to_visual(&to_logical(&visual)), visual, "{logical:?} drawn as {visual:?}");
assert_eq!(to_logical(&visual), logical, "{logical:?} drawn as {visual:?}");
}
}
#[test]
fn ambiguous_lines_still_draw_back_the_same() {
// "abc 12 םולש" is both "abc שלום 12" and "abc 12 שלום"; either must draw the same.
for logical in ["abc שלום 12", "שלום 12 abc", "a (ב) c", "דוח Report 2026", "(1) שלום"] {
let visual = drawn(logical);
assert_eq!(to_visual(&to_logical(&visual)), visual, "{logical:?}");
}
}
#[test]
fn brackets_mapped_to_their_logical_character_pair_up() {
// LibreOffice draws "(טיוטה)" with ToUnicode giving the logical brackets: `)הטויט(`.
assert_eq!(to_logical(")הטויט("), "(טיוטה)");
// Brackets mapped to the shape drawn read the same.
assert_eq!(to_logical("(הטויט)"), "(טיוטה)");
}
#[test]
fn left_to_right_text_is_untouched() {
for s in ["", "Hello, world (1)", "日本語のテキスト", "Ünïcödé 12:30"] {
assert_eq!(to_logical(s), s);
assert_eq!(to_visual(s), s);
}
}
#[test]
fn points_stay_on_their_letter() {
// Each point follows its letter in both orders.
let visual = drawn("שָׁלוֹם");
let first = visual.chars().next();
assert_eq!(first, Some('ם'));
assert!(visual.contains("לוֹ") || visual.contains("וֹל"), "{visual:?}");
assert_eq!(to_logical(&visual), "שָׁלוֹם");
}
#[test]
fn controls_are_not_drawn_and_hostile_input_does_not_panic() {
assert_eq!(to_visual("\u{200F}שלום\u{202B}"), "םולש");
for s in ["\u{05B8}", "\u{05B8}\u{05B8}א", "(((", "א\u{0300}", "1-", "-1א", "א\n\tב", "\u{202E}abc"] {
let _ = to_visual(&to_logical(s));
}
let long = "שלום 12 abc (x) ".repeat(2_000);
assert_eq!(to_visual(&to_logical(&to_visual(&long))), to_visual(&long));
}
}

View File

@ -97,9 +97,45 @@ pub fn flatten_fill_sign(doc: &mut Document, pages: &[usize]) -> Result<usize, E
flatten_which(doc, pages, Which::FillSign)
}
/// The first annotation flattening would turn into page content with `?` in it, before anything
/// changes: one whose appearance PdfCraft drew with `?` for characters its fonts can't draw
/// (`pdfcraft_fonts::UNDRAWN_MARK`: a form field's value, a comment's text), or a comment without
/// an appearance whose text the standard fonts can't draw. Its page (0-based), whether it is a
/// form field, and the character.
fn undrawn_on(doc: &Document, all: &[pdfcraft_model::Page], pages: &[usize], which: Which) -> Option<(usize, bool, char)> {
for &i in pages {
let list = all.get(i)?.dict.get(b"Annots").map(|a| doc.resolve(a)).and_then(|a| a.as_array().cloned()).unwrap_or_default();
for entry in &list {
let Some(d) = doc.resolve(entry).as_dict().cloned() else { continue };
let flags = d.get(b"F").and_then(|f| doc.resolve(f).as_int()).unwrap_or(0);
if !selected(doc, &d, which) || flags & (HIDDEN | NO_VIEW) != 0 {
continue;
}
let widget = d.name(b"Subtype") == Some(b"Widget");
let drawn = pdfcraft_fonts::undrawn_mark(&d);
let would_draw = || (!widget && appearance(doc, &d).is_none()).then(|| pdfcraft_annot::appearance::undrawable_text(&d)).flatten();
if let Some(c) = drawn.or_else(|| would_draw().map(|(_, c)| c)) {
return Some((i, widget, c));
}
}
}
None
}
fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize, EditError> {
let all = page_list(doc);
check(pages, all.len())?;
// `?` in an appearance can still be fixed (a field's value, a comment's text are stored as
// typed, and other viewers redraw form fields); flattened, it would be the page for good.
if let Some((i, widget, c)) = undrawn_on(doc, &all, pages, which) {
return Err(EditError::Invalid(format!(
"can't flatten {} on page {}: it shows ? in place of \"{}\" (U+{:04X}), which its font can't draw, and flattening would make that permanent (nothing was flattened); change or remove that text first",
if widget { "a form field" } else { "a comment" },
i + 1,
c.escape_debug(),
u32::from(c)
)));
}
let mut drawn = 0;
let mut removed_widgets: HashSet<ObjRef> = HashSet::new();
for &i in pages {
@ -132,7 +168,8 @@ fn flatten_which(doc: &mut Document, pages: &[usize], which: Which) -> Result<us
if flags & (HIDDEN | NO_VIEW) != 0 || appearance(doc, &d).is_some() {
continue;
}
// A subtype this build cannot draw only fails here; it is still removed below.
// A subtype this build cannot draw only fails here; it is still removed below. (Text
// the standard fonts can't draw was refused before anything changed: `undrawn_on`.)
let _ = pdfcraft_annot::set_appearance(doc, r);
}
for (k, entry) in list.iter().enumerate() {

View File

@ -395,12 +395,6 @@ pub fn undrawable_message(c: char) -> String {
format!("the standard fonts can't draw \"{c}\" (U+{:04X}); only Western European characters can be added as text for now", u32::from(c))
}
/// The first character of plain `text` that Helvetica/WinAnsi can't draw (line and page breaks
/// and tabs are layout, not glyphs).
pub fn first_undrawable_plain(text: &str) -> Option<char> {
text.chars().find(|c| !matches!(c, '\r' | '\n' | '\t' | '\u{c}') && !pdfcraft_fonts::win_ansi_encodable(*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)));
@ -629,6 +623,7 @@ mod flatten;
pub use flatten::{flatten, flatten_fill_sign};
pub mod images;
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre};
pub mod bidi;
pub mod text;
pub use text::{BlockStyle, LineEdit, TextBlock, TextLine, reading_blocks, replace_block, replace_line, rewrite_block, text_blocks, text_lines};
pub mod added;

View File

@ -234,6 +234,58 @@ fn flattening_keeps_a_note_whose_appearance_is_missing() {
assert!(flat.contains("q 1 0 0 1 10 10 cm /PCFl0 Do Q"), "{flat}");
}
/// Flattening never makes a `?` permanent: it refuses, changing nothing, a text box without an
/// appearance whose text Helvetica can't draw (another program's), and a comment or form field
/// whose appearance PdfCraft drew with `?` (marked `PCUndrawn`). A comment another program drew
/// with a font that has those characters (an appearance, no mark) flattens as before.
#[test]
fn flattening_refuses_what_would_bake_in_question_marks() {
let page = stream("", " ");
let refused = |annot: &str, comments: bool, fields: bool, what: &str| {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
annot,
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
let err = flatten(&mut doc, &[0], comments, fields).unwrap_err().to_string();
assert!(err.contains(what) && err.contains("page 1") && err.contains("\"欢\" (U+6B22)") && err.contains("nothing was flattened"), "{err}");
assert_eq!(write_incremental(&doc, &SaveOptions::default()).unwrap(), before, "a refused flatten changes nothing");
assert!(pdfcraft_model::pages(&doc)[0].dict.contains(b"Annots"), "the annotation is still there");
};
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
true,
false,
"a comment",
);
refused(
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /V <FEFF6B228FCE> /Rect [10 10 150 40] /F 4 /AP << /N 6 0 R >> /PCUndrawn <FEFF6B22> >>",
false,
true,
"a form field",
);
// Drawn by another program, with a font that has 欢: no mark, so it flattens.
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << >> /Contents 4 0 R /Annots [5 0 R] >>",
&page,
"<< /Type /Annot /Subtype /FreeText /Rect [10 10 150 40] /DA (0 g /Helv 12 Tf) /Contents <FEFF6B228FCE> /F 4 /AP << /N 6 0 R >> >>",
"<< /Type /XObject /Subtype /Form /BBox [0 0 140 30] /Length 0 >>\nstream\n\nendstream",
]);
assert_eq!(flatten(&mut doc, &[0], true, false).unwrap(), 1);
}
/// Display space is in points, so on a `/UserUnit 2` page an item's box maps to half as many
/// user-space units. Items record the `/UserUnit` they were written for; one without it was
/// written when display space was in user-space units, and reads back scaled to points.
@ -1779,6 +1831,148 @@ fn the_graphics_state_carries_over_between_content_streams() {
assert!(close(l.rect[0], 100.0) && close(l.size, 10.0), "{l:?}");
}
/// One page whose font draws a few Hebrew letters (ToUnicode): A ב, B ד, C י, D ו, E ק, F ש,
/// G ל, H ם. Content streams draw Hebrew in visual order, so "בדיוק" is `(EDCBA)`.
fn hebrew_page(content: &str) -> Document {
let cmap = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapType 2 def \
1 begincodespacerange <00> <FF> endcodespacerange 9 beginbfchar \
<20> <0020> <41> <05D1> <42> <05D3> <43> <05D9> <44> <05D5> <45> <05E7> <46> <05E9> <47> <05DC> <48> <05DD> \
endbfchar endcmap CMapName currentdict /CMap defineresource pop end end";
// Widths for codes 32..=72: a space, then the eight letters at 65..=72.
let widths: Vec<&str> = (32..=72)
.map(|c| {
if c == 32 {
"250"
} else if c >= 65 {
"500"
} else {
"0"
}
})
.collect();
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".into(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
format!(
"<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+FakeHebrew /FirstChar 32 /LastChar 72 /Widths [{}] /ToUnicode 6 0 R >>",
widths.join(" ")
),
format!("<< /Length {} >>\nstream\n{cmap}\nendstream", cmap.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
Document::open(Arc::new(out)).unwrap()
}
/// The report: Edit text showed "בדיוק" (exactly) reversed, because the line was read in the
/// order the page draws it.
#[test]
fn hebrew_lines_read_in_typing_order() {
let doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET BT /F1 12 Tf 300 600 Td (EDCBA HDGF) Tj ET");
let lines: Vec<String> = text::text_lines(&doc, 0).unwrap().into_iter().map(|l| l.text).collect();
assert_eq!(lines, ["בדיוק", "שלום בדיוק"]);
let blocks: Vec<String> = text::text_blocks(&doc, 0).unwrap().into_iter().map(|b| b.text).collect();
assert_eq!(blocks, ["בדיוק", "שלום בדיוק"]);
}
/// Text the page's own font can show is written back in drawing order, right-aligned to where
/// the line ended, and reads back as typed.
#[test]
fn hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = text::text_lines(&doc, 0).unwrap()[0].rect;
// One line: the bytes are the visual order of the typed text.
assert_eq!(text::replace_line(&mut doc, 0, 0, "שלום בדיוק").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(EDCBA HDGF)"), "{content}");
assert_eq!(text::text_lines(&reopen(&doc), 0).unwrap()[0].text, "שלום בדיוק");
// A paragraph: shorter text stays right-aligned at the old right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "דוב").unwrap().substituted, None);
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("(ADB)"), "{content}");
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "דוב");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] > before[0] + 1.0, "shorter text starts further right: {:?} vs {before:?}", line.rect);
// Longer text grows to the left, not off the right edge.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
text::replace_block(&mut doc, 0, 0, "שלום בדיוק שלום").unwrap();
let doc = reopen(&doc);
let line = text::text_lines(&doc, 0).unwrap().swap_remove(0);
assert_eq!(line.text, "שלום בדיוק שלום");
assert!((line.rect[2] - before[2]).abs() < 0.5, "right edge {} → {}", before[2], line.rect[2]);
assert!(line.rect[0] < before[0] - 1.0, "longer text grows left: {:?} vs {before:?}", line.rect);
// An alignment asked for still wins.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let left = text::BlockStyle { align: Some(Align::Left), ..Default::default() };
text::rewrite_block(&mut doc, 0, 0, Some("דוב"), &left).unwrap();
let line = text::text_lines(&reopen(&doc), 0).unwrap().swap_remove(0);
assert!((line.rect[0] - before[0]).abs() < 0.5, "left-aligned: {:?} vs {before:?}", line.rect);
}
/// Letters the page's font lacks are drawn with the Hebrew face from craft-fonts; a build
/// without it says so and changes nothing. Hebrew points are refused, not misdrawn.
#[test]
fn hebrew_letters_the_page_font_lacks_use_the_hebrew_face() {
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
let before = page_content_bytes(&doc, 0);
let pointed = text::replace_line(&mut doc, 0, 0, "שָׁלוֹם עוֹלָם");
let Err(EditError::Invalid(msg)) = pointed else { panic!("expected a clear error, got {pointed:?}") };
assert!(msg.contains("niqqud"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
// ע and ה aren't in the page's font.
let typed = "שלום עולם, עמוד 12 (PDF)";
let line = text::replace_line(&mut doc, 0, 0, typed);
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
let Err(EditError::Invalid(msg)) = line else { panic!("expected a clear error, got {line:?}") };
assert!(msg.contains("Hebrew fallback font") && msg.contains("CRAFT_FONTS_DIR"), "{msg}");
assert_eq!(page_content_bytes(&doc, 0), before);
eprintln!("built without a craft-fonts Hebrew face: the Type 3 path is checked with CRAFT_FONTS_DIR");
return;
}
assert_eq!(line.unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
let content = String::from_utf8_lossy(&page_content_bytes(&doc, 0)).into_owned();
assert!(content.contains("/PCHe"), "{content}");
let reopened = reopen(&doc);
assert_eq!(text::text_lines(&reopened, 0).unwrap()[0].text, typed);
// Every glyph has an outline, so nothing is drawn as a box.
let p = pdfcraft_model::pages(&reopened).swap_remove(0);
let res = reopened.resolve(p.dict.get(b"Resources").unwrap());
let fonts = reopened.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let font = reopened.resolve(fonts.as_dict().unwrap().get(b"PCHe").unwrap());
let procs = reopened.resolve(font.as_dict().unwrap().get(b"CharProcs").unwrap());
let procs = procs.as_dict().unwrap();
assert_eq!(procs.iter().count(), typed.chars().collect::<std::collections::HashSet<_>>().len());
for (_, glyph) in procs.iter() {
let pdfcraft_cos::Object::Stream(s) = &*reopened.resolve(glyph) else { panic!("glyph stream") };
let path = String::from_utf8(s.decoded().unwrap()).unwrap();
assert!(path.starts_with(|c: char| c.is_ascii_digit()) && path.contains(" d1"), "{path}");
}
// A whole paragraph too, right-aligned.
let mut doc = hebrew_page("BT /F1 12 Tf 300 700 Td (EDCBA) Tj ET");
assert_eq!(text::replace_block(&mut doc, 0, 0, "עברית מלאה").unwrap().substituted.as_deref(), Some("Noto Sans Hebrew Type3"));
assert_eq!(text::text_blocks(&reopen(&doc), 0).unwrap()[0].text, "עברית מלאה");
}
/// #125: page-content tools refuse text the standard fonts can't draw instead of writing `?`,
/// and leave the document untouched.
#[test]
@ -2051,6 +2245,37 @@ fn hostile_form_xobjects_are_read_once() {
assert!(images::reading_images(&doc, 0).unwrap().is_empty());
}
#[test]
fn added_thai_text_wraps_clusters_and_survives_reopen() {
let mut doc = fixture();
let t = AddedText {
rect: [20.0, 100.0, 250.0, 300.0], text: "สวัสดี น้ำ ผู้ใช้ ปู่ ๑๒๓ Hello 123".into(), size: 16.0, ..AddedText::default()
};
if pdfcraft_fonts::document_thai_font().is_none() {
assert_eq!(first_undrawable(&t), Some('ส'));
let before = list_added(&doc);
let err = add_content(&mut doc, 0, &Content::Text(t)).unwrap_err().to_string();
assert!(err.contains("Thai") && err.contains("CRAFT_FONTS_DIR"), "{err}");
assert_eq!(list_added(&doc), before);
return;
}
assert_eq!(first_undrawable(&t), None);
add_content(&mut doc, 0, &Content::Text(t.clone())).unwrap();
let reopened = reopen(&doc);
assert_eq!(list_added(&reopened)[0].content, Content::Text(AddedText { rect: super::added::text_rect(&t), ..t.clone() }));
let narrow = AddedText { rect: [0.0, 0.0, 16.0, 200.0], text: "กิ่กิ่กิ่".into(), ..t.clone() };
assert_eq!(super::added::lines(&narrow), ["กิ่", "กิ่", "กิ่"]);
update_content(&mut doc, 0, 0, &Content::Text(AddedText { text: "แก้ไข น้ำ".into(), ..t.clone() })).unwrap();
assert!(!own(&doc, 0).is_empty());
let bad = Content::Text(AddedText { text: "ไทย 日".into(), ..t });
let before = list_added(&doc);
assert!(update_content(&mut doc, 0, 0, &bad).is_err());
assert_eq!(list_added(&doc), before);
delete_content(&mut doc, 0, 0).unwrap();
assert!(list_added(&doc).is_empty());
assert!(arabic_font_names(&doc, 0).is_empty());
}
#[test]
fn group_moves_preserve_glyph_bytes_and_unselected_text_state() {
for content in [

View File

@ -38,7 +38,7 @@ pub struct TextLine {
/// Whether new text in this font can only be shown by substituting another font (no
/// Unicode mapping for the line, so nothing could be reused).
pub decodable: bool,
/// The Japanese fallback face the font subsets, when the line is in one (see [`same_font`]).
/// The Japanese or Hebrew fallback face the font subsets, when the line is in one (see [`same_font`]).
face: Option<String>,
/// The content stream piece the line starts in, and its operators (indices into the page's
/// streams parsed joined).
@ -152,7 +152,7 @@ struct Ts {
/// The operators that set the current fill colour (`g`, `rg`, `k`, or `cs` + `sc`/`scn`).
fill: Vec<Op>,
font: Option<(Vec<u8>, Rc<Metrics>)>,
/// The Japanese fallback face the font subsets, when the font is one (see [`is_japanese_fallback`]).
/// The Japanese or Hebrew fallback face the font subsets, when the font is one (see [`is_type3_fallback`]).
face: Option<String>,
size: f64,
char_spacing: f64,
@ -358,7 +358,7 @@ fn interpret(
let (m, face) = cache.entry(name.to_vec()).or_insert_with(|| {
let dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned());
let metrics = dict.as_ref().map_or_else(Metrics::fallback, |d| Metrics::from_dict(doc, d));
let face = dict.filter(is_japanese_fallback).map(|_| metrics.base_font.clone());
let face = dict.filter(is_type3_fallback).map(|_| metrics.base_font.clone());
(Rc::new(metrics), face)
});
ts.font = Some((name.to_vec(), m.clone()));
@ -555,6 +555,10 @@ pub fn text_lines(doc: &Document, page: usize) -> Result<Vec<TextLine>, EditErro
}
last = Some((si, s.bt, s.baseline, s.end_x, s.size.max(1.0)));
}
// Content streams draw right-to-left lines in visual order; the editor works in typing order.
for l in &mut lines {
l.text = crate::bidi::to_logical(&l.text);
}
// Lines of only spaces aren't editable text.
lines.retain(|l| !l.text.trim().is_empty());
Ok(lines)
@ -741,14 +745,17 @@ fn needs_type3(text: &str) -> bool {
/// The `/Name` of the Type 3 fonts generated for Japanese text.
const JAPANESE_FONT_NAME: &str = "PCJapanese";
/// The `/Name` of the Type 3 fonts generated for Hebrew text.
const HEBREW_FONT_NAME: &str = "PCHebrew";
/// Whether `font` is one of those generated fonts: a replacement longer than one of them is split
/// across several, which read back as one line (see [`same_font`]).
fn is_japanese_fallback(font: &Dict) -> bool {
font.name(b"Subtype") == Some(b"Type3") && font.name(b"Name") == Some(JAPANESE_FONT_NAME.as_bytes())
/// Whether `font` is one of those generated fonts (Japanese or Hebrew): a replacement longer than
/// one of them is split across several, which read back as one line (see [`same_font`]).
fn is_type3_fallback(font: &Dict) -> bool {
font.name(b"Subtype") == Some(b"Type3")
&& font.name(b"Name").is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes())
}
/// Whether a run shown in `font` (a resource name, and the Japanese fallback face it subsets, if
/// Whether a run shown in `font` (a resource name, and the Japanese or Hebrew fallback face it subsets, if
/// any) continues `line`: the same font resource, or another subset of the same face.
fn same_font(line: &TextLine, font: &[u8], face: Option<&str>) -> bool {
line.font.as_bytes() == font || (line.face.is_some() && line.face.as_deref() == face)
@ -777,7 +784,7 @@ const MAX_TYPE3_GLYPHS: usize = 240;
struct Type3Fallback {
/// The family its glyphs come from ("Shippori Mincho").
family: &'static str,
/// The fonts' resource names in order; the first is `PCJp`.
/// The fonts' resource names in order; the first is `PCJp` (`PCHe` for Hebrew text).
names: Vec<String>,
/// Every unique character with its advance (em), in order of first appearance.
widths: Vec<(char, f64)>,
@ -800,8 +807,8 @@ 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!("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)),
GlyphError::Missing => EditError::Invalid(format!("the {} fallback font has no glyph for U+{:04X}", face.family, ch as u32)),
GlyphError::TooComplex => EditError::Invalid(format!("the {} fallback glyph U+{:04X} is too complex", face.family, ch as u32)),
})?;
let (dx, width) = proportional(ch, &glyph);
let scale = 1000.0;
@ -865,6 +872,32 @@ fn fallback_face<'a>(faces: &[&'a CraftFont], chars: &[char], has_glyph: impl Fn
faces.iter().copied().find(|face| chars.iter().all(|ch| has_glyph(face, *ch))).or_else(|| faces.first().copied())
}
/// This build has no Hebrew face to draw replacement text with.
fn no_hebrew_font() -> EditError {
EditError::Invalid(
"this text needs PdfCraft's Hebrew fallback font (Noto Sans Hebrew), which this build doesn't include \
(to build it in, set CRAFT_FONTS_DIR to a craft-fonts checkout that has it)"
.into(),
)
}
/// The faces a Type 3 replacement may draw `text` with, best first. Text with Hebrew takes the
/// Hebrew face first (its Latin and digits too, so a line keeps one look), then the Japanese
/// faces for anything it lacks; other text takes the Japanese faces, as before.
fn type3_faces(text: &str, family: crate::added::Family, bold: bool) -> Result<(Vec<&'static CraftFont>, bool), EditError> {
let japanese = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
if !text.chars().any(crate::bidi::is_hebrew) {
return Ok((japanese, false));
}
// Points are drawn on their letter with the font's positioning, which a one-glyph-per-
// character Type 3 font can't do; refuse rather than print them beside the letter.
if text.chars().any(crate::bidi::is_hebrew_mark) {
return Err(EditError::Invalid("Hebrew points (niqqud) can't be written into a PDF yet; type the text without them".into()));
}
let hebrew = pdfcraft_fonts::document_hebrew_font(bold).ok_or_else(no_hebrew_font)?;
Ok((std::iter::once(hebrew).chain(japanese).collect(), true))
}
fn type3_font(
doc: &mut Document,
fonts_res: &mut Dict,
@ -873,7 +906,7 @@ fn type3_font(
bold: bool,
max_glyphs: usize,
) -> Result<Type3Fallback, EditError> {
let faces = pdfcraft_fonts::document_japanese_fonts_for_style(family == crate::added::Family::Times, bold);
let (faces, hebrew) = type3_faces(text, family, bold)?;
// Each unique character once, in order of first appearance.
let mut chars = Vec::new();
let mut seen = std::collections::HashSet::new();
@ -886,16 +919,20 @@ fn type3_font(
return Err(EditError::Invalid("replacement text is empty".into()));
}
// The faces differ in coverage (e.g. of Cyrillic), so use the best face that has every
// character. When none has them all, the best face reports the character it lacks.
let face = fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(no_japanese_font)?;
// character. When none has them all, the best face reports the character it lacks. Hebrew
// text keeps the Hebrew face and takes each character it lacks from the next face that has it.
let face = if hebrew { faces.first().copied() } else { fallback_face(&faces, &chars, |face, ch| japanese_glyph_from(face, ch).is_ok()) }
.ok_or_else(no_japanese_font)?;
let others: &[&CraftFont] = if hebrew { faces.get(1..).unwrap_or_default() } else { &[] };
// Every outline is read before the document is touched, so a character that fails leaves it
// as it was. Each font takes the next `per_font` characters; codes restart at 1 in each.
let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS);
let mut glyphs = Vec::with_capacity(chars.len());
for (i, ch) in chars.into_iter().enumerate() {
let (path, width) = type3_path(face, ch)?;
// Hebrew text: the first face with the glyph, else the Hebrew face's error.
let (path, width) = others.iter().fold(type3_path(face, ch), |found, f| found.or_else(|e| type3_path(f, ch).map_err(|_| e)))?;
let code = u8::try_from((i % per_font).saturating_add(1))
.map_err(|_| EditError::Invalid("Japanese replacement has too many unique characters".into()))?;
.map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?;
glyphs.push(Type3Glyph { code, ch, path, width });
}
let mut codes = HashMap::with_capacity(glyphs.len());
@ -911,14 +948,15 @@ fn type3_font(
codes.insert(glyph.ch, (names.len(), glyph.code));
widths.push((glyph.ch, glyph.width));
}
names.push(add_type3_font(doc, fonts_res, face, group));
names.push(add_type3_font(doc, fonts_res, face, hebrew, group));
}
Ok(Type3Fallback { family: face.family, names, widths, codes })
}
/// Adds one generated font whose codes 1 to `glyphs.len()` show `glyphs`, and returns its resource
/// name (`PCJp`, then `PCJp1`, ...). Nothing here can fail: the outlines were read already.
fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, glyphs: Vec<Type3Glyph>) -> String {
/// name (`PCJp`, then `PCJp1`, ...; `PCHe`, ... for Hebrew text). Nothing here can fail: the
/// outlines were read already.
fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, hebrew: bool, glyphs: Vec<Type3Glyph>) -> String {
let last_char = glyphs.len();
let mut charprocs = Dict::new();
let mut widths = Vec::with_capacity(last_char);
@ -947,7 +985,7 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, gl
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"Name".to_vec(), Object::name(JAPANESE_FONT_NAME));
font.set(b"Name".to_vec(), Object::name(if hebrew { HEBREW_FONT_NAME } else { JAPANESE_FONT_NAME }));
let mut descriptor = Dict::new();
descriptor.set(b"Type".to_vec(), Object::name("FontDescriptor"));
descriptor.set(b"FontName".to_vec(), Object::name(&format!("{}-{}", face.family, face.style).replace(' ', "")));
@ -967,11 +1005,12 @@ fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &CraftFont, gl
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref));
let mut name = String::from("PCJp");
let prefix = if hebrew { "PCHe" } else { "PCJp" };
let mut name = String::from(prefix);
let mut suffix = 0usize;
while fonts_res.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("PCJp{suffix}");
name = format!("{prefix}{suffix}");
}
fonts_res.set(name.as_bytes().to_vec(), Object::Dict(font));
name
@ -1010,7 +1049,9 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
// The line's own font, when it can show every character.
let font = fonts_res.get(target.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d));
let reused = font.as_ref().and_then(|m| m.encode(&text));
// Typed in logical order; drawn left to right (right-to-left runs reversed).
let shown = crate::bidi::to_visual(&text);
let reused = font.as_ref().and_then(|m| m.encode(&shown));
let mut substituted = None;
let mut replacement: Vec<Op> = Vec::new();
// ' and " also move to the next line; keep that.
@ -1028,8 +1069,8 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
None => {
if needs_type3(&text) {
let fallback = type3_font(doc, &mut fonts_res, &text, source_family(&target.base_font), target.bold, max_glyphs)?;
let runs = type3_encode(&fallback, &text)
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the Japanese fallback")))?;
let runs = type3_encode(&fallback, &shown)
.ok_or_else(|| EditError::Invalid(format!("\"{text}\" can't be shown by the {} fallback", fallback.family)))?;
let size = font_size_before(&ops, first).unwrap_or(target.size);
for (name, bytes) in runs {
replacement.push(Op::new("Tf", vec![Object::name(&name), pdfcraft_content::num(size)]));
@ -1038,10 +1079,10 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
replacement.push(Op::new("Tf", vec![Object::name(&target.font), pdfcraft_content::num(size)]));
substituted = Some(format!("{} Type3", fallback.family));
} else {
let win = pdfcraft_fonts::win_ansi(&text);
let win = pdfcraft_fonts::win_ansi(&shown);
// WinAnsi turns what it can't show into '?'; refuse rather than print the wrong thing.
let back: String = win.iter().map(|b| char::from_u32(u32::from(*b)).unwrap_or('?')).collect();
if text.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
if shown.chars().zip(back.chars()).any(|(a, b)| b == '?' && a != '?') {
return Err(EditError::Invalid(format!("\"{text}\" has characters neither {} nor Helvetica can show", target.base_font)));
}
// The size in text space: the current Tf's size.
@ -1292,15 +1333,26 @@ pub(crate) fn rewrite_block_with_limit(
if !(dx.is_finite() && dy.is_finite()) {
return Err(EditError::Invalid("the paragraph can't be moved that far".into()));
}
// A paragraph keeps its width (or takes the one asked for); a single line grows to the right,
// up to the page's margin.
// Right-to-left text (more Hebrew or Arabic letters than Latin) is right-aligned unless an
// alignment is asked for, and a single right-to-left line keeps its right edge as it grows.
let rtl = crate::bidi::rtl_base(&text);
let anchored_right = rtl && style.width.is_none() && members.len() == 1;
// A paragraph keeps its width (or takes the one asked for); a single line grows to the right
// (to the left when right-to-left), up to the page's margin.
let width = match style.width {
// Capped at the largest page PDF allows (14 400 pt).
Some(w) if w.is_finite() => w.clamp(size * k, 14_400.0),
Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())),
None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (p.crop(doc)[0] + 36.0)),
None if members.len() == 1 => (b.rect[2] - b.rect[0]).max(size * k).max(p.crop(doc)[2] - 36.0 - (b.rect[0] + dx)),
None => (b.rect[2] - b.rect[0]).max(size * k),
};
if !width.is_finite() {
return Err(EditError::Invalid("the paragraph's width must be a number".into()));
}
let align = style.align.or(rtl.then_some(crate::added::Align::Right));
// Where a right-aligned line ends, from the paragraph's left edge (user space).
let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width };
// The move and the width are checked here, before the fallback fonts are written: a refused
// paragraph leaves the document as it was. The move is in page space, so it is taken back through
// the CTM's linear part into the space the paragraph is drawn in.
@ -1397,32 +1449,38 @@ pub(crate) fn rewrite_block_with_limit(
// Alignment: each line's offset from the left edge, in text space.
let offset = |line: &str| -> f64 {
let free = (width - advance(line)).max(0.0) / k;
match style.align {
match align {
Some(crate::added::Align::Center) => free / 2.0,
// A right-to-left line anchored at its right edge may start left of the old box.
Some(crate::added::Align::Right) if anchored_right => (right_edge - advance(line)) / k,
Some(crate::added::Align::Right) => free,
// A justified right-to-left paragraph ends its last line at the right edge.
Some(crate::added::Align::Justify) if rtl => free,
_ => 0.0,
}
};
// Justify: word spacing (single-byte code 32 only) so each line but the last fills the width.
let single_byte = !reuse || metrics.as_ref().is_some_and(|m| !m.composite);
let justify = style.align == Some(crate::added::Align::Justify) && single_byte;
let justify = align == Some(crate::added::Align::Justify) && single_byte;
let mut x = 0.0;
let mut tw_set = 0.0;
let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space
for (i, line) in wrapped.iter().enumerate() {
let dx = offset(line);
let spaces = line.matches(' ').count();
let tw =
if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 };
// A stretched line fills the width from the left edge.
let dx = if tw > 0.0 { 0.0 } else { offset(line) };
if i > 0 || dx != 0.0 {
block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })]));
}
x = dx;
let spaces = line.matches(' ').count();
let tw =
if justify && i + 1 < wrapped.len() && spaces > 0 { (width - advance(line)).max(0.0) / k / o_state.scale / spaces as f64 } else { 0.0 };
if tw != tw_set {
block_ops.push(Op::new("Tw", vec![n(tw)]));
tw_set = tw;
}
for (font, bytes) in encode(line).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
// Each wrapped line in typing order; drawn left to right (right-to-left runs reversed).
for (font, bytes) in encode(&crate::bidi::to_visual(line)).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? {
if font != shown_font {
block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)]));
shown_font = font;
@ -1695,4 +1753,27 @@ mod tests {
assert_eq!(fallback_face(&faces, &['a', 'ф'], |_, ch| ch.is_ascii()).map(|f| f.family), Some("Mincho"));
assert!(fallback_face(&[], &['a'], has).is_none());
}
#[test]
fn hebrew_and_japanese_fallback_shards_are_recognized_as_ours() {
static HEBR: &[&str] = &["Hebr"];
let face = CraftFont { family: "FakeHebrew", style: "Regular", scripts: HEBR, bytes: b"" };
let glyph = |code, ch| Type3Glyph { code, ch, path: b"0 0 0 0 0 0 d1\nf\n".to_vec(), width: 0.5 };
let mut doc = Document::new_empty();
let mut fonts = Dict::new();
// A replacement split across two Hebrew fonts, then one Japanese font.
let he = add_type3_font(&mut doc, &mut fonts, &face, true, vec![glyph(1, 'א')]);
let he1 = add_type3_font(&mut doc, &mut fonts, &face, true, vec![glyph(1, 'ב')]);
let jp = add_type3_font(&mut doc, &mut fonts, &face, false, vec![glyph(1, 'あ')]);
assert_eq!([he.as_str(), he1.as_str(), jp.as_str()], ["PCHe", "PCHe1", "PCJp"]);
for name in [&he, &he1, &jp] {
let font = fonts.get(name.as_bytes()).and_then(Object::as_dict).cloned().unwrap_or_default();
assert!(is_type3_fallback(&font), "{name} isn't recognized as a generated fallback font");
}
// Someone else's Type 3 font is not ours.
let mut other = Dict::new();
other.set(b"Subtype".to_vec(), Object::name("Type3"));
other.set(b"Name".to_vec(), Object::name("PCHebrewX"));
assert!(!is_type3_fallback(&other));
}
}

View File

@ -85,7 +85,7 @@ impl Session {
pub fn compare_report(&self, old: DocId, new: DocId) -> Result<Arc<Vec<u8>>, EditError> {
let c = self.compare(old, new)?;
let name = |id| self.get(id).map(|d| d.name.clone()).unwrap_or_default();
self.create_from_text("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
self.create_report("Compare Report", &pdfcraft_compare::report(&c, &name(old), &name(new)))
}
/// Mark the differences in `new` as comments: highlights over replaced and inserted text

View File

@ -56,7 +56,7 @@ const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub const DATE_LANGUAGES: [DateLanguage; 17] = [
pub static DATE_LANGUAGES: [DateLanguage; 21] = [
DateLanguage {
code: "en",
name: "English",
@ -160,6 +160,15 @@ pub const DATE_LANGUAGES: [DateLanguage; 17] = [
days: ["воскресенье", "понедельник", "вторник", "среда", "четверг", "пятница", "суббота"],
days_short: ["вс", "пн", "вт", "ср", "чт", "пт", "сб"],
},
DateLanguage {
code: "kk",
name: "Қазақша",
months: ["қаңтар", "ақпан", "наурыз", "сәуір", "мамыр", "маусым", "шілде", "тамыз", "қыркүйек", "қазан", "қараша", "желтоқсан"],
months_with_day: None,
months_short: ["қаң", "ақп", "нау", "сәу", "мам", "мау", "шіл", "там", "қыр", "қаз", "қар", "жел"],
days: ["жексенбі", "дүйсенбі", "сейсенбі", "сәрсенбі", "бейсенбі", "жұма", "сенбі"],
days_short: ["жк", "дс", "сс", "ср", "бс", "жм", "сб"],
},
DateLanguage {
code: "bg",
name: "Български",
@ -263,6 +272,72 @@ pub const DATE_LANGUAGES: [DateLanguage; 17] = [
days: ["niedziela", "poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota"],
days_short: ["niedz.", "pon.", "wt.", "śr.", "czw.", "pt.", "sob."],
},
DateLanguage {
code: "sl",
name: "Slovenščina",
months: ["januar", "februar", "marec", "april", "maj", "junij", "julij", "avgust", "september", "oktober", "november", "december"],
months_with_day: Some([
"januarja",
"februarja",
"marca",
"aprila",
"maja",
"junija",
"julija",
"avgusta",
"septembra",
"oktobra",
"novembra",
"decembra",
]),
months_short: ["jan.", "feb.", "mar.", "apr.", "maj", "jun.", "jul.", "avg.", "sep.", "okt.", "nov.", "dec."],
days: ["nedelja", "ponedeljek", "torek", "sreda", "četrtek", "petek", "sobota"],
days_short: ["ned.", "pon.", "tor.", "sre.", "čet.", "pet.", "sob."],
},
DateLanguage {
code: "el",
name: "Ελληνικά",
months: [
"Ιανουάριος",
"Φεβρουάριος",
"Μάρτιος",
"Απρίλιος",
"Μάιος",
"Ιούνιος",
"Ιούλιος",
"Αύγουστος",
"Σεπτέμβριος",
"Οκτώβριος",
"Νοέμβριος",
"Δεκέμβριος",
],
months_with_day: Some([
"Ιανουαρίου",
"Φεβρουαρίου",
"Μαρτίου",
"Απριλίου",
"Μαΐου",
"Ιουνίου",
"Ιουλίου",
"Αυγούστου",
"Σεπτεμβρίου",
"Οκτωβρίου",
"Νοεμβρίου",
"Δεκεμβρίου",
]),
months_short: ["Ιαν", "Φεβ", "Μαρ", "Απρ", "Μαΐ", "Ιουν", "Ιουλ", "Αυγ", "Σεπ", "Οκτ", "Νοε", "Δεκ"],
days: ["Κυριακή", "Δευτέρα", "Τρίτη", "Τετάρτη", "Πέμπτη", "Παρασκευή", "Σάββατο"],
days_short: ["Κυρ", "Δευ", "Τρί", "Τετ", "Πέμ", "Παρ", "Σάβ"],
},
DateLanguage {
code: "tr",
name: "Türkçe",
months: ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"],
months_with_day: None,
months_short: ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"],
days: ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"],
days_short: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
},
];
/// The date language for `code` (any case), or `None` when there's none.

View File

@ -38,7 +38,7 @@ pub use pdfcraft_organize::LabelStyle;
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
pub use pdfcraft_cos::Algorithm;
pub use pdfcraft_create::{CONVERTIBLE, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
pub use pdfcraft_edit::{
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
};
@ -49,7 +49,7 @@ pub use pdfcraft_forms::{
};
pub use pdfcraft_a11y as a11y;
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable, first_undrawable_plain, undrawable_message};
pub use pdfcraft_edit::{BlockStyle, PageImage, TextBlock, TextLine, first_undrawable};
pub use pdfcraft_measure as measure;
pub use pdfcraft_xfa::Report as XfaLayout;
@ -72,7 +72,7 @@ pub enum ImageEdit {
},
Delete,
}
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_outline};
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, 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.
@ -108,8 +108,8 @@ pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
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, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps, ReviewState, Rgb,
Shape, StampGroup, StampKind, Style, rect_quad,
AppearanceCache, 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;
@ -166,6 +166,10 @@ enum Scope {
Comments,
/// Only form field values (and their widget appearances).
Form,
/// Only page content streams, resources and added content/marks: the page tree,
/// annotations, form fields, links, outline and fonts are unchanged, so the full
/// re-inspection is skipped (seconds on very large files).
Content,
}
/// Edits during which field scripts run.
@ -199,6 +203,21 @@ fn scope_of(edit: &Edit) -> Scope {
| Edit::FillAnnotation { .. }
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
// Page content, resources and added content/marks only: the page tree, annotations,
// fields, links and document metadata are untouched (see `refresh_content`).
Edit::MoveObjects { .. }
| Edit::EditTextLine { .. }
| Edit::EditTextBlock { .. }
| Edit::EditPageImage { .. }
| Edit::AddText { .. }
| Edit::AddImage { .. }
| Edit::UpdateContent { .. }
| Edit::DeleteContent { .. }
| Edit::ReplaceImage { .. }
| Edit::AddHeaderFooter { .. }
| Edit::AddWatermark { .. }
| Edit::AddBackground { .. }
| Edit::RemoveMarks { .. } => Scope::Content,
Edit::Batch { edits, .. } => {
let mut scopes = edits.iter().map(scope_of);
let first = scopes.next().unwrap_or(Scope::Full);
@ -218,6 +237,10 @@ struct Editor {
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
keys: Keys,
/// Display appearances built for annotations without any, reused while no annotation edit
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
/// that may change annotations; see [`AppearanceCache`].
appearances: AppearanceCache,
}
pub struct Document {
@ -262,9 +285,87 @@ pub struct Document {
pub xfa: Option<XfaLayout>,
/// The parsed template of a laid-out XFA form, for its scripts.
xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
fonts_stale: bool,
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
/// (also in `info.warnings`, kept there when the document is re-read).
pub xfa_warnings: Vec<String>,
/// What the last successful [`Session::apply`] wrote that its fonts can't draw: kept as typed,
/// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it.
pub edit_notices: Vec<TextNotice>,
}
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat
/// keeps such text too, drawing it with fonts PdfCraft can't embed yet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextNotice {
/// The form field's full name, or `None` for a comment.
pub field: Option<String>,
/// The page (0-based) of the comment or of the field's widget.
pub page: usize,
pub ch: char,
/// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances`
/// set); otherwise the `?` shows in every viewer.
pub redrawn_elsewhere: bool,
}
impl std::fmt::Display for TextNotice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch));
let page = self.page + 1;
match (&self.field, self.redrawn_elsewhere) {
(Some(name), true) => write!(
f,
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value"
),
(Some(name), false) => write!(
f,
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere"
),
(None, _) => write!(
f,
"a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer"
),
}
}
}
/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying
/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order.
fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<TextNotice> {
let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
let mut names: Option<std::collections::HashMap<pdfcraft_cos::ObjRef, String>> = None;
let mut out: Vec<TextNotice> = Vec::new();
for (page, p) in pages.iter().enumerate() {
let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a));
for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) {
let now = next.get(r);
let Some(d) = now.as_dict() else { continue };
let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue };
// Only what this edit drew: a new annotation, or a new appearance.
let was = prev.get(r);
if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) {
continue;
}
let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| {
let names = names.get_or_insert_with(|| {
pdfcraft_forms::fields(next)
.into_iter()
.flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>())
.collect()
});
names.get(&r).cloned().unwrap_or_default()
});
if field.is_some() && out.iter().any(|n| n.field == field) {
continue;
}
out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch });
}
}
out
}
impl Document {
@ -592,8 +693,10 @@ fn is_placeholder_title(title: &str) -> bool {
/// What is displayed: the working file, plus (in memory only, never saved) the appearances
/// PdfCraft draws for comments that have none ([`pdfcraft_annot::with_missing_appearances`]).
/// The working file is unchanged, and so is everything that reads it (saving, signatures, …).
fn display_bytes(editor: Option<&Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances(&e.cos)) else { return bytes.clone() };
fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
let Some(display) = editor.and_then(|e| pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances)) else {
return bytes.clone();
};
// Writing fails only as saving the document would; the page then shows as it always did.
write_incremental(&display, &SaveOptions::default()).map(Arc::new).unwrap_or_else(|_| bytes.clone())
}
@ -2324,8 +2427,10 @@ pub struct Session {
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
let report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
xfa_redraw(doc)?;
// The form's own values: one its field's font can't draw is drawn with `?` and reported.
report.warnings.extend(undrawn_field_warnings(doc));
Ok(report)
}
@ -2341,6 +2446,17 @@ pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String>
Ok(())
}
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
/// can't draw (laying out an XFA form's own values).
fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec<String> {
pdfcraft_forms::undrawn_fields(doc)
.into_iter()
.map(|(name, c)| {
format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c))
})
.collect()
}
/// The form's fields as the XFA data layer wants them.
fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum> {
use pdfcraft_forms::FieldKind as K;
@ -2657,12 +2773,12 @@ impl Session {
let (mut editor, read_only_reason) = match cos {
Ok(Ok(cos)) => {
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys }), None)
(Some(Editor { cos, undo: Vec::new(), redo: Vec::new(), keys, appearances: AppearanceCache::default() }), None)
}
Ok(Err(e)) => (None, Some(e.to_string())),
Err(_) => (None, Some("the document structure could not be read for editing".into())),
};
let display = display_bytes(editor.as_ref(), &bytes);
let display = display_bytes(editor.as_mut(), &bytes);
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
let (form, adopted) = editor
.as_ref()
@ -2720,7 +2836,9 @@ impl Session {
js_output: Default::default(),
xfa,
xfa_template: None,
fonts_stale: false,
xfa_warnings: Vec::new(),
edit_notices: Vec::new(),
});
Ok(id)
}
@ -2735,6 +2853,7 @@ impl Session {
let today = self.today();
let js_off = self.js_off;
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
let name = doc.name.clone();
let is_xfa = doc.info.xfa.is_some();
let mut cx = EditCtx::new(now, doc.generation ^ (id.0 << 48));
@ -2764,7 +2883,7 @@ impl Session {
// every calculation (and a reset recalculates). They read the data, so it is brought up
// to date first.
if let Some(tpl) = cx.xfa.clone() {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) {
let datasets = &mut cx.xfa_datasets;
let notes = guard(|| xfa_sync_datasets(&mut next, Some(&tpl), datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
@ -2797,7 +2916,7 @@ impl Session {
}
// XFA forms keep their values in the datasets packet too, for Adobe's viewers.
let mut xfa_notes = Vec::new();
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full) {
if is_xfa && matches!(scope_of(&edit), Scope::Form | Scope::Full | Scope::Content) {
let (tpl, datasets) = (cx.xfa.as_deref(), &mut cx.xfa_datasets);
xfa_notes = guard(|| xfa_sync_datasets(&mut next, tpl, datasets))
.unwrap_or_else(|m| Err(format!("writing the XFA data failed unexpectedly ({m})")))
@ -2809,6 +2928,13 @@ impl Session {
if signed && rewrites(&next) && !rewrites(&editor.cos) {
return Err(EditError::SignedRewrite(edit.label().to_string()));
}
// What this edit drew with `?` (a value or comment text its font can't draw, stored as
// typed): reported, never silent (AGENTS.md §4).
let notices = if matches!(scope_of(&edit), Scope::Form | Scope::Comments | Scope::Full) {
guard(|| undrawn_notices(&editor.cos, &next)).unwrap_or_default()
} else {
Vec::new()
};
let previous = std::mem::replace(&mut editor.cos, next);
let keys = keys_after(&edit).unwrap_or_else(|| editor.keys.clone());
let previous_keys = std::mem::replace(&mut editor.keys, keys);
@ -2819,7 +2945,8 @@ impl Session {
}
editor.redo.clear();
Self::adopt_keys(doc);
let refreshed = guard(|| Self::refresh_scoped(doc, scope)).unwrap_or_else(|m| Err(EditError::Reopen(m)));
let refreshed = guard(|| if scope == Scope::Content { Self::refresh_content(doc, Some(&edit)) } else { Self::refresh_scoped(doc, scope) })
.unwrap_or_else(|m| Err(EditError::Reopen(m)));
if let Err(e) = refreshed {
// Roll back: the edit produced something we cannot display.
if let Some(ed) = doc.editor.as_mut()
@ -2834,6 +2961,7 @@ impl Session {
}
doc.dirty = true;
doc.generation += 1;
doc.edit_notices = notices;
note_warnings(&mut doc.xfa_warnings, &xfa_notes);
let notes = doc.xfa_warnings.clone();
note_warnings(&mut doc.info.warnings, &notes);
@ -2850,6 +2978,7 @@ impl Session {
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
let editor = doc.editor.as_mut().ok_or(EditError::NothingToUndo)?;
let (label, prev, keys, scope) = editor.undo.pop().ok_or(EditError::NothingToUndo)?;
let current = std::mem::replace(&mut editor.cos, prev);
@ -2864,6 +2993,7 @@ impl Session {
pub fn redo(&mut self, id: DocId) -> Result<Label, EditError> {
let doc = self.doc_mut(id)?;
doc.edit_notices.clear();
let editor = doc.editor.as_mut().ok_or(EditError::NothingToRedo)?;
let (label, next, keys, scope) = editor.redo.pop().ok_or(EditError::NothingToRedo)?;
let current = std::mem::replace(&mut editor.cos, next);
@ -2892,7 +3022,15 @@ impl Session {
if scope == Scope::Full || doc.info.encrypted != doc.editor.as_ref().is_some_and(|e| e.cos.output_handler().is_some()) {
return Self::refresh(doc);
}
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
if scope == Scope::Content {
return Self::refresh_content(doc, None);
}
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// Comment edits, and form edits (a script may change annotation properties), may change
// annotation dictionaries, invalidating cached appearances.
if matches!(scope, Scope::Comments | Scope::Form) {
editor.appearances.clear();
}
let bytes = if editor.cos.is_modified() {
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
} else {
@ -2943,6 +3081,8 @@ impl Session {
}
// Handled above; kept as a full refresh so the match stays total.
Scope::Full => return Self::refresh(doc),
// Handled above (content-only refresh); the arm keeps the match total.
Scope::Content => return Self::refresh_content(doc, None),
}
doc.info.file_size = bytes.len();
doc.form = form;
@ -2955,9 +3095,68 @@ impl Session {
Ok(())
}
/// Which derived lists a content-only edit can change: added content (`None`, from
/// undo/redo, re-reads both: always correct, rarely paid).
fn content_refresh_lists(edit: Option<&Edit>) -> (bool, bool) {
let touches_added = edit.is_none_or(|e| {
matches!(
e,
Edit::AddText { .. } | Edit::AddImage { .. } | Edit::UpdateContent { .. } | Edit::DeleteContent { .. } | Edit::ReplaceImage { .. }
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
});
let touches_marks = edit.is_none_or(|e| {
matches!(e, Edit::AddHeaderFooter { .. } | Edit::AddWatermark { .. } | Edit::AddBackground { .. } | Edit::RemoveMarks { .. })
});
(touches_added, touches_marks)
}
/// Rebuild working bytes, display bytes and renderer after a content-only edit: page
/// content streams, resources and added content/marks. The page tree, annotations, form
/// fields, links, outline and fonts are unchanged, so the full re-inspection (seconds on
/// very large files) is skipped. `edit` selects which derived lists are re-read (`None`,
/// from undo/redo, re-reads both: always correct, rarely paid). The cached display
/// appearances are kept: content edits leave every annotation dictionary alone.
fn refresh_content(doc: &mut Document, edit: Option<&Edit>) -> Result<(), EditError> {
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
{
doc.xfa = Some(report);
}
let bytes = if editor.cos.is_modified() {
Arc::new(write_incremental(&editor.cos, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?)
} else {
editor.cos.bytes().clone()
};
// The edit must still leave a file the viewer can open; anything else rolls back above.
pdfcraft_render::check_pages(bytes.clone(), doc.password.as_deref(), doc.info.pages.len()).map_err(|e| EditError::Reopen(e.to_string()))?;
let (touches_added, touches_marks) = Self::content_refresh_lists(edit);
if touches_added {
doc.added = pdfcraft_edit::list_added(&editor.cos);
}
if touches_marks {
doc.marks = pdfcraft_edit::marks_present(&editor.cos);
}
doc.info.file_size = bytes.len();
if !doc.signatures.is_empty() {
doc.signatures = signatures_of(&editor.cos, &bytes, &doc.trust, &doc.sig_cache);
}
doc.display = display_bytes(Some(editor), &bytes);
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
doc.bytes = bytes;
doc.fonts_stale = true;
Ok(())
}
/// Rebuild working bytes, inspection and renderer from the current edit state.
fn refresh(doc: &mut Document) -> Result<(), EditError> {
let Some(editor) = doc.editor.as_ref() else { return Ok(()) };
// A full refresh may follow structural or annotation changes: cached appearances
// may be stale, so they are rebuilt below.
if let Some(editor) = doc.editor.as_mut() {
editor.appearances.clear();
}
let Some(editor) = doc.editor.as_mut() else { return Ok(()) };
// A script may have laid an XFA form out again (rows added, subforms shown).
if doc.xfa.is_some()
&& let Some(report) = pdfcraft_xfa::existing_layout(&editor.cos)
@ -2979,6 +3178,7 @@ impl Session {
}
note_warnings(&mut info.warnings, &doc.xfa_warnings);
doc.info = info;
doc.fonts_stale = false;
let mut form = pdfcraft_forms::fields(&editor.cos);
xfa::mark_script_buttons(&editor.cos, &mut form);
doc.form = Arc::new(form);
@ -3136,11 +3336,20 @@ impl Session {
self.write_new(&pdfcraft_create::from_images_with_resolution(images, resolution)?)
}
/// A new document from plain text (US Letter, 11 pt Helvetica).
/// A new document from plain text (US Letter, 11 pt Helvetica). Text Helvetica can't draw is
/// refused, naming the line and the character (never set as `?`).
pub fn create_from_text(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
self.write_new(&pdfcraft_create::from_text(title, text, pdfcraft_create::LETTER, 11.0)?)
}
/// [`Self::create_from_text`] for a report PdfCraft writes from documents (comment summaries,
/// compare reports), which can quote any language: characters Helvetica can't draw are written
/// as `[U+XXXX]`, and the report's first line says so, rather than the report being refused.
fn create_report(&self, title: &str, text: &str) -> Result<Arc<Vec<u8>>, EditError> {
let (doc, _escaped) = pdfcraft_create::from_text_escaped(title, text, pdfcraft_create::LETTER, 11.0)?;
self.write_new(&doc)
}
/// Convert a file Create understands (an image or plain text) to PDF bytes; a PDF is checked
/// (it must open and allow copying pages) and returned as it is.
pub fn convert_to_pdf(&self, name: &str, bytes: &Arc<Vec<u8>>) -> Result<(SourceKind, Arc<Vec<u8>>), EditError> {
@ -3557,7 +3766,7 @@ impl Session {
pub fn summarize_comments(&self, id: DocId, sort: SummarySort) -> Result<Arc<Vec<u8>>, EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
let text = comment_summary(&doc.name, &doc.info.annotations, sort);
self.create_from_text(&format!("Summary of Comments on {}", doc.name), &text)
self.create_report(&format!("Summary of Comments on {}", doc.name), &text)
}
/// The certificates trusted for signing.
@ -3655,6 +3864,16 @@ impl Session {
self.docs.iter().find(|d| d.id == id)
}
/// Read document `id`'s font list again if content edits since the last full inspection may
/// have changed it (Document Properties ▸ Fonts, `doc_info`). A no-op otherwise.
pub fn refresh_fonts(&mut self, id: DocId) {
let Some(doc) = self.docs.iter_mut().find(|d| d.id == id && d.fonts_stale) else { return };
if let Ok(info) = pdfcraft_render::inspect(doc.bytes.clone(), doc.password.as_deref()) {
doc.info.fonts = info.fonts;
}
doc.fonts_stale = false;
}
pub fn docs(&self) -> &[Document] {
&self.docs
}

View File

@ -1170,6 +1170,34 @@ fn form_fixture() -> Vec<u8> {
out
}
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
/// viewers to redraw it.
#[test]
fn a_value_its_font_cannot_draw_is_reported_by_the_edit_that_drew_it() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let id = s.open("form.pdf", None, Arc::new(form_fixture()), None).unwrap();
let fill = |s: &mut Session, name: &str, v: FieldValue| s.apply(id, Edit::SetFieldValue { name: name.into(), value: v });
fill(&mut s, "name", FieldValue::Text("张三".into())).unwrap();
let d = s.get(id).unwrap();
assert_eq!(d.form[0].value, ["张三"]);
let n = &d.edit_notices;
assert_eq!(n.len(), 1, "{n:?}");
assert_eq!((n[0].field.as_deref(), n[0].page, n[0].ch, n[0].redrawn_elsewhere), (Some("name"), 0, '张', true));
assert!(n[0].to_string().contains("\"张\" (U+5F20)") && n[0].to_string().contains("other PDF viewers redraw"), "{}", n[0]);
fill(&mut s, "ok", FieldValue::Check(true)).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty(), "an unrelated edit reports nothing");
fill(&mut s, "name", FieldValue::Text("李四".into())).unwrap();
assert_eq!(s.get(id).unwrap().edit_notices.len(), 1, "a new value is reported again");
assert!(fill(&mut s, "missing", FieldValue::Text("x".into())).is_err());
assert!(s.get(id).unwrap().edit_notices.is_empty(), "no stale notice after a failed edit");
fill(&mut s, "name", FieldValue::Text("Ada".into())).unwrap();
assert!(s.get(id).unwrap().edit_notices.is_empty());
let saved = pdfcraft_cos::Document::open(s.save_bytes(id).unwrap()).unwrap();
assert!(pdfcraft_forms::need_appearances(&saved));
}
#[test]
fn filling_a_form_shows_saves_and_undoes() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
@ -3361,3 +3389,103 @@ fn enhance_camera_page_refuses_bad_corners_and_pages() {
assert!(matches!(s.enhance_camera_page(DocId(999), 0, ok), Err(EditError::NoDocument)));
assert_eq!(s.get(id).unwrap().can_undo(), None, "nothing changed");
}
/// A content-only edit must not disturb the inspection results: pages, annotations, fields,
/// links and marks are unchanged, only the working bytes, the added-content list and the
/// display copy move.
#[test]
fn content_edit_keeps_pages_annotations_fields_and_links() {
let (mut session, id) = session_with(2);
let before = session.get(id).unwrap();
let pages = format!("{:?}", before.info.pages);
let annotations = format!("{:?}", before.info.annotations);
let links = format!("{:?}", before.links);
let form = before.form.clone();
let marks = before.marks.clone();
let bytes = before.bytes.clone();
let text = AddedText {
rect: [20.0, 20.0, 200.0, 60.0],
text: "hello".into(),
family: FontFamily::Helvetica,
bold: false,
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
};
session.apply(id, Edit::AddText { page: 1, text }).unwrap();
let after = session.get(id).unwrap();
assert_eq!(format!("{:?}", after.info.pages), pages, "page geometry is untouched");
assert_eq!(format!("{:?}", after.info.annotations), annotations);
assert_eq!(format!("{:?}", after.links), links);
assert_eq!(after.form, form, "no field was touched");
assert_eq!(after.marks, marks, "no mark was touched");
assert!(after.added.iter().any(|a| a.page == 1), "the added text is listed");
assert_ne!(after.bytes, bytes, "the working file carries the edit");
assert_ne!(after.display, bytes, "the display copy carries the edit too");
// The display bytes still open and render the edited page.
let mut renderer = pdfcraft_render::PageRenderer::new(after.display.clone(), after.config.clone());
let rendered = renderer.render(pdfcraft_render::RenderRequest { page: 1, scale: 1.0, ..Default::default() });
assert!(rendered.error.is_none(), "{:?}", rendered.error);
// Undo goes through the same narrow refresh without the edit at hand.
session.undo(id).unwrap();
let undone = session.get(id).unwrap();
assert!(undone.added.is_empty(), "undo drops the added text");
assert_eq!(format!("{:?}", undone.info.pages), pages);
}
/// `refresh_content` re-reads only the derived lists the edit can change (undo/redo, with no
/// edit at hand, re-read both).
#[test]
fn content_refresh_lists_match_the_storages_an_edit_touches() {
let target = |kind| ObjectTarget { kind, index: 0 };
let added = [
Edit::AddText {
page: 0,
text: AddedText {
rect: [0.0, 0.0, 10.0, 10.0],
text: String::new(),
family: FontFamily::Helvetica,
bold: false,
italic: false,
size: 12.0,
color: [0.0, 0.0, 0.0],
align: TextAlign::Left,
},
},
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Added)], offset: [1.0, 0.0] },
];
for edit in &added {
assert_eq!(Session::content_refresh_lists(Some(edit)), (true, false), "{}", edit.label());
}
let plain = [
Edit::MoveObjects { page: 0, objects: vec![target(crate::ObjectKind::Text)], offset: [1.0, 0.0] },
Edit::EditTextBlock { page: 0, block: 0, text: String::new(), style: pdfcraft_edit::BlockStyle::default() },
];
for edit in &plain {
assert_eq!(Session::content_refresh_lists(Some(edit)), (false, false), "{}", edit.label());
}
let marks = [Edit::AddWatermark { pages: vec![0], settings: Watermark::default(), replace: false, file: None }];
for edit in &marks {
assert_eq!(Session::content_refresh_lists(Some(edit)), (false, true), "{}", edit.label());
}
assert_eq!(Session::content_refresh_lists(None), (true, true), "undo/redo re-read both");
}
/// A content edit can add a font (Add text in Times); the narrow refresh leaves the font list
/// as it was, and `refresh_fonts` reads it again before Properties ▸ Fonts or `doc_info` show it.
#[test]
fn fonts_added_by_a_content_edit_show_after_refresh_fonts() {
let (mut session, id) = session_with(1);
let has_times = |s: &Session| s.get(id).unwrap().info.fonts.iter().any(|f| f.name.contains("Times"));
assert!(!has_times(&session));
let text = AddedText { text: "serif".into(), rect: [20.0, 20.0, 200.0, 60.0], family: FontFamily::Times, ..Default::default() };
session.apply(id, Edit::AddText { page: 0, text }).unwrap();
assert!(!has_times(&session), "the narrow refresh doesn't re-inspect");
session.refresh_fonts(id);
assert!(has_times(&session), "{:?}", session.get(id).unwrap().info.fonts);
// Nothing changed since: a second call is a no-op.
let fonts = format!("{:?}", session.get(id).unwrap().info.fonts);
session.refresh_fonts(id);
assert_eq!(format!("{:?}", session.get(id).unwrap().info.fonts), fonts);
}

View File

@ -453,6 +453,7 @@ impl<'a, 'b> Runner<'a, 'b> {
let report = pdfcraft_xfa::rerender(self.doc, self.tpl).map_err(|e| e.to_string())?;
self.console(report.warnings);
crate::xfa_redraw(self.doc)?;
self.console(crate::undrawn_field_warnings(self.doc));
self.by_som = None;
self.fields = None;
self.cached = None;

View File

@ -27,7 +27,7 @@ fn one_page() -> Document {
fn ink_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 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, preview: false });
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;

View File

@ -9,7 +9,13 @@ Today it holds what generated appearances need without a font program:
enough for line breaking and alignment, not for typesetting.
- `wrap`: greedy line breaking with that measure (paragraphs on newlines, long words split).
- `win_ansi`: Unicode → WinAnsiEncoding bytes (`?` for characters it can't represent), and
`literal` to write bytes as a PDF literal string.
`literal` to write bytes as a PDF literal string. Nothing writes that `?` silently. Callers
check `first_undrawable` (or `UnicodeCMap::first_unencodable`, for a character beyond U+FFFF in
UCS-2), then either refuse with `undrawable_message` (one wording that names the character and
what can be written instead) where the `?` would be permanent page content, or, for a form
value or a comment's text, which is stored as typed, draw the `?` and mark the annotation with
`set_undrawn_mark` (`UNDRAWN_MARK`, read back by `undrawn_mark`) so the edit is reported and
flattening refuses. `script_missing` names what the typed-signature script font and its Inter fallback both lack.
It also holds the fonts of the optional [craft-fonts](https://github.com/storytold/craft-fonts)
build input. `build.rs` embeds them as `CRAFT_FONTS` when the build sets
@ -25,6 +31,10 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
titles), in manifest order.
- `ui_telugu_fonts`: the `Telu` faces for Telugu interface text (the Telugu catalog, file names,
document titles), in manifest order.
- `ui_hebrew_fonts` / `document_hebrew_font(bold)`: the `Hebr` faces (Noto Sans Hebrew) for
Hebrew interface text, and the face whose outlines become the Type 3 fallback for Hebrew text
written by Edit text (Bold when asked for and present, else Regular). `None` without a
craft-fonts input that has one; the editor then reports a clear error.
- `document_japanese_font` / `japanese_glyph`: the face (Shippori Mincho, then BIZ UDMincho) whose
outlines become the Type 3 fallback font for Japanese text written into PDFs. Without it,
`japanese_glyph` returns `GlyphError::NoFont` and the editor reports a clear error.
@ -40,6 +50,10 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
- `document_japanese_fonts_for_style`: every `Jpan` face in that order of preference, so the
editor can use the first one that has all of the replacement's glyphs (the faces differ in
coverage, e.g. of Cyrillic).
- `ui_thai_fonts` / `document_thai_font` / `shape_thai`: the `Thai` faces for the interface
and shaped Thai Add Text content. Harfrust positions stacked vowel/tone marks and decomposes
Sara Am; cluster ToUnicode entries preserve the original text. The shared Type 3 writer
embeds outlines, so reopening does not depend on installed fonts.
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect
@ -50,7 +64,7 @@ The descriptor does not change glyph programs or replace the Type 3 `FontBBox` m
only Gothic Regular, so it cannot preserve bold. No new font assets or changes to that input are
needed.
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab` or `Telu` face, to keep the web build small: the web build
wasm32 builds embed only BIZ UDPGothic Regular and any `Arab`, `Telu`, `Hebr` or `Thai` face, to keep the web build small: the web build
currently has no Chinese face, so Chinese there still shows the replacement glyph.
Font files are never
committed here (`AGENTS.md` §1.4; team members: [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md), internal).

View File

@ -1,7 +1,7 @@
//! Embeds the fonts of the optional craft-fonts build input (`CRAFT_FONTS_DIR`) as `CRAFT_FONTS`.
//! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads
//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular
//! and any `Arab` or `Telu` face), to keep the web build within hosting limits (Cloudflare Pages:
//! and any `Arab`, `Telu`, `Hebr` or `Thai` face), to keep the web build within hosting limits (Cloudflare Pages:
//! 25 MiB per file).
use std::fmt::Write as _;
@ -53,7 +53,10 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let arabic = scripts.split(',').any(|s| s.trim() == "Arab");
// Telugu faces too, for the Telugu interface.
let telugu = scripts.split(',').any(|s| s.trim() == "Telu");
let web_face = (*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu;
// And the Hebrew ones (small), for Hebrew text and Edit text on the web.
let hebrew = scripts.split(',').any(|s| s.trim() == "Hebr");
let web_face =
(*family == "BIZ UDPGothic" && *style == "Regular") || arabic || telugu || hebrew || scripts.split(',').any(|s| s.trim() == "Thai");
if wasm && !web_face {
continue;
}

View File

@ -66,6 +66,34 @@ pub fn ui_telugu_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Telu")).collect()
}
/// Thai faces shared by the interface and PDF text writer.
pub fn ui_thai_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Thai")).collect()
}
pub fn document_thai_font() -> Option<&'static CraftFont> {
ui_thai_fonts().into_iter().next()
}
/// The `Hebr` craft-fonts faces for Hebrew interface text (file names, document titles, text
/// being edited), in manifest order. Empty when built without craft-fonts or when it has no
/// Hebrew face.
pub fn ui_hebrew_fonts() -> Vec<&'static CraftFont> {
CRAFT_FONTS.iter().filter(|f| f.covers("Hebr")).collect()
}
/// The face for Hebrew text written into PDFs: the `Hebr` face of the asked weight, else its
/// Regular, else any `Hebr` face. `None` without craft-fonts or without a Hebrew face.
pub fn document_hebrew_font(bold: bool) -> Option<&'static CraftFont> {
hebrew_face(CRAFT_FONTS, bold)
}
fn hebrew_face(faces: &[CraftFont], bold: bool) -> Option<&CraftFont> {
let hebr = || faces.iter().filter(|f| f.covers("Hebr"));
let style = if bold { "Bold" } else { "Regular" };
hebr().find(|f| f.style == style).or_else(|| hebr().find(|f| f.style == "Regular")).or_else(|| hebr().next())
}
/// Interface CJK faces in fallback order for the UI language: Simplified Chinese first when
/// `prefer_hans`, otherwise Japanese first (the historical default).
///
@ -251,6 +279,23 @@ mod tests {
assert!(!BARRED_FAMILIES.iter().any(|barred| "Noto Sans Arabic".starts_with(barred)));
}
#[test]
fn hebrew_document_faces_follow_the_weight() {
static HEBR_LATN: &[&str] = &["Hebr", "Latn"];
let faces = [
CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Jpan"], bytes: b"not-hebrew" },
CraftFont { family: "FakeHebrew", style: "Regular", scripts: HEBR_LATN, bytes: b"regular" },
CraftFont { family: "FakeHebrew", style: "Bold", scripts: HEBR_LATN, bytes: b"bold" },
];
assert_eq!(hebrew_face(&faces, false).map(|f| f.bytes), Some(&b"regular"[..]));
assert_eq!(hebrew_face(&faces, true).map(|f| f.bytes), Some(&b"bold"[..]));
// No bold face: Regular, never a face of another script.
assert_eq!(hebrew_face(&faces[..2], true).map(|f| f.bytes), Some(&b"regular"[..]));
assert!(hebrew_face(&faces[..1], false).is_none());
assert_eq!(ui_hebrew_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Hebr")).count());
assert_eq!(document_hebrew_font(false).is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Hebr")));
}
#[test]
fn craft_fonts_are_optional_and_consistent() {
// Holds with or without CRAFT_FONTS_DIR.

View File

@ -5,17 +5,20 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod arabic;
mod craft;
mod encodings;
pub mod pdf;
mod script;
pub use arabic::{ShapedCluster, arabic_glyph, arabic_has, shape_arabic};
mod shaping;
pub use craft::{
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_japanese_fonts, ui_telugu_fonts,
CRAFT_FONTS, CraftFont, SHIPPORI_MINCHO, document_arabic_font, document_hebrew_font, document_japanese_font, document_japanese_font_for_style,
document_japanese_fonts_for_style, document_thai_font, ui_arabic_fonts, ui_chinese_fonts, ui_cjk_fonts, ui_hebrew_fonts, ui_japanese_fonts,
ui_telugu_fonts, ui_thai_fonts,
};
pub use script::{GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_outline};
pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
};
pub use shaping::{ShapedCluster, ShapedScript, arabic_glyph, arabic_has, shape_arabic, shape_thai, shaped_glyph, thai_has};
/// Approximate advance of `s` in Helvetica (or Arial) at `size` points.
pub fn helvetica_width(s: &str, size: f64) -> f64 {
@ -151,7 +154,17 @@ impl UnicodeCMap {
})
}
/// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?`.
/// The first character of `s` this encoding can't hold, which [`Self::encode`] would write as
/// `?`: a character beyond U+FFFF in UCS-2 (`𠀋`). UTF-16, UTF-8 and UTF-32 hold every one.
pub fn first_unencodable(self, s: &str) -> Option<char> {
match self {
Self::Ucs2 => s.chars().find(|c| u16::try_from(u32::from(*c)).is_err()),
Self::Utf16 | Self::Utf8 | Self::Utf32 => None,
}
}
/// Encode `s`; tabs become spaces, and characters UCS-2 can't hold become `?` (check them
/// first with [`Self::first_unencodable`]: PdfCraft refuses such text rather than write `?`).
pub fn encode(self, s: &str) -> Vec<u8> {
let mut out = Vec::with_capacity(s.len().saturating_mul(2));
let mut units = [0u16; 2];
@ -186,6 +199,47 @@ pub fn first_non_win_ansi(s: &str) -> Option<char> {
s.chars().find(|c| !win_ansi_encodable(*c))
}
/// The first character of `s` the standard fonts (Helvetica and the rest of the standard 14, in
/// WinAnsiEncoding) can't draw, so that a generated appearance would show it as `?`. Line breaks
/// don't count: appearances lay lines out themselves. PdfCraft refuses such text with
/// [`undrawable_message`] instead of writing `?` (AGENTS.md §4: never lose work silently).
pub fn first_undrawable(s: &str) -> Option<char> {
s.chars().find(|c| !matches!(c, '\n' | '\r') && !win_ansi_encodable(*c))
}
/// The error for text the standard fonts can't draw ([`first_undrawable`]): which character, in
/// what (`"the comment"`, `"the reason"`), and what can be written instead. One wording for every
/// path that writes text in the standard fonts, so agents and users can act on it the same way.
pub fn undrawable_message(what: &str, c: char) -> String {
format!(
"the standard fonts can't draw \"{}\" (U+{:04X}) in {what}; only Western European characters can be written there for now",
c.escape_debug(),
u32::from(c)
)
}
/// The private key on an annotation (a comment or a form field's widget) whose appearance PdfCraft
/// drew with `?` in place of characters its fonts can't draw: a text string holding the first
/// such character. The text itself (a field's `/V`, a comment's `/Contents`) is stored as typed;
/// the mark lets flattening refuse to make that `?` permanent, lets an edit report it, and tells
/// such an appearance apart from one another program drew with a font that has those characters.
pub const UNDRAWN_MARK: &[u8] = b"PCUndrawn";
/// The character [`UNDRAWN_MARK`] records on `d`, if PdfCraft drew its appearance with `?`.
pub fn undrawn_mark(d: &pdfcraft_cos::Dict) -> Option<char> {
d.get(UNDRAWN_MARK).and_then(pdfcraft_cos::Object::as_string).and_then(|s| s.to_text().chars().next())
}
/// Record (`Some`) or clear (`None`) [`UNDRAWN_MARK`] on `d`, for the appearance just drawn.
pub fn set_undrawn_mark(d: &mut pdfcraft_cos::Dict, c: Option<char>) {
match c {
Some(c) => d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::PdfString::text(&c.to_string())),
None => {
d.remove(UNDRAWN_MARK);
}
}
}
/// 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);
@ -216,6 +270,30 @@ mod tests {
}
assert_eq!(first_non_win_ansi("Café — 5€"), None);
assert_eq!(first_non_win_ansi("Dvořák"), Some('ř'));
// Line breaks are laid out, not drawn; other controls would be written as `?`.
assert_eq!(first_undrawable("Café\r\n— 5€\tok"), None);
assert_eq!(first_undrawable("签名 Ada"), Some('签'));
assert_eq!(first_undrawable("a\u{7}b"), Some('\u{7}'));
assert_eq!(first_undrawable("𠀋"), Some('𠀋'));
let m = undrawable_message("the comment", '签');
assert_eq!(m, "the standard fonts can't draw \"签\" (U+7B7E) in the comment; only Western European characters can be written there for now");
assert!(undrawable_message("the reason", '\u{7}').contains("\"\\u{7}\" (U+0007)"));
assert!(undrawable_message("x", '𠀋').contains("(U+2000B)"));
}
#[test]
fn the_undrawn_mark_records_one_character_and_clears() {
let mut d = pdfcraft_cos::Dict::new();
assert_eq!(undrawn_mark(&d), None);
set_undrawn_mark(&mut d, Some('𠀋'));
assert_eq!(undrawn_mark(&d), Some('𠀋'), "beyond U+FFFF, as a UTF-16 text string");
set_undrawn_mark(&mut d, Some('签'));
assert_eq!(undrawn_mark(&d), Some('签'));
set_undrawn_mark(&mut d, None);
assert!(!d.contains(UNDRAWN_MARK));
// Another value under the key is not a character: no mark.
d.set(UNDRAWN_MARK.to_vec(), pdfcraft_cos::Object::Bool(true));
assert_eq!(undrawn_mark(&d), None);
}
#[test]
@ -261,6 +339,12 @@ mod tests {
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' ']);
// That `?` is never written silently: callers check first.
assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和𠀋"), Some('𠀋'));
assert_eq!(UnicodeCMap::Ucs2.first_unencodable("令和"), None);
for cmap in [UnicodeCMap::Utf16, UnicodeCMap::Utf8, UnicodeCMap::Utf32] {
assert_eq!(cmap.first_unencodable("令和𠀋"), None, "{cmap:?}");
}
assert_eq!(UnicodeCMap::Utf8.encode("令"), "令".as_bytes());
assert_eq!(UnicodeCMap::Utf32.encode("令"), [0, 0, 0x4e, 0xe4]);
assert_eq!(cjk_width("令和", 10.0), 20.0);

View File

@ -188,9 +188,21 @@ pub(crate) fn bounded_outline(font: &FontRef, gid: GlyphId, width: f64) -> Resul
Ok(GlyphOutline { contours: pen.contours, width, bbox })
}
/// The first character of `text` (other than white space) that neither the script font of typed
/// signatures nor its Inter fallback has a glyph for: [`script_outline`] would leave it out.
/// `None` when they can draw all of them.
pub fn script_missing(text: &str) -> Option<char> {
let font = FontRef::new(FONT).ok()?;
let charmap = font.charmap();
let fallback = FontRef::new(INTER_REGULAR).ok();
let fallback_map = fallback.as_ref().map(|f| f.charmap());
text.chars().find(|c| !c.is_whitespace() && charmap.map(*c).is_none() && fallback_map.as_ref().and_then(|m| m.map(*c)).is_none())
}
/// The outlines of `text` in the script font. Characters it lacks (Cyrillic, Greek, …) are drawn
/// from Inter, slanted and scaled to the script's capital height; characters neither font has are
/// skipped. Over-limit input returns an empty outline instead of a silently truncated signature.
/// skipped (check [`script_missing`] first where nobody sees a preview). Over-limit input returns
/// an empty outline instead of a silently truncated signature.
pub fn script_outline(text: &str) -> ScriptOutline {
if text.chars().take(MAX_SIGNATURE_CHARS + 1).count() > MAX_SIGNATURE_CHARS {
return ScriptOutline::default();
@ -281,6 +293,17 @@ mod tests {
assert!(mixed.width > latin_only.width + 0.5, "{} vs {}", mixed.width, latin_only.width);
}
#[test]
fn missing_script_glyphs_are_named() {
assert_eq!(super::script_missing("Ada Lovelace-King"), None);
assert_eq!(super::script_missing("Zoë Ñúñez"), None);
assert_eq!(super::script_missing("Ada 张三"), Some('张'));
// Cyrillic and Greek come from the Inter fallback (#963), so they aren't missing.
assert_eq!(super::script_missing("Саша Дермановић Νίκος"), None);
assert_eq!(super::script_missing("Саша 张"), Some('张'));
assert_eq!(super::script_missing(" \t"), None);
}
#[test]
fn a_name_becomes_outlines() {
let o = super::script_outline("Ada L.");

View File

@ -1,5 +1,4 @@
//! Arabic text written into PDFs: shaping (joining forms, lam-alef ligatures, mark placement) with
//! the craft-fonts `Arab` face, and that face's glyph outlines.
//! Arabic and Thai text shaping with the matching craft-fonts face and bounded glyph outlines.
use std::sync::OnceLock;
@ -10,10 +9,12 @@ use skrifa::{FontRef, GlyphId, MetadataProvider};
use crate::{GlyphError, GlyphOutline};
/// One cluster of shaped Arabic text: the glyphs that show one character (a letter's body and
/// One cluster of shaped text: the glyphs that show one character (a letter's body and
/// dots, say), or several characters for a ligature. Em units.
#[derive(Clone, Debug, PartialEq)]
pub struct ShapedCluster {
/// Face that owns the glyph ids.
pub script: ShapedScript,
/// Glyph ids, each with its offset from the cluster's pen position (marks sit above or below).
pub glyphs: Vec<(u32, [f64; 2])>,
pub advance: f64,
@ -21,34 +22,76 @@ pub struct ShapedCluster {
pub text: String,
}
fn face() -> Option<&'static (FontRef<'static>, ShaperData)> {
/// The face owning a shaped glyph id. IDs from different faces must never share a cache key.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ShapedScript {
Arabic,
Thai,
}
impl ShapedScript {
pub fn font(self) -> Option<&'static crate::CraftFont> {
match self {
Self::Arabic => crate::document_arabic_font(),
Self::Thai => crate::document_thai_font(),
}
}
}
fn face(script: ShapedScript) -> Option<&'static (FontRef<'static>, ShaperData)> {
static FACE: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
FACE.get_or_init(|| {
let font = FontRef::new(crate::document_arabic_font()?.bytes).ok()?;
let data = ShaperData::new(&font);
Some((font, data))
})
.as_ref()
static THAI: OnceLock<Option<(FontRef<'static>, ShaperData)>> = OnceLock::new();
let cache = match script {
ShapedScript::Arabic => &FACE,
ShapedScript::Thai => &THAI,
};
cache
.get_or_init(|| {
let font = FontRef::new(script.font()?.bytes).ok()?;
let data = ShaperData::new(&font);
Some((font, data))
})
.as_ref()
}
/// Whether the Arabic document face has a glyph for `c` (false without the face).
pub fn arabic_has(c: char) -> bool {
face().is_some_and(|(font, _)| font.charmap().map(c).is_some())
face(ShapedScript::Arabic).is_some_and(|(font, _)| font.charmap().map(c).is_some())
}
/// Shape one directional run of `text` with the Arabic document face. The clusters come in
/// drawing order, left to right. [`GlyphError::NoFont`] without the face;
/// [`GlyphError::Missing`] when it lacks a character.
pub fn shape_arabic(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphError> {
let (font, data) = face().ok_or(GlyphError::NoFont)?;
shape_script(text, rtl, ShapedScript::Arabic)
}
pub fn thai_has(c: char) -> bool {
face(ShapedScript::Thai).is_some_and(|(font, _)| font.charmap().map(c).is_some())
}
/// Shape Thai with grapheme clusters so Sara Am decomposition and stacked marks keep their
/// original Unicode text exactly once, including when wrapped or copied from the saved PDF.
pub fn shape_thai(text: &str) -> Result<Vec<ShapedCluster>, GlyphError> {
shape_script(text, false, ShapedScript::Thai)
}
fn shape_script(text: &str, rtl: bool, kind: ShapedScript) -> Result<Vec<ShapedCluster>, GlyphError> {
let (font, data) = face(kind).ok_or(GlyphError::NoFont)?;
let shaper = data.shaper(font).build();
let scale = 1.0 / f64::from(shaper.units_per_em().max(1));
let mut buffer = UnicodeBuffer::new();
buffer.push_str(text);
buffer.set_direction(if rtl { Direction::RightToLeft } else { Direction::LeftToRight });
buffer.set_script(script::ARABIC);
// One cluster per character, so marks keep their own text for copy and search.
buffer.set_cluster_level(BufferClusterLevel::Characters);
buffer.set_script(match kind {
ShapedScript::Arabic => script::ARABIC,
ShapedScript::Thai => script::THAI,
});
// Preserve the historical Arabic per-character mapping; Thai marks stay with their base.
buffer.set_cluster_level(match kind {
ShapedScript::Arabic => BufferClusterLevel::Characters,
ShapedScript::Thai => BufferClusterLevel::MonotoneGraphemes,
});
let shaped = shaper.shape(buffer, ShapeOptions::new());
// Clusters are byte offsets into `text`; a cluster shows the characters up to the next one.
let mut starts: Vec<usize> = shaped.glyph_infos().iter().map(|g| g.cluster as usize).collect();
@ -66,7 +109,7 @@ pub fn shape_arabic(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphEr
_ => {
let end = starts.get(starts.partition_point(|s| *s <= start)).copied().unwrap_or(text.len());
let text = text.get(start..end).unwrap_or_default().to_string();
out.push((start, ShapedCluster { glyphs: Vec::new(), advance: 0.0, text }));
out.push((start, ShapedCluster { script: kind, glyphs: Vec::new(), advance: 0.0, text }));
// Just pushed.
let Some((_, c)) = out.last_mut() else { continue };
c
@ -82,7 +125,12 @@ pub fn shape_arabic(text: &str, rtl: bool) -> Result<Vec<ShapedCluster>, GlyphEr
/// Glyph `id` of the Arabic document face, bounded like [`crate::japanese_glyph`]. Glyphs without
/// outlines (spaces) come back empty.
pub fn arabic_glyph(id: u32) -> Result<GlyphOutline, GlyphError> {
let (font, _) = face().ok_or(GlyphError::NoFont)?;
shaped_glyph(ShapedScript::Arabic, id)
}
/// A bounded outline from the same face that produced the shaped glyph id.
pub fn shaped_glyph(script: ShapedScript, id: u32) -> Result<GlyphOutline, GlyphError> {
let (font, _) = face(script).ok_or(GlyphError::NoFont)?;
if font.maxp().map_or(true, |m| id >= u32::from(m.num_glyphs())) {
return Err(GlyphError::Missing);
}
@ -169,3 +217,36 @@ mod tests {
assert!(shape_arabic("", true).unwrap().is_empty());
}
}
#[cfg(test)]
mod thai_tests {
use super::*;
#[test]
fn thai_clusters_preserve_sara_am_and_stacked_marks() {
if crate::document_thai_font().is_none() {
assert_eq!(shape_thai("น้ำ"), Err(GlyphError::NoFont));
assert!(!thai_has('ก'));
return;
}
for text in ["น้ำ", "กิ่", "ผู้ใช้", "ปู่", "เก้า", "สวัสดีภาษาไทย", "๑๒๓", ""]
{
let clusters = shape_thai(text).unwrap();
assert_eq!(clusters.iter().map(|c| c.text.as_str()).collect::<String>(), text);
for c in clusters {
assert_eq!(c.script, ShapedScript::Thai);
assert!(c.advance.is_finite() && c.advance >= 0.0);
for (id, pos) in c.glyphs {
assert!(pos.iter().all(|v| v.is_finite()));
shaped_glyph(c.script, id).unwrap();
}
}
}
let stacked = shape_thai("กิ่").unwrap();
assert_eq!(stacked.len(), 1, "base, vowel and tone must wrap together");
assert!(stacked[0].glyphs.len() >= 3);
assert!(stacked[0].glyphs.iter().any(|(_, p)| p[0] != 0.0 || p[1] != 0.0));
assert_eq!(shape_thai("日"), Err(GlyphError::Missing));
assert_eq!(shaped_glyph(ShapedScript::Thai, u32::MAX).map(|_| ()), Err(GlyphError::Missing));
}
}

View File

@ -33,7 +33,15 @@ delete_field(&mut doc, "email")?;
field tree; the form's `/DA` and `/Q` are the last fallback.
- Text and choice fields get new appearance streams (`/Tx BMC … EMC`): auto-size (`0 Tf`), comb,
multiline wrapping, password masking, quadding, `/MK` background and border, list-box selection.
The `/DA` font is used when `/DR` has it as a simple font; otherwise Helvetica (WinAnsi).
The `/DA` font is used when `/DR` has it as a simple font or a composite font with a predefined
Unicode CMap; otherwise Helvetica (WinAnsi). A value or button caption the font can't hold
(Chinese in a Helvetica field, a character beyond U+FFFF in a UCS-2 CMap) is kept, as Acrobat
keeps it: `/V` is stored as typed, the appearance draws `?` for those characters, the widget is
marked with the first one (`pdfcraft_fonts::UNDRAWN_MARK`, cleared once all of it can be drawn)
and the form sets `/NeedAppearances`, so other viewers draw the field from its value with fonts
that have them. Not in a document that declares PDF/A, which forbids that flag (`declares_pdfa`).
`undrawn_fields` lists the marked fields; the engine reports each edit that draws one, and
flattening refuses to make that `?` permanent.
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
- Values are validated (options, `MaxLen`, single vs multi-select, NoToggleToOff, read-only) and a

View File

@ -8,9 +8,13 @@
//! The `/DA` font is used when the form's `/DR` defines it as a simple font that can encode the
//! displayed text (using its own encoding and Differences), or as a composite font with a
//! predefined Unicode CMap such as `UniJIS-UTF16-H`, as Japanese forms use. Otherwise (unsupported
//! characters, other composite fonts, missing resources) Helvetica is used. Widths use the
//! approximate metrics of `pdfcraft-fonts` (Helvetica, or one em per full-width character in
//! composite fonts).
//! characters, other composite fonts, missing resources) Helvetica is used. Text the chosen font
//! can't hold (outside WinAnsi in Helvetica, or beyond U+FFFF in a UCS-2 CMap) is drawn as `?`, as
//! Acrobat keeps a value its field font can't show: the value itself is stored as typed,
//! [`field_appearance_drawn`] returns the first such character, and the caller marks the widget
//! (`pdfcraft_fonts::UNDRAWN_MARK`) and asks other viewers to redraw the field
//! (`/NeedAppearances`). 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
@ -139,6 +143,19 @@ impl FontText {
}
}
/// The first character of `s` the encoding can't hold, which [`Self::encode`] writes as `?`
/// (a reused simple font is only chosen when it holds them all): reported by
/// [`field_appearance_drawn`], so that `?` is never silent (AGENTS.md §4).
fn first_unencodable(&self, s: &str) -> Option<char> {
match self {
Self::WinAnsi => pdfcraft_fonts::first_undrawable(s),
Self::Simple(metrics) => {
s.chars().map(|c| if c == '\t' { ' ' } else { c }).find(|c| metrics.encode(c.encode_utf8(&mut [0; 4])).is_none())
}
Self::Unicode(cmap) => cmap.first_unencodable(s),
}
}
fn width(&self, s: &str, size: f64) -> f64 {
match self {
Self::WinAnsi | Self::Simple(_) => helvetica_width(s, size),
@ -268,7 +285,8 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
(c, 0.0)
}
/// The appearance of a text or choice field's widget showing `values`.
/// The appearance of a text or choice field's widget showing `values`. Characters its font can't
/// hold come out as `?`; [`field_appearance_drawn`] says which.
pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]) -> Stream {
field_appearance_as(doc, f, w, values, true)
}
@ -276,6 +294,13 @@ pub fn field_appearance(doc: &Document, f: &Field, w: &Widget, values: &[String]
/// [`field_appearance`], where `format` false means `values` are already what to show (a
/// custom Format script ran).
pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> Stream {
field_appearance_drawn(doc, f, w, values, format).0
}
/// [`field_appearance_as`] with the first character it drew as `?` because the field's font can't
/// hold it: the form's font is a simple (WinAnsi) font, Helvetica stands in for one PdfCraft can't
/// write with (an embedded subset, `Identity-H`), or a UCS-2 CMap meets a character beyond U+FFFF.
pub fn field_appearance_drawn(doc: &Document, f: &Field, w: &Widget, values: &[String], format: bool) -> (Stream, Option<char>) {
// The Format event: what is shown, not what is stored.
let formatted: Vec<String>;
let values = if format
@ -319,9 +344,16 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
let inner_w = (width - 2.0 * pad).max(1.0);
let q = wd.get(b"Q").and_then(|o| doc.resolve(o).as_int()).unwrap_or(f.quadding);
let mut body: Vec<u8> = Vec::new();
let show = |body: &mut Vec<u8>, x: f64, y: f64, text: &str| {
// The first character drawn that the font can't hold (drawn as `?`), for the caller to report.
let mut unencodable: Option<char> = None;
let mut show = |body: &mut Vec<u8>, x: f64, y: f64, text: &str| {
// A line break in a single-line value is a space, as in viewers (multiline text is split).
let text: String = text.chars().map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }).collect();
if unencodable.is_none() {
unencodable = enc.first_unencodable(&text);
}
body.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes());
body.extend(literal(&enc.encode(text)));
body.extend(literal(&enc.encode(&text)));
body.extend_from_slice(b" Tj\n");
};
let x_for = |text: &str, size: f64| -> f64 {
@ -419,7 +451,7 @@ pub fn field_appearance_as(doc: &Document, f: &Field, w: &Widget, values: &[Stri
d.set(b"Subtype".to_vec(), Object::name("Form"));
set_form_box(&mut d, place);
d.set(b"Resources".to_vec(), Object::Dict(res));
Stream::flate(d, &content)
(Stream::flate(d, &content), unencodable)
}
/// On/Off appearances for a check box or radio button that has none (a check mark or a dot,

View File

@ -669,13 +669,19 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
Ok(field_name)
}
/// Regenerate every widget appearance of a field from its current value and settings.
/// Regenerate every widget appearance of a field from its current value and settings. A value or
/// button caption its font can't hold is drawn with `?` for those characters, the widget is marked
/// (`pdfcraft_fonts::UNDRAWN_MARK`) and other viewers are asked to redraw the form (see
/// `ask_viewers_to_redraw`), as when a value is set.
pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
let mut undrawn = false;
for w in &f.widgets {
let mut missing = None;
let ap = match f.kind {
FieldKind::Text | FieldKind::Combo | FieldKind::List => {
let s = appearance::field_appearance(doc, &f, w, &f.value);
let (s, m) = appearance::field_appearance_drawn(doc, &f, w, &f.value, true);
missing = m;
let r = doc.add(Object::Stream(s));
let mut d = Dict::new();
d.set(b"N".to_vec(), Object::Ref(r));
@ -686,7 +692,8 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
appearance::check_box_states(doc, w, f.kind, &on)
}
FieldKind::PushButton => {
let s = button_appearance(doc, w);
let (s, m) = button_appearance(doc, w);
missing = m;
let r = doc.add(Object::Stream(s));
let mut d = Dict::new();
d.set(b"N".to_vec(), Object::Ref(r));
@ -700,8 +707,15 @@ pub fn redraw_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
d
}
};
undrawn |= missing.is_some();
let existing = crate::merged_appearance(doc, w.obj, &ap);
doc.update_dict(w.obj, |d| d.set(b"AP".to_vec(), Object::Dict(existing)))?;
doc.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(existing));
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
}
if undrawn {
crate::ask_viewers_to_redraw(doc)?;
}
Ok(())
}
@ -737,8 +751,9 @@ fn form_stream(place: crate::appearance::Placement, content: Vec<u8>, resources:
}
/// 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 {
/// centred caption (unless the layout is icon only, `/TP 1`). The caption is drawn in Helvetica,
/// with the first character it can't draw (drawn as `?`), for the caller to mark and report.
fn button_appearance(doc: &Document, w: &Widget) -> (Stream, Option<char>) {
let (mut c, place) = frame_only(doc, w);
let (width, height) = (place.layout_w, place.layout_h);
let wobj = doc.get(w.obj);
@ -762,7 +777,9 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
xobjects.set(b"Icon".to_vec(), Object::Ref(icon));
}
}
let mut missing = None;
if !icon_only && !caption.is_empty() {
missing = pdfcraft_fonts::first_undrawable(&caption);
let size = ((height - 4.0) * 0.6).clamp(4.0, 14.0);
let tw = helvetica_width(&caption, size);
content.extend(format!("BT /Helv {size:.2} Tf 0 g 1 0 0 1 {:.3} {:.3} Tm ", (width - tw) / 2.0, (height - size * 0.7) / 2.0).bytes());
@ -781,7 +798,7 @@ fn button_appearance(doc: &Document, w: &Widget) -> Stream {
if !xobjects.is_empty() {
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
}
form_stream(place, content, res)
(form_stream(place, content, res), missing)
}
/// Give a push button (an image field) the picture `image` (an image XObject of `px` pixels):

View File

@ -1096,11 +1096,13 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
[one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
_ => None,
};
let mut undrawn = false;
for w in &f.widgets {
let stream = match &shown {
Some(s) => appearance::field_appearance_as(doc, f, w, std::slice::from_ref(s), false),
None => appearance::field_appearance(doc, f, w, values),
let (stream, missing) = match &shown {
Some(s) => appearance::field_appearance_drawn(doc, f, w, std::slice::from_ref(s), false),
None => appearance::field_appearance_drawn(doc, f, w, values, true),
};
undrawn |= missing.is_some();
let ap = doc.add(Object::Stream(stream));
let mut fresh = Dict::new();
fresh.set(b"N".to_vec(), Object::Ref(ap));
@ -1108,11 +1110,66 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
doc.update_dict(w.obj, |d| {
d.set(b"AP".to_vec(), Object::Dict(apd));
d.remove(b"AS");
pdfcraft_fonts::set_undrawn_mark(d, missing);
})?;
}
if undrawn {
ask_viewers_to_redraw(doc)?;
}
Ok(())
}
/// A value its widget's font can't show is stored as typed and drawn with `?` (as Acrobat keeps
/// such a value): `/NeedAppearances` then asks other viewers to draw the fields from their values
/// with fonts that have those characters (the interactive form dictionary, §12.7.3). Not in a
/// document that declares PDF/A, which forbids it: there the widget's mark
/// (`pdfcraft_fonts::UNDRAWN_MARK`) is all, and the edit's report says the `?` shows everywhere.
pub(crate) fn ask_viewers_to_redraw(doc: &mut Document) -> Result<(), FormError> {
if declares_pdfa(doc) || need_appearances(doc) {
return Ok(());
}
let Some(root) = doc.root() else { return Ok(()) };
let entry = doc.get(root).as_dict().and_then(|c| c.get(b"AcroForm").cloned());
match entry {
Some(Object::Ref(r)) => doc.update_dict(r, |d| d.set(b"NeedAppearances".to_vec(), Object::Bool(true)))?,
Some(Object::Dict(mut af)) => {
af.set(b"NeedAppearances".to_vec(), Object::Bool(true));
doc.update_dict(root, |c| c.set(b"AcroForm".to_vec(), Object::Dict(af)))?;
}
_ => {}
}
Ok(())
}
/// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`).
pub fn need_appearances(doc: &Document) -> bool {
acroform(doc).is_some_and(|af| matches!(af.get(b"NeedAppearances").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true))))
}
/// Whether the document's XMP metadata declares PDF/A conformance (`pdfaid:part`).
pub fn declares_pdfa(doc: &Document) -> bool {
let Some(root) = doc.root() else { return false };
let Some(m) = doc.get(root).as_dict().and_then(|c| c.get(b"Metadata").cloned()) else { return false };
let m = doc.resolve(&m);
match &*m {
Object::Stream(s) => s.decoded().is_ok_and(|b| b.windows(11).any(|w| w == b"pdfaid:part")),
_ => false,
}
}
/// Every field with a widget whose appearance PdfCraft drew with `?` for a character its font
/// can't hold (`pdfcraft_fonts::UNDRAWN_MARK`): the field's name and that character, once per
/// field.
pub fn undrawn_fields(doc: &Document) -> Vec<(String, char)> {
fields(doc)
.into_iter()
.filter_map(|f| {
let c = f.widgets.iter().find_map(|w| doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark))?;
Some((f.name, c))
})
.collect()
}
/// Acrobat's Clear form: every field (or the named ones) back to its default value.
pub fn reset(doc: &mut Document, names: Option<&[String]>) -> Result<usize, FormError> {
let all = fields(doc);

View File

@ -369,6 +369,8 @@ fn japanese_choices_use_the_unicode_cid_font() {
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();
// An Identity-H subset can't be addressed by Unicode: Helvetica stands in, and can't draw 令.
// The value is kept, drawn as `??`, marked, and other viewers are asked to redraw the form.
set_value(&mut doc, "subset", &FieldValue::Text("令和".into())).unwrap();
let doc = reopen(&doc);
let all = fields(&doc);
@ -392,10 +394,115 @@ fn japanese_choices_use_the_unicode_cid_font() {
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!(sub.windows(4).any(|x| x == b"(??)"), "{}", String::from_utf8_lossy(&sub));
assert_eq!(sub_fonts, ["Helv"]);
assert_eq!(field(&all, "subset").value, ["令和"]);
// Only that widget is marked: the CID fonts drew theirs.
assert_eq!(undrawn_fields(&doc), [("subset".to_string(), '令')]);
assert!(need_appearances(&doc));
}
/// Text a field's font can't hold is kept, as Acrobat keeps it: the value is stored as typed, the
/// appearance draws `?` for those characters, the widget is marked with the first one, and the
/// form asks other viewers to redraw it (`/NeedAppearances`). Drawable text clears the mark.
#[test]
fn text_a_field_font_cannot_hold_is_kept_marked_and_redrawn_elsewhere() {
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 [7 0 R 8 0 R 9 0 R] >>".into(),
"<< /Fields [7 0 R 8 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R /CN 6 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> >>] >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /V (Ada) /Rect [50 700 200 715] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (cn) /DA (/CN 10 Tf 0 g) /Rect [50 600 200 615] /P 3 0 R >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (city) /Ff 131072 /DA (/Helv 10 Tf 0 g) /Opt [(Zurich) <FEFF5317004E4EAC>] /Rect [50 500 200 515] /P 3 0 R >>".into(),
];
let mut doc = document(&objs);
let shown = |doc: &Document, name: &str| {
let all = fields(doc);
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 mark = doc.get(w.obj).as_dict().and_then(pdfcraft_fonts::undrawn_mark);
(String::from_utf8_lossy(&s.decoded().unwrap()).into_owned(), mark)
};
// A WinAnsi field: the Chinese value is kept and marked; Helvetica draws `?` for it.
assert!(!need_appearances(&doc));
set_value(&mut doc, "name", &FieldValue::Text("签名 Ada".into())).unwrap();
assert_eq!(field(&fields(&doc), "name").value, ["签名 Ada"]);
let (ap, mark) = shown(&doc, "name");
assert!(ap.contains("(?? Ada) Tj"), "{ap}");
assert_eq!(mark, Some('签'));
assert!(need_appearances(&doc), "other viewers are asked to redraw the form");
// A choice whose label Helvetica can't draw, the same way.
set_value(&mut doc, "city", &FieldValue::Text("北N京".into())).unwrap();
assert_eq!(shown(&doc, "city").1, Some('北'));
// A UCS-2 CMap holds 签, so nothing is marked, but not a character beyond U+FFFF.
set_value(&mut doc, "cn", &FieldValue::Text("签名".into())).unwrap();
assert_eq!(shown(&doc, "cn").1, None);
set_value(&mut doc, "cn", &FieldValue::Text("𠀋".into())).unwrap();
assert_eq!(field(&fields(&doc), "cn").value, ["𠀋"]);
assert_eq!(shown(&doc, "cn").1, Some('𠀋'));
let mut undrawn = undrawn_fields(&doc);
undrawn.sort();
assert_eq!(undrawn, [("city".to_string(), '北'), ("cn".to_string(), '𠀋'), ("name".to_string(), '签')]);
// Drawable text clears the mark; a line break in a single-line value is a space, not `?`.
set_value(&mut doc, "name", &FieldValue::Text("Ada\nLovelace".into())).unwrap();
let (ap, mark) = shown(&doc, "name");
assert!(ap.contains("(Ada Lovelace) Tj") && !ap.contains('?'), "{ap}");
assert_eq!(mark, None);
// Kept through a save.
let doc = reopen(&doc);
assert_eq!(field(&fields(&doc), "city").value, ["北N京"]);
assert!(need_appearances(&doc));
}
/// A document that declares PDF/A can't ask other viewers to redraw its fields (PDF/A forbids
/// `/NeedAppearances`): the value is still kept and its widget marked, and the flag stays off.
#[test]
fn a_pdfa_form_keeps_the_value_without_asking_viewers_to_redraw() {
let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" pdfaid:part="2" pdfaid:conformance="B"/></rdf:RDF></x:xmpmeta>"#;
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R /Metadata 7 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
"<< /Fields [6 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (name) /DA (/Helv 10 Tf 0 g) /Rect [50 700 200 715] /P 3 0 R >>".into(),
format!("<< /Type /Metadata /Subtype /XML /Length {} >>\nstream\n{xmp}\nendstream", xmp.len() + 1),
];
let mut doc = document(&objs);
assert!(declares_pdfa(&doc));
set_value(&mut doc, "name", &FieldValue::Text("张三".into())).unwrap();
assert_eq!(field(&fields(&doc), "name").value, ["张三"]);
assert_eq!(undrawn_fields(&doc), [("name".to_string(), '张')]);
assert!(!need_appearances(&doc), "PDF/A forbids /NeedAppearances");
}
/// A button caption Helvetica can't draw is kept and marked like a value; redrawing a value another
/// program stored (a dynamic form's layout, a property change) draws and marks it rather than
/// failing, and an edit to an unrelated field never fails because of it.
#[test]
fn captions_and_redrawn_values_are_kept_and_marked() {
let mut doc = fixture();
let go = add_field(&mut doc, 0, [50.0, 50.0, 150.0, 80.0], &NewField::Button { caption: "提交".into() }, Some("go2")).unwrap();
assert_eq!(undrawn_fields(&doc), [(go.clone(), '提')]);
assert!(need_appearances(&doc));
// A value stored by another program that Helvetica can't draw.
let name = field(&fields(&doc), "name").clone();
doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("张三"))).unwrap();
redraw_field(&mut doc, "name").unwrap();
let mut undrawn = undrawn_fields(&doc);
undrawn.sort();
assert_eq!(undrawn, [(go, '提'), ("name".to_string(), '张')]);
set_value(&mut doc, "notes", &FieldValue::Text("Unrelated".into())).unwrap();
// Drawable again: the mark goes.
doc.update_dict(name.obj, |d| d.set(b"V".to_vec(), PdfString::text("Ada"))).unwrap();
redraw_field(&mut doc, "name").unwrap();
assert!(!undrawn_fields(&doc).iter().any(|(n, _)| n == "name"));
}
#[test]

View File

@ -234,6 +234,14 @@ mod tests {
assert_eq!(w.up, [0.0, 20.0]);
}
/// The text layer is Helvetica in WinAnsiEncoding: every character the model can recognise
/// must be one it can hold, or recognised text would be saved as `?`. A model with a wider
/// alphabet (Chinese, for example) needs a text layer in another font first.
#[test]
fn the_model_alphabet_fits_the_text_layer_font() {
assert_eq!(pdfcraft_fonts::first_undrawable(ALPHABET), None);
}
#[test]
fn text_layer_is_invisible_text_fitted_to_each_box() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };

View File

@ -37,6 +37,9 @@ pub enum RedactError {
Unreadable(usize),
#[error("redaction could not be verified: {0} item(s) were still found under the marks, so nothing was changed")]
Residue(usize),
/// A mark's overlay text has a character Helvetica can't draw; nothing was redacted.
#[error("{0}")]
Undrawable(String),
#[error("page {0} has vertical-writing text under a redaction mark, which PdfCraft cannot remove yet, so nothing was changed")]
VerticalText(usize),
#[error(transparent)]
@ -276,6 +279,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
if chosen.is_empty() {
return Err(RedactError::NothingToApply);
}
// Overlay text is drawn in Helvetica; applying can't be undone in the file, so text it can't
// draw (a mark another program made) is refused before anything changes, never burnt in as `?`.
if let Some((m, c)) = chosen.iter().find_map(|m| pdfcraft_fonts::first_undrawable(&m.overlay).map(|c| (m, c))) {
return Err(RedactError::Undrawable(format!(
"{}; change or clear that mark's overlay text, then apply again (nothing was redacted)",
pdfcraft_fonts::undrawable_message(&format!("the overlay text of a redaction mark on page {}", m.page + 1), c)
)));
}
let mut report = Report { marks: chosen.len(), ..Report::default() };
let mut by_page: Vec<usize> = chosen.iter().map(|m| m.page).collect();
by_page.sort_unstable();

View File

@ -560,6 +560,39 @@ fn overlay_text_takes_its_font_size_colour_alignment_and_repeats() {
assert!(fonts.contains(b"PCCour") && !fonts.contains(b"PCTimes"));
}
/// Overlay text the standard fonts can't draw: refused when it is marked, and for a mark another
/// program made, refused when it is applied, before anything is redacted, rather than burnt into
/// the page as `?`.
#[test]
fn overlay_text_the_standard_fonts_cannot_draw_is_refused() {
let mut doc = one_page(b"BT /F1 12 Tf 20 250 Td (Secret salary figures) Tj ET", "", Vec::new());
let look = pdfcraft_annot::OverlayLook::default();
let mark = |overlay: &str| Shape::Redact { quads: vec![rect_quad([10.0, 200.0, 290.0, 270.0])], overlay: overlay.into(), look };
let add = |doc: &mut Document, shape: Shape| {
let style = Style::default_for(&shape);
add_annotation(doc, &NewAnnotation { page: 0, shape, style, contents: String::new(), author: "T".into() }, &Meta::default())
};
let err = add(&mut doc, mark("机密")).unwrap_err().to_string();
assert!(err.contains("\"机\" (U+673A)") && err.contains("overlay text"), "{err}");
assert!(marks(&doc).is_empty());
// A mark whose overlay text was written by another program.
add(&mut doc, mark("REDACTED")).unwrap();
let obj = marks(&doc)[0].obj;
doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text("机密"))).unwrap();
let before = content(&doc, 0);
let err = apply(&mut doc, None).unwrap_err();
assert!(
matches!(&err, RedactError::Undrawable(m) if m.contains("page 1") && m.contains("U+673A") && m.contains("nothing was redacted")),
"{err:?}"
);
assert_eq!(content(&doc, 0), before, "nothing was redacted");
assert_eq!(marks(&doc).len(), 1, "the mark is still there");
// Cleared, it applies.
doc.update_dict(obj, |d| d.set(b"OverlayText".to_vec(), pdfcraft_cos::PdfString::text(""))).unwrap();
apply(&mut doc, None).unwrap();
assert!(!content(&reopen(&doc), 0).contains("Secret"));
}
#[test]
fn redaction_codes_join_in_set_order_and_reject_strangers() {
use crate::codes::{CODE_SETS, CodeSet};

View File

@ -346,6 +346,32 @@ pub struct FontInfo {
pub encoding: Option<String>,
}
/// Check that `bytes` still open as the same document: the parser reads them and the page
/// count matches. Content-only edits skip the full [`inspect`] (seconds on very large files)
/// but must still catch an edit that produced something the viewer cannot display, so the
/// caller can roll it back. Never panics: parser crashes are caught and reported.
pub fn check_pages(bytes: Arc<Vec<u8>>, password: Option<&str>, expected: usize) -> Result<(), OpenError> {
let pdf = catch_unwind(AssertUnwindSafe(|| Pdf::new_with_password(bytes, password.unwrap_or(""))))
.map_err(|p| OpenError::Invalid(format!("the parser crashed: {}", crate::raster::panic_message(&p))))?
.map_err(|e| match e {
LoadPdfError::Decryption(DecryptionError::PasswordProtected) => {
if password.is_some() {
OpenError::WrongPassword
} else {
OpenError::NeedsPassword
}
}
LoadPdfError::Decryption(other) => OpenError::Unsupported(format!("encryption not supported: {other:?}")),
LoadPdfError::Invalid => OpenError::Invalid("no readable page tree or cross-reference data was found".into()),
})?;
let pages = catch_unwind(AssertUnwindSafe(|| pdf.pages().len()))
.map_err(|p| OpenError::Invalid(format!("the page tree could not be read: {}", crate::raster::panic_message(&p))))?;
if pages != expected {
return Err(OpenError::Invalid(format!("the edit changed the page count from {expected} to {pages}")));
}
Ok(())
}
/// Inspect a document. Fails only if the renderer itself cannot open the file (or needs a
/// password). Never panics: parser crashes are caught and reported.
pub fn inspect(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocInfo, OpenError> {

View File

@ -16,7 +16,7 @@ pub mod text;
pub use inspect::{
Annotation, Attachment, AttachmentSource, DestView, DocInfo, Field, FieldKind, FontInfo, Layer, LayerOp, Link, LinkTarget, OutlineItem, PageInfo,
Xfa, attachment_data, inspect, pretty_date,
Xfa, attachment_data, check_pages, inspect, pretty_date,
};
pub use pixels::Pixels;
pub use raster::{

View File

@ -156,6 +156,10 @@ pub struct RenderRequest {
pub scale: f32,
/// Caller-defined tag, echoed back (used for zoom generations / thumbnail vs page).
pub tag: u64,
/// Low-detail render for previews (page backdrops, thumbnails): images covering only a
/// few device pixels are skipped. Never set it for a final render (tiles, whole pages,
/// print, export): previews may differ by those pixels.
pub preview: bool,
}
/// What a render that panicked or overran the watchdog is remembered by: its page and kind. Not
@ -331,7 +335,10 @@ fn render_page<'a>(
// 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());
// Previews render at low detail (tiny images are skipped); finals always draw all.
let mut preview_settings = settings.clone();
preview_settings.preview = req.preview;
render_into(page, cache, &preview_settings, &rs, pixels.bytes_mut());
Ok((w.into(), h.into(), pixels, None))
}));
match result {
@ -536,6 +543,8 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
#[cfg(not(target_arch = "wasm32"))]
fn same_request(a: RenderRequest, b: RenderRequest) -> bool {
// `preview` is excluded on purpose: it names the same work for cancellation, only the
// detail level of the result differs.
a.page == b.page && a.kind == b.kind && a.tile == b.tile && a.scale.to_bits() == b.scale.to_bits() && a.tag == b.tag
}
@ -1889,7 +1898,7 @@ mod tests {
)
.into_bytes();
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!((page.width, page.height), (20, 20));
let (pixels, remainder) = page.rgba.as_chunks::<4>();
@ -1907,7 +1916,7 @@ mod tests {
pool.set_stuck_after(std::time::Duration::ZERO);
let gate = render_gate(0, false);
*lock(&pool.shared.render_gates) = vec![gate.clone()];
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 };
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() };
pool.set_queue(vec![req(0)]);
gate.entered.wait();
let first = pool.try_recv().expect("the watchdog answers while the render is held");
@ -1934,7 +1943,7 @@ mod tests {
let pool = RenderPool::new(Arc::new(ONE_PAGE_TWICE.to_vec()), 2, RenderConfig::default());
let gate = render_gate(0, true);
*lock(&pool.shared.render_gates) = vec![gate.clone()];
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 };
let req = |page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() };
pool.set_queue(vec![req(0)]);
gate.entered.wait();
// What the canvas sends next: the page it is still waiting for, then another one.
@ -2056,8 +2065,9 @@ mod tests {
}
pdf += "trailer << /Root 1 0 R >>\n%%EOF";
let pool = RenderPool::new(Arc::new(pdf.into_bytes()), 3, RenderConfig::default());
let wanted: Vec<RenderRequest> =
(0..pages).map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000 }).collect();
let wanted: Vec<RenderRequest> = (0..pages)
.map(|page| RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 2000, ..Default::default() })
.collect();
// The canvas's loop: take what arrived, then queue every page still missing whenever
// that list changes (requests already rendering included).
let mut got = vec![0; pages];
@ -2460,7 +2470,7 @@ trailer << /Root 1 0 R >>
#[test]
fn renders_a_blue_rectangle() {
let pool = RenderPool::new(Arc::new(ONE_PAGE.to_vec()), 1, RenderConfig::default());
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7 }]);
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 7, ..Default::default() }]);
let page = loop {
if let Some(p) = pool.try_recv() {
break p;
@ -2517,7 +2527,7 @@ trailer << /Root 1 0 R >>
#[test]
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 });
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() });
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");
@ -2551,10 +2561,10 @@ trailer << /Root 1 0 R >>
#[test]
fn missing_page_and_garbage_input_fail_gracefully() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
assert!(r.render(RenderRequest { page: 9, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }).error.is_some());
let mut g = PageRenderer::new(Arc::new(b"not a pdf at all".to_vec()), RenderConfig::default());
assert_eq!(g.page_count(), 0);
assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }).error.is_some());
assert!(g.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }).error.is_some());
}
#[test]
@ -2598,7 +2608,7 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 };
let req = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() };
let p = r.render(req);
assert!(p.error.is_none(), "{:?}", p.error);
assert_eq!((p.width, p.height), (596, 842));
@ -2641,7 +2651,7 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show its /Yes state");
@ -2676,7 +2686,7 @@ endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(20, 80), vec![255, 0, 0, 255], "checkbox must show the /Yes appearance, not the decoy stream");
@ -2756,7 +2766,7 @@ trailer << /Root 1 0 R >>
};
let render = |bytes: Vec<u8>| {
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })
};
for (case, form) in [
("zero-width /BBox", "/BBox [0 0 0 20]"),
@ -2808,7 +2818,7 @@ trailer << /Root 1 0 R >>
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{form}: {:?}", p.error);
// The bounding box of everything drawn, in device pixels.
let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0, 0);
@ -2861,7 +2871,7 @@ trailer << /Root 1 0 R >>
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), config);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{annot}: {:?}", p.error);
p
};
@ -2932,7 +2942,7 @@ trailer << /Root 1 0 R >>
%%EOF";
let render = |hide: bool| {
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig { hide_comments: hide, ..RenderConfig::default() });
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })
};
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
let shown = render(false);
@ -2948,8 +2958,8 @@ trailer << /Root 1 0 R >>
assert!(pool.is_inline());
assert!(pool.try_recv().is_none());
pool.set_queue(vec![
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1 },
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2 },
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 1, ..Default::default() },
RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 2, ..Default::default() },
]);
assert_eq!(pool.try_recv().map(|p| p.request.tag), Some(1));
assert_eq!(pool.try_recv().map(|p| (p.request.tag, p.width)), Some((2, 50)));
@ -2998,7 +3008,7 @@ trailer << /Root 1 0 R >>
pool.set_stuck_after(std::time::Duration::ZERO);
let gate = render_gate(0, false);
*lock(&pool.shared.render_gates) = vec![gate.clone()];
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 }]);
pool.set_queue(vec![RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() }]);
gate.entered.wait();
let first = pool.try_recv().expect("the watchdog answers for the held render");
assert!(first.error.as_deref().is_some_and(|e| e.contains("took longer")), "{:?}", first.error);
@ -3023,7 +3033,7 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let t = out.text.expect("text");
assert_eq!(t.plain_text(), "Hello World\nSecond");
@ -3047,7 +3057,7 @@ trailer << /Root 1 0 R >>
%%EOF";
let px = |cfg: RenderConfig| {
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), cfg);
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 0.5, tag: 0, ..Default::default() });
p.rgba[..4].to_vec()
};
assert_eq!(px(RenderConfig::default()), vec![255, 0, 0, 255], "layer is on by default");
@ -3079,7 +3089,7 @@ trailer << /Root 1 0 R >>
};
let render = |doc: String| {
let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })
};
let looped = render(pdf("/Pattern cs /P1 scn 0 0 10 10 re f"));
assert_eq!((looped.width, looped.height), (40, 40));
@ -3158,7 +3168,7 @@ trailer << /Root 1 0 R >>
};
let render = |doc: String| {
let mut r = PageRenderer::new(Arc::new(doc.into_bytes()), RenderConfig::default());
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 })
r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() })
};
let looped = render(pdf("1 0 d0 BT /F1 1 Tf (A) Tj ET 0 0 1 1 re f"));
assert_eq!((looped.width, looped.height), (40, 40));
@ -3197,7 +3207,7 @@ trailer << /Root 1 0 R >>
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
}
@ -3289,7 +3299,7 @@ trailer << /Root 1 0 R >>
fn jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering() {
for bpc in [8, 32] {
let mut renderer = PageRenderer::new(Arc::new(jpx_midpoint_pdf(bpc, 1)), RenderConfig::default());
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{bpc} bits: {:?}", page.error);
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "other page content draws");
if bpc == 8 {
@ -3327,7 +3337,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3366,7 +3376,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3404,7 +3414,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3509,7 +3519,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a self-multiplying Type 3 glyph must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3799,7 +3809,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge JBIG2 region must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3860,7 +3870,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx
.recv_timeout(std::time::Duration::from_secs(20))
@ -3902,7 +3912,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(120)).expect("a large page must render");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3921,7 +3931,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("an inline image must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -3953,7 +3963,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a content bomb must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4217,7 +4227,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a self-painting form must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4249,7 +4259,7 @@ trailer << /Root 1 0 R >>
pdf.extend_from_slice(&form);
pdf.extend_from_slice(b"\nendstream endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n");
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
// The last paint is at user (30, 30)–(34, 34): device row 40 - 32 = 8.
assert_eq!(&page.rgba[((8 * 40 + 32) * 4)..][..4], &[255, 0, 0, 255], "the 300th paint is drawn");
@ -4277,7 +4287,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a sampled-function bomb must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4302,7 +4312,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4370,7 +4380,7 @@ trailer << /Root 1 0 R >>
",
);
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(page.error.is_none(), "{:?}", page.error);
let middle = &page.rgba[((28 * 40 + 12) * 4)..][..4];
assert!(middle != [255, 255, 255, 255] && middle[3] == 255, "a small patch is shaded: {middle:?}");
@ -4419,7 +4429,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 25.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 25.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("overlapping mesh triangles must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4447,7 +4457,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(60)).expect("a mesh shading must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4494,7 +4504,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a Type 1 font must not stall the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4521,7 +4531,7 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 }));
let _ = tx.send(r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() }));
});
let page = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("an absurd image must not hang the renderer");
assert!(page.error.is_none(), "{:?}", page.error);
@ -4723,8 +4733,8 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf.as_bytes().to_vec()), RenderConfig::default());
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let _ = tx.send((px.error, text.error));
});
let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("rendering a huge /W range must not hang");
@ -4764,7 +4774,7 @@ trailer << /Root 1 0 R >>
fn malformed_simple_font_widths_keep_later_widths_and_the_font() {
let render = |widths: &str| {
let mut r = PageRenderer::new(Arc::new(type3_boxes(widths)), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{widths}: {:?}", p.error);
p
};
@ -4795,8 +4805,8 @@ trailer << /Root 1 0 R >>
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(type3_boxes("/FirstChar 4294967295 /LastChar 4294967295 /Widths [600]")), RenderConfig::default());
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0 });
let px = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let text = r.render(RenderRequest { page: 0, kind: RequestKind::Text, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let _ = tx.send((px, text.error));
});
let (px, text) = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("a huge /FirstChar must not exhaust memory");
@ -4831,7 +4841,7 @@ trailer << /Root 1 0 R >>
};
let render = |bytes: Vec<u8>, scale: f32| {
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
p
};
@ -4877,9 +4887,9 @@ trailer << /Root 1 0 R >>
#[test]
fn tiles_match_full_render() {
let mut r = PageRenderer::new(Arc::new(ONE_PAGE.to_vec()), RenderConfig::default());
let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0 });
let full = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() });
let tile = Tile { x: 30, y: 40, w: 50, h: 30 };
let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0 });
let part = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: Some(tile), scale: 2.0, tag: 0, ..Default::default() });
assert_eq!((part.width, part.height), (50, 30));
for y in 0..30u32 {
for x in 0..50u32 {
@ -4943,6 +4953,9 @@ trailer << /Root 1 0 R >>
/// what it was without skipping, where paint reaches past a path into the next tile: round
/// and square caps, a sharp miter, a rotated and a stretched matrix, a hairline, dashes, a
/// curve, text, and a page shown rotated.
///
/// Images are skipped the same way (vendored patch (10)): an image on a tile, one crossing
/// tile lines, and one fully off the page.
#[test]
fn skipping_paths_off_the_canvas_changes_no_pixel() {
// Tiles of 80 device pixels at scale 2 meet every 40 pt; each mark crosses such a line
@ -4957,20 +4970,29 @@ q 0.7071 0.7071 -0.7071 0.7071 160 20 cm 15 w 1 J 0 0 m 20 0 l S Q
0 1 0 RG 3 w 10 200 m 10 290 70 290 70 200 c S
BT /F1 30 Tf 70 205 Td (Hg) Tj ET
q 4 0 0 0.25 0 0 cm 12 w 1 J 2 500 m 8 500 l S Q
300 300 m 320 320 l S";
300 300 m 320 320 l S
q 10 0 0 10 5 5 cm /Im0 Do Q
q 30 0 0 30 100 100 cm /Im0 Do Q
q 5 0 0 5 500 500 cm /Im0 Do Q";
// A 4x4 RGB image of plain `A` bytes (no `endstream` inside, so the raw stream parses).
let pixels = "A".repeat(4 * 4 * 3);
for rotate in [0, 90] {
let pdf = format!(
"%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 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 240 240] /Rotate {rotate} /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj
6 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream
{pixels}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len()
content.len(),
pixels.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let mut both = |tile| {
@ -4991,6 +5013,125 @@ trailer << /Root 1 0 R >>
}
}
/// Previews skip images that would cover only a few device pixels (vendored patch (11)):
/// a tiny draw is missing from the preview but present in the final, while a large draw
/// is in both. Finals never set the flag, so they draw everything.
#[test]
fn preview_renders_skip_tiny_images_but_finals_draw_everything() {
let content = "q 50 0 0 50 100 100 cm /Im0 Do Q
q 0.5 0 0 0.5 10 10 cm /Im0 Do Q";
let pixels = "A".repeat(4 * 4 * 3);
let pdf = format!(
"%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 240 240] /Contents 4 0 R /Resources << /XObject << /Im0 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Image /Width 4 /Height 4 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length {} >> stream
{pixels}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
content.len(),
pixels.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let mut render = |preview| r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview });
let first = render(false);
let second = render(false);
assert!(first.error.is_none(), "{:?}", first.error);
assert_eq!(first.rgba, second.rgba, "finals are deterministic");
let gray = first.rgba.as_chunks::<4>().0.iter().filter(|px| px[0] == 0x41 && px[1] == 0x41 && px[2] == 0x41).count();
assert!(gray > 1000, "the large image draws {gray} pixels");
let preview = render(true);
assert!(preview.error.is_none(), "{:?}", preview.error);
assert_ne!(preview.rgba, first.rgba, "the tiny image is skipped only in the preview");
}
/// A page whose stream objects 5, 6, … are `objects` (dictionary, data), drawn by `content`
/// with `resources`, on a 130 × 70 pt page.
fn image_cache_pdf(content: &str, resources: &str, objects: &[(&str, &[u8])]) -> Vec<u8> {
let mut pdf = format!(
"%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 130 70] /Contents 4 0 R /Resources {resources} >> endobj
4 0 obj << /Length {} >> stream
{content}
endstream endobj
",
content.len()
)
.into_bytes();
for (i, (dict, data)) in objects.iter().enumerate() {
pdf.extend(format!("{} 0 obj {dict} stream\n", i + 5).bytes());
pdf.extend_from_slice(data);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
pdf
}
/// The RGB of the pixel at (`x`, `y`) (from the top left) of a `width`-pixel-wide RGBA raster.
fn rgb_at(rgba: &[u8], width: usize, x: usize, y: usize) -> [u8; 3] {
let i = (y * width + x) * 4;
[rgba[i], rgba[i + 1], rgba[i + 2]]
}
const ONE_PIXEL_RGB: &str = "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Length 3 >>";
/// Two images whose dictionaries are byte for byte the same (uncompressed, one size) but
/// whose pixels differ each draw their own: the decoded-image cache tells them apart by the
/// stream, not by the dictionary's bytes alone, in one render and across renders sharing a
/// cache, and previews never hand their entries to a final render.
#[test]
fn decoded_images_with_identical_dictionaries_keep_their_own_pixels() {
let pdf = image_cache_pdf(
// B over A at the same place and size (so one decode target): B must show. A alone
// on the right, at a size of its own.
"q 50 0 0 50 10 10 cm /A Do Q q 50 0 0 50 10 10 cm /B Do Q q 40 0 0 40 80 15 cm /A Do Q",
"<< /XObject << /A 5 0 R /B 6 0 R >> >>",
&[(ONE_PIXEL_RGB, b"\xff\x00\x00"), (ONE_PIXEL_RGB, b"\x00\x00\xff")],
);
let req = |preview| RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview };
let mut r = PageRenderer::new(Arc::new(pdf.clone()), RenderConfig::default());
let out = r.render(req(false));
assert!(out.error.is_none(), "{:?}", out.error);
let w = out.width as usize;
assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 0, 255], "the second image drew the first one's pixels");
assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]);
// One cache across a preview and then a final render, as a render worker keeps it.
let pdf = parse(&Arc::new(pdf), None).expect("parses");
let cache = RenderCache::new();
let settings = InterpreterSettings::default();
let preview = render_page(&pdf, &cache, &settings, false, req(true)).expect("preview renders");
let fin = render_page(&pdf, &cache, &settings, false, req(false)).expect("final renders");
let fresh = render_page(&pdf, &RenderCache::new(), &settings, false, req(false)).expect("final renders");
assert_eq!(rgb_at(&preview.2, w, 35, 35), [0, 0, 255]);
assert_eq!(&fin.2[..], &fresh.2[..], "a final render after a preview matches one with a fresh cache");
}
/// An image drawn under a transfer function (`/TR`) is decoded with it, even when the same
/// image was drawn without one just before (and the other way round).
#[test]
fn decoded_images_under_a_transfer_function_are_not_shared() {
let pdf = image_cache_pdf(
// The image, then the same image at the same place under the inverting function
// (one decode target), then the image alone on the right.
"q 50 0 0 50 10 10 cm /A Do Q q /Inv gs 50 0 0 50 10 10 cm /A Do Q q 40 0 0 40 80 15 cm /A Do Q",
"<< /XObject << /A 5 0 R >> /ExtGState << /Inv << /TR 6 0 R >> >> >>",
&[(ONE_PIXEL_RGB, b"\xff\x00\x00"), ("<< /FunctionType 4 /Domain [0 1] /Range [0 1] /Length 14 >>", b"{ 1 exch sub }")],
);
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(out.error.is_none(), "{:?}", out.error);
let w = out.width as usize;
assert_eq!(rgb_at(&out.rgba, w, 35, 35), [0, 255, 255], "the transfer function was not applied to a cached image");
assert_eq!(rgb_at(&out.rgba, w, 100, 35), [255, 0, 0]);
}
/// A Japanese CID font that isn't embedded (Adobe-Japan1, as `HeiseiMin-W3` with
/// `UniJIS-UCS2-H` in #260's test file) drew nothing: hayro's substitutes for fonts that
/// aren't embedded are Latin-only. With craft-fonts (the build input release builds embed),
@ -5018,7 +5159,7 @@ trailer << /Root 1 0 R >>
};
for base_font in ["HeiseiMin-W3", "HeiseiKakuGo-W5"] {
let mut r = PageRenderer::new(Arc::new(pdf(base_font).into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font}: {:?}", p.error);
let inked = p.rgba.as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count();
if pdfcraft_fonts::document_japanese_font().is_none() {
@ -5082,7 +5223,7 @@ trailer << /Root 1 0 R >>
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error);
p.rgba
};
@ -5156,7 +5297,7 @@ trailer << /Root 1 0 R >>
cmap.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{base_font} <{code}>: {:?}", p.error);
p.rgba
};
@ -5242,7 +5383,7 @@ endstream endobj
};
let render = |bytes: Vec<u8>| {
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
assert!(p.error.is_none(), "{:?}", p.error);
p
};
@ -5344,7 +5485,7 @@ endstream endobj
trailer << /Root 1 0 R >>
%%EOF";
let mut r = PageRenderer::new(Arc::new(pdf.to_vec()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
let px = |x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
assert_eq!(px(25, 50), vec![255, 0, 0, 255], "inside the mask");
assert_eq!(px(75, 50), vec![255, 255, 255, 255], "outside the mask");
@ -5371,7 +5512,7 @@ trailer << /Root 1 0 R >>
content.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{content}: {:?}", p.error);
p
};

View File

@ -19,17 +19,18 @@ struct Key {
tile: Option<Tile>,
scale: u32,
tag: u64,
preview: bool,
}
impl From<RenderRequest> for Key {
fn from(r: RenderRequest) -> Self {
Self { page: r.page, kind: r.kind, tile: r.tile, scale: r.scale.to_bits(), tag: r.tag }
Self { page: r.page, kind: r.kind, tile: r.tile, scale: r.scale.to_bits(), tag: r.tag, preview: r.preview }
}
}
impl Key {
fn request(self) -> RenderRequest {
RenderRequest { page: self.page, kind: self.kind, tile: self.tile, scale: f32::from_bits(self.scale), tag: self.tag }
RenderRequest { page: self.page, kind: self.kind, tile: self.tile, scale: f32::from_bits(self.scale), tag: self.tag, preview: self.preview }
}
}

View File

@ -1909,11 +1909,31 @@ pub fn display_date(pdf: &str) -> String {
/// The visible signature: the signer's name large on the left, or the graphic in its place, and
/// the details on the right (Acrobat's standard layout), in Helvetica. A graphic with no details
/// beside it fills the box.
/// beside it fills the box. An error, before anything is drawn, when a detail it writes in
/// Helvetica (the signer's name, the certificate's subject, the reason or the location) has a
/// character Helvetica can't draw: signing invisibly, or leaving that part out, still works. A
/// graphic doesn't change this: the name it replaces on the left is still among the details.
fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate, opts: &SignOptions) -> Result<Stream, SignError> {
use pdfcraft_fonts::{helvetica_width, literal, win_ansi, wrap};
use pdfcraft_fonts::{first_undrawable, helvetica_width, literal, undrawable_message, win_ansi, wrap};
let (w, h) = ((rect[2] - rect[0]).max(0.0), (rect[3] - rect[1]).max(0.0));
let a = &opts.appearance;
let dn = cert.subject.display();
if w > 1.0 && h > 1.0 {
let shown = [
(a.name, "the signer's name", Some(name)),
(a.distinguished_name, "the certificate's subject", Some(dn.as_str())),
(a.reason, "the reason", opts.reason.as_deref()),
(a.location, "the location", opts.location.as_deref()),
];
for (on, what, text) in shown {
if on && let Some(c) = text.and_then(first_undrawable) {
return Err(SignError::Unsupported(format!(
"{}; sign without a visible appearance, or leave {what} out of it",
undrawable_message(&format!("{what} of the visible signature"), c)
)));
}
}
}
let mut lines: Vec<String> = Vec::new();
let label = |l: &str, v: &str| if a.labels { format!("{l}{v}") } else { v.to_string() };
// The labels are the caller's words, so a signature signed in another language reads in it.
@ -1923,7 +1943,7 @@ fn appearance(doc: &mut Document, rect: [f64; 4], name: &str, cert: &Certificate
lines.push(name.to_string());
}
if a.distinguished_name {
lines.push(label(&wording.distinguished_name, &cert.subject.display()));
lines.push(label(&wording.distinguished_name, &dn));
}
if a.reason
&& let Some(r) = opts.reason.as_deref().filter(|r| !r.is_empty())

View File

@ -1738,6 +1738,29 @@ fn encrypted_documents_take_a_document_timestamp() {
assert!(matches!(pdfcraft_sign::timestamp_document(&print_only, &tsa, "D:20261006120000Z"), Err(SignError::Pdf(_))));
}
/// A visible signature draws its details in Helvetica: a reason or location it can't draw is
/// refused with the character and what to do instead, rather than drawn as `?`. An
/// invisible signature stores the same reason as text and signs.
#[test]
fn visible_appearance_text_helvetica_cannot_draw_is_refused() {
let id = pkcs12::open(&data("ec-p256.p12"), "test").unwrap();
let shown = pdfcraft_sign::Appearance { reason: true, location: true, ..pdfcraft_sign::Appearance::default() };
let chinese = SignOptions { reason: Some("同意此文件".into()), appearance: shown.clone(), ..opts() };
let err = pdfcraft_sign::sign(&open(&fixture()), &id, &chinese).unwrap_err().to_string();
assert!(err.contains("\"同\" (U+540C)") && err.contains("the reason") && err.contains("sign without a visible appearance"), "{err}");
let located = SignOptions { location: Some("北京".into()), appearance: shown, ..opts() };
assert!(pdfcraft_sign::sign(&open(&fixture()), &id, &located).unwrap_err().to_string().contains("the location"));
// Left out of the appearance, or signed invisibly: it signs, and the reason is kept as text.
let hidden = SignOptions { reason: Some("同意此文件".into()), ..opts() };
assert!(!hidden.appearance.reason, "the default appearance doesn't show the reason");
pdfcraft_sign::sign(&open(&fixture()), &id, &hidden).unwrap();
let invisible = SignOptions { rect: None, ..chinese };
let signed = pdfcraft_sign::sign(&open(&fixture()), &id, &invisible).unwrap();
let doc = open(&signed);
let s = signatures(&doc, &signed, &TrustStore::default()).into_iter().find(|s| s.signed).unwrap();
assert_eq!(s.reason.as_deref(), Some("同意此文件"));
}
/// Every sentence this crate says about a signature is listed in `WORDINGS`, and every sentence on
/// that list is one it still says: the details are written as `Detail::plain`/`Detail::of`, the
/// sentences the modules that read a signature word for themselves are pushed as notes and quirks,

View File

@ -445,7 +445,14 @@ fn figure_picture(ctx: &egui::Context, doc: &pdfcraft_engine::Document, f: &pdfc
};
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let out = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, tile: Some(tile), scale, tag: 0 });
let out = r.render(pdfcraft_render::RenderRequest {
page,
kind: pdfcraft_render::RequestKind::Pixels,
tile: Some(tile),
scale,
tag: 0,
preview: false,
});
if out.error.is_some() || out.width == 0 {
return None;
}

View File

@ -29,6 +29,8 @@ const GRID_TAG: u64 = 1 << 61;
const STALE_TAG: u64 = u64::MAX;
/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
const TILE_THRESHOLD: f32 = 4096.0;
/// Base device-pixel side of a page tile. The actual side grows with the zoom
/// ([`tile_side`]), so a zoomed viewport is covered by a few tiles instead of many.
const TILE: u32 = 1024;
/// Longest side of the backdrop drawn under tiles, and of a live page preview (signature drag).
pub(crate) const BASE_SIDE: f32 = 2048.0;
@ -450,6 +452,23 @@ pub struct DocView {
/// Sharp renders of the pages the grid shows larger than a thumbnail: only those in view,
/// at the size drawn (device pixels wide).
grid_pages: HashMap<usize, (u32, TextureHandle)>,
/// Zoom-settle debounce for heavy pages: the last requested device scale, and the egui
/// time until which sharp whole-page/tile re-renders are held back. While the zoom is
/// still moving, every frame would otherwise cancel the in-flight full-page interpret
/// (seconds on image-heavy CAD plots) and start it over, so a sharp raster never
/// arrives. The stale raster stays on screen stretched meanwhile.
last_scale: f32,
sharp_hold_until: f64,
/// Pan-settle debounce for tiled pages: the scroll offset last frame, and the egui time
/// until which sharp tile re-renders are held back. Each tile costs a full page
/// interpret, so a drag would otherwise queue a storm of them (one per frame) while the
/// in-flight ones cannot be cancelled mid-operator. Older tiles and the backdrop stay
/// stretched meanwhile; fresh tiles go out once the pan settles.
last_scroll: Vec2,
pan_hold_until: f64,
/// Pages whose raster proved slow (see `SLOW_MILLIS`): only these get the zoom/pan
/// settle holds. Fast pages render straight through, with no added latency.
slow_pages: HashSet<usize>,
}
/// The page grid's zoom range and the step of its buttons and keys.
@ -618,6 +637,11 @@ impl DocView {
grid_zoom_request: None,
grid_anchor: None,
grid_pages: HashMap::new(),
last_scale: 0.0,
sharp_hold_until: 0.0,
last_scroll: Vec2::ZERO,
pan_hold_until: 0.0,
slow_pages: HashSet::new(),
comments: Default::default(),
measure: Default::default(),
forms: Default::default(),
@ -764,6 +788,7 @@ impl DocView {
self.texts.clear();
self.text_failed.clear();
self.errors.clear();
self.slow_pages.clear();
self.waiting_since.clear();
self.last_queue.clear();
self.selection = None;
@ -797,6 +822,7 @@ impl DocView {
self.stale_tiles.insert(key);
}
}
self.slow_pages.remove(&page);
self.texts.remove(&page);
self.text_failed.remove(&page);
self.errors.remove(&page);
@ -1316,7 +1342,15 @@ impl DocView {
.filter_map(|(page, _)| {
let p = info.pages.get(page)?;
let scale = thumbnail_scale(p.width, p.height, density);
Some(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag: THUMB_TAG | u64::from(scale.to_bits()) })
Some(RenderRequest {
page,
kind: RequestKind::Pixels,
tile: None,
scale,
tag: THUMB_TAG | u64::from(scale.to_bits()),
// Thumbnails are previews: tiny images are skipped for them.
preview: true,
})
})
.collect()
}
@ -1349,7 +1383,9 @@ impl DocView {
// decide how much prefetch fits beside it.
let visible = self.frame_visible.contains(&req.page);
if let Some(tile) = req.tile {
let key = (req.page, req.tag, tile.x / TILE, tile.y / TILE);
// Keyed by exact device-pixel origin: the tile side varies with the zoom
// (`tile_side`), so grid indices would need the scale to decode.
let key = (req.page, req.tag, tile.x, tile.y);
let bytes = rgba_bytes(tile.w as usize, tile.h as usize);
if (!visible && tile_bytes.saturating_add(bytes) > TILE_BYTES) || !tiles.insert(key) {
continue;
@ -1510,6 +1546,11 @@ impl DocView {
self.errors.insert(r.request.page, e);
return 0;
}
// Remember heavy pages: only these get the zoom/pan settle holds, so fast pages
// never pay added latency. Stale entries die with the tiles on edits.
if r.millis >= SLOW_MILLIS {
self.slow_pages.insert(r.request.page);
}
let page = r.request.page;
let bytes = rgba_bytes(r.width as usize, r.height as usize);
if bytes != r.rgba.len() || !self.admit_texture(r.request, bytes) {
@ -1518,7 +1559,7 @@ impl DocView {
// The length was checked above, so the renderer's buffer becomes the texture data as is.
let img = texture_image([r.width as usize, r.height as usize], r.rgba);
if let Some(t) = r.request.tile {
let key = (page, r.request.tag, t.x / TILE, t.y / TILE);
let key = (page, r.request.tag, t.x, t.y);
let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
self.tiles.insert(key, tex);
self.stale_tiles.remove(&key);
@ -1559,7 +1600,7 @@ impl DocView {
return true;
}
if let Some(t) = req.tile {
let key = (req.page, req.tag, t.x / TILE, t.y / TILE);
let key = (req.page, req.tag, t.x, t.y);
self.tiles.remove(&key);
let retained = self.tiles.values().fold(0usize, |n, tex| n.saturating_add(texture_bytes(tex)));
visible || bytes <= TILE_BYTES.saturating_sub(retained)
@ -1670,7 +1711,7 @@ impl DocView {
let tag = scale_tag(scale) | PRINT_TAG;
let fresh = self.print_pages.get(&page).is_some_and(|(have, _)| *have == tag);
if !fresh {
self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag });
self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag, preview: false });
}
}
}
@ -1686,9 +1727,28 @@ fn tag_scale(tag: u64) -> f32 {
(tag as f64 / 65536.0) as f32
}
/// Device-pixel side of a page tile at `scale` for a page `page_max_px` device pixels on
/// its long side. Each tile costs a full page interpret, so tiles grow with the zoom and
/// with the page: a zoomed viewport, or a giant sheet shown whole, is covered by a few
/// large tiles instead of dozens of small ones. Bounded by the render backend (tile sides
/// above 4096 are clamped there) and kept granular at ordinary zooms.
fn tile_side(scale: f32, page_max_px: u32) -> u32 {
if !scale.is_finite() || scale <= 0.0 {
return TILE;
}
let by_zoom = (TILE as f32 * (scale / 6.0)).clamp(TILE as f32, 4096.0) as u32;
// At most four tiles along the long side, so a giant sheet needs a handful of
// interprets instead of dozens.
by_zoom.max(page_max_px.div_ceil(4).min(4096))
}
/// Tiles of earlier zooms kept for a page while its current tiles are rendered.
const OLD_TILES: usize = 64;
/// A page whose raster took at least this long is heavy: zoom/pan holds apply to it,
/// so gestures stay on stretched surfaces instead of queueing full-page interprets.
const SLOW_MILLIS: u32 = 500;
/// `r` moved so its corner lies on a whole physical pixel, so that a raster drawn from there
/// maps texel for texel onto the screen.
fn snap_to_pixels(r: Rect, ppp: f32) -> Rect {
@ -2159,6 +2219,18 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let ppp = ui.ctx().pixels_per_point();
let scale = view.render_scale(ppp);
let tag = scale_tag(scale);
// Hold sharp re-renders while the zoom is still moving: a new scale every frame would
// cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD
// plot with ~1000 images renders for seconds) the sharp raster never arrives and the
// view looks stuck on a soft image. The stale raster stays stretched meanwhile; the
// sharp request goes out once the zoom settles.
let now_scale = ui.input(|i| i.time);
if (scale - view.last_scale).abs() > f32::EPSILON {
view.last_scale = scale;
view.sharp_hold_until = now_scale + 0.35;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(380));
}
let sharp_held = now_scale < view.sharp_hold_until;
view.current_scale_tag = tag;
let hand = app.quick_tool == QuickTool::Hand;
let tool = app.quick_tool;
@ -2368,6 +2440,11 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
// (found by mapping the visible screen corners back into the page). Sides round
// up as whole-page rasters do, so the last partial row and column get tiles.
let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale));
// Adaptive tile side for this zoom and page, within the GPU texture limit:
// a zoomed viewport or a giant sheet is covered by a few large tiles
// instead of many small ones, and each tile costs one full page interpret.
// Never zero (u32 division below).
let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64);
let vis = r.intersect(visible);
let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c));
let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0)));
@ -2380,34 +2457,46 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
.tiles
.iter()
.filter(|((p, t, _, _), _)| *p == i && *t != tag)
.map(|((_, t, tx, ty), tex)| (tag_scale(*t), *tx, *ty, tex))
.map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex))
.collect();
older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs()));
for (s, tx, ty, tex) in older {
for (s, ox, oy, tex) in older {
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
let [w, h] = tex.size();
let (x, y) = ((tx * TILE) as f32, (ty * TILE) as f32);
let (x, y) = (ox as f32, oy as f32);
xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
}
for ty in vy0 / TILE..=(vy1.saturating_sub(1)) / TILE {
for tx in vx0 / TILE..=(vx1.saturating_sub(1)) / TILE {
let (x, y) = (tx * TILE, ty * TILE);
let (w, h) = (TILE.min(dw.saturating_sub(x)), TILE.min(dh.saturating_sub(y)));
if w == 0 || h == 0 {
continue;
}
wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
if let Some(tex) = view.tiles.get(&(i, tag, tx, ty)) {
let (fw, fh) = (dw as f32, dh as f32);
xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
painter,
tex.id(),
x as f32 / fw,
y as f32 / fh,
(x + w) as f32 / fw,
(y + h) as f32 / fh,
);
}
// Visible tiles, nearest the viewport centre first: each tile costs a full
// page interpret, so the workers sharpen what is looked at before the edges.
let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0);
let mut coords: Vec<(u32, u32)> = Vec::new();
for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts {
for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts {
coords.push((tx, ty));
}
}
coords.sort_by(|a, b| {
let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs();
let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs();
da.total_cmp(&db)
});
for (tx, ty) in coords {
let (x, y) = (tx * ts, ty * ts);
let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y)));
if w == 0 || h == 0 {
continue;
}
wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
let (fw, fh) = (dw as f32, dh as f32);
xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
painter,
tex.id(),
x as f32 / fw,
y as f32 / fh,
(x + w) as f32 / fw,
(y + h) as f32 / fh,
);
}
}
}
@ -2684,6 +2773,9 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.clicks = release_clicks;
}
let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks };
// Only on the frame egui reports the multi-click: `view.clicks` outlives it, and
// acting on every frame re-selected the word as soon as a selection was cleared
// (a highlight made by the double-click left its word selected).
if release_clicks > 0 && clicks >= 2 && over_text {
if view.selection.is_none() {
let span = text.nearest(vx, vy).and_then(|a| match clicks {
@ -2901,6 +2993,19 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.auto_scroll.paint(ui, avail);
// Collect all main-page demand, including cached surfaces, for byte admission later.
// Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding
// fresh tiles back while the scroll offset is still moving keeps a drag on the stretched
// backdrop/older tiles instead of queueing a storm of interprets (one per frame) that
// the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles;
// pages with nothing on screen yet still queue.
let scroll_offset = out.state.offset;
let now_settle = ui.input(|i| i.time);
if (scroll_offset - view.last_scroll).length() > 2.0 {
view.pan_hold_until = now_settle + 0.25;
ui.ctx().request_repaint_after(std::time::Duration::from_millis(280));
}
view.last_scroll = scroll_offset;
let pan_held = now_settle < view.pan_hold_until;
let (mut wanted, visible_now) = out.inner;
let cur = view.current;
wanted.sort_by_key(|w| (!visible_now.contains(&w.0), w.0.abs_diff(cur), w.3.is_some()));
@ -2916,8 +3021,51 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
view.tiles.remove(&k);
}
}
let mut queue: Vec<RenderRequest> =
wanted.iter().map(|&(page, scale, tag, tile)| RenderRequest { page, kind: RequestKind::Pixels, tile, scale, tag }).collect();
let mut queue: Vec<RenderRequest> = wanted
.iter()
.map(|&(page, wanted_scale, wanted_tag, tile)| RenderRequest {
page,
kind: RequestKind::Pixels,
tile,
scale: wanted_scale,
tag: wanted_tag,
// The low-res backdrop under tiles is a preview: tiny images are skipped for it.
// It is the whole-page request whose scale is not the current one; sharp tiles
// and whole-page finals always draw everything.
preview: tile.is_none() && (wanted_scale != scale || wanted_tag != tag),
})
.collect();
// While the zoom or pan is settling, hold back sharp re-renders of pages that already
// show something (a stale whole-page raster or older tiles, drawn stretched): each new
// scale would cancel the running full-page interpret and restart it from scratch, and
// each pan frame would queue a storm of full-page tile interprets.
// Pages with nothing on screen yet still queue, so panning to a fresh page draws it.
if sharp_held || pan_held {
let main_tag = tag;
queue.retain(|req| {
// Fast pages render straight through: the hold only protects heavy pages
// whose full-page interpret costs seconds.
if !view.slow_pages.contains(&req.page) {
return true;
}
if req.tile.is_some() {
let has_current_tile = view.tiles.keys().any(|(p, t, _, _)| *p == req.page && *t == req.tag);
if has_current_tile {
return false;
}
let has_any_surface = view.pages.contains_key(&req.page) || view.tiles.keys().any(|(p, _, _, _)| *p == req.page);
!has_any_surface
} else if req.tag != main_tag {
// The low-res backdrop under tiles is zoom-independent: let it through,
// it is the stretched surface the hold relies on.
true
} else {
// The stale raster stays stretched until the zoom or pan settles; a page with
// nothing on screen yet still renders.
!view.pages.contains_key(&req.page)
}
});
}
// Text layers: visible pages for selection, every page while a search is active.
let need_text = |p: &usize| !view.texts.contains_key(p) && !view.text_failed.contains(p);
let mut text_pages: Vec<usize> = if hand { Vec::new() } else { visible_now.iter().copied().filter(need_text).collect() };
@ -2926,7 +3074,14 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
let rest: Vec<usize> = (0..n).map(|k| (cur + k) % n).filter(need_text).filter(|p| !text_pages.contains(p)).collect();
text_pages.extend(rest);
}
queue.extend(text_pages.into_iter().map(|page| RenderRequest { page, kind: RequestKind::Text, tile: None, scale: 1.0, tag: TEXT_TAG }));
queue.extend(text_pages.into_iter().map(|page| RenderRequest {
page,
kind: RequestKind::Text,
tile: None,
scale: 1.0,
tag: TEXT_TAG,
preview: false,
}));
view.frame_queue = queue;
view.frame_visible = visible_now.iter().copied().collect();
@ -3858,7 +4013,15 @@ fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool
if !thumb_ok && ui.is_rect_visible(c) {
in_view.push((i, want));
if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) {
sharper.push(RenderRequest { page: i, kind: RequestKind::Pixels, tile: None, scale: want / p.width.max(1.0), tag: GRID_TAG });
sharper.push(RenderRequest {
page: i,
kind: RequestKind::Pixels,
tile: None,
scale: want / p.width.max(1.0),
tag: GRID_TAG,
// Grid cells are previews: tiny images are skipped for them.
preview: true,
});
}
}
if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) {
@ -4075,7 +4238,7 @@ 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 });
let out = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.5, tag: 0, preview: false });
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);
@ -4085,6 +4248,24 @@ trailer << /Root 1 0 R >>
assert_eq!(img.pixels.as_ptr().cast::<u8>(), at, "the renderer's own buffer");
}
#[test]
fn tiles_stay_granular_up_close_and_cover_giant_sheets_in_a_handful() {
// Ordinary zooms keep 1024 px tiles; degenerate scales fall back to it, never zero.
assert_eq!(tile_side(1.0, 2000), TILE);
assert_eq!(tile_side(4.0, 2000), TILE);
assert_eq!(tile_side(f32::NAN, 2000), TILE);
assert_eq!(tile_side(f32::INFINITY, 2000), TILE);
assert_eq!(tile_side(0.0, 2000), TILE);
// Zoomed viewports need a few large tiles instead of many small ones.
assert_eq!(tile_side(12.0, 2000), 2048);
assert_eq!(tile_side(20.0, 2000), 3413);
assert_eq!(tile_side(100.0, 2000), 4096);
// A giant sheet at fit width (6740 px long side) is covered four tiles a side.
assert_eq!(tile_side(96.0 / 72.0, 6740), 1685);
// The backend clamps tile sides at 4096.
assert_eq!(tile_side(20.0, 100_000), 4096);
}
#[test]
fn grid_zoom_is_clamped_and_sharp_renders_tolerate_a_pinch() {
assert!(sharp_enough(900, 876.0) && sharp_enough(800, 876.0) && sharp_enough(1300, 876.0));

View File

@ -37,11 +37,24 @@ pub(crate) type UndrawableMemo = std::cell::RefCell<Option<(AddedText, Option<ch
/// 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."),
tl!("The PDF text fonts can't draw “{c}” ({code}). Choose supported text."),
&[("c", &c.to_string()), ("code", &format!("U+{:04X}", u32::from(c)))],
)
}
/// The warning after an edit that saved text its font can't draw: PdfCraft drew `?` for it (a form
/// field, which other viewers redraw from its value, or a comment, where the `?` shows everywhere).
pub(crate) fn undrawn_message(n: &pdfcraft_engine::TextNotice) -> String {
let template = if n.redrawn_elsewhere {
tl!(
"This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it."
)
} else {
tl!("The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved.")
};
crate::i18n::fmt(template, &[("c", &n.ch.to_string()), ("code", &format!("U+{:04X}", u32::from(n.ch)))])
}
const SELECT_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// The text being typed or retyped in place.
@ -453,7 +466,7 @@ pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) ->
if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) {
s.color = picked;
}
ui.label(egui::RichText::new(tl!("Standard fonts; text outside Windows-1252 isn't supported yet.")).small().color(t.text_faint));
ui.label(egui::RichText::new(tl!("Thai and Arabic use regular fallback fonts when available.")).small().color(t.text_faint));
(s != *style).then_some(s)
}

View File

@ -25,6 +25,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
app.view_draft = None;
return;
};
// Properties ▸ Fonts shows the fonts as they are now, after any content edits.
if let (Dialog::Properties(PropsTab::Fonts), Some((_, id))) = (dialog, app.active_ids()) {
app.session.refresh_fonts(id);
}
// Seed the editable Description fields from the document when the dialog opens.
if let (Dialog::Properties(_), Some((_, id))) = (dialog, app.active_ids())
&& app.props_draft.as_ref().is_none_or(|(d, _)| *d != id)
@ -897,7 +901,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
return;
}
Dialog::Preferences => {
close = crate::js_ui::preferences_body(ui, app, &t);
// The settings outgrow a short window: scroll them rather than run off the screen.
let max = (ui.ctx().content_rect().height() - 120.0).max(200.0);
close = egui::ScrollArea::vertical().max_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)).inner;
return;
}
Dialog::RecognizeText => {

View File

@ -12,6 +12,12 @@ use crate::canvas::{DocView, PageXform};
const ACCENT: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// How many faint Edit-text outlines are drawn per page per frame. CAD plots can draw
/// ~1000 tiny images on one page; stroking all of them every frame (plus a hover query
/// per box) stalls the UI. Past this, only the selected and hovered boxes are outlined:
/// hit-testing still covers every image, so nothing becomes unselectable.
const MAX_OUTLINES: usize = 250;
/// A paragraph being edited.
#[derive(Clone, Debug, PartialEq)]
pub struct LineEditor {
@ -283,25 +289,33 @@ pub(crate) fn image_input(
) -> bool {
let boxes: Vec<Rect> = images.iter().map(|im| xf.user_rect(info, page, im.rect.map(|v| v as f32))).collect();
let painter = ui.painter();
for b in &boxes {
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
let pointer = ui.input(|i| i.pointer.hover_pos());
let hover_pos = ui.input(|i| i.pointer.hover_pos());
let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len());
for (i, b) in boxes.iter().enumerate() {
let selected_box = selected == Some(i);
let hovered = ui.input(|inp| inp.pointer.hover_pos()).is_some_and(|p| b.contains(p));
let stroke = if selected_box {
Stroke::new(1.5, ACCENT)
} else if hovered {
Stroke::new(1.5, ACCENT.gamma_multiply(0.7))
} else {
Stroke::new(0.75, ACCENT.gamma_multiply(0.35))
};
if selected_box {
painter.rect_filled(*b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
// Few images: faint outlines for all of them, as before. Many: only the selected and
// hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images.
let faint_all = boxes.len() <= MAX_OUTLINES;
if faint_all {
for b in &boxes {
painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
}
let hovered_box = hover_pos.and_then(|p| boxes.iter().rposition(|b| b.contains(p)));
if !faint_all {
if let Some(i) = selected {
painter.rect_stroke(boxes[i], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
if let Some(h) = hovered_box.filter(|h| Some(*h) != selected) {
painter.rect_stroke(boxes[h], CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside);
}
}
for i in selected.into_iter().chain(hovered_box).collect::<Vec<_>>() {
let b = boxes[i];
if selected == Some(i) {
painter.rect_filled(b, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04));
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside);
} else {
painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);
}
painter.rect_stroke(*b, CornerRadius::ZERO, stroke, egui::StrokeKind::Outside);
}
// The selected image: frame, corner handles, dragging.
if let Some(i) = selected {
@ -330,7 +344,7 @@ pub(crate) fn image_input(
}
}
if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag)
&& let Some(p) = pointer
&& let Some(p) = hover_pos
{
let preview = match opposite {
// Resize from the opposite corner, keeping the aspect ratio.
@ -362,7 +376,7 @@ pub(crate) fn image_input(
return true;
}
}
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p)) else { return false };
let Some(hit) = boxes.iter().rposition(|b| b.contains(p)) else { return false };
if selected != Some(hit) {
painter.rect_stroke(boxes[hit], CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside);

View File

@ -43,8 +43,8 @@ impl PdfCraftApp {
let info = &doc.info;
let view = &mut self.views[i];
view.signature_drag.committed(&edit, doc.edit_generation());
if let Some(page) = comment_page(&edit) {
// Comment edits change one page: keep every other raster.
if let Some(page) = comment_page(&edit).or_else(|| content_page(&edit)) {
// Comment and content edits change one page: keep every other raster.
view.page_changed(page);
} else if let Some(form_before) = form_before
&& doc.info.pages.len() == page_count_before
@ -138,6 +138,11 @@ impl PdfCraftApp {
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
self.quick_tool = crate::QuickTool::Select;
}
// Text saved as typed but drawn with `?` (its font can't draw it): said, not silent.
// Before the scripts' output, so an alert they raise still shows.
if let Some(n) = self.session.get(id).and_then(|d| d.edit_notices.first()).cloned() {
self.notify(crate::content_ui::undrawn_message(&n));
}
let out = self.session.take_js_output(id);
self.handle_js(id, out);
true
@ -833,7 +838,23 @@ impl PdfCraftApp {
}
}
/// The page a comment edit changes (`None` for other edits).
/// The single page a content edit changes (`None` for other edits). Like comment edits,
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
fn content_page(edit: &Edit) -> Option<usize> {
match edit {
Edit::MoveObjects { page, .. }
| Edit::EditTextLine { page, .. }
| Edit::EditTextBlock { page, .. }
| Edit::EditPageImage { page, .. }
| Edit::AddText { page, .. }
| Edit::AddImage { page, .. }
| Edit::UpdateContent { page, .. }
| Edit::DeleteContent { page, .. }
| Edit::ReplaceImage { page, .. } => Some(*page),
_ => None,
}
}
fn comment_page(edit: &Edit) -> Option<usize> {
match edit {
Edit::AddAnnotation(a) => Some(a.page),
@ -1294,6 +1315,29 @@ mod revert_draft_tests {
}
}
/// A value the field's font can't draw is kept, and the user is told why it shows `?`, in the
/// interface language; a drawable value says nothing.
#[test]
fn a_value_its_font_cannot_draw_is_kept_and_explained() {
let mut app = app();
let fill =
|app: &mut PdfCraftApp, text: &str| app.apply_edit(Edit::SetFieldValue { name: "name".into(), value: FieldValue::Text(text.into()) });
assert!(fill(&mut app, "张三"));
let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice");
assert!(toast.contains("“张” (U+5F20)") && toast.contains("other PDF viewers redraw the field"), "{toast}");
assert_eq!(app.session.get(app.views[0].id).unwrap().form.iter().find(|f| f.name == "name").unwrap().value, ["张三"]);
// The shell sets the drawing language each frame; here, directly (per thread).
let previous = crate::i18n::current();
crate::i18n::set_current(crate::i18n::Lang::from_code("zh-hans").expect("registered"));
assert!(fill(&mut app, "李四"));
crate::i18n::set_current(previous);
let toast = app.toast.as_ref().map(|t| t.0.clone()).expect("a notice");
assert!(toast.contains("此字段的字体无法显示“李”(U+674E)"), "{toast}");
app.toast = None;
assert!(fill(&mut app, "Ada"));
assert!(app.toast.is_none(), "{:?}", app.toast);
}
#[test]
fn unsaved_flag_follows_dirty_state() {
use std::sync::atomic::Ordering;
@ -1366,3 +1410,19 @@ mod revert_draft_tests {
assert_eq!(app.toast.as_ref().unwrap().0, pdfcraft_engine::EditError::NoDocument.to_string());
}
}
#[cfg(test)]
mod content_page_tests {
use super::content_page;
use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
/// Single-page content edits keep every other page's rasters; anything else still
/// invalidates the whole document.
#[test]
fn single_page_content_edits_map_to_their_page() {
let moved = Edit::MoveObjects { page: 3, objects: vec![ObjectTarget { kind: ObjectKind::Text, index: 0 }], offset: [1.0, 0.0] };
assert_eq!(content_page(&moved), Some(3));
assert_eq!(content_page(&Edit::RotatePages { pages: vec![0], degrees: 90 }), None);
assert_eq!(content_page(&Edit::SetInfo { key: "Title".into(), value: "x".into() }), None);
}
}

View File

@ -705,7 +705,7 @@
Centre توسيط
Align right محاذاة لليمين
Justify ضبط
Standard fonts; text outside Windows-1252 isn't supported yet. خطوط قياسية؛ النص خارج Windows-1252 غير مدعوم بعد.
Thai and Arabic use regular fallback fonts when available. تستخدم التايلاندية والعربية خطوطًا بديلة بالنمط العادي عند توفرها.
Edit image تحرير الصورة
Rotate counterclockwise تدوير عكس عقارب الساعة
Rotate clockwise تدوير مع عقارب الساعة
@ -2151,7 +2151,10 @@ calendar date December ديسمبر
Reverse open arrow سهم مفتوح معكوس
Reverse closed arrow سهم مغلق معكوس
Slash شرطة مائلة
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. لا تستطيع خطوط نص PDF عرض «{c}» ({code}). اختر نصًا مدعومًا.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. الخطوط القياسية لا تستطيع رسم «{c}» ({code}). حاليًا يمكن إضافة محارف أوروبا الغربية فقط كنص.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. خط هذا الحقل لا يستطيع رسم «{c}» ({code})، لذا يعرض PdfCraft علامة ? مكانه. القيمة محفوظة، وتعيد برامج عرض PDF الأخرى رسم الحقل منها.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. الخطوط التي يكتب بها PdfCraft لا تستطيع رسم «{c}» ({code})، لذا يظهر بشكل ? في كل برامج عرض PDF. النص محفوظ.
Larger pages صفحات أكبر
Smaller pages صفحات أصغر
Reset page size إعادة ضبط حجم الصفحة

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -385,7 +385,7 @@
Centre Центрирай
Align right Подравни вдясно
Justify Двустранно подравняване
Standard fonts; text outside Windows-1252 isn't supported yet. Стандартни шрифтове; текст извън Windows-1252 все още не се поддържа.
Thai and Arabic use regular fallback fonts when available. Тайландският и арабският използват резервни шрифтове с нормален стил, когато са налични.
Edit image Редактирай изображение
Rotate counterclockwise Завърти обратно на часовниковата стрелка
Rotate clockwise Завърти по часовниковата стрелка
@ -2093,7 +2093,10 @@ action wizard Start Старт
Clear Recent Files Изчисти последно отваряните файлове
Remove from Recent Премахни от последно отваряните
Remove {name} from Recent Премахни {name} от последно отваряните
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Шрифтовете за PDF текст не могат да изобразят „{c}“ ({code}). Изберете поддържан текст.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартните шрифтове не могат да покажат „{c}“ ({code}). Засега като текст могат да се добавят само западноевропейски символи.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифтът на това поле не може да покаже „{c}“ ({code}), затова PdfCraft показва ? на негово място. Стойността е запазена и другите програми за PDF преначертават полето по нея.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифтовете, с които PdfCraft пише, не могат да покажат „{c}“ ({code}), затова във всяка програма за PDF то се показва като ?. Текстът е запазен.
Larger pages По-големи страници
Smaller pages По-малки страници
Reset page size Възстанови размера на страниците

Can't render this file because it contains an unexpected character in line 8 and column 16.

View File

@ -782,7 +782,7 @@ key Space Mezerník
Centre Na střed
Align right Zarovnat doprava
Justify Do bloku
Standard fonts; text outside Windows-1252 isn't supported yet. Standardní písma; text mimo Windows-1252 zatím není podporován.
Thai and Arabic use regular fallback fonts when available. Thajština a arabština používají běžná náhradní písma, pokud jsou k dispozici.
Edit image Upravit obrázek
Rotate counterclockwise Otočit proti směru hodinových ručiček
Rotate clockwise Otočit po směru hodinových ručiček
@ -2178,7 +2178,7 @@ calendar date December prosince
Home / End Home / End
H / Space (hold) H / mezerník (podržet)
Line ending Zakončení čáry
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardní písma neumí vykreslit „{c}“ ({code}). Jako text lze zatím přidávat jen západoevropské znaky.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Písma pro text v PDF neumí vykreslit „{c}“ ({code}). Zvolte podporovaný text.
Larger pages Větší stránky
Smaller pages Menší stránky
Reset page size Obnovit velikost stránek
@ -2324,3 +2324,5 @@ scroll speed Fast Rychlá
Original Originál
Greyscale Stupně šedi
Black and white Černobílé
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Písmo tohoto pole neumí zobrazit „{c}“ ({code}), proto PdfCraft místo něj ukazuje ?. Hodnota se uloží a jiné prohlížeče PDF pole podle ní překreslí.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Písma, kterými PdfCraft píše, neumí zobrazit „{c}“ ({code}), proto se ve všech prohlížečích PDF zobrazí jako ?. Text se uloží.

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -678,7 +678,7 @@
Centre Zentriert
Align right Rechtsbündig
Justify Blocksatz
Standard fonts; text outside Windows-1252 isn't supported yet. Standardschriften; Text außerhalb von Windows-1252 wird noch nicht unterstützt.
Thai and Arabic use regular fallback fonts when available. Thai und Arabisch verwenden, falls verfügbar, Ersatzschriften im normalen Schriftschnitt.
Edit image Bild bearbeiten
Rotate counterclockwise Gegen den Uhrzeigersinn drehen
Rotate clockwise Im Uhrzeigersinn drehen
@ -2135,7 +2135,10 @@ action wizard Start Starten
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 PDF text fonts can't draw “{c}” ({code}). Choose supported text. Die PDF-Textschriften können „{c}“ ({code}) nicht darstellen. Wählen Sie unterstützten Text.
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.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Die Schrift dieses Felds kann „{c}“ ({code}) nicht darstellen, daher zeigt PdfCraft an dieser Stelle ? an. Der Wert ist gespeichert, und andere PDF-Viewer zeichnen das Feld anhand des Werts neu.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Die Schriften, mit denen PdfCraft schreibt, können „{c}“ ({code}) nicht darstellen, daher erscheint es in jedem PDF-Viewer als ?. Der Text ist gespeichert.
Larger pages Größere Seiten
Smaller pages Kleinere Seiten
Reset page size Seitengröße zurücksetzen

Can't render this file because it contains an unexpected character in line 6 and column 51.

File diff suppressed because it is too large Load Diff

View File

@ -699,7 +699,7 @@
Centre Centrar
Align right Alinear a la derecha
Justify Justificar
Standard fonts; text outside Windows-1252 isn't supported yet. Fuentes estándar; el texto fuera de Windows-1252 aún no es compatible.
Thai and Arabic use regular fallback fonts when available. El tailandés y el árabe usan fuentes alternativas de estilo normal cuando están disponibles.
Edit image Editar imagen
Rotate counterclockwise Girar a la izquierda
Rotate clockwise Girar a la derecha
@ -2048,7 +2048,10 @@
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…
For page sizes and margins. Para tamaños de página y márgenes.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Las fuentes de texto PDF no pueden mostrar «{c}» ({code}). Elija texto compatible.
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.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. La fuente de este campo no puede mostrar «{c}» ({code}), así que PdfCraft muestra ? en su lugar. El valor se guarda y los demás visores de PDF vuelven a dibujar el campo a partir de él.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Las fuentes con las que escribe PdfCraft no pueden mostrar «{c}» ({code}), así que aparece como ? en todos los visores de PDF. El texto se guarda.
Open arrow Flecha abierta
Closed arrow Flecha cerrada
Butt Extremo recto

Can't render this file because it contains an unexpected character in line 6 and column 60.

View File

@ -678,7 +678,7 @@
Centre Centrer
Align right Aligner à droite
Justify Justifier
Standard fonts; text outside Windows-1252 isn't supported yet. Polices standard ; le texte hors Windows-1252 n'est pas encore pris en charge.
Thai and Arabic use regular fallback fonts when available. Le thaï et l’arabe utilisent des polices de remplacement de style normal lorsqu’elles sont disponibles.
Edit image Modifier l'image
Rotate counterclockwise Pivoter vers la gauche
Rotate clockwise Pivoter vers la droite
@ -2089,7 +2089,10 @@
No recent files Aucun fichier récent
Go to page… Aller à la page…
For page sizes and margins. Pour les tailles de page et les marges.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Les polices de texte PDF ne peuvent pas afficher « {c} » ({code}). Choisissez un texte pris en charge.
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.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. La police de ce champ ne peut pas afficher « {c} » ({code}) : PdfCraft affiche ? à sa place. La valeur est enregistrée, et les autres lecteurs PDF redessinent le champ à partir de celle-ci.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Les polices qu'utilise PdfCraft pour écrire ne peuvent pas afficher « {c} » ({code}) : il apparaît comme ? dans tous les lecteurs PDF. Le texte est enregistré.
Larger pages Pages plus grandes
Smaller pages Pages plus petites
Reset page size Rétablir la taille des pages

Can't render this file because it contains an unexpected character in line 6 and column 39.

View File

@ -675,7 +675,7 @@
Centre Középre
Align right Jobbra igazítás
Justify Sorkizárás
Standard fonts; text outside Windows-1252 isn't supported yet. Szabványos betűtípusok; a Windows-1252-n kívüli szöveg még nem támogatott.
Thai and Arabic use regular fallback fonts when available. A thai és az arab szöveg normál stílusú helyettesítő betűtípusokat használ, ha rendelkezésre állnak.
Edit image Kép szerkesztése
Rotate counterclockwise Forgatás az óramutató járásával ellentétesen
Rotate clockwise Forgatás az óramutató járásával megegyezően
@ -2127,7 +2127,10 @@ action wizard Start Indítás
Reverse open arrow Fordított nyitott nyíl
Reverse closed arrow Fordított zárt nyíl
Slash Perjel
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. A PDF szövegének betűtípusai nem tudják megjeleníteni ezt: „{c}” ({code}). Válasszon támogatott szöveget.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. A szabványos betűtípusok nem tudják megjeleníteni ezt: „{c}” ({code}). Egyelőre csak nyugat-európai karakterek adhatók hozzá szövegként.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. A mező betűtípusa nem tudja megjeleníteni ezt: „{c}” ({code}), ezért a PdfCraft ? jelet mutat a helyén. Az érték mentve van, és a többi PDF-megjelenítő ebből rajzolja újra a mezőt.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. A PdfCraft által íráshoz használt betűtípusok nem tudják megjeleníteni ezt: „{c}” ({code}), ezért minden PDF-megjelenítőben ? látszik a helyén. A szöveg mentve van.
Larger pages Nagyobb oldalak
Smaller pages Kisebb oldalak
Reset page size Oldalméret visszaállítása

Can't render this file because it contains an unexpected character in line 7 and column 44.

View File

@ -701,7 +701,7 @@
Centre Centra
Align right Allinea a destra
Justify Giustifica
Standard fonts; text outside Windows-1252 isn't supported yet. Caratteri standard; il testo al di fuori di Windows-1252 non è ancora supportato.
Thai and Arabic use regular fallback fonts when available. Il thailandese e l’arabo usano caratteri sostitutivi in stile normale, quando disponibili.
Edit image Modifica immagine
Rotate counterclockwise Ruota in senso antiorario
Rotate clockwise Ruota in senso orario
@ -2144,6 +2144,9 @@
The document was closed before it could be saved. Il documento è stato chiuso prima che potesse essere salvato.
The pages were extracted but not deleted, because the document changed while you were choosing a folder. Le pagine sono state estratte ma non eliminate, perché il documento è cambiato mentre sceglievi una cartella.
The printer driver's own settings for this print: paper tray, paper type, finishing and more. Le impostazioni proprie del driver della stampante per questa stampa: vassoio, tipo di carta, finitura e altro.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. I caratteri del testo PDF non possono visualizzare «{c}» ({code}). Scegliere un testo supportato.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Il font di questo campo non può disegnare «{c}» ({code}), quindi PdfCraft mostra ? al suo posto. Il valore è salvato e gli altri visualizzatori PDF ridisegnano il campo a partire da esso.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. I font con cui PdfCraft scrive non possono disegnare «{c}» ({code}), quindi appare come ? in ogni visualizzatore PDF. Il testo è salvato.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. I caratteri standard non possono disegnare “{c}” ({code}). Per ora si possono aggiungere come testo solo caratteri dell'Europa occidentale.
This printer offers no settings of its own. Questa stampante non offre impostazioni proprie.
Today: {date} Oggi: {date}

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -705,7 +705,7 @@
Centre 中央揃え
Align right 右揃え
Justify 両端揃え
Standard fonts; text outside Windows-1252 isn't supported yet. 標準フォントを使用します。Windows-1252 に含まれない文字にはまだ対応していません。
Thai and Arabic use regular fallback fonts when available. タイ語とアラビア語には、利用可能な場合、標準スタイルの代替フォントを使用します。
Edit image 画像を編集
Rotate counterclockwise 反時計回りに回転
Rotate clockwise 時計回りに回転
@ -2019,7 +2019,10 @@
Go to page… ページへ移動…
For page sizes and margins. ページサイズと余白に使います。
Reopen the files that were open when PdfCraft last closed 前回 PdfCraft を閉じたときに開いていたファイルを再度開く
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF テキスト用フォントでは「{c}」({code})を表示できません。対応している文字を使用してください。
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準フォントでは「{c}」({code})を表示できません。現在、テキストとして追加できるのは西ヨーロッパの文字のみです。
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. このフィールドのフォントでは「{c}」({code})を表示できないため、PdfCraft では ? と表示されます。値は保存され、他の PDF ビューアーではこの値からフィールドが再描画されます。
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft が書き込みに使うフォントでは「{c}」({code})を表示できないため、どの PDF ビューアーでも ? と表示されます。テキストは保存されています。
Open arrow 開いた矢印
Closed arrow 閉じた矢印
Butt 平ら

Can't render this file because it contains an unexpected character in line 6 and column 47.

File diff suppressed because it is too large Load Diff

View File

@ -125,8 +125,18 @@ fn plural_polish(n: u64) -> usize {
}
}
/// Slovene (CLDR `sl`): one (1, 101, 201 ...) | two (2, 102 ...) | few (3, 4, 103 ...) | other.
fn plural_sl(n: u64) -> usize {
match n % 100 {
1 => 0,
2 => 1,
3 | 4 => 2,
_ => 3,
}
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 17] = [
pub static LANGUAGES: [LangInfo; 21] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@ -156,6 +166,8 @@ pub static LANGUAGES: [LangInfo; 17] = [
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, rtl: false, catalog: OnceLock::new() },
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, rtl: false, catalog: OnceLock::new() },
// Kazakh; every `kk-*` locale (`kk-KZ`, `kk-KZ.UTF-8`) resolves here.
LangInfo { code: "kk", name: "Қазақша", source: include_str!("kk.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
LangInfo {
code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
@ -177,6 +189,11 @@ pub static LANGUAGES: [LangInfo; 17] = [
LangInfo { code: "ar", name: "العربية", source: include_str!("ar.tsv"), plural: plural_arabic, rtl: true, catalog: OnceLock::new() },
// Polish; every `pl-*` locale (`pl-pl`, `pl_pl.UTF-8`) resolves here.
LangInfo { code: "pl", name: "Polski", source: include_str!("pl.tsv"), plural: plural_polish, rtl: false, catalog: OnceLock::new() },
// Slovene; every `sl-*` locale (`sl-SI`) resolves here. Four plural forms (one, two, few, other).
LangInfo { code: "sl", name: "Slovenščina", source: include_str!("sl.tsv"), plural: plural_sl, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "el", name: "Ελληνικά", source: include_str!("el.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Turkish; every `tr-*` locale (`tr-TR`, `tr_TR.UTF-8`) resolves here.
LangInfo { code: "tr", name: "Türkçe", source: include_str!("tr.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
];
impl LangInfo {
@ -826,7 +843,7 @@ mod tests {
labels.insert(label.to_string());
}
assert!(labels.len() > 20 && labels.contains("Comb of characters"), "the scan found the dialog's labels: {labels:?}");
for code in ["bg", "cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "te", "uk", "zh-hans", "zh-hant"] {
for code in ["bg", "cs", "de", "es", "fr", "hu", "it", "ja", "pt-br", "ru", "te", "tr", "uk", "zh-hans", "zh-hant"] {
let lang = Lang::from_code(code).expect("registered");
let missing: Vec<_> = labels.iter().filter(|l| !has(lang, l)).collect();
assert!(missing.is_empty(), "{code} lacks Shared Field Properties labels: {missing:#?}");
@ -1376,6 +1393,97 @@ mod tests {
set_current(Lang::EN);
}
/// Turkish covers every registered menu title and menu command label, keeps ellipses, and
/// persists in preferences.
#[test]
fn turkish_covers_every_menu_label() {
let tr_lang = Lang::from_code("tr").expect("tr registered");
assert_eq!(tr_lang.name(), "Türkçe");
assert_eq!(tr(tr_lang, "File"), "Dosya");
assert_eq!(tr(tr_lang, "Türkçe Belge.pdf"), "Türkçe Belge.pdf");
assert_eq!((0..=6).map(|n| (tr_lang.0.plural)(n)).collect::<Vec<_>>(), [0; 7]);
for spec in pdfcraft_engine::commands::COMMANDS {
let Some(menu) = spec.menu else { continue };
assert!(has(tr_lang, menu), "Turkish lacks the menu title {menu:?}");
assert!(has(tr_lang, spec.label), "Turkish lacks the {menu} menu label {:?} ({})", spec.label, spec.id);
}
const KEEP_AS_IS: &[&str] = &[
"OK",
"PDF/A…",
"Microsoft Word (.docx)",
"PostScript / EPS",
"Preflight",
"JavaScript",
"ZIP",
"{n} {kind}",
"{rule} - {status}",
"Poster",
"{field}: {e}",
"`{command}` {when}",
"{0}: {1}",
"{month} {y}",
"+LOC",
"−LOC",
"ΔLOC",
"+Bin",
"−Bin",
"Model",
"Home / End",
];
let (entries, errors) = parse_entries(tr_lang.0.source, tr_lang.0.plural_forms());
assert!(errors.is_empty(), "Turkish catalog errors: {errors:?}");
for e in &entries {
assert!(
e.source != e.translation
|| KEEP_AS_IS.contains(&e.source.as_str())
|| (e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str())),
"{:?} is untranslated",
e.source
);
assert!(!e.translation.contains("..."), "use … rather than three dots: {:?}", e.translation);
assert_eq!(e.source.starts_with(' '), e.translation.starts_with(' '), "leading space mismatch: {:?}", e.translation);
assert_eq!(e.source.ends_with(' '), e.translation.ends_with(' '), "trailing space mismatch: {:?}", e.translation);
}
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "tr").unwrap();
assert!(app.set_option("language", "tu").is_err());
assert_eq!(app.language, "tr");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "tr");
}
/// Turkish translates every registered command, menu and All tools label.
#[test]
fn turkish_covers_commands_and_catalogue() {
let tr_lang = Lang::from_code("tr").expect("tr registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(tr_lang, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(tr_lang, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(tr_lang, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(tr_lang, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(tr_lang, item.label), "missing item: {}", item.label);
}
}
}
}
/// Turkish translates every `tl!("…")` literal in the UI source.
#[test]
fn turkish_covers_ui_literals() {
let literals = ui_literals();
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let tr_lang = Lang::from_code("tr").expect("tr registered");
let missing: Vec<_> = literals.iter().filter(|label| !has(tr_lang, label)).collect();
assert!(missing.is_empty(), "untranslated Turkish UI literals: {missing:#?}");
}
#[test]
fn simplified_chinese_is_registered() {
let zh = Lang::from_code("zh-hans").expect("zh-hans registered");
@ -1897,6 +2005,125 @@ mod tests {
set_current(Lang::EN);
}
#[test]
fn slovene_is_registered_and_persists() {
let sl = Lang::from_code("sl").expect("sl registered");
assert_eq!(sl.name(), "Slovenščina");
assert_eq!(normalize_pref("SL"), Some("sl"));
for tag in ["sl", "sl-SI", "sl_SI.UTF-8", "SL-si", "sl_SI@variant"] {
assert_eq!(lang_from_tag(tag), Some(sl), "{tag}");
}
assert_eq!(first_supported("sl-SI\r\nen-US"), Some(sl));
assert_eq!(tr(sl, "File"), "Datoteka");
assert_eq!(tr(sl, "Save as…"), "Shrani kot…");
assert_eq!(tr(sl, "Bookmarks"), "Zaznamki");
assert_eq!(tr(sl, "Layers"), "Plasti");
assert_eq!(tr(sl, "Poročilo {n}.pdf"), "Poročilo {n}.pdf");
assert_eq!(tr_ctx(sl, "comment menu", "Edit"), "Uredi");
assert_eq!(tr_ctx(sl, "signature pad", "Type"), "Vtipkaj");
assert_eq!(tr_ctx(sl, "action wizard", "Start"), "Začni");
// one (1), two (2), few (3, 4), other (0, 5+)
assert_eq!((0..=5).map(|n| (sl.0.plural)(n)).collect::<Vec<_>>(), [3, 0, 1, 2, 2, 3]);
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "SL").unwrap();
assert_eq!(app.language, "sl");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "sl");
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, "sl");
}
#[test]
fn slovene_plurals_cover_dual_few_teens_and_large_counts() {
let sl = Lang::from_code("sl").expect("sl registered");
for (counts, page, field) in [
(vec![1, 101, 201, 1001, 1101], "stran", "polje"),
(vec![2, 102, 202, 1002], "strani", "polji"),
(vec![3, 4, 103, 104, 1003], "strani", "polja"),
(vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], "strani", "polj"),
] {
for n in counts {
assert_eq!(trn(sl, n, "{n} page", "{n} pages"), format!("{n} {page}"));
assert_eq!(trn(sl, n, "{n} field", "{n} fields"), format!("{n} {field}"));
}
}
}
#[test]
fn slovene_diagnostics_preserve_user_values() {
let sl = Lang::from_code("sl").expect("sl registered");
set_current(sl);
assert_eq!(action_label("Custom action {n}"), "Custom action {n}");
assert_eq!(fmt(t("{n} pages selected"), &[("n", "3")]), "Izbranih strani: 3");
assert_eq!(tr(sl, "CheckBox"), "Potrditveno polje");
set_current(Lang::EN);
}
/// Slovene translates every registered command and every All tools group, section and item.
#[test]
fn slovene_covers_commands_and_catalogue() {
let sl = Lang::from_code("sl").expect("sl registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(sl, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(sl, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(sl, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(sl, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(sl, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English.
#[test]
fn slovene_covers_ui_literals() {
let sl = Lang::from_code("sl").expect("sl 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(sl, label)).collect();
assert!(missing.is_empty(), "untranslated Slovene UI literals: {missing:#?}");
}
#[test]
fn bulgarian_is_registered() {
let bg = Lang::from_code("bg").expect("bg registered");
@ -2245,6 +2472,112 @@ mod tests {
}
}
/// Kazakh translates every registered command and every All tools label.
#[test]
fn kazakh_covers_commands_and_catalogue() {
let kk = Lang::from_code("kk").expect("kk registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(kk, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(kk, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(kk, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(kk, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(kk, item.label), "missing item: {}", item.label);
}
}
}
}
/// Preserve only product/format names, abbreviations and templates with no English words.
#[test]
fn kazakh_catalog_does_not_leave_english_ui_text() {
let kk = Lang::from_code("kk").expect("kk registered");
let (entries, errors) = parse_entries(kk.0.source, kk.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 Kazakh 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| {
let key_cap = e.context == "key" && KEY_CAP_NAMES.contains(&e.source.as_str());
e.source == e.translation && !keep.contains(&e.source.as_str()) && !key_cap
})
.collect();
assert!(unchanged.is_empty(), "untranslated Kazakh catalog entries: {unchanged:#?}");
}
/// Kazakh translates every tl!("literal") in the UI source, including direct lookups and
/// multiline literals, following the complete-catalog check for Simplified Chinese.
#[test]
fn kazakh_covers_ui_literals() {
let kk = Lang::from_code("kk").expect("kk 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(kk, label)).collect();
assert!(missing.is_empty(), "untranslated Kazakh UI literals: {missing:#?}");
}
/// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls,
/// following the complete-catalog check for Simplified Chinese.
#[test]

View File

@ -700,7 +700,7 @@
Centre Wyśrodkuj
Align right Wyrównaj do prawej
Justify Wyjustuj
Standard fonts; text outside Windows-1252 isn't supported yet. Standardowe czcionki; tekst spoza Windows-1252 nie jest jeszcze obsługiwany.
Thai and Arabic use regular fallback fonts when available. Tajski i arabski używają zwykłych czcionek zastępczych, gdy są dostępne.
Edit image Edytuj obraz
Rotate counterclockwise Obróć w lewo
Rotate clockwise Obróć w prawo
@ -2001,7 +2001,10 @@
Couldn't import the signature image: {e} Nie można zaimportować obrazu podpisu: {e}
Go to page… Przejdź do strony…
Reopen the files that were open when PdfCraft last closed Otwórz ponownie pliki otwarte przy ostatnim zamknięciu PdfCraft
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Czcionki tekstu PDF nie obsługują znaku „{c}” ({code}). Wybierz obsługiwany tekst.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Standardowe czcionki nie obsługują znaku „{c}” ({code}). Na razie można dodawać tylko tekst ze znakami zachodnioeuropejskimi.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Czcionka tego pola nie obsługuje znaku „{c}” ({code}), więc PdfCraft wyświetla w jego miejscu ?. Wartość została zapisana, a inne przeglądarki PDF odrysują pole na jej podstawie.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Czcionki, którymi pisze PdfCraft, nie obsługują znaku „{c}” ({code}), więc w każdej przeglądarce PDF w jego miejscu widać ?. Tekst został zapisany.
Open arrow Otwarta strzałka
Closed arrow Zamknięta strzałka
Butt Poprzeczna kreska

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -339,7 +339,7 @@
Centre Centralizar
Align right Alinhar à direita
Justify Justificar
Standard fonts; text outside Windows-1252 isn't supported yet. Fontes padrão; texto fora de Windows-1252 ainda não é suportado.
Thai and Arabic use regular fallback fonts when available. O tailandês e o árabe usam fontes alternativas de estilo normal quando disponíveis.
Edit image Editar imagem
Rotate counterclockwise Girar no sentido anti-horário
Rotate clockwise Girar no sentido horário
@ -2031,7 +2031,10 @@
This web address mixes letters from different alphabets, a common way to imitate another site's name. Este endereço da web mistura letras de alfabetos diferentes, uma forma comum de imitar o nome de outro site.
This web address uses letters from another alphabet, which can look like familiar ones. Este endereço da web usa letras de outro alfabeto, que podem parecer familiares.
Only continue if you trust this document. An address can carry information from the document, such as what you typed into its form. Continue apenas se você confiar neste documento. Um endereço pode carregar informações do documento, como o que você digitou no formulário dele.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. As fontes de texto PDF não podem exibir “{c}” ({code}). Escolha um texto compatível.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. As fontes padrão não conseguem desenhar “{c}” ({code}). Por enquanto, apenas caracteres da Europa Ocidental podem ser adicionados como texto.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. A fonte deste campo não consegue desenhar “{c}” ({code}), então o PdfCraft mostra ? no lugar. O valor foi salvo, e outros leitores de PDF redesenham o campo a partir dele.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. As fontes que o PdfCraft usa para escrever não conseguem desenhar “{c}” ({code}), então ele aparece como ? em qualquer leitor de PDF. O texto foi salvo.
Line ending Fim de linha
Show Mostrar
Sort Classificar

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -699,7 +699,7 @@
Centre По центру
Align right Выровнять вправо
Justify По ширине
Standard fonts; text outside Windows-1252 isn't supported yet. Стандартные шрифты; текст вне Windows-1252 пока не поддерживается.
Thai and Arabic use regular fallback fonts when available. Для тайского и арабского текста используются доступные резервные шрифты обычного начертания.
Edit image Редактировать изображение
Rotate counterclockwise Повернуть против часовой стрелки
Rotate clockwise Повернуть по часовой стрелке
@ -2049,7 +2049,10 @@
Created a PDF from the first {n} files PDF создан из первых файлов: {n}
Go to page… Перейти на страницу…
For page sizes and margins. Для размеров страниц и полей.
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Шрифты текста PDF не могут отобразить «{c}» ({code}). Выберите поддерживаемый текст.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартные шрифты не могут отобразить «{c}» ({code}). Пока в виде текста можно добавлять только западноевропейские символы.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифт этого поля не может отобразить «{c}» ({code}), поэтому PdfCraft показывает на его месте ?. Значение сохранено, и другие программы просмотра PDF перерисуют поле по нему.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифты, которыми пишет PdfCraft, не могут отобразить «{c}» ({code}), поэтому во всех программах просмотра PDF на его месте будет ?. Текст сохранён.
Larger pages Крупнее страницы
Smaller pages Мельче страницы
Reset page size Сбросить размер страниц

Can't render this file because it contains an unexpected character in line 9 and column 16.

File diff suppressed because it is too large Load Diff

View File

@ -682,7 +682,7 @@ comment menu Edit సవరించు
Centre మధ్యకు అమర్చు
Align right కుడికి అమర్చు
Justify రెండు వైపులా అమర్చు
Standard fonts; text outside Windows-1252 isn't supported yet. ప్రామాణిక ఫాంట్‌లు; Windows-1252 పరిధికి బయటి టెక్స్ట్‌కు ఇంకా మద్దతు లేదు.
Thai and Arabic use regular fallback fonts when available. థాయ్ మరియు అరబిక్ కోసం అందుబాటులో ఉన్నప్పుడు సాధారణ శైలి ప్రత్యామ్నాయ ఫాంట్‌లను ఉపయోగిస్తారు.
Edit image చిత్ర సవరణ
Rotate counterclockwise అపసవ్యదిశలో తిప్పు
Rotate clockwise సవ్యదిశలో తిప్పు

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

View File

@ -703,7 +703,7 @@
Centre По центру
Align right Вирівняти праворуч
Justify За шириною
Standard fonts; text outside Windows-1252 isn't supported yet. Стандартні шрифти; текст поза Windows-1252 ще не підтримується.
Thai and Arabic use regular fallback fonts when available. Для тайського й арабського тексту використовуються доступні резервні шрифти звичайного накреслення.
Edit image Редагувати зображення
Rotate counterclockwise Повернути проти годинникової стрілки
Rotate clockwise Повернути за годинниковою стрілкою
@ -2151,7 +2151,10 @@ calendar date December грудня
Reverse open arrow Зворотна відкрита стрілка
Reverse closed arrow Зворотна закрита стрілка
Slash Коса риска
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. Шрифти тексту PDF не можуть відобразити «{c}» ({code}). Виберіть підтримуваний текст.
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. Стандартні шрифти не можуть відобразити «{c}» ({code}). Поки що як текст можна додавати лише західноєвропейські символи.
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. Шрифт цього поля не може відобразити «{c}» ({code}), тому PdfCraft показує на його місці ?. Значення збережено, і інші програми перегляду PDF перемалюють поле за ним.
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. Шрифти, якими пише PdfCraft, не можуть відобразити «{c}» ({code}), тому в усіх програмах перегляду PDF на його місці буде ?. Текст збережено.
Larger pages Більші сторінки
Smaller pages Менші сторінки
Reset page size Скинути розмір сторінок

Can't render this file because it contains an unexpected character in line 6 and column 47.

View File

@ -391,7 +391,7 @@
Centre 居中
Align right 右对齐
Justify 两端对齐
Standard fonts; text outside Windows-1252 isn't supported yet. 标准字体;尚不支持 Windows-1252 之外的文本。
Thai and Arabic use regular fallback fonts when available. 泰语和阿拉伯语使用可用的常规样式备用字体。
Edit image 编辑图像
Rotate counterclockwise 逆时针旋转
Rotate clockwise 顺时针旋转
@ -2100,7 +2100,10 @@
Couldn't import the signature image: {e} 无法导入签名图像:{e}
Go to page… 转到页面…
For page sizes and margins. 用于页面尺寸和边距。
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF 文本字体无法显示“{c}”({code})。请选择受支持的文本。
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 标准字体无法显示“{c}”({code})。目前只能添加西欧字符作为文本。
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. 此字段的字体无法显示“{c}”({code}),PdfCraft 会在该处显示 ?。值已保存,其他 PDF 阅读器会根据该值重新绘制此字段。
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft 写入时使用的字体无法显示“{c}”({code}),因此它在所有 PDF 阅读器中都会显示为 ?。文本已保存。
Larger pages 放大页面
Smaller pages 缩小页面
Reset page size 重置页面大小

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -347,7 +347,7 @@
Centre 居中
Align right 右對齊
Justify 兩端對齊
Standard fonts; text outside Windows-1252 isn't supported yet. 標準字型;尚不支援 Windows-1252 之外的文字。
Thai and Arabic use regular fallback fonts when available. 泰語和阿拉伯語使用可用的一般樣式備用字型。
Edit image 編輯圖片
Rotate counterclockwise 逆時針旋轉
Rotate clockwise 順時針旋轉
@ -2010,7 +2010,10 @@
Go to page… 轉到頁面…
For page sizes and margins. 用於頁面尺寸和邊距。
Reopen the files that were open when PdfCraft last closed 重新開啟上次關閉 PdfCraft 時開啟的檔案
The PDF text fonts can't draw “{c}” ({code}). Choose supported text. PDF 文字字型無法顯示「{c}」({code})。請選擇支援的文字。
The standard fonts can't draw “{c}” ({code}). Only Western European characters can be added as text for now. 標準字型無法顯示「{c}」({code})。目前只能新增西歐字元作為文字。
This field's font can't draw “{c}” ({code}), so PdfCraft shows ? in its place. The value is saved, and other PDF viewers redraw the field from it. 此欄位的字型無法顯示「{c}」({code}),PdfCraft 會在該處顯示 ?。值已儲存,其他 PDF 閱讀器會依據該值重新繪製此欄位。
The fonts PdfCraft writes with can't draw “{c}” ({code}), so it shows as ? in every PDF viewer. The text is saved. PdfCraft 寫入時使用的字型無法顯示「{c}」({code}),因此它在所有 PDF 閱讀器中都會顯示為 ?。文字已儲存。
Open arrow 開口箭頭
Closed arrow 閉合箭頭
Butt 平頭

Can't render this file because it contains an unexpected character in line 9 and column 16.

View File

@ -66,7 +66,7 @@ impl SignatureDrag {
let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0));
let tag = (scale * 1000.0) as u64;
if layers.tag != Some(tag) {
layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, ..Default::default() }]);
layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, preview: true, ..Default::default() }]);
layers.tag = Some(tag);
}
if let Some(out) = layers.renderer.try_recv()

View File

@ -204,8 +204,9 @@ pub fn installed_font_definitions(cjk: CjkPreference) -> FontDefinitions {
}
/// The interface fonts: Inter (and JetBrains Mono for code) first, then egui's defaults, then
/// the CJK, Arabic and Telugu faces of the optional craft-fonts build input as the last fallback
/// in every family. Without craft-fonts there is no Japanese, Chinese, Arabic or Telugu face here.
/// the CJK, Arabic, Telugu, Hebrew and Thai faces of the optional craft-fonts build input as the
/// last fallback in every family. Without craft-fonts there is no Japanese, Chinese, Arabic,
/// Telugu, Hebrew or Thai face here.
pub fn font_definitions() -> FontDefinitions {
font_definitions_for(false)
}
@ -237,9 +238,15 @@ pub fn font_definitions_for(prefer_hans: bool) -> FontDefinitions {
fonts.families.entry(family).or_default().push(name.clone());
}
}
// Arabic-script faces (file names, document titles) and Telugu faces (the Telugu interface,
// file names) after the CJK ones; the ranges don't overlap.
for face in pdfcraft_fonts::ui_arabic_fonts().into_iter().chain(pdfcraft_fonts::ui_telugu_fonts()) {
// Arabic-script faces (file names, document titles), Telugu faces (the Telugu interface,
// file names), Hebrew faces (file names, text being edited) and Thai faces after the CJK ones; the
// ranges don't overlap.
for face in pdfcraft_fonts::ui_arabic_fonts()
.into_iter()
.chain(pdfcraft_fonts::ui_telugu_fonts())
.chain(pdfcraft_fonts::ui_hebrew_fonts())
.chain(pdfcraft_fonts::ui_thai_fonts())
{
let name = face.name();
if !fonts.font_data.contains_key(&name) {
add(&mut fonts, &name, face.bytes);

View File

@ -89,7 +89,8 @@ impl PdfCraftApp {
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
// About 800 px along the longer side is plenty to find the margins.
let size = doc.info.pages.get(page).map_or(792.0, |p| p.width.max(p.height));
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0 });
let out =
r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale: (800.0 / size).clamp(0.1, 4.0), tag: 0, preview: false });
if let Some(e) = out.error {
return Err(e);
}
@ -132,7 +133,7 @@ impl PdfCraftApp {
}
let config = RenderConfig { password: doc.password.as_deref().map(std::sync::Arc::from), ..RenderConfig::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0 });
let out = r.render(RenderRequest { page, kind: RequestKind::Pixels, tile: Some(tile), scale, tag: 0, preview: false });
if let Some(e) = out.error {
return Err(e);
}

View File

@ -211,3 +211,23 @@ fn a_kept_draft_goes_with_its_deleted_page() {
h.run_steps(2);
assert!(h.state().views[0].content.draft.is_none());
}
#[test]
fn typing_thai_text_keeps_marks_and_commits_to_page() {
if pdfcraft_fonts::document_thai_font().is_none() {
return;
}
let fonts = pdfcraft_ui_egui::theme::font_definitions();
let face = pdfcraft_fonts::document_thai_font().unwrap();
assert!(fonts.families[&egui::FontFamily::Proportional].contains(&face.name()));
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(2);
let p = at(&h, 40.0, 100.0);
click(&mut h, p);
h.event(egui::Event::Text("น้ำ กิ่ ผู้ใช้ Hello".into()));
h.run_steps(3);
let p = at(&h, 40.0, 200.0);
click(&mut h, p);
assert_eq!(texts(&h), ["น้ำ กิ่ ผู้ใช้ Hello"]);
}

View File

@ -19,6 +19,9 @@ fn shows(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool {
}
fn retype(h: &mut Harness<'static, PdfCraftApp>, text: &str) {
// The preferences scroll; bring the field into view before clicking it.
h.get_by_role_and_label(Role::TextInput, "Pattern").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::TextInput, "Pattern").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);

View File

@ -218,7 +218,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, Hungarian, Ukrainian, Italian) are drawn entirely by the app's own faces
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian, Italian, Slovene) 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]
@ -227,17 +227,21 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
let defs = theme::font_definitions();
for (code, catalog) in [
("cs", include_str!("../src/i18n/cs.tsv")),
("el", include_str!("../src/i18n/el.tsv")),
("pt-br", include_str!("../src/i18n/pt-br.tsv")),
("de", include_str!("../src/i18n/de.tsv")),
("es", include_str!("../src/i18n/es.tsv")),
("fr", include_str!("../src/i18n/fr.tsv")),
("ru", include_str!("../src/i18n/ru.tsv")),
("kk", include_str!("../src/i18n/kk.tsv")),
("bg", include_str!("../src/i18n/bg.tsv")),
("hu", include_str!("../src/i18n/hu.tsv")),
("uk", include_str!("../src/i18n/uk.tsv")),
("it", include_str!("../src/i18n/it.tsv")),
("tr", include_str!("../src/i18n/tr.tsv")),
("sl", include_str!("../src/i18n/sl.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()));

View File

@ -92,11 +92,11 @@ fn document_scripts_and_preferences() {
h.run_steps(2);
let id = h.state().active_ids().unwrap().1;
assert_eq!(h.state().session.get(id).unwrap().document_scripts()[0].0, "init");
// Main added preferences above JavaScript; keep the checkbox inside this test viewport.
h.set_size(egui::vec2(1400.0, 1400.0));
h.state_mut().dialog = Some(Dialog::Preferences);
h.run_steps(2);
// Below the fold in a 900-point window: the preferences scroll to it.
h.get_by_label("Enable Acrobat JavaScript").scroll_to_me();
h.run_steps(4);
assert!(h.ctx.content_rect().contains_rect(h.get_by_label("Enable Acrobat JavaScript").rect()));
h.get_by_label("Enable Acrobat JavaScript").click();
h.run_steps(2);
assert!(!h.state().session.javascript());

View File

@ -800,6 +800,8 @@ fn preferences_selects_default_workspace_for_next_open() {
use pdfcraft_ui_egui::Mode;
let mut h = harness(|app| app.set_option("dialog", "preferences").unwrap());
h.get_by_label("Default workspace mode");
h.get_by_role_and_label(Role::RadioButton, "Read").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::RadioButton, "Read").click();
h.run_steps(2);
assert_eq!(h.state().default_mode, Mode::Read);

View File

@ -89,6 +89,7 @@ fn shared_properties_preserve_mixed_values_and_undo_the_whole_change() {
h.state_mut().bulk_field_props.as_mut().unwrap().required.value = true;
h.get_by_label("Appearance").click();
h.run_steps(4);
// Below the fold of the dialog's scroll area with the roomier rows (#487).
h.get_by_label("Line Thickness:").scroll_to_me();
h.run_steps(4);
h.get_by_label("Line Thickness:").click();

View File

@ -29,7 +29,9 @@ fn the_identity_name_signs_new_comments_and_is_remembered() {
});
h.run_steps(4);
h.get_by_label_contains("Name on new comments");
// Replace the name in the field.
// Replace the name in the field (the preferences scroll to it in a 900-point window).
h.get_by_role_and_label(Role::TextInput, "Name on new comments").scroll_to_me();
h.run_steps(4);
h.get_by_role_and_label(Role::TextInput, "Name on new comments").click();
h.run_steps(1);
h.key_press_modifiers(Modifiers::COMMAND, Key::A);

View File

@ -77,8 +77,28 @@ fn quick_clicks_with_the_highlighter_mark_only_the_word() {
let (mut h, at) = harness(|app| assert!(app.execute("comment.highlight")));
for _ in 0..4 {
click(&mut h, at);
// The double-click's highlight re-renders the page; give its text layer up to 8 frames
// to come back (still well inside egui's 0.3 s multi-click window at 1/60 s a frame), so
// a loaded machine doesn't make the later clicks land on a page with no text yet.
for _ in 0..8 {
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(20));
h.run_steps(1);
}
}
h.run_steps(3);
// Under load the mark can land a few frames later; wait for it (egui's clock is fixed per
// frame, so waiting can't turn the clicks into a different run).
for _ in 0..200 {
let app = h.state();
if app.session.get(app.views[0].id).is_some_and(|d| !d.info.annotations.is_empty()) && !app.render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
h.run_steps(1);
}
let app = h.state();
let doc = app.session.get(app.views[0].id).unwrap();
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();

View File

@ -376,6 +376,8 @@ fn preferences_set_the_default_zoom() {
let mut h = harness(&[]);
assert!(h.state_mut().execute("app.preferences"));
h.run_steps(3);
h.get_by_label("Zoom to page level").scroll_to_me();
h.run_steps(4);
h.get_by_label("Zoom to page level").click();
h.run_steps(2);
assert!(h.state_mut().execute("create.blank"));
@ -872,7 +874,14 @@ fn compare_page_with_raster(h: &mut Harness<'static, PdfCraftApp>, pdf: &[u8], z
let img = h.render().expect("renders");
let scale = zoom * 96.0 / 72.0 * ppp;
let mut renderer = pdfcraft_render::PageRenderer::new(std::sync::Arc::new(pdf.to_vec()), pdfcraft_render::RenderConfig::default());
let page = renderer.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Pixels, tile: None, scale, tag: 0 });
let page = renderer.render(pdfcraft_render::RenderRequest {
page: 0,
kind: pdfcraft_render::RequestKind::Pixels,
tile: None,
scale,
tag: 0,
preview: false,
});
assert!(page.error.is_none(), "{:?}", page.error);
// Compare the lined area (page x 40..270 pt, y 60..330 pt from the top), screen against raster.
let (ox, oy) = ((r.min.x * ppp).round() as i64, (r.min.y * ppp).round() as i64);

View File

@ -584,6 +584,18 @@ impl Emitter<'_> {
);
}
Item::Text(s) => {
// The form's own text, set in the standard fonts: what they can't draw is
// shown as `?` and reported once (the layout goes on), never silently.
if let Some(c) = pdfcraft_fonts::first_undrawable(&s.text) {
const PREFIX: &str = "some of the form's text has characters the standard fonts can't draw";
if !self.warnings.iter().any(|w| w.starts_with(PREFIX)) {
self.warnings.push(format!(
"{PREFIX} (the first is \"{}\" (U+{:04X})); they are shown as ? until PdfCraft can embed fonts for them",
c.escape_debug(),
u32::from(c)
));
}
}
let name = s.face.resource_name();
fonts.insert(name, s.face.family.base_font(s.face.bold, s.face.italic));
content.extend(

View File

@ -191,6 +191,25 @@ fn rendering_into_the_pdf_replaces_the_placeholder_page_and_adds_fields() {
assert!(!is_dynamic(&doc));
}
/// A dynamic form's own text that the standard fonts can't draw is laid out anyway and reported
/// once, never written as `?` silently.
#[test]
fn form_text_the_standard_fonts_cannot_draw_is_reported() {
let xdp = r##"<?xml version="1.0" encoding="UTF-8"?>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/"><template xmlns="http://www.xfa.org/schema/xfa-template/3.3/"><subform name="form" layout="tb">
<draw w="3in" h="0.3in"><value><text>申请表</text></value></draw><draw w="3in" h="0.3in"><value><text>签名</text></value></draw><draw w="3in" h="0.3in"><value><text>Name</text></value></draw>
</subform></template></xdp:xdp>"##;
let mut doc = Document::open(Arc::new(shell(xdp))).unwrap();
let report = render_into(&mut doc).unwrap();
let hits: Vec<&String> = report.warnings.iter().filter(|w| w.contains("standard fonts can't draw")).collect();
assert_eq!(hits.len(), 1, "reported once: {:?}", report.warnings);
assert!(hits[0].contains("\"申\" (U+7533)"), "{hits:?}");
// Latin text alone reports nothing.
let latin = xdp.replace("申请表", "Application").replace("签名", "Signature");
let mut doc = Document::open(Arc::new(shell(&latin))).unwrap();
assert!(!render_into(&mut doc).unwrap().warnings.iter().any(|w| w.contains("standard fonts")));
}
#[test]
fn documents_without_xfa_are_not_touched() {
let mut doc = Document::new_empty();

View File

@ -1,10 +1,12 @@
# 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**, **తెలుగు**, **Magyar**, **Українська**, **Italiano** 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**, **Українська**, **Italiano**, **العربية** or **Türkçe**. 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**, **Українська**, **Italiano**, **العربية** or **Slovenščina**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Displayed keyboard shortcuts use the same active interface language. For example, German shows `Strg+K` and `Strg+Umschalt+S` in the toolbar, menus, command palette, tooltips and Keyboard shortcuts dialog; macOS keeps its modifier symbols (`⌘`, `⇧`, `⌃`). Key names have their own `key` catalog context, so the Delete key can read `Entf` while the command remains `Löschen`. A missing key-name entry falls back to the English key name. Actual bindings and machine-facing command ids remain stable.
**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, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic 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, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Turkish locales (`tr`, `tr_TR`) the Turkish 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, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one, and Slovenian locales (`sl`, `sl_SI`) the Slovenian 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`:
@ -12,14 +14,19 @@ 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`, `hu`, `uk`, `it`, `ar`), 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`, `uk`, `it` or `ar`). 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`, `it`, `ar`, `tr`), 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`, `uk`, `it`, `ar` or `tr`). 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, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech covers 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. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese and Arabic translate them (about 2,000–2,140 entries each); Czech and Turkish 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. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian, Italian, Brazilian Portuguese, Arabic and Slovenian translate them (about 2,000–2,190 entries each); Czech covers 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. Per-language coverage, script support and what each language still needs are in [localization-parity.md](localization-parity.md).
Whole sentences that the layers below word — what the accessibility checker found, what the PDF/A verifier reports, what a signature's details say, what is wrong with the pages to print, what an edit is called in the history — are translated too, because those layers hand the sentence over apart from the document's own words. See [Sentences the engine words](#sentences-the-engine-words).
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.
Edit text reads Hebrew lines in typing order and writes them back in drawing order (`crates/edit/src/bidi.rs`); letters the page's own font lacks are drawn with the craft-fonts `Hebr` face (Noto Sans Hebrew) as Type 3 glyphs, and a build without one says so. Hebrew points (niqqud) can't be written yet. The craft-fonts revision pinned for CI and releases has no `Hebr` face yet, so until one is added releases report that error instead of drawing the missing letters.
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, Hungarian, Italian and Turkish 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.
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, Hungarian, Italian and Slovenian need only the bundled Latin faces (tested in `tests/fonts.rs`). Slovenian has four plural forms: one (1, 101, 201…), two (2, 102…), few (3, 4, 103…) and other (everything else, including 0, 5–20 and 21+), so `@plural` entries list four forms (`1 stran`, `2 strani`, `3 strani`, `5 strani`). 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.
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). Until craft-fonts has an allowed `Hans` face, the desktop app draws a Chinese interface with a Chinese face already installed on the machine (Microsoft YaHei or JhengHei on Windows, PingFang, Hiragino Sans GB or STHeiti on macOS, WenQuanYi or Droid Sans Fallback on Linux; never Noto CJK or Source Han), placed before the Japanese faces so a line keeps one face (#826). The web build has no installed fonts to fall back on. Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian 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.
The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), the labels added since, and a review of the existing wording. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`).

View File

@ -1974,8 +1974,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.text"]
tools = ["page_add_text", "content_update"]
evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
evidence = ["crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/ui-egui/tests/content.rs::clicking_elsewhere_keeps_the_text_and_starts_a_new_box", "crates/automation/tests/automation.rs::adding_content_through_tools", "crates/edit/src/tests.rs::added_text_and_images_are_page_content_that_stays_editable", "crates/engine/src/tests.rs::added_text_and_images_render_and_stay_editable", "crates/edit/src/tests.rs::added_arabic_text_is_shaped_in_display_order_and_stays_searchable", "crates/automation/tests/automation.rs::thai_text_through_tools_saves_reopens_extracts_and_renders", "crates/ui-egui/tests/content.rs::typing_thai_text_keeps_marks_and_commits_to_page"]
notes = "page content, editable later (retype, move, restyle); standard fonts, Windows-1252 text. Arabic text is shaped with the craft-fonts Arab face (Type 3 outlines with ToUnicode) and laid out in bidirectional display order; it is drawn regular whatever the bold/italic setting, Hebrew and other right-to-left scripts are not covered, and typing in the box is still left to right (misc.rtl-ui). Thai Add Text uses the craft-fonts Thai face, with shaped clusters and ToUnicode; Thai is regular style, wrapping preserves clusters but is not dictionary-based, and forms/Fill and Sign are not covered by this change. Each click on the page keeps the box being typed and starts a new one; Done and Discard beside the box"
[[feature]]
id = "edit.find-replace"
@ -1995,11 +1995,11 @@ title = "Edit right-to-left and CJK text"
area = "C"
tier = "P1"
milestone = "M7"
status = "planned"
status = "partial"
commands = []
tools = []
evidence = []
notes = ""
tools = ["text_lines", "text_paragraphs", "text_edit"]
evidence = ["crates/edit/src/tests.rs::hebrew_lines_read_in_typing_order", "crates/edit/src/tests.rs::hebrew_edits_are_drawn_right_to_left_and_keep_their_right_edge", "crates/edit/src/tests.rs::hebrew_letters_the_page_font_lacks_use_the_hebrew_face", "crates/edit/src/bidi.rs::hebrew_sentences_numbers_latin_and_brackets_round_trip"]
notes = "Hebrew: lines and paragraphs read in typing order and are written back in drawing order, right-aligned by default; letters the page's font lacks use the craft-fonts Noto Sans Hebrew face as Type 3 glyphs. Japanese uses its Type 3 fallback. Missing: Hebrew points (niqqud) in replacement text, writing Arabic (shaping), right-to-left typing in the edit box (misc.rtl-ui), lines a producer draws word by word from the right, vertical CJK."
[[feature]]
id = "edit.rotated-text-editing"

Some files were not shown because too many files have changed in this diff Show More