Merge remote-tracking branch 'upstream/main' into fix/util-scand

# Conflicts:
#	crates/js/src/formcalc.rs
This commit is contained in:
Eric Kaden committed 2026-10-21 19:42:26 -04:00
commit 628c827572
93 files changed
+3394 -601

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
ignore_usernames:
- "dependabot[bot]"
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@448cde739abec1eaf0646249e3d25bb8d9bc7702 # v31.11.2
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# 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/nix.yml@main
+20
View File
@@ -0,0 +1,20 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+5 -1
View File
@@ -4,7 +4,7 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (272)
## In this repository (276)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
@@ -280,6 +280,10 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.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 |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
| `assets/icons/superscript.svg` | Lucide icon "superscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/subscript.svg` | Lucide icon "subscript" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/pilcrow.svg` | Lucide icon "pilcrow" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/move-horizontal.svg` | Lucide icon "move-horizontal" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
## Compiled in through dependencies (6)
+66 -22
View File
@@ -7,7 +7,7 @@ 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 = "6eee0b34db6529f26235bfc07fa1fdb9030b8e7b0b10ede271ca9242cb491919"
sha256 = "8be1b81993259177ec53d3ffcf4da073e395e50ad6644eb55ce636b7488712d8"
# 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`).
@@ -30,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 = "9870c0fb9f7198155be16adee7f45d36cb9a254af90b545a3ce5b04cbbbf24d4"
sha256 = "b770b000410c1213a8560424105429ac3504554974525b10e74470cd6c646f8f"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -41,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 = "fc31e40877ce115cfb7033c57dd6acabcae3725b249364387e3fe23e04d94e5e"
sha256 = "4f44b9d3f17c8bed1728c5afc4b16dc7c38e7c1fab4754e8b886a9c49f3ff0fe"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,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 = "5de19834f293efaea68cb6b7f51a71d320391f53345f56cfebcd73dab828b665"
sha256 = "bd7f9eb26e1ec98c7899bcf1fd6eeb3b6f5b7b866e97f083f10bee7676c5ddb5"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,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 = "cef0d22341d0722f029c6543fc846fce164331b7ad8f693a6754b7567b9d9e95"
sha256 = "2aaeb565b95f1471feaca31497d4da61257f098f4ef7dbe5c4a8a48e7cc1c76e"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -74,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 = "c0ca7761cbabd8096bc9af54cdf6b88b3ae0cd8210003db8b3356e2706e44fc8"
sha256 = "acc8d3e7f132d5bafaed3ad6ae10e2392d1bcdd6c96ac92e01c2daae555b96f0"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -85,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 = "a8afd51c3296f44b0446d6b06492b1348444b3e3c448a326ad213d18ce7af714"
sha256 = "3e7f4ef94a55fa11abc042312566267a13172895931b8a7b5af3c0ca41f9c8f4"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -96,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 = "7676a2dfaa7d66e2e9ef608d2d9de7f863557dcecbef6d94bafa981097405e6d"
sha256 = "1e38ddb698123f152c4eaf6f567e29fcefb4cefde4121924d1fc604f40dcea54"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -107,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 = "9a22996c0308ea5b20ec5e328504cd83cbb560147763faaf0e6987f974844c29"
sha256 = "f8225ad3d5b25c465a3cd555fedfe1bb0bfbcfa026586653a8df1be38f7f7b7e"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -118,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 = "eacc972d65a2d951e94e1a8cf944638e8328f73f6c05759de6059a425f159cd7"
sha256 = "caa382d3fad6d00c87fe8760bfec1c3130a6ccbea7cc687ab8b660a521993c65"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -129,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 = "dfa4aa540cc7d61fa22a27a949e6eec0f78dcc5b883d0e4e004d695e460b95aa"
sha256 = "5db94802049eac0b0321fb784a060b9f939343075db2dfd07b7293868ef9ed88"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -140,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 = "27e02e7bb3d378bceca76988a9dceaa68d974f57cbee962954deca6184cf62aa"
sha256 = "c63009ccf8afb975a7749f313c58fce4b8b41d290d80633f44cb08873195d86f"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -151,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 = "9675edd36c5ae54d9893fd10c4325aab664941bf714c08fbfeef9134fa42056c"
sha256 = "14f743ecfd488fc87e058b41df72f0835dc13b21b230c8b88054d39846ad4e31"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "ce18fc96f8516aa917e4a8bcfd290edec13a9800b3d1ee102e4b2a71b8467d2b"
sha256 = "ba43b383c8c8eb97f6bc39f4d635fe2282d92cb4a4bfdcaf81bba81c82fc64a3"
[[asset]]
path = "crates/ui-egui/src/i18n/kk.tsv"
@@ -173,7 +173,7 @@ 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 = "286a1e2e051748eb4bcee3906918dc78d0704f8a37bde22dc50d3376d5e75017"
sha256 = "2ba2a772b2991b306253ad5b853698af2502487fcc354d3906454f58fc3be4ba"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -184,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 = "77593b7497c67f4a79cc31fa43fdd6eae11601dd87c079a8e666b25cdd9117ba"
sha256 = "71217d26f3ea73d3746a76066365877a9964340981f5b1547073f9cb634c2e98"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -195,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 = "d7d93a03f22ab380696f69b20b12104bf30e78c4440b683df63594af716894e2"
sha256 = "1029628f81cce97ddd7468629fdfcfab13987742efc585f308b9af1ffa3f0f97"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -206,7 +206,7 @@ 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 = "889a0d0a9ea53ca0a818c2ba672c053c232d2cca140f8d63127353e6f36505fe"
sha256 = "76478b1a56a92f6527fd13babb3b013463a0dc70a2be6a50fca0ab25665b8e6c"
[[asset]]
path = "crates/ui-egui/src/i18n/tr.tsv"
@@ -217,7 +217,7 @@ 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 = "bae8b21651ae2a69c71c91b69ba79727254dbfb078c1be312a13383332934927"
sha256 = "e3346071d10f3f2d42c6f5fec56c4e7b5f8988a2a9e3a08d1c38e399fa6218b0"
[[asset]]
path = "crates/ui-egui/src/i18n/lt.tsv"
@@ -228,7 +228,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7fa70f68a9fa24d0b1cac05fe3aaabaeaca9750c57416124bd317a4eda806d5a"
sha256 = "7275aa4245985cbcd72b15ae6ba81fef28e0df74ccafbf6dc0e4075fe1b0f8cc"
[[asset]]
@@ -3352,7 +3352,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "95b5fc6d10f7bc8371688772741870448de716fa2dd9af62fdfe4e009d269b45"
sha256 = "f6aff113d8c5a43e4c52d465ee106f36238dd8581423cb0cd39cdcf991cbead2"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3363,7 +3363,7 @@ 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 = "3ac2b113045b7e399dd6d9ddb7773e5b0874c8b43c2ba4cca5854598a2a7ba92"
sha256 = "cb2cd5e2bc11c23f4f56eef1f836eecf0710b77d77b992d795a1dfa5bbebd9a8"
[[asset]]
@@ -3376,3 +3376,47 @@ licence_file = "vendor/vello_common/LICENSE-MIT"
kind = "image"
usage = "Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file"
sha256 = "78c59a0cfd2c829909d77006f94e34dac1ae5d1f90e7f10f72e84de541fef697"
[[asset]]
path = "assets/icons/superscript.svg"
title = "Lucide icon \"superscript\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/superscript.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "34af289365aaa19295f874e4fda3b56196e203785d6fe1d9c3c929ae290518a0"
[[asset]]
path = "assets/icons/subscript.svg"
title = "Lucide icon \"subscript\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/subscript.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "06462f57e87a14e6496da9216eee9906b83ded0ff99b70498efa37ae88a5cba4"
[[asset]]
path = "assets/icons/pilcrow.svg"
title = "Lucide icon \"pilcrow\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pilcrow.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "d344d9f9222f6c1b698c0c82a753a2212d9bd7785f06c00cd6e4910fe2fee8f8"
[[asset]]
path = "assets/icons/move-horizontal.svg"
title = "Lucide icon \"move-horizontal\""
author = "Lucide Contributors (portions Cole Bemis, Feather)"
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/move-horizontal.svg)"
licence = "ISC"
licence_file = "assets/icons/LICENSE-lucide.txt"
kind = "icon"
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
sha256 = "864ab2f19254e6ac7240bc6a98323197332e8f714d4d3f94831f41309e5ef2f7"
+1
View File
@@ -31,6 +31,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
- No `unsafe` (`unsafe_code = "forbid"`).
- Every crash fix gets a synthetic regression test. See `AGENTS.md` §4 (and, for team members, the internal `craftrules/standards/never-crash.md`).
- **Rust only** in the product and build (`xtask`). No handwritten JS/TS.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Review every update as you make it, at four zooms.** Not an end-of-task pass — a per-update habit, so a defect never gets built on. **Clause:** checked arithmetic, `get()` not `[i]`, char-boundary slicing, the right error through `?`, `<` against `<=`, the 1-based/0-based seam, capped allocations. **Function:** is it total — name an input that panics; walk empty, zero, max, malformed, cyclic; is the return type honest. **File/module:** two sources of truth, dead or duplicated code, is the public surface still smallest and the doc comment still true. **Project:** layering, wasm, undo/redo, save fidelity, encryption and permissions, a matching `automation` tool or registry entry, and whether a `parity/` status or note just became untrue. Scale the zoom to the change, and fix what a level turns up before widening.
- Tests mirror the zooms: property → unit → crate integration → end-to-end.
- **Prove a test fails before the fix by actually reverting the fix.** A test nobody has seen fail is not evidence. This has caught two worthless tests in this repo: one confinement test passed with path resolution switched off entirely, because the tools failed on a nonexistent document id before reaching the path.
Generated
+53 -46
View File
@@ -1887,7 +1887,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -3938,8 +3938,9 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"chrono",
"craft-quit",
"eframe",
"egui",
@@ -3963,7 +3964,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3975,7 +3976,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3986,7 +3987,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3997,7 +3998,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4019,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4035,19 +4036,20 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.9.0"
version = "0.11.0"
[[package]]
name = "pdfcraft-content"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"chrono",
"hayro-syntax",
"pdfcraft-crypt",
"pdfcraft-filters",
@@ -4060,7 +4062,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4076,7 +4078,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"aes",
"md-5",
@@ -4088,7 +4090,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4103,7 +4105,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4143,14 +4145,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"proptest",
@@ -4160,7 +4162,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4170,30 +4172,32 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
"pdfcraft-cos",
"pdfcraft-fonts",
"pdfcraft-model",
"proptest",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-geom"
version = "0.9.0"
version = "0.11.0"
[[package]]
name = "pdfcraft-js"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"boa_engine",
"chrono",
]
[[package]]
name = "pdfcraft-measure"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4206,14 +4210,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4223,7 +4227,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4236,11 +4240,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
version = "0.9.0"
version = "0.11.0"
[[package]]
name = "pdfcraft-organize"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4251,7 +4255,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4261,7 +4265,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4272,7 +4276,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4283,7 +4287,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4299,7 +4303,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4318,7 +4322,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4326,6 +4330,7 @@ dependencies = [
"bp256",
"bp384",
"cbc",
"chrono",
"crypto-bigint",
"des",
"ecdsa",
@@ -4352,15 +4357,16 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
version = "0.9.0"
version = "0.11.0"
[[package]]
name = "pdfcraft-ui-egui"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"arboard",
"base64 0.22.1",
"bytemuck",
"chrono",
"craft-quit",
"craft-ui",
"eframe",
@@ -4400,7 +4406,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"eframe",
"egui",
@@ -4412,7 +4418,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4426,7 +4432,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -4934,7 +4940,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5484,7 +5490,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5554,7 +5560,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5565,9 +5571,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -6431,7 +6437,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -7306,9 +7312,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
@@ -7514,7 +7520,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -8034,6 +8040,7 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"anyhow",
"chrono",
"lopdf",
"pdfcraft-cos",
"pdfcraft-oracle",
+36 -34
View File
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
version = "0.11.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# Whether a path stored in a document may be opened (GoToR link targets).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.9.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.9.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.9.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.9.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.9.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.9.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.9.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.9.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.9.0" }
pdfcraft-model = { path = "crates/model", version = "0.9.0" }
pdfcraft-content = { path = "crates/content", version = "0.9.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.9.0" }
pdfcraft-print = { path = "crates/print", version = "0.9.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.9.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.9.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.9.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.9.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.9.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.9.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.9.0" }
pdfcraft-js = { path = "crates/js", version = "0.9.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.9.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.9.0" }
pdfcraft-export = { path = "crates/export", version = "0.9.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.9.0" }
pdfcraft-create = { path = "crates/create", version = "0.9.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.9.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.9.0" }
pdfcraft-render = { path = "crates/render", version = "0.9.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.9.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.9.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.9.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.9.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
pdfcraft-model = { path = "crates/model", version = "0.11.0" }
pdfcraft-content = { path = "crates/content", version = "0.11.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
pdfcraft-print = { path = "crates/print", version = "0.11.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
pdfcraft-js = { path = "crates/js", version = "0.11.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
pdfcraft-export = { path = "crates/export", version = "0.11.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
pdfcraft-create = { path = "crates/create", version = "0.11.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
pdfcraft-render = { path = "crates/render", version = "0.11.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
# pdf
hayro = "0.7.1"
@@ -68,6 +68,8 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
log = "0.4"
kurbo = "0.13"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
# testing
proptest = "1"
+7 -4
View File
@@ -17,10 +17,13 @@
</p>
<p align="center">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-12a58a?style=flat-square">
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-0a7563?style=flat-square">
<img alt="Platforms: macOS, Windows, Linux, FreeBSD, web" src="https://img.shields.io/badge/runs%20on-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20web-12a58a?style=flat-square">
<img alt="No account, no telemetry" src="https://img.shields.io/badge/no%20account-no%20telemetry-0a7563?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_pdfcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/pdfcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
+4
View File
@@ -172,6 +172,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
## Progress log
- **2026-10-19 (M6, renames keep formulas):** Renaming a field renames it in every calculation that reads it, in the same undo step: `AFSimple_Calculate` field lists, simplified field notation (the comment and the JavaScript after it) and `getField` calls naming it in one string literal; the rest of each script is kept as written. A sum used to drop the renamed field, notation read it as 0 and a script kept a stale value. Field Properties says how many formulas changed and `form_set_props` lists them as `formulas_updated`. Names built at run time and other events' scripts are not rewritten. Estimates unchanged.
- **2026-10-19 (M14, #3373):** The main menu and its long submenus scroll in short windows, keeping their commands reachable. Estimates unchanged.
- **2026-10-19 (M3, #3387):** Save-prompt shortcuts yield to later consent and utility dialogs; a synthetic UI regression checks that an edited document stays open. Estimates unchanged.
@@ -216,8 +218,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-12 (M7, picture right-click menu):** The Edit text & images menu of a picture gains Cut Image, Redact Image (a redaction mark over the picture, applied from Redact a PDF), Create Link… and Recognize Text… (for that page), in all 21 catalogs. The menu was drawn only while the pointer was over the picture, so its items outside the picture (most of a logo's menu, and Delete below a page) could not be clicked; an open menu now stays drawn. Still missing from Acrobat's menu: Arrange (bring to front, send backward), Remove crop, Draw freehand and Edit image using… (opening an external editor).
- **2026-10-12 (M2, inline-image boundaries):** Unfiltered inline images with known sample layouts keep pixel bytes that look like the `EI` terminator when content is rewritten. Regression tests cover row padding, colour-space and parameter aliases, masks and malformed sizes; classic and packed saved PDFs retain the pixels in independent readers and renderers. Filtered and resource-defined layouts retain the existing tolerant heuristic. Estimates unchanged.
- **2026-10-12 (M3, drawing markups):** While a comment is being drawn, the pointer in a band along any edge of the view (or just past it) scrolls the view up, down, left or right, so points beyond what is shown can be reached. A polygon, cloud or run of connected lines takes back its last point with Backspace, Delete or Undo (the first point's removal cancels it). Edge scrolling has unit tests of its maths only; it has not been exercised end to end in the app by an automated test.
- **2026-10-15 (M2, large object streams):** An object stream's index of `objnum offset` pairs is read once per stream instead of once per object fetched from it (vendored hayro-syntax patch 22), so reading every object of a stream of N objects no longer takes N² steps. A Firefox page whose 65,542 objects share one stream (pdf.js corpus `bug1978317.pdf`) drew in 37 s and extracted its text in 40 s; it now takes 0.5 s and 1.9 s, and leaves the robustness baseline's known timeouts. 40,000 pages in one stream open in 0.2 s instead of 54 s. The regression fails before the fix. Estimates unchanged.
- **2026-10-12 (gap 6, #1319, #1305, #1324):** Editing an XFA form keeps a radio item's stored text when it contains a space, so a later layout still selects it. Replace Pages on a split selection replaces those pages and leaves the gap; editing the dialog range still replaces the span. Marking a figure decorative rewrites only that figure's content stream, so a header, added text and unknown stream entries stay. Estimates unchanged.
- **2026-10-15 (M14, gap 24):** `misc.localization` and `misc.installers` move from planned to partial. Localization: 22 catalogs ship, but Chinese shows tofu in releases, Telugu has no release font and Arabic is not mirrored. Installers: macOS and Windows releases are signed, Linux packages are not. The Acrobat menu diff itself remains open. Estimates unchanged.
- **2026-10-15 (M14, PNG export):** Export ▸ Image as PNG, `pdfcraft-cli render` and the `page_render` automation/MCP tool write an opaque page as RGB at deflate level 3 instead of RGBA at the default level 6: the files stay the same size and are written 3–5× faster (a 300-dpi scan drawn at 220 dpi: 805 → 167 ms; a text page at 220 dpi: 70 → 40 ms; an annual-report page at 150 dpi: 179 → 64 ms). A page with translucent pixels is still written as RGBA. The automation tools use the engine's encoder instead of a copy of it. Estimates unchanged.
- **2026-10-13 (G14, Vietnamese localization):** Added Vietnamese (`vi`, Tiếng Việt) as an interface and date language with a 2,591-entry catalog (`plural_none`) covering 100% of the current localizable key union (plus `theme`/`Appearance`). Locale tags `vi` / `vi-VN` / `vi_VN.UTF-8` resolve; calendar months use numeric `Tháng 1`…`12` / `tháng 1`…`12`. Native-speaker community review recorded. The twelve-language localization mean moves from ≈59% to ≈68%; weighted readiness ≈49% → ≈50%. Status stays `partial`: engine/OS details, document contents, filenames, CLI/MCP and external help stay outside the catalog by design.
- **2026-10-13 (M3, typed zoom):** The right rail's zoom percentage is an editable field: type 150, 150% or 62.5 % and press Enter, Escape discards, and nonsense keeps the current zoom. The control channel's zoom option shares its parser, which now refuses zero, negative and non-finite values.
+2
View File
@@ -23,6 +23,8 @@ egui = { workspace = true }
serde_json = { workspace = true }
# Records from every crate go to standard error and the log file (src/logging.rs).
log = { workspace = true }
# The log timestamp goes through chrono (no hand-rolled calendar code).
chrono = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
# Finder double-click / Open With / Dock drops arrive as Apple events (#73); this small audited
+35 -15
View File
@@ -90,7 +90,10 @@ fn matches(name: &str, target: &str) -> bool {
}
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
/// output never changes for a value the old formatter accepted and logging never panics.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
@@ -99,20 +102,25 @@ pub fn timestamp(t: SystemTime) -> String {
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). `days` is at most
/// `u64::MAX / 86_400`, so none of the arithmetic below can overflow.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + u64::from(m <= 2);
(y, m, d)
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
/// reproducing the old `civil_from_days` result exactly without hand-rolled day math. 1970-01-01
/// is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -337,6 +345,18 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_767_225_599)), "2025-12-31T23:59:59.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
// the old formatter gave, rather than falling back to the epoch.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
}
#[test]
+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-move-horizontal"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m18 8 4 4-4 4" />
<path d="M2 12h20" />
<path d="m6 8-4 4 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 375 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-pilcrow"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M13 4v16" />
<path d="M17 4v16" />
<path d="M19 4H9.5a4.5 4.5 0 0 0 0 9H13" />
</svg>

After

Width:  |  Height:  |  Size: 380 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-subscript"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m4 5 8 8" />
<path d="m12 5-8 8" />
<path d="M20 19h-4c0-1.5.44-2 1.5-2.5S20 15.33 20 14c0-.47-.17-.93-.48-1.29a2.11 2.11 0 0 0-2.62-.44c-.42.24-.74.62-.9 1.07" />
</svg>

After

Width:  |  Height:  |  Size: 467 B

+17
View File
@@ -0,0 +1,17 @@
<!-- @license lucide-static v1.49.0 - ISC -->
<svg
class="lucide lucide-superscript"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m4 19 8-8" />
<path d="m12 19-8-8" />
<path d="M20 12h-4c0-1.5.442-2 1.5-2.5S20 8.334 20 7.002c0-.472-.17-.93-.484-1.29a2.105 2.105 0 0 0-2.617-.436c-.42.239-.738.614-.899 1.06" />
</svg>

After

Width:  |  Height:  |  Size: 486 B

+1 -1
View File
@@ -17,7 +17,6 @@ pdfcraft-platform = { workspace = true }
serde_json = { workspace = true }
sha2 = "0.11"
tempfile = "3.27"
png = "0.18"
base64 = { version = "0.22", optional = true }
@@ -28,6 +27,7 @@ mcp = ["dep:base64"]
[dev-dependencies]
pdfcraft-fonts = { workspace = true }
png = "0.18"
flate2.workspace = true
pdfcraft-model = { workspace = true }
pdfcraft-xfa = { workspace = true }
+12
View File
@@ -386,12 +386,24 @@ impl Automation {
} else {
edits.pop().ok_or_else(|| bad("give a field name"))?
};
// A rename also renames the field in the formulas that read it.
let before = if a.get("name").is_some() { Some(self.doc(a)?.form.clone()) } else { None };
let mut out = self.apply(a, edit)?;
if bulk {
out["fields"] = json!(names);
} else {
out["field"] = json!(new_name);
}
if let Some(before) = before {
let after = &self.doc(a)?.form;
// The renamed field itself, found by object: the name stored can differ from the
// argument (trimmed, kept under its parent's prefix).
let own =
names.first().and_then(|n| before.iter().find(|f| &f.name == n)).and_then(|f| after.iter().find(|g| g.obj == f.obj)).map(|g| &g.name);
let mut updated = pdfcraft_engine::form_calculations_changed(&before, after);
updated.retain(|f| Some(f) != own);
out["formulas_updated"] = json!(updated);
}
Ok(out)
}
+2 -17
View File
@@ -2506,24 +2506,9 @@ fn outline(items: &[pdfcraft_render::OutlineItem]) -> Value {
Value::Array(items.iter().map(|o| json!({ "title": o.title, "page": o.page.map(|p| p + 1), "children": outline(&o.children) })).collect())
}
/// A rendered raster as PNG, through the engine's encoder (Export ▸ Image writes the same).
fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>> {
let mut rgba = premultiplied.to_vec();
for px in rgba.as_chunks_mut::<4>().0 {
let a = u32::from(px[3]);
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
}
}
}
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, width, height);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().map_err(failed)?;
w.write_image_data(&rgba).map_err(failed)?;
w.finish().map_err(failed)?;
Ok(out)
pdfcraft_engine::export::encode_png(width, height, premultiplied).map_err(failed)
}
/// Extract the text of `pages` (0-based) with one renderer per worker thread.
+1 -1
View File
@@ -374,7 +374,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"form_set_props",
"Field properties",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
"Change field properties: give field for one field, or fields for a list of unique field names (1–1000). A bulk change is atomic and one Undo step; an invalid or locked field leaves every field unchanged. Only the given properties change, and partial appearance changes preserve each field's other colours and font. name (rename), rect (position), rotation and caption require a single field. A rename also renames the field in the calculations that read it (calculate field lists and notation, and getField calls naming it in a string literal; names built at run time are not rewritten), in the same Undo step, and formulas_updated lists the fields whose calculation changed. caption changes one push-button normal label (widget: 1-based index, default 1); an empty string clears it. Other widgets and icon-only layouts are preserved. General and Options: tooltip, read_only, required, multiline, max_length (0 = no limit), options (combo/list items), font_size (0 = auto), format, validate, calculate (Acrobat's native AF rules). Options: align (left, center, right), default (Reset form's value; check boxes/radio buttons use their on state, \"\" for off), flags { scroll, rich_text, password, file_select, spell_check, comb (needs max_length), sort, editable, multi_select, commit_immediately }, check_style (check boxes/radio buttons: check, circle, cross, diamond, square, star). rotation is 0, 90, 180 or 270 degrees counterclockwise and swaps width/height when the axis changes. Undoable.",
)
.with(schema(
json!({
+68 -11
View File
@@ -3799,6 +3799,60 @@ fn form_formats_and_calculations_through_tools() {
));
}
/// Renaming a field renames it in the formulas that read it: a sum dropped the field, notation
/// read it as 0 and a script kept a stale value.
#[test]
fn renaming_a_field_keeps_the_formulas_that_read_it() {
let dir = workdir("formrename");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
for (i, name) in ["a", "b", "c", "d", "e", "x", "sum", "product", "double", "total"].into_iter().enumerate() {
let y = 20 + 30 * i;
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "text", "rect": [20, y, 180, y + 22], "name": name }));
}
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "sum", "calculate": { "op": "sum", "fields": ["a", "b"] } }));
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "product", "calculate": { "notation": "c * d" } }));
ok(
&mut a,
"form_set_script",
json!({ "doc": doc, "field": "double", "event": "calculate", "script": "event.value = getField(\"e\").value * 2;" }),
);
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "total", "calculate": { "op": "sum", "fields": ["x"] } }));
let renamed = |a: &mut Automation, from: &str, to: &str| ok(a, "form_set_props", json!({ "doc": doc, "field": from, "name": to }));
assert_eq!(renamed(&mut a, "a", "qty")["formulas_updated"], json!(["sum"]));
assert_eq!(renamed(&mut a, "c", "price")["formulas_updated"], json!(["product"]));
assert_eq!(renamed(&mut a, "e", "base")["formulas_updated"], json!(["double"]));
assert_eq!(renamed(&mut a, "total", "grand")["formulas_updated"], json!([]), "a calculated field's own name is in no formula");
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "qty": "10", "b": "2", "price": "10", "d": "4", "base": "10", "x": "9" } }));
let values = |a: &mut Automation| {
let f = ok(a, "form_fields", json!({ "doc": doc }));
let value = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).map(|x| x["value"].clone());
[value("sum"), value("product"), value("double"), value("grand")]
};
assert_eq!(values(&mut a), [Some(json!("12")), Some(json!("40")), Some(json!("20")), Some(json!("9"))]);
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap().clone();
assert_eq!(field("sum")["calculate"]["fields"], json!(["qty", "b"]));
assert_eq!(field("product")["calculate"]["notation"], "price * d");
// The rename and the formulas it changed are one undo step: undo the fill, then the last
// three renames.
for _ in 0..4 {
ok(&mut a, "edit_undo", json!({ "doc": doc }));
}
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
let field = |name: &str| f["fields"].as_array().unwrap().iter().find(|x| x["name"] == name).cloned();
assert!(field("c").is_some() && field("price").is_none());
assert_eq!(field("product").unwrap()["calculate"]["notation"], "c * d", "undone together with the rename");
// The renamed field's own formula, changed in the same call, is not one the rename
// rewrote, though the name is stored trimmed.
let r = ok(
&mut a,
"form_set_props",
json!({ "doc": doc, "field": "sum", "name": " added ", "calculate": { "op": "sum", "fields": ["qty", "b", "x"] } }),
);
assert_eq!(r["formulas_updated"], json!([]));
}
#[test]
fn tab_order_and_field_appearance_through_tools() {
let dir = workdir("taborder");
@@ -3880,15 +3934,13 @@ fn natural_image_stamps_through_tools_on_rotated_pages() {
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!(info.color_type, png::ColorType::Rgba);
for ((x, y), colour) in [(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([
[240, 20, 20, 255],
[20, 180, 20, 255],
[20, 20, 240, 255],
[230, 180, 20, 255],
]) {
let offset = ((y * info.width + x) * 4) as usize;
assert_eq!(&pixels[offset..offset + 4], &colour, "page rotation {degrees}");
// A rendered page is opaque, so it comes as RGB.
assert_eq!(info.color_type, png::ColorType::Rgb);
for ((x, y), colour) in
[(80, 90), (120, 90), (80, 110), (120, 110)].into_iter().zip([[240, 20, 20], [20, 180, 20], [20, 20, 240], [230, 180, 20]])
{
let offset = ((y * info.width + x) * 3) as usize;
assert_eq!(&pixels[offset..offset + 3], &colour, "page rotation {degrees}");
}
}
}
@@ -6515,7 +6567,8 @@ fn reading_colors_workdir() -> PathBuf {
dir
}
/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels.
/// Decodes a `page_render` PNG (pages here are 200×300 pt) into RGBA pixels. Opaque pages
/// come as RGB; their alpha is 255.
fn png_pixels(content: &Content) -> Vec<u8> {
let Content::Png { data, width, height } = content else { panic!("expected a PNG") };
assert_eq!((*width, *height), (200, 300));
@@ -6523,7 +6576,11 @@ fn png_pixels(content: &Content) -> Vec<u8> {
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let info = reader.next_frame(&mut pixels).unwrap();
assert_eq!((info.width, info.height), (200, 300));
pixels
match info.color_type {
png::ColorType::Rgba => pixels,
png::ColorType::Rgb => pixels.as_chunks::<3>().0.iter().flat_map(|p| [p[0], p[1], p[2], 255]).collect(),
other => panic!("unexpected colour type {other:?}"),
}
}
/// The RGBA pixel at (`x`, `y`), top-left origin, of a 200-wide image.
+2
View File
@@ -12,6 +12,8 @@ workspace = true
pdfcraft-crypt = { workspace = true }
pdfcraft-filters = { workspace = true }
thiserror = { workspace = true }
# PDF date strings (`pdf_date`) go through chrono (no hand-rolled calendar code).
chrono = { workspace = true }
[dev-dependencies]
pdfcraft-oracle = { workspace = true }
+44 -13
View File
@@ -578,22 +578,33 @@ fn ensure_id(trailer: &mut Dict, opts: &SaveOptions, content: &[u8]) {
/// A PDF date string for "now" given seconds since the Unix epoch (UTC).
pub fn pdf_date(unix_secs: i64) -> String {
let days = unix_secs.div_euclid(86_400);
let secs = unix_secs.rem_euclid(86_400);
// Civil-from-days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let (days, secs) = (unix_secs.div_euclid(86_400), unix_secs.rem_euclid(86_400));
let (y, m, d) = civil_from_days(days);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (and a PDF date is a
// bare four-plus-digit year, never signed). No year clamp — the old code wrote `y` as-is.
format!("D:{y:04}{m:02}{d:02}{:02}{:02}{:02}Z", secs / 3600, secs / 60 % 60, secs % 60)
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// A `from_timestamp` epoch fallback would change dates past `NaiveDate`'s ±262143-year range,
/// which the old hand-rolled converter formatted directly. The Gregorian calendar repeats every
/// 400 years (146_097 days), so chrono converts the day within one cycle and whole cycles go back
/// into the year (an `i128` so the extremes can't overflow), keeping chrono as the only calendar
/// logic and reproducing the old result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
@@ -656,6 +667,26 @@ mod tests {
}
}
#[test]
fn pdf_dates_are_utc_and_byte_identical() {
// `D:YYYYMMDDHHmmSSZ` (ISO 32000-2 §7.9.4), the same strings the hand-rolled code wrote.
assert_eq!(pdf_date(0), "D:19700101000000Z");
assert_eq!(pdf_date(1_790_000_000), "D:20260921141320Z");
// Leap days, including the 400-year rule, and the last second of a year.
assert_eq!(pdf_date(1_709_164_800), "D:20240229000000Z");
assert_eq!(pdf_date(951_782_400), "D:20000229000000Z");
assert_eq!(pdf_date(1_767_225_599), "D:20251231235959Z");
// Before the epoch (seconds into a day are counted forward from midnight UTC).
assert_eq!(pdf_date(-1), "D:19691231235959Z");
// Years past 9999 keep the plain zero-padded year (no `+`), as the hand-rolled code did.
assert_eq!(pdf_date(253_402_300_800), "D:100000101000000Z");
// Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch.
// Pinned to the old hand-rolled output (which had no year clamp — a negative year keeps
// its sign, exactly as before).
assert_eq!(pdf_date(10_000_000_000_000), "D:3188570520174640Z");
assert_eq!(pdf_date(-10_000_000_000_000), "D:-3149180813061320Z");
}
#[test]
fn reals_keep_full_precision_without_exponents() {
for r in [0.1, -0.000_000_123_456_789, 1e-12, 123_456.789_012_345, 1.0 / 3.0, -2.5e7 + 0.25] {
+3 -2
View File
@@ -48,5 +48,6 @@ icu_properties = "2.3"
unicode-segmentation = "1.13"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Already in the graph through lopdf; used only for the local UTC offset ("today").
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# Already in the graph through lopdf; used only for the local UTC offset ("today"). The workspace
# entry is std-only; this target adds `clock` for `chrono::Local::now`.
chrono = { workspace = true, features = ["clock"] }
+86 -10
View File
@@ -138,23 +138,48 @@ pub fn encode_image(width: u32, height: u32, premultiplied: &[u8], format: Image
}
}
/// Premultiplied RGBA → PNG (straight alpha).
/// Premultiplied RGBA → PNG (straight alpha). A raster with no translucent pixel (a rendered
/// page always is one: it is drawn on white) is written as RGB.
///
/// Deflate level 3 with adaptive filtering: on rendered pages it writes files the size of the
/// `png` crate's default (level 6) in a third to a fifth of the time. Measured with RGB at
/// level 3 against RGBA at the default: a 300-dpi scan drawn at 220 dpi took 805 → 167 ms
/// (4.46 → 4.47 MB), a text page at 220 dpi 70 → 40 ms (0.85 → 0.89 MB), and an annual
/// report page at 150 dpi 179 → 64 ms (1.41 → 1.38 MB).
pub fn encode_png(width: u32, height: u32, premultiplied: &[u8]) -> Result<Vec<u8>, String> {
let mut rgba = premultiplied.to_vec();
for px in rgba.as_chunks_mut::<4>().0 {
let a = u32::from(px[3]);
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
// Checked here, not left to the encoder: `as_chunks` below drops a partial trailing pixel,
// and the opaque path would write the rest as a valid image.
let expected = usize::try_from(width).ok().zip(usize::try_from(height).ok()).and_then(|(w, h)| w.checked_mul(h)?.checked_mul(4));
if expected != Some(premultiplied.len()) {
return Err(match expected {
Some(n) => format!("a {width}×{height} raster is {n} bytes of RGBA, got {}", premultiplied.len()),
None => format!("a {width}×{height} raster is too large"),
});
}
let pixels = premultiplied.as_chunks::<4>().0;
let opaque = pixels.iter().all(|px| px[3] == 255);
let (color, data) = if opaque {
(png::ColorType::Rgb, pixels.iter().flat_map(|px| [px[0], px[1], px[2]]).collect::<Vec<u8>>())
} else {
let mut rgba = premultiplied.to_vec();
for px in rgba.as_chunks_mut::<4>().0 {
let a = u32::from(px[3]);
if a != 0 && a != 255 {
for c in &mut px[..3] {
*c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8;
}
}
}
}
(png::ColorType::Rgba, rgba)
};
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, width, height);
enc.set_color(png::ColorType::Rgba);
enc.set_color(color);
enc.set_depth(png::BitDepth::Eight);
enc.set_deflate_compression(png::DeflateCompression::Level(3));
enc.set_filter(png::Filter::Adaptive);
let mut w = enc.write_header().map_err(|e| e.to_string())?;
w.write_image_data(&rgba).map_err(|e| e.to_string())?;
w.write_image_data(&data).map_err(|e| e.to_string())?;
w.finish().map_err(|e| e.to_string())?;
Ok(out)
}
@@ -260,3 +285,54 @@ mod image_name_tests {
assert_eq!(image_file_name("doc", &image(0), 1, 0), "doc_Page_1_Image_0001.png");
}
}
#[cfg(test)]
mod png_tests {
use super::encode_png;
/// The colour type and pixels of a PNG.
fn decode(png: &[u8]) -> (png::ColorType, Vec<u8>) {
let mut reader = png::Decoder::new(std::io::Cursor::new(png)).read_info().unwrap();
let mut pixels = vec![0; reader.output_buffer_size().unwrap()];
let frame = reader.next_frame(&mut pixels).unwrap();
pixels.truncate(frame.buffer_size());
(frame.color_type, pixels)
}
/// A rendered page has no translucent pixel: it is written as RGB, each pixel exactly as
/// rendered (the alpha channel would only make the file bigger and slower to write).
#[test]
fn opaque_rasters_are_written_as_rgb_with_their_exact_pixels() {
let rgba = [[255, 255, 255, 255], [0, 0, 0, 255], [200, 30, 90, 255], [1, 2, 3, 255], [17, 250, 128, 255], [90, 90, 90, 255]];
let (color, pixels) = decode(&encode_png(3, 2, rgba.as_flattened()).unwrap());
assert_eq!(color, png::ColorType::Rgb);
assert_eq!(pixels, rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::<Vec<_>>());
}
/// A raster with translucent pixels keeps its alpha, with the colours un-premultiplied.
#[test]
fn translucent_rasters_keep_straight_alpha() {
let rgba = [[64, 0, 32, 128], [0, 0, 0, 0], [10, 20, 30, 255]];
let (color, pixels) = decode(&encode_png(3, 1, rgba.as_flattened()).unwrap());
assert_eq!(color, png::ColorType::Rgba);
assert_eq!(pixels, [128, 0, 64, 128, 0, 0, 0, 0, 10, 20, 30, 255]);
}
/// A buffer that is not exactly `width × height` RGBA pixels is an error, opaque or not. The
/// opaque path used to drop a partial trailing pixel and write the rest as a valid image.
#[test]
fn rasters_of_the_wrong_length_are_rejected() {
let cases: [(&str, u32, u32, &[u8]); 7] = [
("opaque, a partial pixel after", 1, 1, &[10, 20, 30, 255, 0]),
("opaque, three bytes after", 1, 1, &[10, 20, 30, 255, 0, 0, 0]),
("translucent, a partial pixel after", 1, 1, &[10, 20, 30, 128, 0]),
("opaque, a pixel short", 2, 1, &[10, 20, 30, 255]),
("translucent, a pixel too many", 1, 1, &[10, 20, 30, 128, 1, 2, 3, 4]),
("empty", 1, 1, &[]),
("a size whose byte count overflows", u32::MAX, u32::MAX, &[10, 20, 30, 255]),
];
for (what, width, height, data) in cases {
assert!(encode_png(width, height, data).is_err(), "{what}");
}
}
}
+2 -1
View File
@@ -58,7 +58,8 @@ pub use pdfcraft_edit::{
pub use pdfcraft_forms::{
BorderStyle, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue,
Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger, Widget as FormWidget, af as form_scripts,
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, flags as field_flags,
appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by, calculations_changed as form_calculations_changed,
flags as field_flags,
};
pub use pdfcraft_a11y as a11y;
+1
View File
@@ -17,3 +17,4 @@ thiserror = { workspace = true }
[dev-dependencies]
hayro-syntax = { workspace = true }
proptest = { workspace = true }
+7
View File
@@ -56,6 +56,13 @@ delete_field(&mut doc, "email")?;
Button1, Date1, Signature1; `/Helv` is added to `/DR`; appearances are drawn at once. Radio
buttons joining a group become kid widgets with their own export state. Renaming changes only
the last part of a dotted name.
- Renaming a field renames it in every Calculate script that reads it
(`af::rename_in_calculation`): `AFSimple_Calculate` field lists, simplified field notation
(the `BVCALC` comment and its translation), and `getField` calls whose argument is the name as
one string literal. The rest of each script is kept as written. Names built at run time, a
script stream that isn't UTF-8, other events' scripts and a name another field still has are
left alone. `calculations_changed` compares two readings of a form to say which formulas an
edit rewrote.
- `Widget::caption` reads a push button's normal label (`/MK /CA`, including indirect values).
`FieldProps { caption: Some((widget_index, text)), ..Default::default() }` changes or clears
that label, independently of the field name. The index is 0-based. A label-only edit redraws
+418 -51
View File
@@ -11,6 +11,7 @@
use std::collections::VecDeque;
use std::fmt::Write as _;
use std::ops::Range;
/// The Format tab.
#[derive(Clone, Debug, PartialEq, Default)]
@@ -453,6 +454,250 @@ pub fn calculate_js(c: &Calculate) -> Option<String> {
}
}
// ── renaming fields in calculations ─────────────────────────────────────────────────────────
/// A JavaScript token, as far as finding the field names a calculation reads needs.
#[derive(Clone, Debug, PartialEq)]
enum Js {
Ident,
/// A string literal, its escapes undone.
Str(String),
Punct(char),
/// A number, a regular expression or a template literal.
Other,
}
/// Words after which `/` starts a regular expression instead of dividing.
const BEFORE_REGEX: [&str; 13] = ["return", "typeof", "case", "do", "else", "in", "of", "new", "delete", "void", "throw", "instanceof", "yield"];
/// JavaScript's tokens with their byte ranges, comments left out: enough of the language to tell
/// code from strings, comments and regular expressions. The tokens stop where a string, comment
/// or template literal never ends.
fn js_tokens(js: &str) -> Vec<(Range<usize>, Js)> {
let s = js.as_bytes();
// Bytes past ASCII are parts of identifiers, so a range never ends inside a character.
let word = |b: u8| b.is_ascii_alphanumeric() || b == b'_' || b == b'$' || !b.is_ascii();
let mut out: Vec<(Range<usize>, Js)> = Vec::new();
let mut i = 0;
// Where a regular expression that never ended stopped being read: every `/` before it
// divides, so a line of them is read once, not once for each.
let mut divides_until = 0;
'scan: while let Some(&c) = s.get(i) {
let start = i;
let token = match c {
_ if c.is_ascii_whitespace() => {
i += 1;
continue;
}
b'/' if s.get(i + 1) == Some(&b'/') => {
i = s.iter().skip(i).position(|b| *b == b'\n').map_or(s.len(), |n| i + n);
continue;
}
b'/' if s.get(i + 1) == Some(&b'*') => match js.get(i + 2..).and_then(|rest| rest.find("*/")) {
Some(n) => {
i += n + 4;
continue;
}
None => break 'scan,
},
b'"' | b'\'' => {
let mut p = Parser { s, i, depth: 0 };
let Some(text) = p.string() else { break 'scan };
i = p.i;
Js::Str(text)
}
b'`' => {
let mut j = i + 1;
loop {
match s.get(j) {
None => break 'scan,
Some(b'\\') => j += 2,
Some(b'`') => break,
Some(_) => j += 1,
}
}
i = j + 1;
Js::Other
}
b'/' if i >= divides_until && regex_may_follow(js, &out) => {
// A regular expression ends at a `/` outside a class; a line break first means it
// was a division after all.
let (mut j, mut class) = (i + 1, false);
let end = loop {
match s.get(j) {
None | Some(b'\n') => break None,
Some(b'\\') => j += 2,
Some(b'/') if !class => break Some(j),
Some(b) => {
class = match b {
b'[' => true,
b']' => false,
_ => class,
};
j += 1;
}
}
};
match end {
Some(e) => {
i = e + 1;
while s.get(i).is_some_and(|b| word(*b)) {
i += 1;
}
Js::Other
}
None => {
divides_until = j;
i += 1;
Js::Punct('/')
}
}
}
_ if c.is_ascii_digit() => {
while s.get(i).is_some_and(|b| word(*b) || *b == b'.') {
i += 1;
}
Js::Other
}
_ if word(c) => {
while s.get(i).is_some_and(|b| word(*b)) {
i += 1;
}
Js::Ident
}
_ => {
i += 1;
Js::Punct(char::from(c))
}
};
out.push((start..i, token));
}
out
}
/// Whether a `/` after `tokens` starts a regular expression: after an operator, an opening
/// bracket or a keyword such as `return`, not after a value.
fn regex_may_follow(js: &str, tokens: &[(Range<usize>, Js)]) -> bool {
match tokens.last() {
None => true,
Some((range, Js::Ident)) => js.get(range.clone()).is_some_and(|w| BEFORE_REGEX.contains(&w)),
Some((_, Js::Punct(c))) => !matches!(c, ')' | ']' | '}'),
Some((_, Js::Str(_) | Js::Other)) => false,
}
}
/// A place where a Calculate script names a field.
#[derive(Clone, Debug, PartialEq)]
enum Site {
/// A string literal holding one name: `getField("a")`, or an item of an
/// `AFSimple_Calculate` list.
Literal(String),
/// An `AFSimple_Calculate` list written as one string of comma-separated names.
Commas(String),
/// A name in the simplified field notation of a `BVCALC` comment.
Notation(String),
}
impl Site {
fn names(&self) -> Vec<&str> {
match self {
Site::Literal(name) | Site::Notation(name) => vec![name.as_str()],
Site::Commas(list) => list.split(',').map(str::trim).filter(|n| !n.is_empty()).collect(),
}
}
/// The site written again with field `old` named `new`, or `None` when it doesn't name `old`.
fn renamed(&self, old: &str, new: &str) -> Option<String> {
match self {
Site::Literal(name) => (name == old).then(|| js_str(new)),
Site::Notation(name) => (name == old).then(|| notation_name(new)),
Site::Commas(list) => {
let parts: Vec<&str> = list.split(',').collect();
parts.iter().any(|p| p.trim() == old).then(|| {
// The spaces around each name stay as they were.
let parts: Vec<String> = parts.iter().map(|p| if p.trim() == old { p.replacen(old, new, 1) } else { (*p).to_owned() }).collect();
js_str(&parts.join(","))
})
}
}
}
}
/// Where a Calculate script names fields, in order: the simplified field notation between
/// `BVCALC` and `EVCALC` (where [`parse_calculate`] reads it), the field list of
/// `AFSimple_Calculate`, and every `getField` whose whole argument is one string literal.
/// A name built at run time (`getField("row" + i)`) is not a site.
fn calculation_sites(js: &str) -> Vec<(Range<usize>, Site)> {
let mut sites = Vec::new();
if let (Some(a), Some(b)) = (js.find("BVCALC"), js.find("EVCALC"))
&& let Some(expr) = js.get(a + 6..b)
{
for (range, token) in notation_tokens(expr) {
if let Notation::Name(name) = token {
sites.push((a + 6 + range.start..a + 6 + range.end, Site::Notation(name)));
}
}
}
let tokens = js_tokens(js);
let ident = |k: usize, word: &str| tokens.get(k).is_some_and(|(range, t)| *t == Js::Ident && js.get(range.clone()) == Some(word));
let punct = |k: usize, c: char| tokens.get(k).is_some_and(|(_, t)| *t == Js::Punct(c));
let string = |k: usize| match tokens.get(k) {
Some((range, Js::Str(text))) => Some((range.clone(), text.clone())),
_ => None,
};
for k in 0..tokens.len() {
if ident(k, "getField")
&& punct(k + 1, '(')
&& punct(k + 3, ')')
&& let Some((range, name)) = string(k + 2)
{
sites.push((range, Site::Literal(name)));
}
if !(ident(k, "AFSimple_Calculate") && punct(k + 1, '(') && string(k + 2).is_some() && punct(k + 3, ',')) {
continue;
}
if let Some((range, list)) = string(k + 4) {
sites.push((range, Site::Commas(list)));
continue;
}
// `new Array("a", "b")` or `["a", "b"]`: its items, up to the first that isn't a string.
// Nothing else is read, so no token is read for two calls.
let first = if punct(k + 4, '[') {
k + 5
} else if ident(k + 4, "new") && ident(k + 5, "Array") && punct(k + 6, '(') {
k + 7
} else {
continue;
};
for (range, token) in tokens.iter().skip(first) {
match token {
Js::Str(name) => sites.push((range.clone(), Site::Literal(name.clone()))),
Js::Punct(',') => {}
_ => break,
}
}
}
sites.sort_by_key(|(range, _)| range.start);
sites
}
/// A Calculate script with field `old` named `new` everywhere it reads it (see
/// [`calculation_sites`]), the rest kept as written; `None` when it doesn't read `old`. Also
/// `None` when the new name would read as more than a name there (one holding `EVCALC` would end
/// the notation early), so a script is never left meaning something else.
pub fn rename_in_calculation(js: &str, old: &str, new: &str) -> Option<String> {
let sites = calculation_sites(js);
let edits: Vec<(Range<usize>, String)> = sites.iter().filter_map(|(range, site)| Some((range.clone(), site.renamed(old, new)?))).collect();
if edits.is_empty() {
return None;
}
let out = splice(js, edits);
let expected: Vec<&str> = sites.iter().flat_map(|(_, site)| site.names()).map(|n| if n == old { new } else { n }).collect();
let written = calculation_sites(&out);
let got: Vec<&str> = written.iter().flat_map(|(_, site)| site.names()).collect();
(got == expected).then_some(out)
}
// ── running them ────────────────────────────────────────────────────────────────────────────
/// AFMakeNumber: a number from typed text (currency symbols, group separators, spaces and a
@@ -960,77 +1205,107 @@ pub fn validate(v: &Validate, value: &str) -> Result<(), String> {
})
}
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
pub fn notation_js(expr: &str) -> String {
let mut out = String::new();
let cs: Vec<char> = expr.chars().collect();
let mut i = 0;
while let Some(&c) = cs.get(i) {
if c.is_whitespace() || "+-*/()".contains(c) {
out.push(c);
i += 1;
/// A token of simplified field notation.
#[derive(Clone, Debug, PartialEq)]
enum Notation {
/// A number, as written.
Number,
/// `+ - * / ( )`.
Op(char),
/// A field name, its `\` escapes undone.
Name(String),
}
/// Simplified field notation's tokens, each with its byte range in `src`. Whitespace separates
/// them, and `\` escapes a space or an operator inside a name, as Acrobat writes them.
fn notation_tokens(src: &str) -> Vec<(Range<usize>, Notation)> {
let breaks = |c: char| c.is_whitespace() || "+-*/()".contains(c);
let mut out = Vec::new();
let mut cs = src.char_indices().peekable();
while let Some(&(start, c)) = cs.peek() {
let mut end = start + c.len_utf8();
if c.is_whitespace() {
cs.next();
} else if breaks(c) {
cs.next();
out.push((start..end, Notation::Op(c)));
} else if c.is_ascii_digit() || c == '.' {
while let Some(&d) = cs.get(i).filter(|d| d.is_ascii_digit() || **d == '.') {
out.push(d);
i += 1;
while let Some((i, d)) = cs.next_if(|(_, d)| d.is_ascii_digit() || *d == '.') {
end = i + d.len_utf8();
}
out.push((start..end, Notation::Number));
} else {
let mut name = String::new();
while let Some(&n) = cs.get(i).filter(|n| !(n.is_whitespace() || "+-*/()".contains(**n))) {
// `\` escapes a space or an operator inside a name, as Acrobat writes them.
while let Some((i, n)) = cs.next_if(|(_, n)| !breaks(*n)) {
end = i + n.len_utf8();
if n == '\\'
&& let Some(&next) = cs.get(i + 1)
&& let Some((j, next)) = cs.next()
{
end = j + next.len_utf8();
name.push(next);
i += 2;
continue;
} else {
name.push(n);
}
name.push(n);
i += 1;
}
out.push_str(&format!("AFMakeNumber(this.getField({}).value)", js_str(&name)));
out.push((start..end, Notation::Name(name)));
}
}
out
}
/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names
/// (`\` escapes spaces and operators in names, as Acrobat writes them).
/// A field name as simplified field notation writes it: `\` before a space, an operator or a
/// `\`, and before a first character that would start a number.
fn notation_name(name: &str) -> String {
let mut out = String::new();
for (i, c) in name.chars().enumerate() {
if c.is_whitespace() || "+-*/()\\".contains(c) || (i == 0 && (c.is_ascii_digit() || c == '.')) {
out.push('\\');
}
out.push(c);
}
out
}
/// `src` with each range replaced. The ranges come in order and don't overlap; one that does
/// is left out rather than written over another.
fn splice(src: &str, edits: impl IntoIterator<Item = (Range<usize>, String)>) -> String {
let mut out = String::with_capacity(src.len());
let mut done = 0;
for (range, with) in edits {
let Some(kept) = src.get(done..range.start).filter(|_| range.start <= range.end) else { continue };
out.push_str(kept);
out.push_str(&with);
done = range.end;
}
out.push_str(src.get(done..).unwrap_or_default());
out
}
/// Simplified field notation as the JavaScript Acrobat writes after its `BVCALC` comment:
/// each field name becomes `AFMakeNumber(this.getField("name").value)`, the rest stays.
pub fn notation_js(expr: &str) -> String {
let names = notation_tokens(expr).into_iter().filter_map(|(range, t)| match t {
Notation::Name(name) => Some((range, format!("AFMakeNumber(this.getField({}).value)", js_str(&name)))),
_ => None,
});
splice(expr, names)
}
/// Evaluate simplified field notation: + - * / and parentheses over numbers and field names.
fn eval_notation(src: &str, value_of: &dyn Fn(&str) -> f64) -> Option<f64> {
#[derive(Debug, Clone, PartialEq)]
enum T {
N(f64),
Op(char),
}
let mut toks = Vec::new();
let cs: Vec<char> = src.chars().collect();
let mut i = 0;
while i < cs.len() {
let c = cs[i];
if c.is_whitespace() {
i += 1;
} else if "+-*/()".contains(c) {
toks.push(T::Op(c));
i += 1;
} else if c.is_ascii_digit() || c == '.' {
let s = i;
while i < cs.len() && (cs[i].is_ascii_digit() || cs[i] == '.') {
i += 1;
}
toks.push(T::N(cs[s..i].iter().collect::<String>().parse().ok()?));
} else {
let mut name = String::new();
while i < cs.len() && !(cs[i].is_whitespace() || "+-*/()".contains(cs[i])) {
if cs[i] == '\\' && i + 1 < cs.len() {
i += 1;
}
name.push(cs[i]);
i += 1;
}
toks.push(T::N(value_of(&name)));
}
}
let toks = notation_tokens(src)
.into_iter()
.map(|(range, t)| match t {
Notation::Number => src.get(range)?.parse().ok().map(T::N),
Notation::Op(c) => Some(T::Op(c)),
Notation::Name(name) => Some(T::N(value_of(&name))),
})
.collect::<Option<Vec<T>>>()?;
// Recursive descent, `d` levels deep (at most MAX_NESTING).
fn expr(t: &[T], i: &mut usize, d: usize) -> Option<f64> {
let mut v = term(t, i, d)?;
@@ -1163,6 +1438,98 @@ mod tests {
);
}
#[test]
fn a_rename_rewrites_only_where_a_calculation_reads_the_field() {
let renamed = |js: &str| rename_in_calculation(js, "a", "qty");
// The field lists Acrobat and other tools write for AFSimple_Calculate.
assert_eq!(
renamed("AFSimple_Calculate(\"SUM\", new Array (\"a\", \"b\"));").as_deref(),
Some("AFSimple_Calculate(\"SUM\", new Array (\"qty\", \"b\"));")
);
assert_eq!(renamed("AFSimple_Calculate('AVG', ['b', 'a']);").as_deref(), Some("AFSimple_Calculate('AVG', ['b', \"qty\"]);"));
assert_eq!(renamed("AFSimple_Calculate(\"SUM\", \"a, b,a\");").as_deref(), Some("AFSimple_Calculate(\"SUM\", \"qty, b,qty\");"));
assert_eq!(renamed("AFSimple_Calculate(\"SUM\", new Array(\"ab\", \"b.a\"));"), None, "other names that contain it");
// Notation: the expression in the comment and Acrobat's translation after it.
let notation = |expr: &str| calculate_js(&Calculate::Notation(expr.into())).unwrap();
assert_eq!(rename_in_calculation(&notation("(a + ab) * a / 2"), "a", "Unit Price"), Some(notation("(Unit\\ Price + ab) * Unit\\ Price / 2")));
assert_eq!(rename_in_calculation(&notation("Unit\\ Price * 2"), "Unit Price", "price"), Some(notation("price * 2")));
// Scripts: a getField whose argument is one string literal. Comments, strings, template
// literals, regular expressions and names built at run time are left as written.
let script = "// getField(\"a\")\nvar s = \"getField('a')\", t = `getField(\"a\")`; /* getField(\"a\") */\n\
if (/\"/.test(s)) event.value = this.getField('a').value / 2 * getField(\"a\" + 1).value + getField ( \"ab\" ).value;";
assert_eq!(renamed(script), Some(script.replacen("this.getField('a')", "this.getField(\"qty\")", 1)));
assert_eq!(renamed("event.value = getField(\"b\").value;"), None);
// A new name is escaped as the script needs it.
assert_eq!(
rename_in_calculation("event.value = getField(\"a\").value;", "a", "say \"é\"").as_deref(),
Some("event.value = getField(\"say \\\"\\u00E9\\\"\").value;")
);
// A name that would end the notation early is not written in.
assert_eq!(rename_in_calculation(&notation("a * 2"), "a", "qtyEVCALC"), None);
// Unfinished scripts end their tokens, not the program.
for js in ["getField(\"a", "/* getField(\"a\")", "`getField(\"a\")", "x = /[/", "getField('a\\u12"] {
assert_eq!(renamed(js), None, "{js}");
}
}
/// Scripts are read in one pass: a line of unfinished regular expressions, or calls nested in
/// one another's field lists, don't make a rename read the rest of the script again for each.
#[test]
fn hostile_scripts_are_read_in_one_pass() {
let regexes = format!("x = {} getField(\"a\")", "/[".repeat(500_000));
assert!(rename_in_calculation(&regexes, "a", "b").is_some_and(|js| js.ends_with("getField(\"b\")")));
let nested = format!("{}\"a\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000));
assert_eq!(rename_in_calculation(&nested, "a", "b"), Some(format!("{}\"b\"", "AFSimple_Calculate(\"SUM\", new Array(".repeat(100_000))));
}
/// Notation is read through one lexer: field names with escapes, numbers and operators.
#[test]
fn notation_is_read_by_one_lexer() {
let tokens: Vec<Notation> = notation_tokens("(Unit\\ Price+1.5)*qty_2\\").into_iter().map(|(_, t)| t).collect();
assert_eq!(
tokens,
[
Notation::Op('('),
Notation::Name("Unit Price".into()),
Notation::Op('+'),
Notation::Number,
Notation::Op(')'),
Notation::Op('*'),
Notation::Name("qty_2\\".into())
]
);
assert_eq!(notation_js("2 * a\\ b"), "2 * AFMakeNumber(this.getField(\"a b\").value)");
let values = |name: &str| if name == "Unit Price" { 4.0 } else { 0.5 };
assert_eq!(eval_notation("(Unit\\ Price + 1.5) * qty", &values), Some(2.75));
assert_eq!(eval_notation("1.2.3 + a", &values), None, "a number that isn't one");
assert_eq!(notation_name("2nd + (x)\\"), "\\2nd\\ \\+\\ \\(x\\)\\\\");
}
proptest::proptest! {
/// Any field name written in notation reads back as that one name.
#[test]
fn any_name_written_in_notation_reads_back(name in "\\PC{1,12}") {
let written = notation_name(&name);
proptest::prop_assert_eq!(notation_tokens(&written), vec![(0..written.len(), Notation::Name(name))]);
}
/// Renaming a field in a formula and back gives the formula as it was.
#[test]
fn a_rename_round_trips(old in "\\PC{1,8}", new in "\\PC{1,8}", others in proptest::collection::vec("\\PC{1,8}", 0..4), sum in proptest::bool::ANY) {
proptest::prop_assume!(old != new && !others.contains(&new) && ![&old, &new].into_iter().chain(&others).any(|n| n.contains("VCALC")));
let names: Vec<String> = std::iter::once(old.clone()).chain(others).collect();
let js = if sum {
calculate_js(&Calculate::Simple { op: CalcOp::Sum, fields: names }).unwrap()
} else {
calculate_js(&Calculate::Notation(names.iter().map(|n| notation_name(n)).collect::<Vec<_>>().join(" * "))).unwrap()
};
let renamed = rename_in_calculation(&js, &old, &new);
proptest::prop_assert!(renamed.is_some(), "{js}");
let back = renamed.as_deref().and_then(|r| rename_in_calculation(r, &new, &old));
proptest::prop_assert_eq!(back, Some(js));
}
}
#[test]
fn numbers_format_like_acrobat() {
let n = |d, s, ng, c: &str, p| Format::Number { decimals: d, sep: s, neg: ng, currency: c.into(), prepend: p };
+36
View File
@@ -927,6 +927,10 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
new_name = format!("{prefix}{n}");
doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?;
// Calculations read the field by its new name, unless another field keeps the old one.
if all.iter().filter(|x| x.name == f.name).count() == 1 {
rename_in_calculations(doc, &all, &f.name, &new_name)?;
}
}
// The new default appearance when the size or the look changes.
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
@@ -1216,6 +1220,38 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
Ok(new_name)
}
/// Name field `old` `new` in each field's Calculate script that reads it
/// ([`crate::af::rename_in_calculation`]).
fn rename_in_calculations(doc: &mut Document, all: &[Field], old: &str, new: &str) -> Result<(), FormError> {
let aa_of =
|doc: &Document, r: ObjRef| doc.get(r).as_dict().and_then(|d| d.get(b"AA").map(|a| doc.resolve(a))).and_then(|a| a.as_dict().cloned());
for f in all {
// The script is the field's, or its only widget's when the field has none, as
// `actions_of` reads it.
let only_widget = || match f.widgets.as_slice() {
[w] => aa_of(doc, w.obj).map(|aa| (w.obj, aa)),
_ => None,
};
let Some((owner, mut aa)) = aa_of(doc, f.obj).map(|aa| (f.obj, aa)).or_else(only_widget) else { continue };
let Some(mut action) = aa.get(b"C").and_then(|a| doc.resolve(a).as_dict().cloned()) else { continue };
let js = match action.get(b"JS").map(|js| doc.resolve(js)).as_deref() {
Some(Object::String(s)) => s.to_text(),
// A stream that isn't UTF-8 reads with replacement characters, which must not be
// written back in place of what it holds.
Some(Object::Stream(s)) => match s.decoded().ok().and_then(|b| String::from_utf8(b).ok()) {
Some(js) => js,
None => continue,
},
_ => continue,
};
let Some(js) = crate::af::rename_in_calculation(&js, old, new) else { continue };
action.set(b"JS".to_vec(), PdfString::text(&js));
aa.set(b"C".to_vec(), Object::Dict(action));
doc.update_dict(owner, |d| d.set(b"AA".to_vec(), Object::Dict(aa)))?;
}
Ok(())
}
/// Delete a field: its widgets leave their pages and the field leaves the form.
pub fn delete_field(doc: &mut Document, name: &str) -> Result<(), FormError> {
let all = fields(doc);
+11
View File
@@ -970,6 +970,17 @@ pub fn recalculate_with(doc: &mut Document, scripts: &mut dyn Scripts) -> Result
Ok(changed)
}
/// The fields whose calculation differs between two readings of a form, matched by field object
/// and named as in `after`: the formulas an edit rewrote, such as a rename's.
pub fn calculations_changed(before: &[Field], after: &[Field]) -> Vec<String> {
let before: std::collections::HashMap<ObjRef, &af::Actions> = before.iter().map(|f| (f.obj, &f.actions)).collect();
after
.iter()
.filter(|f| before.get(&f.obj).is_some_and(|b| b.calculate != f.actions.calculate || b.scripts.calculate != f.actions.scripts.calculate))
.map(|f| f.name.clone())
.collect()
}
fn invalid<T>(m: impl Into<String>) -> Result<T, FormError> {
Err(FormError::Invalid(m.into()))
}
+78
View File
@@ -1048,6 +1048,84 @@ fn formats_validation_and_calculations_run_like_acrobat() {
assert_eq!(field(&fields(&doc), "Price").actions.format, Format::None);
}
/// Renaming a field renames it in the calculations that read it: a sum dropped it, notation
/// read it as 0 and a script read a field that no longer existed.
#[test]
fn renaming_a_field_renames_it_in_the_calculations_that_read_it() {
use crate::af::{CalcOp, Calculate};
let mut doc = one_page();
let text = NewField::Text { multiline: false };
for (i, name) in ["a", "b", "sum", "product", "script"].into_iter().enumerate() {
let y = 700.0 - 40.0 * i as f64;
add_field(&mut doc, 0, [50.0, y, 250.0, y + 20.0], &text, Some(name)).unwrap();
}
let calculate = |c: Calculate| FieldProps { calculate: Some(c), ..FieldProps::default() };
set_props(&mut doc, "sum", &calculate(Calculate::Simple { op: CalcOp::Sum, fields: vec!["a".into(), "b".into()] })).unwrap();
set_props(&mut doc, "product", &calculate(Calculate::Notation("a * b + ab".into()))).unwrap();
set_field_script(&mut doc, "script", "calculate", Some("event.value = getField(\"a\").value + getField(\"b\").value;")).unwrap();
set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap();
let check = |doc: &Document| {
let all = fields(doc);
assert_eq!(field(&all, "sum").actions.calculate, Calculate::Simple { op: CalcOp::Sum, fields: vec!["qty".into(), "b".into()] });
assert_eq!(field(&all, "product").actions.calculate, Calculate::Notation("qty * b + ab".into()));
let script = field(&all, "script").actions.scripts.calculate.clone();
assert_eq!(script.as_deref(), Some("event.value = getField(\"qty\").value + getField(\"b\").value;"));
};
check(&doc);
let mut doc = reopen(&doc);
check(&doc);
set_value(&mut doc, "qty", &FieldValue::Text("10".into())).unwrap();
set_value(&mut doc, "b", &FieldValue::Text("2".into())).unwrap();
let all = fields(&doc);
assert_eq!((field(&all, "sum").value.clone(), field(&all, "product").value.clone()), (vec!["12".to_string()], vec!["20".to_string()]));
}
/// The rename reaches a script stored as a stream, an indirect action and the only widget's
/// actions; it leaves a script that isn't UTF-8 as it is, and a name another field still has.
#[test]
fn a_rename_reaches_every_stored_form_of_a_calculation() {
let widget = |name: &str, y: u32, rest: &str| {
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
};
let objs: Vec<String> = vec![
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 15 0 R] >>".into(),
"<< /Fields [5 0 R 6 0 R 7 0 R 11 0 R 12 0 R 13 0 R 14 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
widget("a", 700, ""),
widget("stream", 660, "/AA << /C 8 0 R >>"),
widget("bytes", 620, "/AA << /C << /S /JavaScript /JS 10 0 R >> >>"),
"<< /S /JavaScript /JS 9 0 R >>".into(),
"<< /Length 34 >>\nstream\nevent.value = getField(\"a\").value;\nendstream".into(),
"<< /Length 85 /Filter /ASCIIHexDecode >>\nstream\n2F2A20FF202A2F206576656E742E76616C7565203D206765744669656C6428226122292E76616C75653B>\nendstream".into(),
widget("dup", 580, ""),
widget("dup", 540, ""),
widget("both", 500, "/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\\(\"SUM\", new Array\\(\"dup\", \"a\"\\)\\);) >> >>"),
"<< /FT /Tx /T (kid) /Kids [15 0 R] >>".into(),
"<< /Type /Annot /Subtype /Widget /Parent 14 0 R /Rect [50 460 250 480] /P 3 0 R /AA << /C << /S /JavaScript /JS (event.value = getField\\('a'\\).value;) >> >> >>".into(),
];
let mut doc = document(&objs);
let calc = |doc: &Document, name: &str| {
let all = fields(doc);
let f = field(&all, name);
(f.actions.calculate.clone(), f.actions.scripts.calculate.clone().unwrap_or_default())
};
assert!(calc(&doc, "stream").1.contains("getField(\"a\")"), "{:?}", calc(&doc, "stream"));
set_props(&mut doc, "a", &FieldProps { name: Some("qty".into()), ..FieldProps::default() }).unwrap();
let doc = reopen(&doc);
assert_eq!(calc(&doc, "stream").1, "event.value = getField(\"qty\").value;");
assert_eq!(calc(&doc, "kid").1, "event.value = getField(\"qty\").value;");
assert!(calc(&doc, "bytes").1.contains("getField(\"a\")"), "a script that isn't UTF-8 is not written back");
assert_eq!(calc(&doc, "both").0, af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] });
let mut doc = doc;
set_props(&mut doc, "dup", &FieldProps { name: Some("dup2".into()), ..FieldProps::default() }).unwrap();
assert_eq!(
calc(&doc, "both").0,
af::Calculate::Simple { op: af::CalcOp::Sum, fields: vec!["dup".into(), "qty".into()] },
"the other dup keeps the name"
);
}
#[test]
fn new_text_fields_fit_their_box() {
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
+3
View File
@@ -12,6 +12,9 @@ workspace = true
[dependencies]
boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b"] }
# FormCalc Date/Num2Date calendar conversion goes through chrono (no hand-rolled calendar code);
# std-only, so it stays wasm-safe.
chrono = { workspace = true }
[target.'cfg(target_arch = "wasm32")'.dependencies]
boa_engine = { version = "0.22.0", default-features = false, features = ["annex-b", "js"] }
+65 -23
View File
@@ -1838,30 +1838,50 @@ impl Interp<'_> {
// ── built-in functions ──────────────────────────────────────────────────────────────────────
/// `NaiveDate::num_days_from_ce` of 1900-01-01 minus one: a civil date's day count rebased so
/// that 1900-01-01 is FormCalc day 1 (and 1970-01-01 is day 25_568).
const FORMCALC_EPOCH_FROM_CE: i64 = 693_595;
/// Days in one 400-year Gregorian cycle; the calendar repeats after it.
const DAYS_PER_400_YEARS: i64 = 146_097;
/// Days from 1900-01-01 (day 1, as FormCalc counts) to a civil date.
///
/// chrono's `NaiveDate` stops near ±262143, but FormCalc accepts day numbers up to `MAX_DAYS`
/// (years past 270000). The calendar repeats every 400 years, so chrono converts the date
/// within its cycle and whole cycles are added back; the result matches the proleptic Gregorian
/// count for every year. Callers pass dates `parse_date`/`civil_from_days` already validated;
/// anything else reads as 0.
fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
// Howard Hinnant's algorithm, epoch 1970-01-01, shifted to FormCalc's 1900-01-01 = 1.
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (i64::from(m) + 9) % 12;
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let unix = era * 146_097 + doe - 719_468;
unix + 25_568
use chrono::Datelike;
let (cycles, year) = (i128::from(y.div_euclid(400)), y.rem_euclid(400));
i32::try_from(year)
.ok()
.and_then(|year| chrono::NaiveDate::from_ymd_opt(year, m, d))
.and_then(|date| {
let days = i128::from(date.num_days_from_ce()) - i128::from(FORMCALC_EPOCH_FROM_CE) + cycles * i128::from(DAYS_PER_400_YEARS);
i64::try_from(days).ok()
})
.unwrap_or(0)
}
/// The civil date (year, month, day) at FormCalc day number `n` (day 1 is 1900-01-01), for any
/// `n`: chrono converts the day within its 400-year cycle and the cycles are added back as years.
fn civil_from_days(n: i64) -> (i64, u32, u32) {
let z = n - 25_568 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
use chrono::Datelike;
// Days since CE day 0 (0000-12-31), in i128 so no `n` overflows.
let from_ce = i128::from(n) + i128::from(FORMCALC_EPOCH_FROM_CE);
let (cycles, rest) = (from_ce.div_euclid(i128::from(DAYS_PER_400_YEARS)), from_ce.rem_euclid(i128::from(DAYS_PER_400_YEARS)));
// `rest` is 0..146_097, so CE day `rest` lies in years 0..=400 (day 0 is 0000-12-31).
let date = i32::try_from(rest).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
match date {
Some(date) => {
let y = i128::from(date.year()) + cycles * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), date.month(), date.day())
}
// Unreachable: every `rest` in 0..146_097 is a valid CE day.
None => (1900, 1, 1),
}
}
/// Day numbers (and years) beyond this are refused rather than computed.
@@ -1925,10 +1945,15 @@ fn parse_date(text: &str, picture: &str) -> Option<(i64, u32, u32)> {
/// Number of days in month `m` (1-12) of year `y`, leap years included.
pub(crate) fn days_in(y: i64, m: u32) -> u32 {
match m {
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
2 => 28,
4 | 6 | 9 | 11 => 30,
// The length of the month from chrono (the first of the next month, less the first of this
// one). An invalid month keeps the old 31-day default.
// Month lengths repeat every 400 years, so the year within its cycle is enough (and always
// in chrono's range, whatever `y` is).
let y = y.rem_euclid(400);
let first = |y: i64, m: u32| i32::try_from(y).ok().and_then(|y| chrono::NaiveDate::from_ymd_opt(y, m, 1));
let next = if m == 12 { first(y + 1, 1) } else { first(y, m + 1) };
match (first(y, m), next) {
(Some(a), Some(b)) => (b - a).num_days() as u32,
_ => 31,
}
}
@@ -2591,6 +2616,23 @@ mod tests {
assert_eq!(days_from_civil(1900, 1, 1), 1);
assert_eq!(days_from_civil(1970, 1, 1), 25_568);
assert_eq!(civil_from_days(days_from_civil(2024, 2, 29)), (2024, 2, 29));
// Past chrono's ±262143 the 400-year cycles carry the year; values from the pre-chrono
// formula, both directions, both sides of the epoch.
for (n, date) in [
(99_000_000, (272_952, 10, 16)),
(99_999_999, (275_690, 9, 11)),
(-99_000_000, (-269_153, 3, 16)),
(-99_999_999, (-271_891, 4, 20)),
(72_354_905, (200_000, 12, 31)),
(-73_742_460, (-200_000, 1, 1)),
] {
assert_eq!(civil_from_days(n), date, "{n}");
assert_eq!(days_from_civil(date.0, date.1, date.2), n, "{date:?}");
}
assert_eq!(days_in(272_952, 2), 29, "272952 is a leap year");
assert_eq!(days_in(-269_153, 2), 28);
assert_eq!(days_in(i64::MAX, 12), 31);
let _ = (civil_from_days(i64::MAX), civil_from_days(i64::MIN), days_from_civil(i64::MAX, 12, 31), days_from_civil(i64::MIN, 1, 1));
assert_eq!(parse_date("2001-02-03", "YYYY-MM-DD"), Some((2001, 2, 3)));
assert_eq!(parse_date("Feb 3, 2001", "MMM D, YYYY"), Some((2001, 2, 3)));
assert_eq!(parse_date("2/3/01", "M/D/YY"), Some((2001, 2, 3)));
+11
View File
@@ -651,6 +651,17 @@ mod formcalc {
assert_eq!(calc(r#"Num2Date(45350, "YYYY-MM-DD x")"#), "2024-02-29 x");
}
/// Accepted day numbers past chrono's own date range (about year 262143) keep the dates the
/// pre-chrono algorithm gave, on both sides of the epoch, through Num2Date and Format.
#[test]
fn date_numbers_beyond_chronos_range_keep_their_dates() {
assert_eq!(calc(r#"Num2Date(99000000, "YYYY-MM-DD")"#), "272952-10-16");
assert_eq!(calc(r#"Format("YYYY-MM-DD", 99000000)"#), "272952-10-16");
assert_eq!(calc(r#"Num2Date(99999999, "YYYY-MM-DD")"#), "275690-09-11");
assert_eq!(calc(r#"Num2Date(-99000000, "YYYY-MM-DD")"#), "-269153-03-16");
assert_eq!(calc(r#"Num2Date(-99999999, "YYYY-MM-DD")"#), "-271891-04-20");
}
#[test]
fn str_and_format_round_exact_ties_away_from_zero() {
assert_eq!(calc("Str(2.5, 3, 0)"), " 3");
+233 -18
View File
@@ -84,9 +84,19 @@ pub(crate) struct Scope<'a> {
pub report: &'a mut Report,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
/// Sanitize: optional content groups (and membership dictionaries) that are off; content
/// marked with them is removed.
pub hidden_layers: Vec<ObjRef>,
/// Sanitize: optional content groups that are off; content marked with them is removed.
pub hidden_layers: HashSet<ObjRef>,
/// Whether this pass is evaluating optional-content rules. An empty off set is meaningful:
/// policies such as `/AllOff` can still hide content when every group is on.
pub layers_enabled: bool,
/// Off groups referenced by optional content that remains visible and therefore still needs
/// its original default state after sanitization.
pub retained_off_layers: HashSet<ObjRef>,
/// Cache membership results so repeated blocks cannot repeatedly walk hostile expressions.
ocmd_cache: HashMap<ObjRef, bool>,
/// Direct `/OC` dictionaries on XObjects have no object reference of their own. Cache them
/// by the containing XObject so repeated `Do` calls share the same bounded evaluation.
xobject_oc_cache: HashMap<ObjRef, bool>,
/// Content removed from hidden layers (blocks and XObjects).
pub layer_blocks: usize,
fonts: HashMap<Vec<u8>, Rc<Metrics>>,
@@ -101,7 +111,11 @@ impl<'a> Scope<'a> {
mode,
report,
hidden_text: None,
hidden_layers: Vec::new(),
hidden_layers: HashSet::new(),
layers_enabled: false,
retained_off_layers: HashSet::new(),
ocmd_cache: HashMap::new(),
xobject_oc_cache: HashMap::new(),
layer_blocks: 0,
fonts: HashMap::new(),
used_names: Vec::new(),
@@ -111,20 +125,26 @@ impl<'a> Scope<'a> {
/// Sanitizing visits every form XObject (not only those under a region).
fn everywhere(&self) -> bool {
self.hidden_text.is_some() || !self.hidden_layers.is_empty()
self.hidden_text.is_some() || self.layers_enabled
}
/// Is this optional-content reference (an OCG or OCMD) hidden?
fn layer_hidden(&self, doc: &Document, o: &Object) -> bool {
if self.hidden_layers.is_empty() {
fn layer_hidden(&mut self, doc: &Document, o: &Object) -> bool {
if !self.layers_enabled {
return false;
}
if let Some(r) = o.as_ref()
&& self.hidden_layers.contains(&r)
{
return true;
let reference = o.as_ref();
if let Some(r) = reference {
if let Some(hidden) = self.ocmd_cache.get(&r) {
return *hidden;
}
if self.hidden_layers.contains(&r) {
self.ocmd_cache.insert(r, true);
return true;
}
}
// A membership dictionary (default policy AnyOn): hidden when all its groups are.
// Evaluate the policy against the same default configuration used to select hidden
// groups. A membership dictionary's default policy is AnyOn.
let d = doc.resolve(o);
let Some(d) = d.as_dict() else { return false };
if d.name(b"Type") != Some(b"OCMD") {
@@ -135,7 +155,188 @@ impl<'a> Scope<'a> {
Some(_) => d.get(b"OCGs").and_then(Object::as_ref).into_iter().collect(),
None => Vec::new(),
};
!groups.is_empty() && groups.iter().all(|g| self.hidden_layers.contains(g))
let fail_closed = || groups.iter().any(|g| self.hidden_layers.contains(g));
let hidden = if let Some(expression) = d.get(b"VE") {
let visible = self.visibility_expression(doc, expression, 0, &mut HashSet::new(), &mut 4096);
match visible {
Some(visible) => !visible,
None => fail_closed() || self.expression_mentions_hidden(doc, expression, 0, &mut HashSet::new(), &mut 4096),
}
} else if groups.is_empty() {
false
} else {
let any_on = groups.iter().any(|g| !self.hidden_layers.contains(g));
let all_on = groups.iter().all(|g| !self.hidden_layers.contains(g));
let policy = d.get(b"P").map(|p| doc.resolve(p)).and_then(|p| p.as_name().map(|name| name.to_vec()));
match (d.get(b"P"), policy.as_deref()) {
(None, _) | (Some(_), Some(b"AnyOn")) => !any_on,
(Some(_), Some(b"AllOn")) => !all_on,
(Some(_), Some(b"AnyOff")) => all_on,
(Some(_), Some(b"AllOff")) => any_on,
// An invalid policy must not make a candidate hidden block survive sanitization.
_ => fail_closed(),
}
};
if !hidden {
self.retained_off_layers.extend(groups.iter().filter(|g| self.hidden_layers.contains(g)).copied());
if let Some(expression) = d.get(b"VE") {
let mut referenced = HashSet::new();
if Self::collect_hidden_expression_groups(doc, expression, &self.hidden_layers, 0, &mut HashSet::new(), &mut 4096, &mut referenced) {
self.retained_off_layers.extend(referenced);
} else {
// If an expression cannot be fully walked, preserve every off group so that
// the content we kept does not change visibility after configuration cleanup.
self.retained_off_layers.extend(self.hidden_layers.iter().copied());
}
}
}
if let Some(r) = reference {
self.ocmd_cache.insert(r, hidden);
}
hidden
}
fn xobject_layer_hidden(&mut self, doc: &Document, xobject: ObjRef, oc: &Object) -> bool {
if oc.as_ref().is_some() {
return self.layer_hidden(doc, oc);
}
if let Some(hidden) = self.xobject_oc_cache.get(&xobject) {
return *hidden;
}
let hidden = self.layer_hidden(doc, oc);
self.xobject_oc_cache.insert(xobject, hidden);
hidden
}
fn collect_hidden_expression_groups(
doc: &Document,
expression: &Object,
hidden_layers: &HashSet<ObjRef>,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
found: &mut HashSet<ObjRef>,
) -> bool {
if depth > 64 || *budget == 0 {
return false;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return false;
}
let resolved = doc.resolve(expression);
let complete = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => {
if let Some(r) = reference
&& hidden_layers.contains(&r)
{
found.insert(r);
}
reference.is_some()
}
Object::Array(items) => {
let Some(Object::Name(operator)) = items.first() else { return false };
let operands = match operator.as_slice() {
b"And" | b"Or" if (2..=4097).contains(&items.len()) => items.get(1..),
b"Not" if items.len() == 2 => items.get(1..),
_ => None,
};
operands.is_some_and(|operands| {
operands.iter().all(|item| Self::collect_hidden_expression_groups(doc, item, hidden_layers, depth + 1, path, budget, found))
})
}
_ => false,
};
if let Some(r) = reference {
path.remove(&r);
}
complete
}
/// Evaluate an optional-content visibility expression (`/VE`) against the default config.
/// Malformed, cyclic, or excessive expressions return `None` so the caller can fail closed.
fn visibility_expression(
&self,
doc: &Document,
expression: &Object,
depth: usize,
path: &mut HashSet<ObjRef>,
budget: &mut usize,
) -> Option<bool> {
if depth > 64 || *budget == 0 {
return None;
}
*budget -= 1;
let reference = expression.as_ref();
if let Some(r) = reference
&& !path.insert(r)
{
return None;
}
let resolved = doc.resolve(expression);
let result = match &*resolved {
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => reference.map(|r| !self.hidden_layers.contains(&r)),
Object::Array(items) if (2..=4097).contains(&items.len()) => {
let operator = match items.first() {
Some(Object::Name(n)) => n.as_slice(),
_ => return None,
};
match operator {
b"And" if items.len() >= 2 => {
let mut visible = true;
for item in items.iter().skip(1) {
visible &= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Or" if items.len() >= 2 => {
let mut visible = false;
for item in items.iter().skip(1) {
visible |= self.visibility_expression(doc, item, depth + 1, path, budget)?;
}
Some(visible)
}
b"Not" if items.len() == 2 => Some(!self.visibility_expression(doc, &items[1], depth + 1, path, budget)?),
_ => None,
}
}
_ => None,
};
if let Some(r) = reference {
path.remove(&r);
}
result
}
/// Conservatively find hidden groups in an expression that could not be evaluated. Unknown
/// syntax, cycles, and exhausted budgets count as a possible hidden reference.
fn expression_mentions_hidden(&self, doc: &Document, expression: &Object, depth: usize, path: &mut HashSet<ObjRef>, budget: &mut usize) -> bool {
if depth > 64 || *budget == 0 {
return true;
}
*budget -= 1;
if let Some(r) = expression.as_ref() {
if self.hidden_layers.contains(&r) {
return true;
}
if !path.insert(r) {
return true;
}
}
let resolved = doc.resolve(expression);
let found = match &*resolved {
Object::Array(items) => items.iter().any(|item| self.expression_mentions_hidden(doc, item, depth + 1, path, budget)),
Object::Dict(d) if d.name(b"Type") == Some(b"OCG") => expression.as_ref().is_none_or(|r| self.hidden_layers.contains(&r)),
Object::Dict(_) => true,
_ => false,
};
if let Some(r) = expression.as_ref() {
path.remove(&r);
}
found
}
fn hits(&self, b: [f64; 4]) -> bool {
@@ -534,7 +735,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<
// Layer removal also changes enclosing replacement spans, so remember the operators before
// any cleanup, not just the survivors of the layer pass.
let original = parsed.ops.clone();
if !scope.hidden_layers.is_empty() {
if scope.layers_enabled {
let props = res_dict(doc, resources, b"Properties");
let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props);
parsed.ops = kept;
@@ -928,11 +1129,13 @@ fn xobject(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { return None };
match s.dict.name(b"Subtype") {
_ if s.dict.get(b"OC").is_some_and(|oc| scope.layer_hidden(doc, oc)) => {
_ if s.dict.get(b"OC").is_some_and(|oc| scope.xobject_layer_hidden(doc, r, oc)) => {
// Verify mode must count hidden XObjects too: scan uses that count to decide
// whether the HiddenLayers pass is necessary.
scope.layer_blocks += 1;
if scope.mode == Mode::Verify {
return None;
}
scope.layer_blocks += 1;
out.replaced.push(name);
out.doomed.push(r);
Some(None)
@@ -983,6 +1186,11 @@ fn xobject(
}
if scope.depth >= MAX_DEPTH {
if scope.mode == Mode::Verify {
// Sanitization removes a Form it cannot enter, so the verification scan
// must count that conservative removal or it may decide to skip the pass.
if scope.everywhere() {
scope.layer_blocks += 1;
}
return None;
}
scope.report.forms_removed += 1;
@@ -998,7 +1206,14 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
Err(_) => return None,
Err(_) => {
// Apply drops an undecodable Form while sanitizing. Count it during Verify
// so a zero-count preflight cannot leave its potentially hidden bytes behind.
if scope.everywhere() {
scope.layer_blocks += 1;
}
return None;
}
};
let own = s.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned());
let res = own.clone().unwrap_or_else(|| resources.clone());
@@ -1073,7 +1288,7 @@ fn xobject(
/// Remove marked-content blocks of hidden layers (`/OC /name BDC … EMC`, nested blocks
/// included). Returns the remaining operators and how many blocks went.
fn strip_hidden_layers(doc: &Document, scope: &Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
fn strip_hidden_layers(doc: &Document, scope: &mut Scope<'_>, ops: Vec<Op>, props: &Dict) -> (Vec<Op>, usize) {
let mut out = Vec::with_capacity(ops.len());
// For each open marked-content block: does it hide its contents?
let mut stack: Vec<bool> = Vec::new();
+20 -12
View File
@@ -138,12 +138,13 @@ fn hidden_layers(doc: &Document) -> Vec<ObjRef> {
}
/// Glyphs of hidden text, or content of hidden layers, on every page (`write` = remove them).
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<usize, RedactError> {
fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Result<(usize, std::collections::HashSet<ObjRef>), RedactError> {
let off = if layers { hidden_layers(doc) } else { Vec::new() };
if !text && off.is_empty() {
return Ok(0);
if !text && !layers {
return Ok((0, std::collections::HashSet::new()));
}
let mut total = 0;
let mut retained_off_layers = std::collections::HashSet::new();
// The original image and form XObjects the rewrite drops or replaces, on any page.
let mut doomed = Vec::new();
let mut retired_properties = Vec::new();
@@ -164,13 +165,18 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
}
scope.hidden_layers = off.clone();
scope.hidden_layers = off.iter().copied().collect();
scope.layers_enabled = layers;
let out = process(doc, &mut scope, &data, &resources, Matrix::IDENTITY);
if let Some(error) = out.error {
return Err(error.into());
}
let blocks = scope.layer_blocks;
total += out.residue + blocks + report.glyphs;
let glyphs = scope.report.glyphs;
let retained_for_page = std::mem::take(&mut scope.retained_off_layers);
drop(scope);
total += out.residue + blocks + glyphs;
retained_off_layers.extend(retained_for_page);
if !write {
continue;
}
@@ -216,7 +222,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
// What a page no longer draws must not stay reachable through an inherited or shared
// /Resources either.
crate::interp::unlink_doomed(doc, &doomed, &retired_properties)?;
Ok(total)
Ok((total, retained_off_layers))
}
/// How many items each category would remove (categories with nothing are included as 0).
@@ -239,10 +245,10 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
Hidden::Attachments => sub(doc, &names, b"EmbeddedFiles").map_or(0, |t| name_tree_len(doc, &t, 0)) + count_sub(b"FileAttachment"),
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).unwrap_or(0),
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
Hidden::HiddenLayers => {
let off = hidden_layers(doc).len();
if off == 0 { 0 } else { off + content_pass(&mut doc2, false, true, false).unwrap_or(0) }
off + content_pass(&mut doc2, false, true, false).map(|(n, _)| n).unwrap_or(0)
}
Hidden::Bookmarks => sub(doc, &cat, b"Outlines").map_or(0, |o| outline_len(doc, o.get(b"First"), 0)),
Hidden::LinksActionsScripts => {
@@ -274,9 +280,11 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
let mut done = Vec::new();
// Content first (it needs the layers and the form still in place).
let (text, layers) = (which.contains(&Hidden::HiddenText), which.contains(&Hidden::HiddenLayers));
if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?;
}
let retained_off_layers = if (text && count(Hidden::HiddenText) > 0) || (layers && count(Hidden::HiddenLayers) > 0) {
content_pass(doc, text, layers, true)?.1
} else {
std::collections::HashSet::new()
};
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
// widgets included) leaves the structure tree too.
let listed = |doc: &Document| -> std::collections::HashSet<ObjRef> {
@@ -394,7 +402,7 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
Hidden::HiddenLayers if !off.is_empty() => {
// The off layers leave the configuration (their content is gone already).
if let Some(mut oc) = sub(doc, &cat, b"OCProperties") {
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r));
let keep = |o: &Object| !o.as_ref().is_some_and(|r| off.contains(&r) && !retained_off_layers.contains(&r));
if let Some(Object::Array(a)) = oc.get(b"OCGs").map(|g| (*doc.resolve(g)).clone()) {
oc.set(b"OCGs".to_vec(), Object::Array(a.into_iter().filter(keep).collect()));
}
+414
View File
@@ -492,6 +492,420 @@ fn a_hidden_layers_image_does_not_survive_through_inherited_resources() {
assert!(!bytes.windows(16).any(|w| w == b"HIDDENLAYERIMAGE"), "the off layer's image survived the save");
}
#[test]
fn hidden_ocmd_all_on_content_is_removed_before_its_off_group_is_unlinked() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"BT /F1 10 Tf 10 200 Td (PUBLIC) Tj ET /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(pdfcraft_cos::Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
let before = pdfcraft_edit::text_lines(&doc, 0).unwrap();
assert!(before.iter().any(|line| line.text.contains("PUBLIC")));
assert!(before.iter().any(|line| line.text.contains("SECRET")), "extractor sees source text before sanitization");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let lines = pdfcraft_edit::text_lines(&saved, 0).unwrap();
assert!(lines.iter().any(|line| line.text.contains("PUBLIC")), "visible content remains: {lines:?}");
assert!(!lines.iter().any(|line| line.text.contains("SECRET")), "hidden text is extractable: {lines:?}");
assert!(!write_full(&saved, &SaveOptions::default()).unwrap().windows(6).any(|w| w == b"SECRET"));
}
#[test]
fn hidden_ocmd_content_in_a_form_and_nested_marked_blocks_is_removed() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/Fm Do",
"/Properties << /M 9 0 R >> /XObject << /Fm 10 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /Properties << /M 9 0 R >> >>",
b"/OC /M BDC /OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC EMC",
),
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn hidden_ocmd_alloff_is_sanitized_when_every_group_is_on() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Unused) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>".to_vec(),
],
);
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
#[test]
fn hidden_ve_not_is_sanitized_when_its_group_is_on() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![b"<< /Type /OCG /Name (On) >>".to_vec(), b"<< /Type /OCG /Name (Unused) >>".to_vec(), b"<< /Type /OCMD /VE [/Not 7 0 R] >>".to_vec()],
);
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(!pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET")));
}
fn all_on_ocmd_xobject(subtype: &str, policy: &str) -> Document {
let membership = match policy {
"AllOff" => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
"Not" => "<< /Type /OCMD /VE [/Not 7 0 R] >>",
_ => "<< /Type /OCMD /OCGs [7 0 R] /P /AllOff >>",
};
let (object_number, oc) = if policy == "DirectAllOff" { (8, membership) } else { (9, "8 0 R") };
let xobject = match subtype {
"Image" => stream(
&format!("/Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /OC {oc}"),
b"PRIVATE_HIDDEN_XOBJECT_IMAGE",
),
_ => stream(
&format!("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> >> /OC {oc}"),
b"BT /F1 10 Tf 10 100 Td (PRIVATE_HIDDEN_XOBJECT_FORM) Tj ET",
),
};
let mut extras = vec![b"<< /Type /OCG /Name (On) >>".to_vec()];
if object_number == 9 {
extras.push(membership.as_bytes().to_vec());
}
extras.push(xobject);
let mut doc = one_page(b"/X Do", &format!("/XObject << /X {object_number} 0 R >>"), extras);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"BaseState".to_vec(), Object::name("ON"))]))),
])),
);
})
.unwrap();
doc
}
#[test]
fn hidden_all_on_ocmd_image_and_form_xobjects_are_counted_and_removed() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut failures = Vec::new();
for subtype in ["Image", "Form"] {
for policy in ["AllOff", "Not"] {
let mut doc = all_on_ocmd_xobject(subtype, policy);
let before = scan(&doc);
let hidden_count = before.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map_or(0, |(_, n)| *n);
if hidden_count == 0 {
failures.push(format!("{subtype} /{policy} was not counted: {before:?}"));
}
match remove_hidden(&mut doc, &[Hidden::HiddenLayers]) {
Ok(removed) if removed.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0) => {}
Ok(removed) => failures.push(format!("{subtype} /{policy} remove report is empty: {removed:?}")),
Err(error) => failures.push(format!("{subtype} /{policy} remove failed: {error}")),
}
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_IMAGE".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_IMAGE") {
failures.push(format!("{subtype} /{policy} image payload remains in full save"));
}
if bytes.windows(b"PRIVATE_HIDDEN_XOBJECT_FORM".len()).any(|w| w == b"PRIVATE_HIDDEN_XOBJECT_FORM") {
failures.push(format!("{subtype} /{policy} Form payload remains in full save"));
}
let saved = Document::open(Arc::new(bytes)).unwrap();
if pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("PRIVATE_HIDDEN_XOBJECT_FORM")) {
failures.push(format!("{subtype} /{policy} Form text remains extractable"));
}
if !pdfcraft_edit::page_images(&saved, 0).unwrap().is_empty() {
failures.push(format!("{subtype} /{policy} image remains on the page"));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn repeated_image_with_direct_alloff_membership_is_counted_per_draw() {
use crate::sanitize::{Hidden, scan};
let mut doc = all_on_ocmd_xobject("Image", "DirectAllOff");
let page = pdfcraft_model::pages(&doc)[0].obj;
let contents = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"/X Do /X Do /X Do")));
doc.update_dict(page, |d| d.set(b"Contents".to_vec(), Object::Ref(contents))).unwrap();
let original = write_full(&doc, &SaveOptions::default()).unwrap();
let counts = scan(&doc);
assert_eq!(counts.iter().find(|(h, _)| *h == Hidden::HiddenLayers).map(|(_, n)| *n), Some(3), "{counts:?}");
assert_eq!(write_full(&doc, &SaveOptions::default()).unwrap(), original, "Verify scan must not mutate the document");
}
#[test]
fn undecodable_form_is_counted_and_removed_by_hidden_layer_sanitization() {
use crate::sanitize::{Hidden, remove_hidden, scan};
let mut doc = one_page(
b"/Bad Do",
"/XObject << /Bad 7 0 R >>",
vec![stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Filter /UnknownFilter", b"PRIVATE_UNDECODABLE_FORM")],
);
let counts = scan(&doc);
assert!(counts.iter().any(|(h, n)| *h == Hidden::HiddenLayers && *n > 0), "{counts:?}");
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(b"PRIVATE_UNDECODABLE_FORM".len()).any(|w| w == b"PRIVATE_UNDECODABLE_FORM"));
}
#[test]
fn form_chain_at_max_depth_is_counted_and_removed_by_hidden_layer_sanitization() {
use crate::sanitize::{Hidden, remove_hidden, scan};
const PRIVATE: &[u8] = b"PRIVATE_FORM_BEYOND_MAX_DEPTH";
// MAX_DEPTH is 12. The final Form is encountered at that depth and is removed
// conservatively without being entered. Verify must count that removal or sanitize
// can skip the HiddenLayers pass entirely.
let mut forms = Vec::new();
for depth in 0..=12 {
let content = if depth == 12 {
b"BT /F1 10 Tf 10 100 Td (PRIVATE_FORM_BEYOND_MAX_DEPTH) Tj ET".to_vec()
} else {
format!("/F{} Do", depth + 1).into_bytes()
};
forms.push(stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", &content));
}
let xobjects = (0..=12).map(|depth| format!("/F{depth} {} 0 R", 7 + depth)).collect::<Vec<_>>().join(" ");
let mut doc = one_page(b"/F0 Do", &format!("/XObject << {xobjects} >>"), forms);
assert!(
write_full(&doc, &SaveOptions::default()).unwrap().windows(PRIVATE.len()).any(|w| w == PRIVATE),
"fixture lacked nested payload before sanitization"
);
let counts = scan(&doc);
assert!(counts.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0), "depth-limited Form was not counted: {counts:?}");
let removed = remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
assert!(
removed.iter().any(|(hidden, count)| *hidden == Hidden::HiddenLayers && *count > 0),
"depth-limited Form was not reported removed: {removed:?}"
);
let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
assert!(!bytes.windows(PRIVATE.len()).any(|w| w == PRIVATE), "depth-limited Form payload remains in full save");
}
#[test]
fn hidden_ocmd_resolves_an_indirect_policy_name() {
use crate::sanitize::{Hidden, remove_hidden};
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P 12 0 R >>".to_vec(),
],
);
doc.set(ObjRef::new(12, 0), Object::name("AnyOn"));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
}
#[test]
fn surviving_anyoff_and_not_content_keeps_its_off_group_state() {
use crate::sanitize::{Hidden, remove_hidden};
for use_not in [false, true] {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (VISIBLE) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
if use_not { b"<< /Type /OCMD /VE [/Not 8 0 R] >>".to_vec() } else { b"<< /Type /OCMD /OCGs [8 0 R] /P /AnyOff >>".to_vec() },
],
);
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"D".to_vec(), Object::Dict(Dict::from_iter([(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))]))]))),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let root = doc.root().unwrap();
let catalog = doc.get(root);
let oc = catalog.as_dict().and_then(|d| d.get(b"OCProperties")).map(|o| doc.resolve(o));
let default = oc.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"D")).map(|o| doc.resolve(o));
let off = default.as_deref().and_then(|o| o.as_dict()).and_then(|d| d.get(b"OFF")).map(|o| doc.resolve(o));
assert!(
off.as_deref().and_then(Object::as_array).is_some_and(|a| a.iter().any(|o| o.as_ref() == Some(ObjRef::new(8, 0)))),
"off group lost for /VE Not={use_not}"
);
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert!(pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("VISIBLE")));
}
}
#[test]
fn hidden_ocmd_policy_matches_the_default_configuration() {
use crate::sanitize::{Hidden, remove_hidden};
let cases = [
(None, false, None, false),
(Some("AnyOn"), false, None, false),
(Some("AllOn"), false, None, true),
(Some("AnyOff"), false, None, false),
(Some("AllOff"), false, None, true),
(Some("UnknownPolicy"), false, None, true),
(None, true, None, true),
(None, false, Some("and"), true),
(None, false, Some("and_single_not"), true),
(None, false, Some("and_single_off"), true),
(None, false, Some("or_single_on"), false),
(None, false, Some("or_single_off"), true),
(Some("AllOn"), false, Some("not_off"), false),
(None, false, Some("malformed_not"), true),
(None, false, Some("malformed_without_ocgs"), true),
];
for (policy, malformed_policy, visibility, should_remove) in cases {
let mut doc = one_page(
b"/OC /M BDC BT /F1 10 Tf 10 100 Td (SECRET) Tj ET EMC",
"/Properties << /M 9 0 R >>",
vec![
b"<< /Type /OCG /Name (On) >>".to_vec(),
b"<< /Type /OCG /Name (Off) >>".to_vec(),
b"<< /Type /OCMD /OCGs [7 0 R 8 0 R] /P /AllOn >>".to_vec(),
],
);
let mut membership = Dict::new();
membership.set(b"Type".to_vec(), Object::name("OCMD"));
if visibility != Some("malformed_without_ocgs") {
membership.set(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))]));
}
if let Some(policy) = policy {
membership.set(b"P".to_vec(), Object::name(policy));
} else if malformed_policy {
membership.set(b"P".to_vec(), Object::Int(17));
}
match visibility {
Some("and") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("and_single_not") => membership.set(
b"VE".to_vec(),
Object::Array(vec![Object::name("Not"), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(7, 0))])]),
),
Some("and_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("And"), Object::Ref(ObjRef::new(8, 0))])),
Some("or_single_on") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(7, 0))])),
Some("or_single_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Or"), Object::Ref(ObjRef::new(8, 0))])),
Some("not_off") => membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_not") => membership
.set(b"VE".to_vec(), Object::Array(vec![Object::name("Not"), Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
Some("malformed_without_ocgs") => {
membership.set(b"VE".to_vec(), Object::Array(vec![Object::name("UnknownOperator"), Object::Ref(ObjRef::new(8, 0))]))
}
_ => {}
}
doc.set(ObjRef::new(9, 0), Object::Dict(membership));
let root = doc.root().unwrap();
doc.update_dict(root, |d| {
d.set(
b"OCProperties".to_vec(),
Object::Dict(Dict::from_iter([
(b"OCGs".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(
b"D".to_vec(),
Object::Dict(Dict::from_iter([
(b"Order".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(7, 0)), Object::Ref(ObjRef::new(8, 0))])),
(b"OFF".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(8, 0))])),
])),
),
])),
);
})
.unwrap();
remove_hidden(&mut doc, &[Hidden::HiddenLayers]).unwrap();
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
let has_secret = pdfcraft_edit::text_lines(&saved, 0).unwrap().iter().any(|line| line.text.contains("SECRET"));
assert_eq!(has_secret, !should_remove, "policy {policy:?}, malformed={malformed_policy}, VE={visibility:?}");
}
}
#[test]
fn redacting_an_xfa_field_drops_the_xfa_packet_so_its_value_cannot_survive() {
// A static AcroForm+XFA form whose field value lives both in the widget /V and in the XFA
+95 -2
View File
@@ -1553,13 +1553,24 @@ mod tests {
]);
shared_bad.entered.wait();
shared_healthy.entered.wait();
assert!(pool.try_recv().is_none(), "shared timeouts retry instead of answering yet");
// This watchdog pass retries both pages privately. Page 1's retry has no gate, so on a
// loaded machine it can finish before this same call reads the results: that early
// answer is the healthy page, never a timeout error.
let early = pool.try_recv();
assert!(
early.as_ref().is_none_or(|page| page.request.page == 1 && page.error.is_none()),
"shared timeouts retry instead of answering with an error: {:?}",
early.as_ref().map(|page| (page.request.page, &page.error))
);
shared_bad.release.wait();
shared_healthy.release.wait();
shared_bad.finished.wait();
shared_healthy.finished.wait();
private_bad.entered.wait();
let healthy = pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry");
let healthy = match early {
Some(page) => page,
None => pool.results.recv_timeout(Duration::from_secs(30)).expect("healthy private retry"),
};
assert!(healthy.error.is_none(), "{:?}", healthy.error);
assert_eq!((healthy.request.page, healthy.width, healthy.height, healthy.request.tag), (1, 100, 50, 41));
pool.watchdog();
@@ -4601,6 +4612,88 @@ trailer << /Root 1 0 R >>
pdf
}
/// A document whose `pages` page dictionaries all live in one (uncompressed) object stream,
/// found through a cross-reference stream: every page shows the content `0 0 1 rg 10 10 80 80
/// re f` on a 100 pt square, except the last, which is 50 × 100 pt.
fn pages_in_one_object_stream(pages: usize) -> Vec<u8> {
// 1 catalog, 2 page tree, 3 content, 4 the object stream, 5.. the pages, then the xref.
let page = |n: usize| {
let size = if n + 1 == pages { "50 100" } else { "100 100" };
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {size}] /Contents 3 0 R >>\n")
};
let (mut index, mut body) = (String::new(), String::new());
for n in 0..pages {
index.push_str(&format!("{} {} ", n + 5, body.len()));
body.push_str(&page(n));
}
index.push('\n');
let kids: Vec<String> = (0..pages).map(|n| format!("{} 0 R", n + 5)).collect();
let content = "0 0 1 rg 10 10 80 80 re f";
let mut pdf = b"%PDF-1.5\n".to_vec();
let mut offsets = Vec::new();
for (num, obj) in [
(1, "<< /Type /Catalog /Pages 2 0 R >>".to_string()),
(2, format!("<< /Type /Pages /Kids [{}] /Count {pages} >>", kids.join(" "))),
(3, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())),
(
4,
format!(
"<< /Type /ObjStm /N {pages} /First {} /Length {} >>\nstream\n{index}{body}\nendstream",
index.len(),
index.len() + body.len()
),
),
] {
offsets.push(pdf.len());
pdf.extend_from_slice(format!("{num} 0 obj\n{obj}\nendobj\n").as_bytes());
}
// Cross-reference stream rows of /W [1 4 4]: free 0, objects 1–4 in the file, the pages
// at their index in object 4, and the cross-reference stream itself.
let xref_num = pages + 5;
let xref_at = pdf.len();
let mut rows = vec![0u8, 0, 0, 0, 0, 0, 0, 0xff, 0xff];
let row = |kind: u8, a: usize, b: usize| [[kind].as_slice(), &(a as u32).to_be_bytes(), &(b as u32).to_be_bytes()].concat();
for at in &offsets {
rows.extend(row(1, *at, 0));
}
for n in 0..pages {
rows.extend(row(2, 4, n));
}
rows.extend(row(1, xref_at, 0));
pdf.extend_from_slice(
format!("{xref_num} 0 obj\n<< /Type /XRef /Size {} /W [1 4 4] /Root 1 0 R /Length {} >>\nstream\n", xref_num + 1, rows.len()).as_bytes(),
);
pdf.extend_from_slice(&rows);
pdf.extend_from_slice(format!("\nendstream\nendobj\nstartxref\n{xref_at}\n%%EOF\n").as_bytes());
pdf
}
/// An object stream's index of `objnum offset` pairs is read once, not for every object
/// fetched from it (vendored hayro-syntax patch 22). Read again each time, all of a stream of
/// N objects took N² steps: a Firefox page keeping 65,542 objects in one stream took 37 s to
/// draw and 40 s to extract its text. These 40,000 pages in one stream took about a minute
/// to open that way; now a fraction of a second. The last page, at the end of the index,
/// still has its own size.
#[test]
fn objects_in_one_large_object_stream_are_read_in_linear_time() {
const PAGES: usize = 40_000;
let pdf = pages_in_one_object_stream(PAGES);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let mut r = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
let first = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
let last = r.render(RenderRequest { page: PAGES - 1, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
let _ = tx.send((first, last));
});
let (first, last) =
rx.recv_timeout(std::time::Duration::from_secs(20)).expect("reading every object of one object stream must not take quadratic time");
assert!(first.error.is_none(), "{:?}", first.error);
assert_eq!((first.width, first.height), (100, 100));
assert_eq!(rgb_at(&first.rgba, first.width as usize, 50, 50), [0, 0, 255]);
assert!(last.error.is_none(), "{:?}", last.error);
assert_eq!((last.width, last.height), (50, 100), "the last page is read from its own offset");
}
/// A page whose content expands far beyond its compressed stream stops at the expansion budget,
/// which is 64 times the compressed size and at least `MAX_DECODED_STREAM`, and the page says so.
#[test]
+2
View File
@@ -15,6 +15,8 @@ pdfcraft-cos = { workspace = true }
pdfcraft-annot = { workspace = true }
pdfcraft-fonts = { workspace = true }
thiserror.workspace = true
# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code).
chrono = { workspace = true }
base64 = "0.22"
sha1 = "0.11"
sha2 = "0.11"
+38 -17
View File
@@ -285,6 +285,9 @@ fn contents_len(input: &[u8], form: Form, depth: usize) -> Result<usize, SignErr
}
}
/// `NaiveDate::num_days_from_ce` of 1970-01-01, so the count can be rebased onto the Unix epoch.
const UNIX_EPOCH_DAYS_FROM_CE: i64 = 719_163;
/// A UTC time.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Time {
@@ -327,27 +330,37 @@ impl Time {
/// Seconds since 1970-01-01 (proleptic Gregorian).
pub fn unix(&self) -> i64 {
let (y, m) = if self.month <= 2 { (self.year as i64 - 1, self.month as i64 + 9) } else { (self.year as i64, self.month as i64 - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + self.day as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
use chrono::Datelike;
// The calendar date goes through chrono; the time of day is a plain second count, as
// before. A date chrono cannot represent (an out-of-range field) reads as the epoch day,
// which no well-formed UTCTime/GeneralizedTime produces.
let days = i32::try_from(self.year)
.ok()
.and_then(|y| chrono::NaiveDate::from_ymd_opt(y, self.month, self.day))
.map_or(0, |d| i64::from(d.num_days_from_ce()) - UNIX_EPOCH_DAYS_FROM_CE);
days * 86_400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64
}
/// The UTC time at `t` seconds since 1970-01-01.
///
/// The calendar date goes through chrono's 400-year Gregorian cycle (146_097 days) so a `t`
/// past `NaiveDate`'s ±262143-year range keeps the proleptic Gregorian date the old hand-rolled
/// converter produced instead of falling back to the epoch. The `year` field is a `u32`, so the
/// year is cast exactly as the old code did (`as u32`, wrapping) — no well-formed signing time
/// is anywhere near that range, and this preserves byte-for-byte the old output everywhere.
pub fn from_unix(t: i64) -> Time {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let year = (yoe + era * 400 + if month <= 2 { 1 } else { 0 }) as u32;
use chrono::Datelike;
let (days, secs) = (t.div_euclid(86_400), t.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
let (year, month, day) = match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
// Match the old `as u32` cast: saturate i128→i64 first (the old year was i64), then wrap into u32.
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }) as u32, d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
};
Time { year, month, day, hour: (secs / 3600) as u32, minute: (secs % 3600 / 60) as u32, second: (secs % 60) as u32 }
}
@@ -591,5 +604,13 @@ mod tests {
assert_eq!(Tlv::parse_all(&far.encode()).unwrap().tag, tag::GENERALIZED_TIME);
assert_eq!(Time::from_pdf("D:20261002113005+02'00'"), Some(Time { year: 2026, month: 10, day: 2, hour: 9, minute: 30, second: 5 }));
assert_eq!(Time::from_unix(0), Time { year: 1970, month: 1, day: 1, hour: 0, minute: 0, second: 0 });
// Past chrono's ±262143-year range `from_unix` keeps the proleptic Gregorian date the old
// hand-rolled converter gave, not the epoch. No real signing time is near this; the `year`
// field is a `u32`, so the year wraps exactly as the old `as u32` cast did (values pinned
// from the old formula). This also covers both i64 extremes so neither div_euclid panics.
assert_eq!(Time::from_unix(10_000_000_000_000), Time { year: 318857, month: 5, day: 20, hour: 17, minute: 46, second: 40 });
assert_eq!(Time::from_unix(-10_000_000_000_000), Time { year: 4_294_652_378, month: 8, day: 13, hour: 6, minute: 13, second: 20 });
assert_eq!(Time::from_unix(i64::MAX), Time { year: 219_250_468, month: 12, day: 4, hour: 15, minute: 30, second: 7 });
assert_eq!(Time::from_unix(i64::MIN), Time { year: 4_075_720_767, month: 1, day: 27, hour: 8, minute: 29, second: 52 });
}
}
+3
View File
@@ -25,6 +25,9 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
serde = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono
# (no hand-rolled calendar code); std-only, so it stays wasm-safe.
chrono = { workspace = true }
# Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu.
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
+4
View File
@@ -2433,9 +2433,13 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
}
// No dialog, close prompt or palette over the page: only then does page input count.
let unobstructed = !app.modal_open() && !app.palette_open;
let organize_zoom = app.organize_zoom;
let view = &mut app.views[index];
if view.organize {
// One remembered grid size for every document, kept in the settings.
view.set_grid_zoom(organize_zoom);
organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t);
app.organize_zoom = view.grid_zoom();
return;
}
+3 -3
View File
@@ -500,9 +500,9 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
ui.add_space(10.0);
for (mode, label) in
[(Mode::AllTools, "All tools"), (Mode::Read, "Read"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")]
{
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
if widgets::mode_tab(ui, tl!(label), app.mode == mode).clicked() {
app.switch_mode(mode, true);
}
+49 -13
View File
@@ -1861,24 +1861,37 @@ pub(crate) fn ago(time: SystemTime) -> String {
}
}
/// `time` as "2026-10-08 14:05 UTC".
/// `time` as "2026-10-08 14:05 UTC". A clock before 1970 reads as the epoch; past chrono's year
/// 262143 range the proleptic Gregorian date is kept via the 400-year cycle, not the epoch.
fn utc_stamp(time: SystemTime) -> String {
let secs = time.duration_since(SystemTime::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
let days = i64::try_from(secs / 86_400).unwrap_or(0);
let rem = secs % 86_400;
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{m:02}-{d:02} {:02}:{:02} UTC", rem / 3600, rem % 3600 / 60)
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// A pre-1970 time already clamps to the epoch (`unwrap_or(0)`), so `days` is an unsigned count
/// that can run past `NaiveDate`'s ±262143-year range, where the old hand-rolled converter still
/// produced a date. The Gregorian calendar repeats every 400 years (146_097 days), so chrono
/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then
/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old
/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: u64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days / 146_097, days % 146_097);
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
impl PdfCraftApp {
/// Show the Combine files tab (File ▸ Combine files…), opening it if needed.
pub fn open_combine_tab(&mut self) {
@@ -2596,6 +2609,29 @@ impl PdfCraftApp {
mod tests {
use super::*;
#[test]
fn utc_stamp_is_utc_minutes_and_clamps_pre_epoch() {
use std::time::Duration;
let at = |s: u64| utc_stamp(SystemTime::UNIX_EPOCH + Duration::from_secs(s));
assert_eq!(at(0), "1970-01-01 00:00 UTC");
assert_eq!(at(1_791_446_357), "2026-10-08 07:59 UTC");
// The 400-year leap rule.
assert_eq!(at(951_782_400), "2000-02-29 00:00 UTC");
// A clock before 1970 reads as the epoch, as the old `unwrap_or(0)` did.
assert_eq!(utc_stamp(SystemTime::UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01 00:00 UTC");
// Past chrono's range keeps the proleptic Gregorian date the old code gave, not the epoch.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(utc_stamp(far), "318857-05-20 17:46 UTC");
}
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
if let Some(far) = SystemTime::UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(utc_stamp(far), "292277026596-12-04 15:30 UTC");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(at(253_402_300_800), "10000-01-01 00:00 UTC");
}
/// A one-page PDF that opens with "pw".
fn locked() -> Vec<u8> {
let objs = [
+1 -46
View File
@@ -4,7 +4,7 @@
//! Delete to remove it. Each change is one undoable engine edit.
use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit, FontFamily, TextAlign};
use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
use pdfcraft_render::DocInfo;
use crate::canvas::{DocView, PageXform};
@@ -605,51 +605,6 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option<Edit> {
}
}
/// Format text (shown in the Edit panel while a text item is selected, or for new text).
/// Returns the changed style; for a selected item the caller turns it into an update.
pub(crate) fn format_panel(ui: &mut egui::Ui, t: &Tokens, style: &AddedText) -> Option<AddedText> {
let mut s = style.clone();
widgets::section_title(ui, tl!("Format text"));
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt("font-family").selected_text(s.family.label()).width(110.0).show_ui(ui, |ui| {
for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {
ui.selectable_value(&mut s.family, f, f.label());
}
});
// A list rather than a drag value: every change is an undo step.
egui::ComboBox::from_id_salt("font-size").selected_text(format!("{} pt", s.size)).width(70.0).show_ui(ui, |ui| {
for size in [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0] {
ui.selectable_value(&mut s.size, size, format!("{size} pt"));
}
});
});
ui.horizontal(|ui| {
if ui.selectable_label(s.bold, egui::RichText::new("B").strong()).on_hover_text(tl!("Bold")).clicked() {
s.bold = !s.bold;
}
if ui.selectable_label(s.italic, egui::RichText::new("I").italics()).on_hover_text(tl!("Italic")).clicked() {
s.italic = !s.italic;
}
ui.separator();
for (a, icon, tip) in [
(TextAlign::Left, "align-left", tl!("Align left")),
(TextAlign::Center, "align-center", tl!("Centre")),
(TextAlign::Right, "align-right", tl!("Align right")),
(TextAlign::Justify, "align-justify", tl!("Justify")),
] {
if crate::icons::button(ui, icon, 26.0, s.align == a, tip).clicked() {
s.align = a;
}
}
});
let c = s.color;
if let Some(picked) = crate::comments::swatch_grid(ui, Some(c)) {
s.color = picked;
}
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)
}
/// How to use the tools, under the Add content list.
pub(crate) fn hint(ui: &mut egui::Ui, t: &Tokens) {
ui.label(
+22 -5
View File
@@ -1330,12 +1330,29 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
if field_props_now
&& let Some(d) = app.field_props.take()
&& let Some(props) = d.props()
&& app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
// Keep the (possibly renamed) field selected.
let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
app.views[i].prepare.selected = Some((format!("{prefix}{}", d.name.trim()), d.widget));
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
// A rename also renames the field in the formulas that read it.
let before = props.name.as_ref().and_then(|_| form(app));
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
&& let Some(i) = app.active
{
// Keep the (possibly renamed) field selected.
let prefix = d.field.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default();
let name = format!("{prefix}{}", d.name.trim());
if let (Some(before), Some(after)) = (before, form(app)) {
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
if n > 0 {
app.notify(crate::i18n::trn(
crate::i18n::current(),
n as u64,
"{n} calculation now uses the new name.",
"{n} calculations now use the new name.",
));
}
}
app.views[i].prepare.selected = Some((name, d.widget));
}
}
if props_now && let Some(d) = app.comment_props.take() {
let mut edits = crate::comment_props::edits(&d);
+6 -1
View File
@@ -396,12 +396,17 @@ pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
// Availability is a render projection: the saved group, selection and width survive Home.
let _ = rendered.apply(Action::Close { panel: Panel::Inspector });
}
// Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped
// with another panel, or floating, it keeps its tab so it can be dragged away again; moving it
// to the other side stays a preference and a command.
let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_)));
let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) };
let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits(
ui,
&rendered,
&style,
|panel| crate::i18n::t(panel.label()).into(),
permissions,
render_permissions,
panel_limits,
|ui, panel| match panel {
Panel::Canvas => {
+63 -31
View File
@@ -162,6 +162,10 @@ pub struct LineEditor {
/// The Format text panel's values, and what the paragraph had (to send only changes).
pub look: pdfcraft_engine::AddedText,
look0: pdfcraft_engine::AddedText,
/// A standard family was picked from the font list. The family guessed from a PDF font name
/// (Calibri reads as Helvetica) can be the one picked, so comparing `look` with `look0` alone
/// would miss the choice.
family_chosen: bool,
/// Underline, line spacing (× size; 0 = the paragraph's own), character spacing (pt) and
/// horizontal scale (%), and what they were.
pub extras: Extras,
@@ -182,36 +186,42 @@ impl Default for Extras {
}
}
/// Underline, line spacing, character spacing and horizontal scale for the paragraph being
/// edited (under Format text). Returns `true` when something changed.
pub(crate) fn extras_panel(ui: &mut egui::Ui, e: &mut Extras) -> bool {
let before = *e;
ui.horizontal(|ui| {
if crate::icons::button(ui, "underline", 26.0, e.underline, tl!("Underline")).clicked() {
e.underline = !e.underline;
}
let zero = tl!("Line spacing").to_string();
let label = |v: f64| if v == 0.0 { zero.clone() } else { format!("{v:.2}×") };
egui::ComboBox::from_id_salt("line-spacing").selected_text(label(e.line_spacing)).width(110.0).show_ui(ui, |ui| {
for v in [1.0, 1.15, 1.5, 2.0] {
ui.selectable_value(&mut e.line_spacing, v, label(v));
}
});
});
ui.horizontal(|ui| {
let l = ui.label(tl!("Character spacing"));
ui.add(egui::DragValue::new(&mut e.char_spacing).range(-5.0..=50.0).speed(0.1).suffix(" pt")).labelled_by(l.id);
});
ui.horizontal(|ui| {
let l = ui.label(tl!("Horizontal scale"));
ui.add(egui::DragValue::new(&mut e.scale).range(10.0..=400.0).speed(1.0).suffix(" %")).labelled_by(l.id);
});
*e != before
}
impl LineEditor {
/// What Format text's font list shows: the chosen page font, a standard family picked
/// instead, or the paragraph's own font (its `/BaseFont` without the subset tag).
pub(crate) fn font_shown(&self, sources: &[(String, String)]) -> String {
if let Some(face) = self.font_resource.as_ref().and_then(|r| sources.iter().find(|(res, _)| res == r)).map(|(_, face)| face) {
return face.clone();
}
if self.look.family != self.look0.family || self.family_chosen {
return self.look.family.label().to_string();
}
let base = self.source.as_ref().map(|(block, _)| block.base_font.as_str()).unwrap_or_default();
// "ABCDEF+Calibri" is a subset of Calibri.
let name = match base.split_once('+') {
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest,
_ => base,
};
if name.is_empty() { tl!("Keep source fonts").to_string() } else { name.to_string() }
}
/// Back to the paragraph's own font: no page font chosen and the family it started with.
pub(crate) fn keep_source_font(&mut self) {
self.font_resource = None;
self.look.family = self.look0.family;
self.family_chosen = false;
}
/// A standard family picked from the font list, replacing the paragraph's font even when it
/// is the family the paragraph's font name reads as.
pub(crate) fn choose_family(&mut self, family: pdfcraft_engine::FontFamily) {
self.font_resource = None;
self.look.family = family;
self.family_chosen = true;
}
pub(crate) fn has_changes(&self) -> bool {
self.text != self.original || self.look != self.look0 || self.extras != self.extras0
self.text != self.original || self.look != self.look0 || self.extras != self.extras0 || self.family_chosen
}
/// Extra width permitted by the editor. Source containers now remain fixed.
@@ -229,7 +239,7 @@ impl LineEditor {
let (l, o) = (&self.look, &self.look0);
let mut style = pdfcraft_engine::BlockStyle {
resource: self.font_resource.clone(),
family: (l.family != o.family || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
family: (l.family != o.family || self.family_chosen || l.bold != o.bold || l.italic != o.italic).then_some((l.family, l.bold, l.italic)),
size: (l.size != o.size).then_some(l.size),
color: (l.color != o.color).then_some(l.color),
align: (l.align != o.align).then_some(l.align),
@@ -267,6 +277,7 @@ impl LineEditor {
pub fn applied(&mut self) {
self.selection = None;
self.font_resource = None;
self.family_chosen = false;
self.look0 = self.look.clone();
self.extras0 = self.extras;
self.original = self.text.clone();
@@ -965,6 +976,7 @@ fn open_editor(
focus: true,
look: look_of(l),
look0: look_of(l),
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
});
@@ -1177,7 +1189,7 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32
let Some(line) = lines.get(index) else { continue };
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
look.size = line.size;
if ed.look.family != ed.look0.family {
if ed.look.family != ed.look0.family || ed.family_chosen {
look.family = ed.look.family;
}
if ed.look.bold != ed.look0.bold {
@@ -1193,7 +1205,8 @@ fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32, width: f32
look.size = ed.look.size;
}
let mut font = editor_font(&look, (look.size as f32 * scale).clamp(1.0, 300.0));
let original_face = ed.look.family == ed.look0.family && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic;
let original_face =
ed.look.family == ed.look0.family && !ed.family_chosen && ed.look.bold == ed.look0.bold && ed.look.italic == ed.look0.italic;
let embedded = original_face && ed.fonts.contains_key(&line.font);
if embedded && let Some(name) = ed.fonts.get(&line.font) {
font.family = FontFamily::Name(name.clone().into());
@@ -1474,12 +1487,31 @@ mod tests {
focus: true,
look: look_of(&block),
look0: look_of(&block),
family_chosen: false,
extras: Extras::default(),
extras0: Extras::default(),
};
(app, ed)
}
#[test]
fn picking_the_family_the_font_name_reads_as_still_changes_the_font() {
// A Calibri paragraph reads as Helvetica; picking Helvetica from the list used to leave
// `look` equal to what the paragraph had, so no family change was sent and Calibri stayed.
let (_app, mut ed) = preview_app();
let family = ed.look0.family;
assert_eq!(ed.style().family, None, "nothing chosen yet");
ed.choose_family(family);
assert_eq!(ed.style().family, Some((family, ed.look.bold, ed.look.italic)));
assert!(ed.has_changes());
assert_eq!(ed.font_shown(&[]), family.label());
ed.keep_source_font();
assert_eq!(ed.style().family, None, "Keep source fonts withdraws the choice");
ed.choose_family(family);
ed.applied();
assert_eq!(ed.style().family, None, "an applied choice is the paragraph's font now");
}
#[test]
fn a_loaded_font_is_applied_to_the_paragraph_being_edited() {
// Loading a font used to only register it on the page: the paragraph kept its source
+333
View File
@@ -0,0 +1,333 @@
//! Format text, laid out as Acrobat's panel: the font across the panel; size, colour, lists and
//! alignment; bold, italic, underline, superscript and subscript; line and paragraph spacing;
//! horizontal scale and character spacing. One panel serves a paragraph being edited (with the
//! fonts already on its page and the spacing controls) and added text (the standard families,
//! size, colour, style and alignment). What PdfCraft can't apply yet (lists, superscript and
//! subscript, paragraph spacing, or spacing for added text) is shown disabled and says so.
//!
//! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat.
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
use pdfcraft_engine::{AddedText, FontFamily, TextAlign};
use crate::edit_text_ui::Extras;
use crate::theme::Tokens;
use crate::{icons, widgets};
/// A font picked from the font list. The caller applies it, since keeping the paragraph's own
/// font also means restoring the family it started with.
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum FontChoice {
/// The fonts the paragraph already uses.
KeepSource,
/// One of the page's fonts, by resource name.
Resource(String),
/// A standard family.
Family(FontFamily),
/// Load font…: pick a font file.
Load,
/// A font file installed on this computer.
Installed(std::path::PathBuf),
}
/// What the font list offers and shows.
pub(crate) struct Fonts<'a> {
/// The fonts on the paragraph's page (resource, face); `None` for added text, which has the
/// standard families only.
pub sources: Option<&'a [(String, String)]>,
/// The name the list shows for the current choice.
pub shown: String,
}
/// What the panel changed: the style (in place) and a font picked from the list.
#[derive(Default)]
pub(crate) struct Outcome {
pub changed: bool,
pub font: Option<FontChoice>,
}
const SIZES: [f64; 14] = [8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0];
const LINE_SPACINGS: [f64; 7] = [1.0, 1.15, 1.2, 1.5, 2.0, 2.5, 3.0];
const SCALES: [f64; 8] = [50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0, 200.0];
const CHAR_SPACINGS: [f64; 8] = [-1.0, -0.5, 0.0, 0.5, 1.0, 2.0, 3.0, 5.0];
/// A row's height: the tallest control in it (the 30 pt icon buttons). Every control is centred
/// on the row, whatever its own height, so lists, swatches and buttons line up (#3037).
const ROW: f32 = 30.0;
/// The gap between a row's controls.
const GAP: f32 = 6.0;
/// The icon naming a list or field ([`labelled_icon`]).
const LABEL_ICON: f32 = 22.0;
/// The button beside a number field that opens its list of common values: just an arrow, so the
/// field keeps the room its value needs.
const PRESET_LIST: f32 = 22.0;
/// What can be typed: horizontal scale in percent, character spacing in points.
const SCALE_RANGE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
const CHAR_SPACING_RANGE: std::ops::RangeInclusive<f64> = -5.0..=50.0;
/// A number as the lists show it: no trailing zeros ("1.2", "100", "-0.5").
fn number(v: f64) -> String {
let s = format!("{v:.2}");
s.trim_end_matches('0').trim_end_matches('.').to_string()
}
pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: Option<&mut Extras>, fonts: Fonts) -> Outcome {
let before = (look.clone(), extras.as_deref().copied());
let mut out = Outcome::default();
widgets::section_title(ui, tl!("Format text"));
// The font, across the panel.
let width = ui.available_width();
egui::ComboBox::from_id_salt("format-font")
.selected_text(fonts.shown.as_str())
.width(width)
.show_ui(ui, |ui| {
if let Some(sources) = fonts.sources {
if ui.selectable_label(false, tl!("Keep source fonts")).clicked() {
out.font = Some(FontChoice::KeepSource);
}
for (resource, face) in sources.iter().filter(|(r, _)| !r.starts_with("PCApproved")) {
if ui.selectable_label(false, face).clicked() {
out.font = Some(FontChoice::Resource(resource.clone()));
}
}
ui.separator();
}
for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] {
if ui.selectable_label(fonts.sources.is_none() && look.family == f, f.label()).clicked() {
out.font = Some(FontChoice::Family(f));
}
}
if fonts.sources.is_some() {
ui.separator();
if ui.selectable_label(false, tl!("Load font…")).clicked() {
out.font = Some(FontChoice::Load);
}
#[cfg(not(target_arch = "wasm32"))]
ui.menu_button(tl!("Installed fonts…"), |ui| match crate::files::installed_font_files(ui.ctx()) {
Some(list) if list.is_empty() => {
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
}
Some(list) => {
for font in list {
if ui.button(&font.face).clicked() {
out.font = Some(FontChoice::Installed(font.path));
ui.close();
}
}
}
None => {
ui.label(tl!("Finding document fonts…"));
}
});
}
})
.response
.on_hover_text(format!("{}\n{}", tl!("Font"), tl!("Thai and Arabic use regular fallback fonts when available.")));
ui.add_space(4.0);
// Size and colour | bullets, numbering and alignment.
row(ui, |ui| {
let _ = egui::ComboBox::from_id_salt("format-size")
.selected_text(number(look.size))
.width(64.0)
.show_ui(ui, |ui| {
for size in SIZES {
ui.selectable_value(&mut look.size, size, number(size));
}
})
.response
.on_hover_text(tl!("Font size"));
let swatch = colour_button(ui, t, look.color);
egui::Popup::menu(&swatch).show(|ui| {
if let Some(picked) = crate::comments::swatch_grid(ui, Some(look.color)) {
look.color = picked;
ui.close();
}
});
let (sep, _) = ui.allocate_exact_size(vec2(9.0, 26.0), Sense::hover());
ui.painter().vline(sep.center().x, sep.y_range().shrink(3.0), Stroke::new(1.0, t.divider));
planned(ui, "list", tl!("Bulleted list"));
planned(ui, "list-ordered", tl!("Numbered list"));
let current = match look.align {
TextAlign::Left => "align-left",
TextAlign::Center => "align-center",
TextAlign::Right => "align-right",
TextAlign::Justify => "align-justify",
};
let align = icons::button(ui, current, 30.0, false, tl!("Alignment"));
let align = flyout(ui, t, align);
egui::Popup::menu(&align).show(|ui| {
ui.horizontal(|ui| {
for (a, icon, tip) in [
(TextAlign::Left, "align-left", tl!("Align left")),
(TextAlign::Center, "align-center", tl!("Centre")),
(TextAlign::Right, "align-right", tl!("Align right")),
(TextAlign::Justify, "align-justify", tl!("Justify")),
] {
if icons::button(ui, icon, 30.0, look.align == a, tip).clicked() {
look.align = a;
ui.close();
}
}
});
});
});
// Bold, italic, underline, superscript, subscript.
let mut extras = extras;
row(ui, |ui| {
if icons::button(ui, "bold", 30.0, look.bold, tl!("Bold")).clicked() {
look.bold = !look.bold;
}
if icons::button(ui, "italic", 30.0, look.italic, tl!("Italic")).clicked() {
look.italic = !look.italic;
}
match extras.as_deref_mut() {
Some(e) => {
if icons::button(ui, "underline", 30.0, e.underline, tl!("Underline")).clicked() {
e.underline = !e.underline;
}
}
None => planned(ui, "underline", tl!("Underline")),
}
planned(ui, "superscript", tl!("Superscript"));
planned(ui, "subscript", tl!("Subscript"));
});
// Line and paragraph spacing; horizontal scale and character spacing: two columns, each an
// icon and its control, `half` wide.
let half = ((ui.available_width() - 2.0 * LABEL_ICON - 8.0 - 3.0 * GAP) / 2.0).max(40.0);
ui.add_enabled_ui(extras.is_some(), |ui| {
let mut fallback = Extras::default();
let e = extras.as_deref_mut().unwrap_or(&mut fallback);
row(ui, |ui| {
labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing"));
let own = tl!("Auto").to_string();
let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) };
egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| {
ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str());
for v in LINE_SPACINGS {
ui.selectable_value(&mut e.line_spacing, v, number(v));
}
});
ui.add_space(8.0);
ui.add_enabled_ui(false, |ui| {
labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing"));
let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned"));
let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing")
.selected_text("0")
.width(half)
.show_ui(ui, |_| {})
.response
.on_disabled_hover_text(tip);
});
});
// Any value the engine takes can be typed; the lists hold the common ones.
row(ui, |ui| {
let label = labelled_icon(ui, t, "move-horizontal", tl!("Horizontal scale"));
let scale = Number { value: &mut e.scale, range: SCALE_RANGE, speed: 1.0, suffix: " %" };
number_with_presets(ui, scale, &SCALES, half, (label.id, tl!("Horizontal scale")));
ui.add_space(8.0);
let label = labelled_icon(ui, t, "arrow-left-right", tl!("Character spacing"));
let spacing = Number { value: &mut e.char_spacing, range: CHAR_SPACING_RANGE, speed: 0.1, suffix: " pt" };
number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing")));
});
});
out.changed = (look.clone(), extras.as_deref().copied()) != before;
ui.add_space(6.0);
ui.separator();
out
}
/// One row of the panel, [`ROW`] high, its controls [`GAP`] apart and centred on it.
fn row<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let size = vec2(ui.available_width(), ROW);
ui.allocate_ui_with_layout(size, Layout::left_to_right(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = GAP;
// A list lays itself out from the default control height and grows downward, which leaves
// it below the row's middle; at a list's own height it is centred like the rest.
let list = ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y;
ui.spacing_mut().interact_size.y = ui.spacing().interact_size.y.max(list);
add(ui)
})
.inner
}
/// A control PdfCraft doesn't have yet: its icon, disabled, saying so when hovered.
fn planned(ui: &mut egui::Ui, icon: &str, label: &str) {
let _ = ui
.add_enabled_ui(false, |ui| icons::button(ui, icon, 30.0, false, label))
.inner
.on_disabled_hover_text(format!("{label} — {}", tl!("Planned")));
}
/// The small corner mark Acrobat puts on a button that opens a menu.
fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response {
let r = resp.rect;
let tri = vec![r.right_bottom() + vec2(-3.0, -3.0), r.right_bottom() + vec2(-8.0, -3.0), r.right_bottom() + vec2(-3.0, -8.0)];
ui.painter().add(egui::Shape::convex_polygon(tri, t.text_faint, Stroke::NONE));
resp
}
/// An icon naming the list beside it, for screen readers too. Returns it, for the control it
/// labels.
fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label));
let tint = if ui.is_enabled() { t.icon } else { t.text_faint };
icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint);
resp.on_hover_text(label)
}
/// A value typed in a number field: what it edits, what may be typed, and how it reads.
struct Number<'a> {
value: &'a mut f64,
range: std::ops::RangeInclusive<f64>,
speed: f64,
suffix: &'static str,
}
/// A number field with an arrow beside it that lists common values, in `width` all told: type any
/// value in its range, or pick one. Both are named by `label`: the icon before them and its text.
fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width: f32, label: (egui::Id, &str)) {
ui.scope(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
let field = egui::DragValue::new(&mut *n.value).range(n.range).speed(n.speed).max_decimals(2).suffix(n.suffix);
let height = ui.spacing().interact_size.y;
ui.add_sized([(width - PRESET_LIST - 2.0).max(30.0), height], field).labelled_by(label.0);
let list = icons::button(ui, "chevron-down", PRESET_LIST, false, label.1);
egui::Popup::menu(&list).show(|ui| {
for &v in presets {
if ui.selectable_label(*n.value == v, number(v)).clicked() {
*n.value = v;
ui.close();
}
}
});
});
}
/// The text colour as a square swatch that opens the palette.
fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour")));
let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
let fill = rect.shrink(4.0);
ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b));
let stroke = if resp.hovered() { t.text } else { t.border };
ui.painter().rect_stroke(fill, CornerRadius::same(2), Stroke::new(1.0, stroke), egui::StrokeKind::Outside);
crate::theme::hand(resp).on_hover_text(tl!("Colour"))
}
#[cfg(test)]
mod tests {
#[test]
fn numbers_read_like_the_lists() {
assert_eq!(super::number(1.2), "1.2");
assert_eq!(super::number(100.0), "100");
assert_eq!(super::number(-0.5), "-0.5");
assert_eq!(super::number(0.0), "0");
assert_eq!(super::number(1.15), "1.15");
}
}
+15 -9
View File
@@ -290,19 +290,25 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
.strong(),
);
if ui.small_button("›").on_hover_text(tl!("Next month")).clicked() {
(y, m) = if m == 12 { (y + 1, 1) } else { (y, m + 1) };
// `y + 1` would overflow at `i32::MAX` (a date parsed or persisted with
// that year); stay put on December of the last year instead of panicking.
(y, m) = if m == 12 { y.checked_add(1).map_or((y, m), |next| (next, 1)) } else { (y, m + 1) };
}
});
// Weekday of the 1st (0 = Sunday), Zeller-style via days since 1970-01-01 (a Thursday).
let days_from_civil = |y: i32, m: u32, d: u32| -> i64 {
let (y, m) = if m <= 2 { (y as i64 - 1, m as i64 + 9) } else { (y as i64, m as i64 - 3) };
let era = y.div_euclid(400);
let yoe = y - era * 400;
let doy = (153 * m + 2) / 5 + d as i64 - 1;
era * 146_097 + yoe * 365 + yoe / 4 - yoe / 100 + doy - 719_468
use chrono::Datelike;
// The month the calendar shows: the weekday of its 1st (0 = Sunday) and its
// length, both from chrono. A year chrono can't represent falls back to a plain
// 31-day month starting on Sunday, which only the arrows can reach.
let first_of = |y: i32, m: u32| chrono::NaiveDate::from_ymd_opt(y, m, 1);
let first = first_of(y, m).map_or(0, |d| d.weekday().num_days_from_sunday() as usize);
// `y + 1` for December would overflow at `i32::MAX`; a year chrono can't represent
// (or that overflows here) falls back to the plain 31-day month.
let next_y = if m == 12 { y.checked_add(1) } else { Some(y) };
let len = match (first_of(y, m), next_y.and_then(|next| first_of(next, if m == 12 { 1 } else { m + 1 }))) {
(Some(a), Some(b)) => (b - a).num_days() as usize,
_ => 31,
};
let first = (days_from_civil(y, m, 1) + 4).rem_euclid(7) as usize;
let len = (days_from_civil(if m == 12 { y + 1 } else { y }, if m == 12 { 1 } else { m + 1 }, 1) - days_from_civil(y, m, 1)) as usize;
egui::Grid::new(("date-grid", view.id.0)).spacing([4.0, 2.0]).show(ui, |ui| {
for d in ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] {
ui.label(egui::RichText::new(tl_ctx!("calendar weekday", d)).small());
+9
View File
@@ -2362,6 +2362,7 @@ animation effects Off معطّلة
no unused output name لا يوجد اسم ناتج غير مستخدم
no output folder was chosen لم يتم اختيار مجلد للإخراج
@plural Downloaded {n} PDF|Downloaded {n} PDFs تم تنزيل {n} من ملفات PDF|تم تنزيل ملف PDF واحد ({n})|تم تنزيل ملفي PDF ({n})|تم تنزيل {n} ملفات PDF|تم تنزيل {n} ملف PDF|تم تنزيل {n} من ملفات PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. تم تحديث {n} من العمليات الحسابية لتستخدم الاسم الجديد.|عملية حسابية واحدة ({n}) تستخدم الآن الاسم الجديد.|عمليتان حسابيتان ({n}) تستخدمان الآن الاسم الجديد.|{n} عمليات حسابية تستخدم الآن الاسم الجديد.|{n} عملية حسابية تستخدم الآن الاسم الجديد.|{n} من العمليات الحسابية تستخدم الآن الاسم الجديد.
Scan مسح ضوئي
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. وقّع على ورقة بيضاء والتقط لها صورة في إضاءة ساطعة ومتساوية (استخدم الفلاش). اختر الصورة أو أفلتها هنا: يعثر PdfCraft على التوقيع ويزيل الورق.
Choose photo… اختيار صورة…
@@ -2711,3 +2712,11 @@ certificate Version الإصدار
Make properties default جعل الخصائص افتراضية
Pages selected الصفحات المحددة
Page properties خصائص الصفحة
Bulleted list قائمة نقطية
Numbered list قائمة مرقمة
Alignment المحاذاة
Superscript نص مرتفع
Subscript نص منخفض
Paragraph spacing تباعد الفقرات
Less أقل
Other options خيارات أخرى
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -2304,6 +2304,7 @@ animation effects Off Изключени
no unused output name няма свободно име за изходния файл
no output folder was chosen не е избрана папка за изхода
@plural Downloaded {n} PDF|Downloaded {n} PDFs Изтеглен е {n} PDF|Изтеглени са {n} PDF файла
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} изчисление вече използва новото име.|{n} изчисления вече използват новото име.
Scan Сканиране
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Подпишете се на лист бяла хартия и го снимайте на ярка, равномерна светлина (със светкавица). Изберете снимката или я пуснете тук: PdfCraft открива подписа и премахва хартията.
Choose photo… Избор на снимка…
@@ -2397,3 +2398,12 @@ animation effects Off Изключени
Make properties default Задаване като свойства по подразбиране
Pages selected Избрани страници
Page properties Свойства на страницата
Font Шрифт
Bulleted list Списък с водещи символи
Numbered list Номериран списък
Alignment Подравняване
Superscript Горен индекс
Subscript Долен индекс
Paragraph spacing Разстояние между абзаците
Less По-малко
Other options Други опции
Can't render this file because it contains an unexpected character in line 8 and column 16.
+10
View File
@@ -2415,6 +2415,7 @@ animation effects Off Vypnuto
no unused output name žádný volný název výstupního souboru
no output folder was chosen nebyla vybrána výstupní složka
@plural Downloaded {n} PDF|Downloaded {n} PDFs Stažen {n} soubor PDF|Staženy {n} soubory PDF|Staženo {n} souborů PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} výpočet nyní používá nový název.|{n} výpočty nyní používají nový název.|{n} výpočtů nyní používá nový název.
Scan Skenovat
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podepište se na bílý list papíru a vyfoťte ho při jasném, rovnoměrném světle (použijte blesk). Vyberte fotografii nebo ji sem přetáhněte: PdfCraft najde podpis a odstraní papír.
Choose photo… Vybrat fotografii…
@@ -2508,3 +2509,12 @@ animation effects Off Vypnuto
Make properties default Nastavit vlastnosti jako výchozí
Pages selected Vybrané stránky
Page properties Vlastnosti stránky
Font Písmo
Bulleted list Odrážkový seznam
Numbered list Číslovaný seznam
Alignment Zarovnání
Superscript Horní index
Subscript Dolní index
Paragraph spacing Mezera mezi odstavci
Less Méně
Other options Další možnosti
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -2342,6 +2342,7 @@ animation effects Off Aus
no unused output name kein freier Ausgabename
no output folder was chosen kein Ausgabeordner gewählt
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF heruntergeladen|{n} PDFs heruntergeladen
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} Berechnung verwendet jetzt den neuen Namen.|{n} Berechnungen verwenden jetzt den neuen Namen.
Scan Scannen
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Unterschreiben Sie auf einem weißen Blatt Papier und fotografieren Sie es bei hellem, gleichmäßigem Licht (mit Blitz). Wählen Sie das Foto aus oder ziehen Sie es hierher: PdfCraft findet die Unterschrift und entfernt das Papier.
Choose photo… Foto auswählen…
@@ -2435,3 +2436,12 @@ animation effects Off Aus
Make properties default Eigenschaften als Standard festlegen
Pages selected Ausgewählte Seiten
Page properties Seiteneigenschaften
Font Schrift
Bulleted list Aufzählung
Numbered list Nummerierte Liste
Alignment Ausrichtung
Superscript Hochgestellt
Subscript Tiefgestellt
Paragraph spacing Absatzabstand
Less Weniger
Other options Weitere Optionen
Can't render this file because it contains an unexpected character in line 6 and column 51.
+10
View File
@@ -2338,6 +2338,7 @@ animation effects Off Ανενεργά
no unused output name δεν υπάρχει διαθέσιμο όνομα εξόδου
no output folder was chosen δεν επιλέχθηκε φάκελος εξόδου
@plural Downloaded {n} PDF|Downloaded {n} PDFs Λήφθηκε {n} PDF|Λήφθηκαν {n} PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} υπολογισμός χρησιμοποιεί πλέον το νέο όνομα.|{n} υπολογισμοί χρησιμοποιούν πλέον το νέο όνομα.
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} όνομα πεδίου συγκρούεται με προηγούμενα αρχεία και θα μετονομαστεί: {list}|{n} ονόματα πεδίων συγκρούονται με προηγούμενα αρχεία και θα μετονομαστούν: {list}
Scan Σάρωση
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Υπογράψτε σε ένα λευκό φύλλο χαρτιού και φωτογραφίστε το σε έντονο, ομοιόμορφο φως (χρησιμοποιήστε το φλας). Επιλέξτε τη φωτογραφία ή αφήστε την εδώ: το PdfCraft βρίσκει την υπογραφή και αφαιρεί το χαρτί.
@@ -2440,3 +2441,12 @@ animation effects Off Ανενεργά
Make properties default Ορισμός ιδιοτήτων ως προεπιλογή
Pages selected Επιλεγμένες σελίδες
Page properties Ιδιότητες σελίδας
Font Γραμματοσειρά
Bulleted list Λίστα με κουκκίδες
Numbered list Αριθμημένη λίστα
Alignment Στοίχιση
Superscript Εκθέτης
Subscript Δείκτης
Paragraph spacing Απόσταση παραγράφων
Less Λιγότερα
Other options Άλλες επιλογές
Can't render this file because it contains an unexpected character in line 477 and column 9.
+10
View File
@@ -2470,6 +2470,7 @@ animation effects Off Desactivados
no unused output name no hay un nombre de salida libre
no output folder was chosen no se eligió ninguna carpeta de salida
@plural Downloaded {n} PDF|Downloaded {n} PDFs Se descargó {n} PDF|Se descargaron {n} PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo usa ahora el nuevo nombre.|{n} cálculos usan ahora el nuevo nombre.
Scan Escanear
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma en una hoja de papel blanco y hazle una foto con luz intensa y uniforme (usa el flash). Elige la foto o suéltala aquí: PdfCraft encuentra la firma y elimina el papel.
Choose photo… Elegir foto…
@@ -2570,6 +2571,15 @@ animation effects Off Desactivados
Make properties default Usar como propiedades predeterminadas
Pages selected Páginas seleccionadas
Page properties Propiedades de página
Font Fuente
Bulleted list Lista con viñetas
Numbered list Lista numerada
Alignment Alineación
Superscript Superíndice
Subscript Subíndice
Paragraph spacing Espaciado entre párrafos
Less Menos
Other options Otras opciones
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Estás viendo la versión firmada de «{from}» (revisión {rev}). Es una instantánea: los cambios aquí no afectarán al original.
Can't render this file because it contains an unexpected character in line 6 and column 60.
+10
View File
@@ -2341,6 +2341,7 @@ animation effects Off Désactivés
no unused output name aucun nom de sortie libre
no output folder was chosen aucun dossier de sortie choisi
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF téléchargé|{n} PDF téléchargés
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcul utilise désormais le nouveau nom.|{n} calculs utilisent désormais le nouveau nom.
Scan Numériser
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Signez une feuille de papier blanc et prenez-la en photo sous une lumière vive et uniforme (utilisez le flash). Choisissez la photo ou déposez-la ici : PdfCraft trouve la signature et retire le papier.
Choose photo… Choisir une photo…
@@ -2434,3 +2435,12 @@ animation effects Off Désactivés
Make properties default Définir comme propriétés par défaut
Pages selected Pages sélectionnées
Page properties Propriétés de la page
Font Police
Bulleted list Liste à puces
Numbered list Liste numérotée
Alignment Alignement
Superscript Exposant
Subscript Indice
Paragraph spacing Espacement des paragraphes
Less Moins
Other options Autres options
Can't render this file because it contains an unexpected character in line 6 and column 39.
+10
View File
@@ -2340,6 +2340,7 @@ animation effects Off Ki
no unused output name nincs szabad kimeneti név
no output folder was chosen nincs kiválasztva kimeneti mappa
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF letöltve|{n} PDF letöltve
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} számítás mostantól az új nevet használja.|{n} számítás mostantól az új nevet használja.
Scan Beolvasás
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Írja alá a nevét egy fehér papírlapon, és fényképezze le erős, egyenletes fényben (vakuval). Válassza ki a fényképet, vagy húzza ide: a PdfCraft megkeresi az aláírást, és eltávolítja a papírt.
Choose photo… Fénykép kiválasztása…
@@ -2433,3 +2434,12 @@ animation effects Off Ki
Make properties default Tulajdonságok beállítása alapértelmezettként
Pages selected Kijelölt oldalak
Page properties Oldaltulajdonságok
Font Betűtípus
Bulleted list Felsorolás
Numbered list Számozott lista
Alignment Igazítás
Superscript Felső index
Subscript Alsó index
Paragraph spacing Bekezdéstávolság
Less Kevesebb
Other options Egyéb beállítások
Can't render this file because it contains an unexpected character in line 7 and column 44.
+10
View File
@@ -2350,6 +2350,7 @@ animation effects Off Disattivati
no unused output name nessun nome di output libero
no output folder was chosen nessuna cartella di output scelta
@plural Downloaded {n} PDF|Downloaded {n} PDFs Scaricato {n} PDF|Scaricati {n} PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} calcolo ora usa il nuovo nome.|{n} calcoli ora usano il nuovo nome.
Scan Scansiona
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Firma un foglio di carta bianca e fotografalo con una luce intensa e uniforme (usa il flash). Scegli la foto o trascinala qui: PdfCraft trova la firma e rimuove la carta.
Choose photo… Scegli foto…
@@ -2646,3 +2647,12 @@ certificate Key usage Utilizzo chiave
certificate Self-signed Autofirmato
certificate SHA-1 digest Impronta SHA-1
certificate SHA-256 digest Impronta SHA-256
Font Carattere
Bulleted list Elenco puntato
Numbered list Elenco numerato
Alignment Allineamento
Superscript Apice
Subscript Pedice
Paragraph spacing Spaziatura paragrafi
Less Meno
Other options Altre opzioni
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -2341,6 +2341,7 @@ animation effects Off オフ
no unused output name 使用可能な出力名がありません
no output folder was chosen 出力フォルダーが選択されていません
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} 個の PDF をダウンロードしました
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 件の計算式を新しい名前に更新しました。
Scan スキャン
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 白い紙に署名し、明るく均一な光の下で写真を撮ってください(フラッシュを使用)。写真を選択するか、ここにドロップしてください。PdfCraft が署名を見つけ、紙の部分を取り除きます。
Choose photo… 写真を選択…
@@ -2434,3 +2435,12 @@ animation effects Off オフ
Make properties default プロパティをデフォルトに設定
Pages selected 選択したページ
Page properties ページのプロパティ
Font フォント
Bulleted list 箇条書き
Numbered list 番号付きリスト
Alignment 配置
Superscript 上付き
Subscript 下付き
Paragraph spacing 段落間隔
Less 折りたたむ
Other options その他のオプション
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -2328,6 +2328,7 @@ animation effects Off Өшірулі
no unused output name бос шығыс атауы жоқ
no output folder was chosen шығыс қалтасы таңдалмады
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF жүктелді|{n} PDF жүктелді
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} есептеу енді жаңа атауды пайдаланады.|{n} есептеу енді жаңа атауды пайдаланады.
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}|{n} өріс атауы алдыңғы файлдармен қайшы келеді және қайта аталады: {list}
Scan Сканерлеу
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Ақ қағаз парағына қол қойып, оны жарық әрі біркелкі жарықта суретке түсіріңіз (жарқылды қосыңыз). Суретті таңдаңыз немесе осында тастаңыз: PdfCraft қолтаңбаны тауып, қағазды алып тастайды.
@@ -2432,3 +2433,12 @@ animation effects Off Өшірулі
Make properties default Қасиеттерді әдепкі ету
Pages selected Таңдалған беттер
Page properties Бет қасиеттері
Font Қаріп
Bulleted list Таңбалауыш тізім
Numbered list Нөмірленген тізім
Alignment Туралау
Superscript Жоғарғы индекс
Subscript Төменгі индекс
Paragraph spacing Абзац аралығы
Less Азырақ
Other options Басқа параметрлер
Can't render this file because it contains an unexpected character in line 826 and column 9.
+9
View File
@@ -2598,6 +2598,7 @@ certificate SHA-256 digest SHA-256 santrauka
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} lauko pavadinimas sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadintas: {list}|{n} laukų pavadinimai sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinti: {list}|{n} laukų pavadinimų sutampa su ankstesniuose failuose esančių laukų pavadinimais ir bus pervadinta: {list}
@plural {n} object selected|{n} objects selected Pažymėtas {n} objektas|Pažymėti {n} objektai|Pažymėta {n} objektų
@plural Downloaded {n} PDF|Downloaded {n} PDFs Atsisiųstas {n} PDF failas|Atsisiųsti {n} PDF failai|Atsisiųsta {n} PDF failų
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} skaičiavimas dabar naudoja naują pavadinimą.|{n} skaičiavimai dabar naudoja naują pavadinimą.|{n} skaičiavimų dabar naudoja naują pavadinimą.
key Ctrl Ctrl
key Shift Shift
key Delete Delete
@@ -2685,3 +2686,11 @@ key Space Tarpas
The password must have at least 6 characters. Slaptažodį turi sudaryti bent 6 simboliai.
This file changed on disk. Reload it to see the new version; your unsaved changes will be lost. Šis failas diske pasikeitė. Įkelkite jį iš naujo, kad pamatytumėte naują versiją; neįrašyti pakeitimai bus prarasti.
theme Appearance Išvaizda
Bulleted list Sąrašas su ženkleliais
Numbered list Numeruotas sąrašas
Alignment Lygiuotė
Superscript Viršutinis indeksas
Subscript Apatinis indeksas
Paragraph spacing Tarpas tarp pastraipų
Less Mažiau
Other options Kitos parinktys
Can't render this file because it contains an unexpected character in line 404 and column 9.
+10
View File
@@ -2309,6 +2309,7 @@ animation effects Off Wyłączone
no unused output name brak wolnej nazwy pliku wyjściowego
no output folder was chosen nie wybrano folderu wyjściowego
@plural Downloaded {n} PDF|Downloaded {n} PDFs Pobrano {n} plik PDF|Pobrano {n} pliki PDF|Pobrano {n} plików PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} obliczenie używa teraz nowej nazwy.|{n} obliczenia używają teraz nowej nazwy.|{n} obliczeń używa teraz nowej nazwy.
Scan Skanuj
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpisz się na białej kartce i zrób jej zdjęcie przy jasnym, równomiernym świetle (użyj lampy błyskowej). Wybierz zdjęcie lub upuść je tutaj: PdfCraft znajdzie podpis i usunie papier.
Choose photo… Wybierz zdjęcie…
@@ -2410,3 +2411,12 @@ animation effects Off Wyłączone
Make properties default Ustaw właściwości jako domyślne
Pages selected Zaznaczone strony
Page properties Właściwości strony
Font Czcionka
Bulleted list Lista punktowana
Numbered list Lista numerowana
Alignment Wyrównanie
Superscript Indeks górny
Subscript Indeks dolny
Paragraph spacing Odstęp między akapitami
Less Mniej
Other options Inne opcje
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -2355,6 +2355,7 @@ animation effects Off Desativados
no unused output name nenhum nome de saída disponível
no output folder was chosen nenhuma pasta de saída foi escolhida
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF baixado|{n} PDFs baixados
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} cálculo agora usa o novo nome.|{n} cálculos agora usam o novo nome.
Scan Digitalizar
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Assine uma folha de papel branco e tire uma foto dela com luz forte e uniforme (use o flash). Escolha a foto ou solte-a aqui: o PdfCraft encontra a assinatura e remove o papel.
Choose photo… Escolher foto…
@@ -2448,3 +2449,12 @@ animation effects Off Desativados
Make properties default Tornar propriedades padrão
Pages selected Páginas selecionadas
Page properties Propriedades da página
Font Fonte
Bulleted list Lista com marcadores
Numbered list Lista numerada
Alignment Alinhamento
Superscript Sobrescrito
Subscript Subscrito
Paragraph spacing Espaçamento entre parágrafos
Less Menos
Other options Outras opções
Can't render this file because it contains an unexpected character in line 9 and column 16.
+10
View File
@@ -2254,6 +2254,7 @@ animation effects Off Выкл
no unused output name нет свободного имени для выходного файла
no output folder was chosen не выбрана папка для вывода
@plural Downloaded {n} PDF|Downloaded {n} PDFs Скачан {n} PDF-файл|Скачано {n} PDF-файла|Скачано {n} PDF-файлов
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} вычисление теперь использует новое имя.|{n} вычисления теперь используют новое имя.|{n} вычислений теперь используют новое имя.
Scan Сканировать
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Распишитесь на листе белой бумаги и сфотографируйте его при ярком равномерном освещении (со вспышкой). Выберите фото или перетащите его сюда: PdfCraft найдёт подпись и уберёт бумагу.
Choose photo… Выбрать фото…
@@ -2347,3 +2348,12 @@ animation effects Off Выкл
Make properties default Сделать свойства стандартными
Pages selected Выделенные страницы
Page properties Свойства страницы
Font Шрифт
Bulleted list Маркированный список
Numbered list Нумерованный список
Alignment Выравнивание
Superscript Надстрочный
Subscript Подстрочный
Paragraph spacing Интервал между абзацами
Less Меньше
Other options Другие параметры
Can't render this file because it contains an unexpected character in line 9 and column 16.
+10
View File
@@ -2345,6 +2345,7 @@ animation effects Off Izklopljeni
no unused output name ni prostega izhodnega imena
no output folder was chosen izhodna mapa ni bila izbrana
@plural Downloaded {n} PDF|Downloaded {n} PDFs Prenesen {n} PDF|Prenesena {n} PDF-ja|Preneseni {n} PDF-ji|Prenesenih {n} PDF-jev
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} izračun zdaj uporablja novo ime.|{n} izračuna zdaj uporabljata novo ime.|{n} izračuni zdaj uporabljajo novo ime.|{n} izračunov zdaj uporablja novo ime.
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} ime polja se ujema z imeni iz prejšnjih datotek in bo preimenovano: {list}|{n} imeni polj se ujemata z imeni iz prejšnjih datotek in bosta preimenovani: {list}|{n} imena polj se ujemajo z imeni iz prejšnjih datotek in bodo preimenovana: {list}|{n} imen polj se ujema z imeni iz prejšnjih datotek in bo preimenovanih: {list}
Scan Skeniraj
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Podpišite se na list belega papirja in ga fotografirajte v močni, enakomerni svetlobi (uporabite bliskavico). Izberite fotografijo ali jo spustite sem: PdfCraft poišče podpis in odstrani papir.
@@ -2439,3 +2440,12 @@ animation effects Off Izklopljeni
Make properties default Nastavi lastnosti kot privzete
Pages selected Izbrane strani
Page properties Lastnosti strani
Font Pisava
Bulleted list Označen seznam
Numbered list Oštevilčen seznam
Alignment Poravnava
Superscript Nadpisano
Subscript Podpisano
Paragraph spacing Razmik med odstavki
Less Manj
Other options Druge možnosti
Can't render this file because it contains an unexpected character in line 9 and column 16.
+10
View File
@@ -2242,6 +2242,7 @@ animation effects Off ఆఫ్
no unused output name ఉపయోగించని అవుట్‌పుట్ పేరు లేదు
no output folder was chosen అవుట్‌పుట్ ఫోల్డర్ ఎంచుకోలేదు
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF డౌన్‌లోడ్ చేయబడింది|{n} PDFలు డౌన్‌లోడ్ చేయబడ్డాయి
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} గణన ఇప్పుడు కొత్త పేరును ఉపయోగిస్తుంది.|{n} గణనలు ఇప్పుడు కొత్త పేరును ఉపయోగిస్తాయి.
Scan స్కాన్
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. తెల్ల కాగితంపై సంతకం చేసి, ప్రకాశవంతమైన, సమానమైన వెలుతురులో దాని ఫోటో తీయండి (ఫ్లాష్ ఉపయోగించండి). ఫోటోను ఎంచుకోండి లేదా ఇక్కడ వదలండి: PdfCraft సంతకాన్ని కనుగొని కాగితాన్ని తొలగిస్తుంది.
Choose photo… ఫోటో ఎంచుకోండి…
@@ -2343,3 +2344,12 @@ animation effects Off ఆఫ్
Make properties default లక్షణాలను డిఫాల్ట్‌గా చేయి
Pages selected ఎంచుకున్న పేజీలు
Page properties పేజీ లక్షణాలు
Font ఫాంట్
Bulleted list బుల్లెట్ జాబితా
Numbered list సంఖ్యల జాబితా
Alignment అమరిక
Superscript సూపర్‌స్క్రిప్ట్
Subscript సబ్‌స్క్రిప్ట్
Paragraph spacing పేరా అంతరం
Less తక్కువ
Other options ఇతర ఎంపికలు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+10
View File
@@ -2326,6 +2326,7 @@ animation effects Off Kapalı
no unused output name kullanılmayan çıktı adı yok
no output folder was chosen çıktı klasörü seçilmedi
@plural Downloaded {n} PDF|Downloaded {n} PDFs {n} PDF indirildi
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} hesaplama artık yeni adı kullanıyor.
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} alan adı önceki dosyalarla çakışıyor ve yeniden adlandırılacak: {list}
Scan Tara
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Beyaz bir kâğıda imzanızı atın ve parlak, eşit ışıkta fotoğrafını çekin (flaş kullanın). Fotoğrafı seçin veya buraya bırakın: PdfCraft imzayı bulur ve kâğıdı kaldırır.
@@ -2430,3 +2431,12 @@ animation effects Off Kapalı
Make properties default Özellikleri varsayılan yap
Pages selected Seçilen sayfalar
Page properties Sayfa özellikleri
Font Yazı tipi
Bulleted list Madde işaretli liste
Numbered list Numaralı liste
Alignment Hizalama
Superscript Üst simge
Subscript Alt simge
Paragraph spacing Paragraf aralığı
Less Daha az
Other options Diğer seçenekler
Can't render this file because it contains an unexpected character in line 825 and column 9.
+10
View File
@@ -2363,6 +2363,7 @@ animation effects Off Вимкнено
no unused output name немає вільної назви для вихідного файлу
no output folder was chosen не вибрано теку для виводу
@plural Downloaded {n} PDF|Downloaded {n} PDFs Завантажено {n} PDF-файл|Завантажено {n} PDF-файли|Завантажено {n} PDF-файлів
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} обчислення тепер використовує нову назву.|{n} обчислення тепер використовують нову назву.|{n} обчислень тепер використовують нову назву.
Scan Сканувати
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. Розпишіться на аркуші білого паперу та сфотографуйте його за яскравого рівномірного освітлення (зі спалахом). Виберіть фото або перетягніть його сюди: PdfCraft знайде підпис і прибере папір.
Choose photo… Вибрати фото…
@@ -2456,3 +2457,12 @@ animation effects Off Вимкнено
Make properties default Зробити властивості типовими
Pages selected Вибрані сторінки
Page properties Властивості сторінки
Font Шрифт
Bulleted list Маркований список
Numbered list Нумерований список
Alignment Вирівнювання
Superscript Надрядковий
Subscript Підрядковий
Paragraph spacing Інтервал між абзацами
Less Менше
Other options Інші параметри
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -2588,6 +2588,7 @@ certificate SHA-256 digest Giá trị băm SHA-256
@plural {n} field name collides with earlier files and will be renamed: {list}|{n} field names collide with earlier files and will be renamed: {list} {n} tên trường trùng với các tệp trước và sẽ được đổi tên: {list}
@plural {n} object selected|{n} objects selected Đã chọn {n} đối tượng
@plural Downloaded {n} PDF|Downloaded {n} PDFs Đã tải xuống {n} PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} phép tính giờ dùng tên mới.
key Ctrl Ctrl
key Shift Shift
key Delete Delete
@@ -2683,6 +2684,14 @@ key Space Space
Words Từ
“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one. “{name}” đã thay đổi trên đĩa sau khi được mở hoặc lưu lần cuối. Lưu lúc này sẽ thay thế phiên bản mới hơn đó bằng phiên bản này.
@id file.word_count Đếm từ…
Bulleted list Danh sách dấu đầu dòng
Numbered list Danh sách đánh số
Alignment Căn lề
Paragraph spacing Khoảng cách đoạn
Superscript Chỉ số trên
Subscript Chỉ số dưới
Less Ít hơn
Other options Tùy chọn khác
# Historical snapshot notice bar (View signed version / View revision).
You are viewing the signed version of “{from}” (revision {rev}). It is a snapshot: changes here won't affect the original. Bạn đang xem phiên bản đã ký của “{from}” (bản sửa đổi {rev}). Đây là ảnh nhanh: các thay đổi ở đây không được áp dụng đến bản gốc.
Can't render this file because it contains an unexpected character in line 491 and column 12.
+10
View File
@@ -2305,6 +2305,7 @@ animation effects Off 关
no unused output name 没有可用的输出文件名
no output folder was chosen 未选择输出文件夹
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下载 {n} 个 PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 个计算已改用新名称。
Scan 扫描
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白纸上签名,并在明亮、均匀的光线下拍照(使用闪光灯)。选择照片或将其拖放到此处:PdfCraft 会找到签名并去除纸张背景。
Choose photo… 选择照片…
@@ -2398,3 +2399,12 @@ animation effects Off 关
Make properties default 将属性设为默认
Pages selected 选定的页面
Page properties 页面属性
Font 字体
Bulleted list 项目符号列表
Numbered list 编号列表
Alignment 对齐
Superscript 上标
Subscript 下标
Paragraph spacing 段落间距
Less 收起
Other options 其他选项
Can't render this file because it contains an unexpected character in line 9 and column 16.
+10
View File
@@ -2204,6 +2204,7 @@ animation effects Off 關
no unused output name 沒有可用的輸出檔名
no output folder was chosen 未選擇輸出資料夾
@plural Downloaded {n} PDF|Downloaded {n} PDFs 已下載 {n} 個 PDF
@plural {n} calculation now uses the new name.|{n} calculations now use the new name. {n} 個計算已改用新名稱。
Scan 掃描
Sign a sheet of white paper and take a photo of it in bright, even light (use the flash). Choose the photo or drop it here: PdfCraft finds the signature and removes the paper. 在白紙上簽名,並在明亮、均勻的光線下拍照(使用閃光燈)。選擇相片或將其拖放到此處:PdfCraft 會找到簽名並去除紙張背景。
Choose photo… 選擇相片…
@@ -2384,3 +2385,12 @@ animation effects Off 關
Make properties default 將內容設為預設
Pages selected 選取的頁面
Page properties 頁面屬性
Font 字型
Bulleted list 項目符號清單
Numbered list 編號清單
Alignment 對齊
Superscript 上標
Subscript 下標
Paragraph spacing 段落間距
Less 收起
Other options 其他選項
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -185,6 +185,10 @@ pub static ICONS: &[(&str, &[u8])] = &[
("user-round", include_bytes!("../../../assets/icons/user-round.svg")),
("wand-sparkles", include_bytes!("../../../assets/icons/wand-sparkles.svg")),
("wrench", include_bytes!("../../../assets/icons/wrench.svg")),
("move-horizontal", include_bytes!("../../../assets/icons/move-horizontal.svg")),
("pilcrow", include_bytes!("../../../assets/icons/pilcrow.svg")),
("subscript", include_bytes!("../../../assets/icons/subscript.svg")),
("superscript", include_bytes!("../../../assets/icons/superscript.svg")),
("x", include_bytes!("../../../assets/icons/x.svg")),
("zoom-in", include_bytes!("../../../assets/icons/zoom-in.svg")),
("zoom-out", include_bytes!("../../../assets/icons/zoom-out.svg")),
+1 -1
View File
@@ -99,7 +99,7 @@ mod tests {
#[test]
fn shared_catalog_preserves_existing_icons_and_fallback_uri() {
assert_eq!(ICONS.len(), 186);
assert_eq!(ICONS.len(), 190);
assert!(exists("minimize-2"));
for (name, original) in ICONS {
let expected =
+12
View File
@@ -87,6 +87,7 @@ mod files;
pub mod fill_sign;
mod fling;
pub mod folders_ui;
mod format_text_ui;
pub mod forms_ui;
mod home;
mod icon_data;
@@ -698,6 +699,9 @@ pub struct PdfCraftApp {
/// How large the Combine grid draws its cards (1.0 = the usual; kept in the settings). Set
/// with [`PdfCraftApp::set_combine_zoom`].
pub combine_zoom: f32,
/// How large Organize pages draws its grid, for every document (1.0 = the usual; kept in
/// the settings).
pub organize_zoom: f32,
/// The Combine grid's thumbnails: rendered off the UI thread, a few at a time.
pub(crate) combine_thumbs: combine_grid::Thumbs,
/// Images waiting for the resolution choice (released on cancel).
@@ -1013,6 +1017,7 @@ impl PdfCraftApp {
combine_columns: Default::default(),
combine_view: Default::default(),
combine_zoom: 1.0,
organize_zoom: 1.0,
combine_thumbs: Default::default(),
image_import: None,
custom_stamps: Vec::new(),
@@ -2309,6 +2314,7 @@ impl PdfCraftApp {
v["redact_prefs"] = self.redact_defaults.to_json();
// Set outside json! above, which is at serde_json's macro recursion limit.
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
v.to_string()
}
@@ -2331,6 +2337,10 @@ impl PdfCraftApp {
if let Some(zoom) = v["combine_zoom"].as_f64() {
self.set_combine_zoom(zoom as f32);
}
// Clamped like any grid zoom when the grid next shows; nonsense keeps the usual size.
if let Some(zoom) = v["organize_zoom"].as_f64().map(|z| z as f32).filter(|z| z.is_finite()) {
self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());
}
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {
// Only keep entries whose files still exist.
#[cfg(not(target_arch = "wasm32"))]
@@ -2641,6 +2651,8 @@ impl PdfCraftApp {
return Err("grid-zoom must be between 50 and 300".into());
}
v.set_grid_zoom(percent / 100.0);
// The grid's size is one for every document, as it is remembered.
self.organize_zoom = v.grid_zoom();
}
("rotate", Some(v)) => {
let deg: u16 = value.parse().map_err(|_| "rotate: 0, 90, 180 or 270")?;
+224 -83
View File
@@ -2,7 +2,7 @@
//! right-hand panels (Comments, Bookmarks, Pages, Fields, Layers, Attachments).
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, pos2, vec2};
use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup};
use pdfcraft_engine::catalog::{self, Availability, TOOL_GROUPS, ToolGroup, ToolItem};
use pdfcraft_render::{DocInfo, FieldKind, OutlineItem};
use crate::theme::{self, Tokens};
@@ -144,6 +144,10 @@ fn tool_row(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup) -> egui::Response {
}
fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
if g.id == "edit" {
edit_panel(app, ui, t, g);
return;
}
let (back, close) = panel_header(ui, t, g.label, true);
if back {
app.left = LeftPanel::AllTools;
@@ -185,20 +189,6 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
});
ui.add_space(6.0);
}
// Edit a PDF shows Format text at the top while text is selected or being added.
if g.id == "edit" {
if app.quick_tool == crate::QuickTool::EditText {
let count = app.active_ids().map_or(0, |(i, _)| app.views[i].objects.count());
if count > 0 {
ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected"));
ui.label(tl!("Drag the selection to move it. Esc clears the selection."));
} else {
ui.label(tl!("Shift-click to select several objects. Drag empty space to select an area."));
}
ui.add_space(6.0);
}
format_section(app, ui, t);
}
let mut run = None;
// Redact a PDF has Acrobat's footer: Clear all / Redact all.
let footer = g.id == "redact";
@@ -238,31 +228,13 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
ui.painter().rect_filled(r, CornerRadius::same(4), fill);
ui.painter().text(r.center(), Align2::CENTER_CENTER, chip_label, font, cfg);
let resp = theme::hand(resp).on_hover_text(format!("{} — {}", tl!(item.label), item.command));
// An entry with further commands opens a menu; its caret goes before the chip.
if item.actions.is_empty() {
if resp.clicked() {
run = Some(item.command);
}
} else {
// A caret before the chip says the entry opens a menu (Add / Update / Remove).
let c = r.left_center() - vec2(10.0, 0.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![c + vec2(-3.5, -2.0), c + vec2(3.5, -2.0), c + vec2(0.0, 2.5)],
fg,
egui::Stroke::NONE,
));
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(180.0);
for (n, id) in item.actions.iter().enumerate() {
let Some(spec) = pdfcraft_engine::commands::command(id) else { continue };
// Add stays usable with no document open: it asks for a file first.
let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec);
let label = crate::i18n::menu_label(spec.id, spec.label);
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
run = Some(spec.id);
ui.close();
}
}
});
} else if let Some(cmd) = actions_menu(app, ui, &resp, item, r.left_center() - vec2(10.0, 0.0), fg) {
run = Some(cmd);
}
}
}
@@ -301,6 +273,172 @@ fn tool_detail(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static
}
}
/// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does.
const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"];
/// Add content items shown before More.
const EDIT_CONTENT_SHOWN: usize = 5;
/// Other options: the tools Acrobat's Edit panel links to.
const EDIT_OTHER_TOOLS: [&str; 3] = ["combine", "redact", "form"];
/// Edit a PDF, laid out as Acrobat's Edit panel: the title with Preferences and Close; Format text
/// while text is being edited or added; Modify page as a row of icon buttons and Organize pages;
/// Add content with the rarer items under More; and Other options, the neighbouring tools.
/// Edit text & images itself is on the floating toolbar, and on from the moment Edit opens.
/// Every item comes from the catalogue, so its label, icon and availability have one source.
fn edit_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static ToolGroup) {
let items = || g.sections.iter().flat_map(|s| s.items.iter());
let item = |command: &str| items().find(|i| i.command == command);
let mut run = None;
let mut tool = None;
// Back to All tools only when Edit was opened from there; the Edit tab is its own way back.
let from_all_tools = app.mode != crate::Mode::Edit;
ui.horizontal(|ui| {
if from_all_tools && icons::button(ui, "chevron-left", 26.0, false, tl!("Back to all tools")).clicked() {
app.left = LeftPanel::AllTools;
app.mode = crate::Mode::AllTools;
}
// "Edit" under the Edit tab, as Acrobat titles it; the tool's own name from All tools.
let title = if from_all_tools { tl!(g.label) } else { tl!("Edit") };
let title_width = (ui.available_width() - 2.0 * (26.0 + ui.spacing().item_spacing.x)).max(1.0);
ui.add_sized([title_width, 26.0], egui::Label::new(egui::RichText::new(title).font(theme::semibold(15.5)).color(t.text)).truncate())
.on_hover_text(title);
if icons::button(ui, "settings", 26.0, false, tl!("Preferences")).clicked() {
run = Some("app.preferences");
}
if icons::button(ui, "x", 26.0, false, tl!("Close panel")).clicked() {
app.left_open = false;
}
});
ui.add_space(2.0);
ui.separator();
ui.add_space(4.0);
// Several objects selected: how many, and how to move them. Acrobat's panel has no idle hint.
let count = app.active_ids().filter(|_| app.quick_tool == crate::QuickTool::EditText).map_or(0, |(i, _)| app.views[i].objects.count());
if count > 0 {
ui.label(crate::i18n::trn(crate::i18n::current(), count as u64, "{n} object selected", "{n} objects selected"));
ui.label(tl!("Drag the selection to move it. Esc clears the selection."));
ui.add_space(6.0);
}
// Format text at the top while text is selected or being added.
format_section(app, ui, t);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
widgets::section_title(ui, tl!("Modify page"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
for i in EDIT_PAGE_BUTTONS.iter().filter_map(|c| item(c)) {
// Enabled as the command itself is: a document that forbids page assembly refuses these.
let enabled = pdfcraft_engine::commands::command(i.command).is_some_and(|spec| app.command_enabled(spec));
let resp = ui.add_enabled_ui(enabled, |ui| icons::button(ui, i.icon, 34.0, false, tl!(i.label))).inner;
if resp.clicked() {
run = Some(i.command);
}
}
});
ui.add_space(2.0);
// Organize pages is a workspace of its own: its panel replaces this one, with the grid.
if let Some(i) = item("page.organize")
&& edit_row(ui, t, i.icon, tl!(i.label), i.availability).clicked()
{
// With no document there is no grid to open: the command asks for a file first.
if app.active.is_some() {
tool = Some("organize");
} else {
run = Some("page.organize");
}
}
widgets::section_title(ui, tl!("Add content"));
let content: Vec<_> =
g.sections.iter().filter(|s| s.title == "Add content").flat_map(|s| s.items.iter()).filter(|i| i.command != "edit.edit_text").collect();
let more_id = egui::Id::new("edit_panel_more");
let more = ui.data(|d| d.get_temp::<bool>(more_id)).unwrap_or(false);
let shown = if more { content.len() } else { EDIT_CONTENT_SHOWN.min(content.len()) };
for i in content.iter().take(shown) {
let resp = edit_row(ui, t, i.icon, tl!(i.label), i.availability);
// Header and footer, Watermark and Background open their Add / Update / Remove menu.
if i.actions.is_empty() {
if resp.clicked() {
run = Some(i.command);
}
} else if let Some(cmd) = actions_menu(app, ui, &resp, i, resp.rect.right_center() - vec2(14.0, 0.0), t.text) {
run = Some(cmd);
}
}
if content.len() > EDIT_CONTENT_SHOWN {
ui.add_space(4.0);
let label = if more { tl!("Less") } else { tl!("More") };
let resp = ui
.add(
egui::Label::new(egui::RichText::new(label).color(t.accent_text).font(theme::medium(13.0)))
.selectable(false)
.sense(Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() {
ui.data_mut(|d| d.insert_temp(more_id, !more));
}
}
widgets::section_title(ui, tl!("Other options"));
for other in EDIT_OTHER_TOOLS.iter().filter_map(|id| catalog::group(id)) {
if edit_row(ui, t, other.icon, tl!(other.label), other.availability).clicked() {
tool = Some(other.id);
}
}
});
if let Some(id) = tool {
app.select_tool(id);
}
if let Some(cmd) = run {
app.run_command(cmd);
}
}
/// A tool entry with further commands (Add / Update / Remove): a caret at `caret` says it opens a
/// menu, and each command is enabled by its own needs. Returns the command chosen.
fn actions_menu(app: &PdfCraftApp, ui: &egui::Ui, resp: &egui::Response, item: &ToolItem, caret: egui::Pos2, color: Color32) -> Option<&'static str> {
ui.painter().add(egui::Shape::convex_polygon(
vec![caret + vec2(-3.5, -2.0), caret + vec2(3.5, -2.0), caret + vec2(0.0, 2.5)],
color,
egui::Stroke::NONE,
));
let mut run = None;
egui::Popup::menu(resp).show(|ui| {
ui.set_min_width(180.0);
for (n, id) in item.actions.iter().enumerate() {
let Some(spec) = pdfcraft_engine::commands::command(id) else { continue };
// Add stays usable with no document open: it asks for a file first.
let enabled = (n == 0 && app.active.is_none()) || app.command_enabled(spec);
let label = crate::i18n::menu_label(spec.id, spec.label);
if ui.add_enabled(enabled, egui::Button::new(label)).clicked() {
run = Some(spec.id);
ui.close();
}
}
});
run
}
/// One row of the Edit panel: a monochrome icon and the label, as Acrobat draws them. A planned
/// item is muted and says when it ships when hovered.
fn edit_row(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str, availability: Availability) -> egui::Response {
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
let press = theme::Press::track(ui, &resp);
press.wash(ui, rect, 6, false);
let body = rect.translate(press.offset());
let ready = availability == Availability::Ready;
let fg = if ready { t.text } else { t.text_muted };
icons::paint(ui, Rect::from_min_size(body.min + vec2(6.0, 8.0), vec2(18.0, 18.0)), icon, 17.0, if ready { t.text } else { t.text_faint });
row_label(ui, label, theme::regular(13.5), fg, body.left_center() + vec2(34.0, 0.0), rect.width() - 42.0);
let resp = theme::hand(resp);
match availability {
Availability::Ready => resp,
Availability::Planned(m) => resp.on_hover_text(format!("{} {m}", tl!("Planned for milestone"))),
Availability::Provider => resp.on_hover_text(tl!("Optional: needs an AI provider you configure")),
}
}
/// Add a stamp: Dynamic, Sign Here and Standard Business stamps; click one, then click on the
/// page to place it.
fn stamp_palette(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
@@ -378,50 +516,42 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
if let Some((i, _)) = app.active_ids()
&& let Some(ed) = app.views[i].line_editor.clone()
{
use crate::format_text_ui::{FontChoice, Fonts};
let mut ed = ed;
let mut changed = false;
let font_choices = app.session.get(app.views[i].id).map(|d| d.text_editor_fonts(ed.page)).unwrap_or_default();
egui::ComboBox::from_id_salt("document-font-resource")
.selected_text(
ed.font_resource
.as_ref()
.and_then(|r| font_choices.iter().find(|f| &f.resource == r))
.map_or(tl!("Keep source fonts"), |f| f.face.as_str()),
)
.show_ui(ui, |ui| {
changed |= ui.selectable_value(&mut ed.font_resource, None, tl!("Keep source fonts")).changed();
for font in font_choices.iter().filter(|f| !f.resource.starts_with("PCApproved")) {
changed |= ui.selectable_value(&mut ed.font_resource, Some(font.resource.clone()), &font.face).changed();
}
});
if ui.button(tl!("Load font…")).clicked() {
app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false);
}
#[cfg(not(target_arch = "wasm32"))]
egui::ComboBox::from_id_salt("installed-document-font").selected_text(tl!("Installed fonts…")).show_ui(ui, |ui| {
if let Some(fonts) = crate::files::installed_font_files(ui.ctx()) {
if fonts.is_empty() {
ui.label(tl!("No openly licensed document fonts found. Load a font file."));
}
for font in fonts {
if ui.selectable_label(false, &font.face).clicked() {
app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[font.path]);
}
}
} else {
ui.label(tl!("Finding document fonts…"));
let sources: Vec<(String, String)> = app
.session
.get(app.views[i].id)
.map(|d| d.text_editor_fonts(ed.page))
.unwrap_or_default()
.into_iter()
.map(|f| (f.resource, f.face))
.collect();
let shown = ed.font_shown(&sources);
let out = crate::format_text_ui::panel(ui, t, &mut ed.look, Some(&mut ed.extras), Fonts { sources: Some(&sources), shown });
let mut changed = out.changed;
match out.font {
Some(FontChoice::KeepSource) => {
ed.keep_source_font();
changed = true;
}
});
if let Some(look) = crate::content_ui::format_panel(ui, t, &ed.look) {
ed.look = look;
changed = true;
Some(FontChoice::Resource(resource)) => {
ed.font_resource = Some(resource);
changed = true;
}
Some(FontChoice::Family(family)) => {
ed.choose_family(family);
changed = true;
}
Some(FontChoice::Load) => app.pick_files(crate::files::FilePurpose::TextFont { page: ed.page }, false),
#[cfg(not(target_arch = "wasm32"))]
Some(FontChoice::Installed(path)) => app.use_paths(crate::files::FilePurpose::TextFont { page: ed.page }, &[path]),
#[cfg(target_arch = "wasm32")]
Some(FontChoice::Installed(_)) => {}
None => {}
}
changed |= crate::edit_text_ui::extras_panel(ui, &mut ed.extras);
if changed {
app.apply_line_format(i, ed, Some(ui.ctx()));
}
ui.add_space(6.0);
ui.separator();
return;
}
let image = app.active_ids().and_then(|(i, id)| {
@@ -455,21 +585,32 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
});
match selected {
Some((page, index, text)) => {
if let Some(style) = crate::content_ui::format_panel(ui, t, &text) {
app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style.clone() };
app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style) });
let mut style = text.clone();
added_text_format(ui, t, &mut style);
if style != text {
// The reusable style follows the selection only when the edit took, so a refused one adds nothing odd later.
if app.apply_edit(pdfcraft_engine::Edit::UpdateContent { page, index, content: pdfcraft_engine::AddedContent::Text(style.clone()) }) {
app.text_style = pdfcraft_engine::AddedText { text: String::new(), rect: [0.0; 4], ..style };
}
}
}
None if app.quick_tool == crate::QuickTool::AddText => {
crate::content_ui::hint(ui, t);
if let Some(style) = crate::content_ui::format_panel(ui, t, &app.text_style) {
app.text_style = style;
}
let mut style = app.text_style.clone();
added_text_format(ui, t, &mut style);
app.text_style = style;
}
None => return,
None => {}
}
}
/// Format text for added text: the standard families, size, colour, style and alignment.
fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText) {
let shown = style.family.label().to_string();
let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown });
if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font {
style.family = family;
}
ui.add_space(6.0);
ui.separator();
}
impl PdfCraftApp {
+58 -1
View File
@@ -236,7 +236,7 @@ fn typing_moving_styling_and_deleting_added_content() {
h.run_steps(2);
assert_eq!(h.state().views[0].content.selected, Some((0, 0)), "the new text is selected");
// Bold from the Format panel: one undoable change.
h.get_by_label("B").click();
h.get_by_label("Bold").click();
h.run_steps(3);
let pdfcraft_engine::AddedContent::Text(t) = &added(&h)[0].content else { panic!() };
@@ -585,3 +585,60 @@ fn released_text_does_not_jump_back_before_the_page_is_redrawn() {
let (old, new) = ink_at(&mut h, from, to);
assert!(new > 20 && old == 0, "the redrawn page has the text in its new place ({old} old, {new} new)");
}
/// Format text is laid out as Acrobat's: alignment from its flyout, style buttons by name, and
/// the controls PdfCraft can't apply yet present but disabled.
/// Format text lines its rows up (#3037): the size list is centred beside the colour swatch and
/// the alignment button and is as tall as the font list, each spacing row's controls share one
/// line, and nothing reaches past the font list, which spans the panel.
#[test]
fn format_text_rows_line_up_and_fit_the_panel() {
use egui::accesskit::Role;
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.get_by_label("FORMAT TEXT");
let combo = |h: &Harness<'static, PdfCraftApp>, value: &str| {
h.query_all_by_role(Role::ComboBox).find(|n| n.value().as_deref() == Some(value)).map(|n| n.rect()).expect(value)
};
let (font, size) = (combo(&h, "Helvetica"), combo(&h, "12"));
let middle = |r: egui::Rect| r.center().y;
for label in ["Colour", "Alignment"] {
let other = h.get_by_label(label).rect();
assert!((middle(size) - middle(other)).abs() < 0.5, "the size list ({size:?}) is centred with {label} ({other:?})");
}
assert_eq!(size.height(), font.height(), "the font and size lists are the same height");
// Line and paragraph spacing; scale and character spacing with their lists.
let line = [combo(&h, "Auto"), combo(&h, "0")];
assert!((middle(line[0]) - middle(line[1])).abs() < 0.5, "{line:?}");
// Each: its number field and the arrow that lists common values.
let scale_row: Vec<egui::Rect> =
["Horizontal scale", "Character spacing"].iter().flat_map(|l| h.query_all_by_label(l).map(|n| n.rect())).collect();
assert_eq!(scale_row.len(), 4, "two fields and their two arrows: {scale_row:?}");
for r in &scale_row {
assert!((middle(*r) - middle(scale_row[0])).abs() < 0.5, "{scale_row:?}");
}
for r in h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).chain(scale_row) {
assert!(r.right() <= font.right() + 0.5, "{r:?} reaches past the panel ({font:?})");
}
}
#[test]
fn format_text_panel_sets_alignment_and_lists_planned_controls_disabled() {
use egui_kittest::kittest::NodeT;
let mut h = harness();
assert!(h.state_mut().execute("edit.text"));
h.run_steps(3);
h.get_by_label("FORMAT TEXT");
for planned in ["Bulleted list", "Numbered list", "Superscript", "Subscript"] {
assert!(h.get_by_label(planned).accesskit_node().is_disabled(), "{planned} is shown but disabled");
}
h.get_by_label("Alignment").click();
h.run_steps(2);
h.get_by_label("Centre").click();
h.run_steps(2);
assert_eq!(h.state().text_style.align, pdfcraft_engine::TextAlign::Center);
h.get_by_label("Bold").click();
h.run_steps(2);
assert!(h.state().text_style.bold);
}
+38 -40
View File
@@ -119,26 +119,28 @@ fn control_resize_reports_observed_dimensions_and_rejects_invalid_sizes() {
#[test]
fn control_focus_traverses_real_widgets_and_keyboard_activates() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
assert_eq!(read["widgets"][0]["focused"], true, "{read}");
assert_eq!(read["widgets"][0]["focusable"], true);
ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
let focused =
|found: &serde_json::Value, label: &str| found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == label && w["focused"] == true);
assert!(focused(&convert, "Convert"), "{convert}");
assert!(convert["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focusable"] == true));
ok(&mut h, &c, "ui.key", json!({"key": "Tab"}));
let edit = ok(&mut h, &c, "ui.inspect", json!({"query": "Edit", "role": "Button"}));
assert!(edit["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Edit" && w["focused"] == true), "{edit}");
let sign = ok(&mut h, &c, "ui.inspect", json!({"query": "E-Sign", "role": "Button"}));
assert!(focused(&sign, "E-Sign"), "{sign}");
ok(&mut h, &c, "ui.key", json!({"key": "Tab", "modifiers": ["shift"]}));
let read = ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}));
assert_eq!(read["widgets"][0]["focused"], true);
let convert = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
assert!(focused(&convert, "Convert"), "{convert}");
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
let state = ok(&mut h, &c, "ui.state", json!({}));
assert_eq!(state["mode"], "Read");
assert_eq!(state["mode"], "Convert");
assert_eq!(state["documents"][0]["dirty"], false);
}
#[test]
fn control_button_focus_preserves_document_undo_redo_shortcuts() {
let (mut h, c) = harness_pages(2);
ok(&mut h, &c, "ui.focus", json!({"label": "Read"}));
ok(&mut h, &c, "ui.focus", json!({"label": "Convert"}));
ok(&mut h, &c, "ui.key", json!({"key": "Enter"}));
ok(&mut h, &c, "ui.click", json!({"label": "All tools"}));
assert!(h.ctx.egui_wants_keyboard_input());
@@ -176,9 +178,9 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
json!({"label": "does not exist"}),
json!({"id": "not a number"}),
json!({"id": 0}),
json!({"id": 1, "label": "Read"}),
json!({"id": 1, "label": "Convert"}),
json!({"label": false}),
json!({"label": "Read", "force": true}),
json!({"label": "Convert", "force": true}),
json!([]),
] {
assert!(call(&mut h, &c, "ui.focus", params).is_err());
@@ -186,9 +188,10 @@ fn control_focus_rejects_missing_ambiguous_disabled_and_nonfocusable_targets() {
let widgets = ok(&mut h, &c, "ui.inspect", json!({}));
let nonfocusable = widgets["widgets"].as_array().unwrap().iter().find(|w| w["focusable"] == false).unwrap();
assert!(call(&mut h, &c, "ui.focus", json!({"id": nonfocusable["id"]})).is_err());
let read = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read").unwrap();
ok(&mut h, &c, "ui.focus", json!({"id": read["id"]}));
assert_eq!(ok(&mut h, &c, "ui.inspect", json!({"query": "Read", "role": "Button"}))["widgets"][0]["focused"], true);
let convert = widgets["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert").unwrap();
ok(&mut h, &c, "ui.focus", json!({"id": convert["id"]}));
let found = ok(&mut h, &c, "ui.inspect", json!({"query": "Convert", "role": "Button"}));
assert!(found["widgets"].as_array().unwrap().iter().any(|w| w["label"] == "Convert" && w["focused"] == true), "{found}");
// Home has no active document, so its document-only toolbar controls are disabled.
ok(&mut h, &c, "ui.click", json!({"label": "Home"}));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["home"], true);
@@ -740,8 +743,8 @@ fn japanese_controls_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "ja" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["language"], "ja");
ok(&mut h, &c, "ui.click", json!({ "label": "閲覧" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
ok(&mut h, &c, "ui.click", json!({ "label": "変換" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "表示言語" }));
@@ -766,8 +769,8 @@ fn japanese_controls_and_search_keep_command_ids() {
fn german_preferences_and_search_keep_command_ids() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({ "key": "language", "value": "de" }));
ok(&mut h, &c, "ui.click", json!({ "label": "Lesen" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
ok(&mut h, &c, "ui.click", json!({ "label": "Konvertieren" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
ok(&mut h, &c, "ui.command", json!({ "id": "app.preferences" }));
let prefs = ok(&mut h, &c, "ui.inspect", json!({ "query": "Sprache der Oberfläche" }));
assert!(prefs["count"].as_u64().unwrap() > 0, "{prefs}");
@@ -958,23 +961,24 @@ fn simplified_chinese_signature_prompts_and_errors_keep_document_state() {
#[test]
fn inspect_and_click_by_label_and_id() {
let (mut h, c) = harness();
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "read" }));
let read = found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Read" && w["clickable"] == true).cloned().expect("a Read tab");
let rect = read["rect"].as_array().unwrap();
let found = ok(&mut h, &c, "ui.inspect", json!({ "query": "convert" }));
let convert =
found["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "Convert" && w["clickable"] == true).cloned().expect("a Convert tab");
let rect = convert["rect"].as_array().unwrap();
assert!(rect[2].as_f64().unwrap() > rect[0].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "label": "Read" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
ok(&mut h, &c, "ui.click", json!({ "label": "Convert" }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let all = ok(&mut h, &c, "ui.inspect", json!({ "query": "all tools" }));
let id = all["widgets"].as_array().unwrap().iter().find(|w| w["label"] == "All tools" && w["clickable"] == true).unwrap()["id"].clone();
ok(&mut h, &c, "ui.click", json!({ "id": id }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "AllTools");
// Clicking a point works too (here: the Read tab's centre).
// Clicking a point works too (here: the Convert tab's centre).
let [x0, y0, x1, y1] = [0, 1, 2, 3].map(|i| rect[i].as_f64().unwrap());
ok(&mut h, &c, "ui.click", json!({ "x": (x0 + x1) / 2.0, "y": (y0 + y1) / 2.0 }));
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Read");
assert_eq!(ok(&mut h, &c, "ui.state", json!({}))["mode"], "Convert");
let err = call(&mut h, &c, "ui.click", json!({ "label": "No such button" })).unwrap_err();
assert!(err.contains("no enabled clickable widget"), "{err}");
@@ -1295,19 +1299,13 @@ fn docking_pointer_group_float_close_restore_and_document_commands() {
let (mut h, c) = harness();
ok(&mut h, &c, "ui.set", json!({"key":"panel", "value":"bookmarks"}));
h.run_steps(4);
let from = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").rect().center();
let to = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools").rect().center();
h.event(egui::Event::PointerMoved(from));
h.run_steps(2);
h.event(egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
h.run_steps(2);
for step in 1..=8 {
h.event(egui::Event::PointerMoved(from.lerp(to, step as f32 / 8.0)));
h.run_steps(1);
}
h.event(egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
// Tools docked alone has no tab strip (as Acrobat's tool pane), so it is grouped by command.
assert!(h.query_by_role_and_label(egui::accesskit::Role::Tab, "Tools").is_none());
ok(&mut h, &c, "ui.dock", json!({"operation":"move", "panel":"inspector", "target":"tools", "zone":"center"}));
h.run_steps(4);
assert_eq!(h.state().docking.layout.location(&Panel::Inspector).unwrap().anchor, Some(Panel::Tools));
// Grouped, Tools has its tab again.
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tools");
h.get_by_role_and_label(egui::accesskit::Role::Tab, "Inspector").click_button(egui::PointerButton::Secondary);
h.run_steps(3);
h.get_by_label("Float panel").click();
@@ -1622,10 +1620,10 @@ fn click_and_drag_refuse_coordinates_that_overflow_like_move() {
let e = call(&mut h, &c, method, params.clone()).expect_err(&format!("{method} {params} is refused"));
assert!(e.starts_with(&format!("{method}: ")) && e.contains("finite"), "{e}");
h.run_steps(4);
assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["count"], 1);
assert_eq!(ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["count"], 1);
}
let read = ok(&mut h, &c, "ui.inspect", json!({ "query": "Read", "role": "Button" }))["widgets"][0]["rect"].clone();
let (x, y) = (read[0].as_f64().unwrap() + 4.0, read[1].as_f64().unwrap() + 4.0);
let convert = ok(&mut h, &c, "ui.inspect", json!({ "query": "Convert", "role": "Button" }))["widgets"][0]["rect"].clone();
let (x, y) = (convert[0].as_f64().unwrap() + 4.0, convert[1].as_f64().unwrap() + 4.0);
assert_eq!(ok(&mut h, &c, "ui.click", json!({ "x": x, "y": y }))["clicked"], json!([x, y]));
ok(&mut h, &c, "ui.drag", json!({ "from": [x, y], "to": [x + 10.0, y], "steps": 2 }));
}
+99 -4
View File
@@ -1880,6 +1880,39 @@ fn page_grid_zooms_with_its_buttons_keys_and_pinch() {
assert!(!dirty(&h), "zooming is not an edit");
}
/// As in Acrobat, Organize pages keeps the size last chosen: every document's grid opens at it,
/// and so does the next start of the app.
#[test]
fn page_grid_size_is_remembered_for_every_document_and_restart() {
let mut h = organize(3);
h.get_by_label("Larger pages").click();
h.run_steps(3);
assert_eq!(grid_zoom(&h), 1.25);
h.state_mut().open_bytes("other.pdf", None, fixture(2)).expect("second fixture opens");
h.state_mut().set_option("organize", "on").unwrap();
h.run_steps(4);
let other = h.state().active.expect("the new document is active");
assert_ne!(other, 0);
assert_eq!(h.state().views[other].grid_zoom(), 1.25, "the other document");
let settings = h.state().persist();
let h = harness(3, move |app| {
app.restore(&settings);
app.set_option("organize", "on").unwrap();
});
assert_eq!(grid_zoom(&h), 1.25, "after a restart");
}
#[test]
fn a_damaged_remembered_page_grid_size_is_ignored_or_clamped() {
let mut app = PdfCraftApp::new();
app.restore(r#"{"organize_zoom": "huge"}"#);
assert_eq!(app.organize_zoom, 1.0);
app.restore(r#"{"organize_zoom": 40.0}"#);
assert_eq!(app.organize_zoom, 3.0);
app.restore(r#"{"organize_zoom": -2}"#);
assert_eq!(app.organize_zoom, 0.5);
}
#[test]
fn zoomed_pages_in_view_are_rendered_at_the_size_drawn() {
// A long document: sharpness must not depend on how many pages there are.
@@ -2995,7 +3028,7 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
assert!(h.state().views[0].line_editor.is_some());
// The Format text panel shows the paragraph's look; making it bold applies at once.
h.get_by_label("FORMAT TEXT");
h.get_by_label("B").click();
h.get_by_label("Bold").click();
h.run_steps(4);
let s = h.state();
let doc = s.session.get(s.views[0].id).unwrap();
@@ -3011,6 +3044,62 @@ fn the_format_panel_restyles_the_paragraph_being_edited() {
assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline));
}
/// Horizontal scale and Character spacing take any value in their ranges, not only the listed
/// ones: values between the listed ones and beyond them, typed in Format text, reach the saved
/// file as the engine writes them.
#[test]
fn the_format_panel_takes_any_scale_and_character_spacing() {
let mut h = harness(1, |_| {});
let id = h.state().views[0].id;
let width = |h: &Harness<'static, PdfCraftApp>, view: usize| {
let s = h.state();
let r = s.session.get(s.views[view].id).unwrap().text_blocks(0)[0].rect;
r[2] - r[0]
};
let plain = width(&h, 0);
assert!(h.state_mut().execute("edit.edit_text"));
h.run_steps(2);
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height());
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
assert!(h.state().views[0].line_editor.is_some());
// Typed the way a person does: click the field, type, Enter.
let type_in = |h: &mut Harness<'static, PdfCraftApp>, label: &str, value: &str| {
h.get_by_role_and_label(Role::SpinButton, label).click();
h.run_steps(2);
h.event(egui::Event::Text(value.into()));
h.run_steps(1);
h.key_press(Key::Enter);
h.run_steps(4);
};
let extras = |h: &Harness<'static, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| (e.extras.scale, e.extras.char_spacing));
// Between the listed values (50…200 %, −1…5 pt) and beyond them, within what was always allowed.
type_in(&mut h, "Horizontal scale", "80");
assert_eq!(extras(&h), Some((80.0, 0.0)));
type_in(&mut h, "Horizontal scale", "250");
assert_eq!(extras(&h), Some((250.0, 0.0)));
type_in(&mut h, "Character spacing", "0.25");
assert_eq!(extras(&h), Some((250.0, 0.25)));
type_in(&mut h, "Character spacing", "-2");
assert_eq!(extras(&h), Some((250.0, -2.0)));
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("Edit text"));
// Saved and reopened, the paragraph is as wide as the engine draws it with those values.
let bytes = h.state().session.save_bytes(id).unwrap();
h.state_mut().open_bytes("reference.pdf", None, fixture(1)).expect("opens");
let style = pdfcraft_engine::BlockStyle { scale: Some(250.0), char_spacing: Some(-2.0), ..Default::default() };
assert!(h.state_mut().apply_edit(pdfcraft_engine::Edit::EditTextBlock { page: 0, block: 0, text: "Page 1".into(), style }));
h.state_mut().open_bytes("saved.pdf", None, bytes.as_ref().clone()).expect("the saved file opens");
h.run_steps(3);
let (reference, saved) = (width(&h, 1), width(&h, 2));
assert!(saved > plain * 1.5, "250 % widens it: {plain} → {saved}");
assert!((saved - reference).abs() < 0.5, "saved {saved}, the engine's own {reference}");
}
#[test]
fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
let mut h = harness(1, |_| {});
@@ -3029,7 +3118,7 @@ fn selected_text_formatting_keeps_the_rest_of_the_paragraph_and_reopens() {
h.key_press_modifiers(Modifiers::SHIFT, Key::ArrowLeft);
h.run_steps(2);
assert_eq!(h.state().views[0].line_editor.as_ref().unwrap().selection, Some(5..6));
h.get_by_label("B").click();
h.get_by_label("Bold").click();
h.run_steps(5);
let id = h.state().views[0].id;
let doc = h.state().session.get(id).unwrap();
@@ -3256,11 +3345,17 @@ fn paragraph_drag_points(h: &Harness<'static, PdfCraftApp>, text: &str, offset:
fn drag_paragraph(h: &mut Harness<'static, PdfCraftApp>, text: &str, offset: [f64; 2]) {
let (from, to) = paragraph_drag_points(h, text, offset);
h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
assert!(h.state().views[0].line_editor.is_none());
drag(h, from, to);
assert!(h.state().views[0].line_editor.is_none(), "a body drag must not open the typing editor");
h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
fn assert_dragged_paragraphs(actual: &[pdfcraft_engine::TextBlock], expected: &[pdfcraft_engine::TextBlock]) {
+12 -3
View File
@@ -146,7 +146,10 @@ fn marquee_and_mixed_group_drag_move_once_and_undo_together() {
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), before);
h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
h.key_press_modifiers(Modifiers::COMMAND | Modifiers::SHIFT, Key::Z);
h.run_steps(4);
assert_eq!(objects(&h), after);
@@ -171,7 +174,10 @@ fn modifier_clicks_extend_an_ordinary_selection_and_toggle_objects() {
assert!(!h.state().session.get(h.state().views[0].id).unwrap().dirty);
h.key_press(Key::Escape);
h.run_steps(3);
h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
}
#[test]
@@ -221,7 +227,10 @@ fn a_document_change_invalidates_old_selection_before_the_next_drag() {
.apply(id, Edit::AddText { page: 0, text: AddedText { text: "A new item".into(), rect: [40.0, 210.0, 150.0, 240.0], ..Default::default() } })
.unwrap();
h.run_steps(4);
h.get_by_label("Shift-click to select several objects. Drag empty space to select an area.");
assert!(
h.query_all_by_label_contains("object selected").next().is_none() && h.query_all_by_label_contains("objects selected").next().is_none(),
"no objects selected"
);
assert_eq!(objects(&h).len(), 5);
}
+39
View File
@@ -147,6 +147,45 @@ fn format_validate_and_calculate_tabs() {
assert_eq!(f.actions.calculate, Calculate::Notation("name".into()));
}
/// Renaming a field in Field Properties renames it in the formulas that read it, in the same undo
/// step, and says how many changed.
#[test]
fn renaming_a_field_says_how_many_calculations_now_use_the_new_name() {
use pdfcraft_engine::form_scripts::Calculate;
let mut h = harness();
h.state_mut().execute("form.prepare");
h.run_steps(2);
let rename = |h: &mut Harness<'static, PdfCraftApp>, from: &str, to: &str| {
h.state_mut().open_field_props(from, 0);
h.run_steps(2);
h.state_mut().field_props.as_mut().expect("open").name = to.into();
h.get_by_label("OK").click();
h.run_steps(3);
};
h.state_mut().open_field_props("city", 0);
h.run_steps(2);
h.state_mut().field_props.as_mut().expect("open").calculate = Calculate::Notation("name * 2".into());
h.get_by_label("OK").click();
h.run_steps(3);
let calculation = |h: &Harness<'static, PdfCraftApp>| {
let s = h.state();
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "city").unwrap().actions.calculate.clone()
};
h.state_mut().toast = None;
rename(&mut h, "name", "full_name");
assert_eq!(calculation(&h), Calculate::Notation("full_name * 2".into()));
assert_eq!(h.state().toast.as_ref().map(|t| t.0.as_str()), Some("1 calculation now uses the new name."));
h.state_mut().toast = None;
rename(&mut h, "city", "town");
assert!(names(&h).contains(&"town".to_string()));
assert_eq!(h.state().toast, None, "no formula reads the calculated field itself");
h.state_mut().execute("edit.undo");
h.state_mut().execute("edit.undo");
h.run_steps(2);
assert!(names(&h).contains(&"name".to_string()));
assert_eq!(calculation(&h), Calculate::Notation("name * 2".into()), "undone with the rename");
}
#[test]
fn appearance_tab_restyles_the_field() {
use pdfcraft_engine::{BorderStyle, FieldFont};
+53 -3
View File
@@ -192,6 +192,21 @@ fn read_mode_rail_buttons_open_their_panels() {
h.get_by_label("Alpha section");
}
/// The mode bar matches Acrobat's: All tools, Edit, Convert and E-Sign, with no Read tab. Read
/// mode is still a View command.
#[test]
fn the_mode_bar_has_no_read_tab() {
use pdfcraft_ui_egui::Mode;
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
for label in ["All tools", "Edit", "Convert", "E-Sign"] {
assert!(h.query_all_by_label(label).next().is_some(), "the {label} tab is there");
}
assert!(h.query_all_by_label("Read").next().is_none(), "no Read tab");
h.state_mut().run_command("view.read_mode");
h.run_steps(2);
assert_eq!(h.state().mode, Mode::Read);
}
/// A document that asks to open in Full Screen mode (`/PageMode /FullScreen`) is asked about
/// first, as in Acrobat: "Not now" leaves the window as it is, "Full Screen" enters it.
#[test]
@@ -227,7 +242,7 @@ fn read_mode_closes_the_panes_and_leaving_it_brings_them_back() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
h.get_by_label("Read").click();
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_eq!(h.state().mode, Mode::Read);
assert_eq!(h.state().right, None);
@@ -254,7 +269,7 @@ fn read_mode_hides_the_quick_toolbar() {
h.get_by_label("Hand (H)").click();
h.run_steps(2);
assert_eq!(h.state().quick_tool, QuickTool::Hand);
h.get_by_label("Read").click();
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert!(h.query_by_label("Select (V)").is_none(), "no quick toolbar in Read mode");
assert_eq!(h.state().quick_tool, QuickTool::Select);
@@ -286,7 +301,7 @@ fn a_pane_closed_in_read_mode_stays_closed_when_leaving_it() {
use pdfcraft_ui_egui::{Mode, RightPanel};
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
assert_eq!(h.state().right, Some(RightPanel::Comments));
h.get_by_label("Read").click();
h.state_mut().run_command("view.read_mode");
h.run_steps(3);
assert_eq!(h.state().right, None);
// Open Comments from the rail, then close it again.
@@ -1425,3 +1440,38 @@ fn the_edit_tab_turns_on_edit_text_and_images() {
h.run_steps(3);
assert_eq!(h.state().quick_tool, QuickTool::Hand);
}
/// Organize pages in the Edit panel opens the Organize workspace: its own panel and the page grid,
/// not the grid behind the Edit panel.
#[test]
fn organize_pages_from_the_edit_panel_opens_its_panel() {
use pdfcraft_ui_egui::LeftPanel;
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.get_by_label("Edit").click();
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::Tool("edit"));
assert!(
h.query_by_label("Shift-click to select several objects. Drag empty space to select an area.").is_none(),
"no idle hint above Format text, as in Acrobat"
);
h.get_by_label("Organize pages").click();
h.run_steps(3);
assert_eq!(h.state().left, LeftPanel::Tool("organize"));
assert!(h.state().views[0].organize, "the page grid is showing");
}
/// Header and footer, Watermark and Background keep their Add / Update / Remove menu in the Edit
/// panel, as under All tools: Update and Remove wait for the document to have that kind of mark.
#[test]
fn the_edit_panel_keeps_the_add_update_remove_menu() {
let mut h = harness(|app| app.open_bytes("fixture.pdf", None, FIXTURE.to_vec()).expect("fixture opens"));
h.get_by_label("Edit").click();
h.run_steps(3);
h.get_by_label("Watermark").click();
h.run_steps(3);
assert_eq!(enabled(&h, "Add watermark…"), Some(true));
assert_eq!(enabled(&h, "Remove watermark"), Some(false), "nothing to remove yet");
h.get_by_label("Add watermark…").click();
h.run_steps(3);
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Marks(pdfcraft_engine::MarkKind::Watermark)));
}
+1 -1
View File
@@ -1594,7 +1594,7 @@ fn switching_modes_keeps_the_current_page_at_fixed_zoom() {
h.event(wheel(MouseWheelUnit::Point, -35.0, TouchPhase::Move));
h.run_steps(8);
let before = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
h.get_by_label("Read").click();
h.state_mut().run_command("view.read_mode");
h.run_steps(8);
assert_eq!(h.state().views[0].current, 17, "switching to Read should keep the current page");
let after = rect(&h, 17).unwrap().top() - h.state().views[0].viewport_rect().top();
+6 -1
View File
@@ -11,6 +11,7 @@
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: AcroForm XML text; estimates unchanged)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M6: preserve XML/XFDF attribute whitespace; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: raw-deflate fallback preserves decoding-limit errors; refreshed upstream; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M2: objects in one large object stream are read in linear time; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-14 · **Change:** minor (M14: the Italian catalog has every entry the other catalogs had and the code looks up, 2,528 entries; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-16 | **Last updated:** 2026-10-16 | **Change:** minor (M10 #1201 partial: lossless image Decode; estimates unchanged) | **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (M2/M6: spot-colour images convert once per pixel value; widget placement and the Fields panel scale with the field count; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -19,6 +20,7 @@
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (M3 #2080 candidate: explicit search jumps join per-tab history; rail and control-channel Back/Forward; estimates unchanged)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4 #1310 #1311 #1312: undo, revert and the next save keep the last saved revision; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-15 · **Change:** minor (M14: PNG export writes opaque pages as RGB at deflate level 3, the same size 3–5× faster; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M3 #1980: exact quit modifiers preserve Ctrl+Alt+Q email input; M3 candidate: native opens retain chosen tools through password retries and defer startup intake while preparation is pending; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M13: CLI check visits each directory once through aliases and cycles; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-12 · **Change:** minor (M4: Extract and Split preserve shared Type 3 glyph resources; estimates unchanged) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
@@ -141,7 +143,7 @@ total matches the parity estimate.
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. |
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. |
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
@@ -200,6 +202,7 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | minor | M6: renaming a field renames it in the calculations that read it; `form_set_props` returns `formulas_updated` and Field Properties says how many changed. Estimates unchanged. |
| 2026-10-18 | minor | M5, PR #1971: nested source `/Page` and `/Pages` dictionaries export as null without traversing page content or the page tree. Synthetic saved/reopened three-page regression retains unrelated metadata and aliases. FDF import semantics, copied-graph limits and estimates unchanged. |
| 2026-10-15 | minor | M5: budget retained FDF entries before cloning; discovery keeps lazy array owners and annotation positions, with alias/cache-eviction stress fixtures. Limits cover the copied graph; source parsing/page discovery retain existing COS/parser limits, with no total source-memory bound. FDF import semantics and estimates unchanged. |
| 2026-10-15 | minor | M7: Edit text caret height follows its row, with a kittest regression; estimates unchanged |
@@ -220,8 +223,10 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
| 2026-10-12 | minor | M14/#1395: XDG configuration preferences, safe migration and fallback; logs/recovery retain data locations, portable/non-XDG unchanged. Estimates unchanged. |
| 2026-10-12 | minor | M6: XFDF field import and data extraction preserve all text around XML comments and processing instructions; synthetic save/reopen regressions. Estimates unchanged. |
| 2026-10-12 | minor | M2: preserve whitespace-bounded EI bytes in known unfiltered inline images; row padding, masks and aliases tested, with independent saved-PDF pixel checks. Estimates unchanged. |
| 2026-10-15 | minor | M2: an object stream's index is parsed once per stream (hayro-syntax patch 22), not once per object fetched from it; a page whose 65,542 objects share one stream draws in 0.5 s instead of 37 s. Estimates unchanged. |
| 2026-10-12 | minor | M1 #1338: a full rewrite keeps unknown trailer entries and objects reachable only from them. Estimates unchanged. |
| 2026-10-12 | minor | M7 #1443: adding a word to a multi-line paragraph keeps its line breaks. Composite subset fonts whose `/W` leaves out default-width glyphs (Chrome, Skia) no longer measure those letters as missing, and paragraphs drawn turned on the page group into rows along their own baselines. Estimates unchanged. |
| 2026-10-15 | minor | M14: PNG export (Export ▸ Image, `pdfcraft-cli render`, `page_render`) writes opaque pages as RGB at deflate level 3: the same file size, 3–5× faster to write. Estimates unchanged. |
| 2026-10-14 | minor | M14: Italian catalog completed (2,528 entries) and dropped from the ≈ 2,000–2,140-entry group; estimates unchanged |
| 2026-10-12 | minor | M4 #1310 #1311 #1312: undo back to the opened file is clean, Revert returns to the last saved file, and the next save keeps that revision. Estimates unchanged. |
| 2026-10-14 | minor | M5: reconciled PR #1457 with upstream main, preserving comment submission shortcuts and upstream roadmap progress. Estimates unchanged. |
+10 -10
View File
@@ -1094,8 +1094,8 @@ milestone = "M3"
status = "shipped"
commands = ["view.read_mode"]
tools = []
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
notes = "Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
evidence = ["crates/ui-egui/tests/control.rs::inspect_and_click_by_label_and_id", "crates/ui-egui/tests/commands.rs::every_registered_command_is_implemented", "crates/ui-egui/tests/ui.rs::the_mode_bar_has_no_read_tab", "crates/ui-egui/tests/view.rs::switching_modes_keeps_the_current_page_at_fixed_zoom"]
notes = "View ▸ Read mode (and the Read mode workspace) hides the panes and tool chrome. The mode bar has no Read tab, as in Acrobat, where reading is the viewer with no tool in use: its tabs are All tools, Edit, Convert and E-Sign. Switching between Read and the docking workspace preserves the document’s current scroll position at fixed zoom; each tab keeps its own position"
[[feature]]
id = "view.full-screen"
@@ -1986,8 +1986,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited"]
notes = "character spacing in points and horizontal scaling in percent for a paragraph (Tc, Tz), rewrapped"
evidence = ["crates/edit/src/tests.rs::justify_underline_and_spacing", "crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/ui-egui/tests/editing.rs::the_format_panel_takes_any_scale_and_character_spacing"]
notes = "character spacing in points (-5 to 50) and horizontal scaling in percent (10 to 400) for a paragraph (Tc, Tz), rewrapped; Format text takes any value in those ranges typed, or a common one from its list"
[[feature]]
id = "edit.lists"
@@ -2592,8 +2592,8 @@ milestone = "M7"
status = "shipped"
commands = ["edit.edit_text"]
tools = ["text_edit"]
evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools"]
notes = "Edit a PDF ▸ Format text shows the paragraph being edited (family, bold/italic guessed from its font, size) and applies changes at once as one undo step each, keeping the paragraph open"
evidence = ["crates/edit/src/tests.rs::paragraphs_take_new_formatting", "crates/ui-egui/tests/editing.rs::the_format_panel_restyles_the_paragraph_being_edited", "crates/automation/tests/automation.rs::editing_existing_text_through_tools", "crates/ui-egui/tests/content.rs::format_text_panel_sets_alignment_and_lists_planned_controls_disabled", "crates/ui-egui/tests/content.rs::format_text_rows_line_up_and_fit_the_panel"]
notes = "Edit a PDF ▸ Format text, laid out as Acrobat's panel, for the paragraph being edited and for added text: font (the source font kept, the document's fonts, families, installed or loaded font files), size, colour, alignment, bold and italic, line spacing, horizontal scale and character spacing; each change applies at once as one undo step, keeping the paragraph open. Shown disabled with a Planned tooltip until they exist: bulleted and numbered lists (edit.lists), superscript and subscript (edit.text-super-subscript), paragraph spacing, and underline on added text."
[[feature]]
id = "edit.adobe-express-edits"
@@ -2884,8 +2884,8 @@ milestone = "M4"
status = "shipped"
commands = ["page.organize"]
tools = []
evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn"]
notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), 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 they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. Missing: a slider as in Acrobat; the size is per document view and not remembered between sessions; the Pages side panel has no size control."
evidence = ["crates/ui-egui/tests/editing.rs::page_grid_zooms_with_its_buttons_keys_and_pinch", "crates/ui-egui/tests/editing.rs::a_zoomed_page_grid_still_inserts_and_moves_at_the_right_gap", "crates/ui-egui/tests/editing.rs::zoomed_pages_in_view_are_rendered_at_the_size_drawn", "crates/ui-egui/tests/editing.rs::page_grid_size_is_remembered_for_every_document_and_restart"]
notes = "Organize page grid: 50–300% with toolbar buttons (and a percentage that resets to 100%), 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 they are in view (the thumbnail stands in until then, and after an edit), so text stays sharp however long the document is. As in Acrobat, the size last chosen is one for every document and kept in the settings across restarts (also set by --grid-zoom). Missing: a slider as in Acrobat; the Pages side panel has no size control."
[[feature]]
id = "organize.split-every-n"
@@ -4968,8 +4968,8 @@ milestone = "M6"
status = "shipped"
commands = ["form.field.properties"]
tools = ["form_set_props", "form_fill"]
evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools"]
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending"
evidence = ["crates/ui-egui/tests/prepare.rs::format_validate_and_calculate_tabs", "crates/automation/tests/automation.rs::form_formats_and_calculations_through_tools", "crates/forms/src/tests.rs::renaming_a_field_renames_it_in_the_calculations_that_read_it", "crates/forms/src/tests.rs::a_rename_reaches_every_stored_form_of_a_calculation", "crates/automation/tests/automation.rs::renaming_a_field_keeps_the_formulas_that_read_it", "crates/ui-egui/tests/prepare.rs::renaming_a_field_says_how_many_calculations_now_use_the_new_name"]
notes = "sum/product/average/min/max of picked fields, simplified field notation; custom script pending. Renaming a field renames it in the calculations that read it (field lists, notation, and getField calls naming it in a string literal), in the rename's undo step; Field Properties says how many changed and form_set_props lists them. Not rewritten: names built at run time, other events' scripts, document-level scripts. Whether Acrobat does this is not observed."
[[feature]]
id = "form.props-selection-change"
+2
View File
@@ -0,0 +1,2 @@
sonar.projectKey=storytold_pdfcraft
sonar.organization=storytold
+1 -1
View File
@@ -17,7 +17,7 @@ In `hayro-interpret`, Type 3 glyph bounding boxes are transformed to device spac
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) `/Interpolate` governs the pre-resize too (#624): a minified image that does not request interpolation is pre-resized with nearest-neighbour, planned without the Catmull-Rom filter limits (`image_resampling_size` takes the flag); a Type 3 glyph image is sampled bicubically only when interpolated; a minified image with a matching soft mask is nearest-sampled straight into premultiplied RGBA; (11) `may_paint_rect`: an image whose stream-size box, mapped to the device, misses the canvas is skipped before decoding (decoded data can only shrink against that box), so a tile of an image-heavy page no longer decodes, resamples and rasterizes every image on it; (12) preview renders (`InterpreterSettings::preview`: backdrops, thumbnails, grid cells, signature-drag backgrounds) skip images under `PREVIEW_MIN_IMAGE_AREA` device pixels, while final renders always draw them; (13) reading colours (#897): `RenderSettings::color_replacement` takes a `ColorReplacement` (text colour, background, `only_black`, `line_art`) that remaps solid text and line-art paint while rendering, glyph outlines counting as text and stencil-mask ink as line art; images, image alpha masks, soft masks and pattern cells keep their colours; `render_pdf` gains a `color_replacement: Option<ColorReplacement>` argument (pass `None` for true colours); the upstream-only `[dev-dependencies.log]` entry is removed from `Cargo.toml` (log is a normal dependency since the logging patch), so `cargo test -p hayro` runs this crate's unit tests; (14) a shrunk, interpolated, axis-aligned image is snapped to whole device pixels: shrunk once to its rounded device rectangle and drawn 1:1 at the rounded corner, instead of a ceil-sized raster resampled again at a fractional scale and offset (`snap_minified_image`; scans looked soft next to Acrobat); (15) a soft mask of another size than its image is filtered when shrunk, whatever its `/Interpolate` (#1649): such a mask carries the shape on its own (Word saves text as a tiny black image with a glyph-bitmap mask), and nearest-neighbour dropped every stroke thinner than the shrink factor; (16) under a soft mask or a blend mode, axial and radial shadings are sampled instead of drawn as native gradients (#2128): vello_cpu 0.0.8 copies an opaque gradient over fully covered tiles without the mask or blend mode, so PowerPoint's faded gradient fills came out solid; (17) the renderer keeps annotation paints out of the reading-colours mapping between `begin_annotations` and `end_annotations` (#1548); (18) the page renders as one transparency group with its background painted inside it as the initial backdrop, so a Multiply highlight blends with a dark reading-colours background instead of staying an opaque block (identity on white pages); (20) a tiling pattern whose step is too large for a cell pixmap, Line truncated
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` |
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependencyLine truncated
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dependencyLine truncated
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW, RunLength and ASCII85 never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice; (16) revision 5–6 (AES-256) passwords are tried in their SASLprep form (RFC 4013, then UTF-8, at most 127 bytes, as ISO 32000-2 §7.6.4.3.3 Algorithm 2.A asks and pdfcraft-crypt writes them) and then, if that differs, as given (`aes256_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Do the above.`; `stringprep`, already in the tree through pdfcraft-crypt, is an optional dLine truncated
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589); (4) the object lookup keeps its cycle history through compressed objects, requires an ordinary in-file container, and caps the chain of indirect lookups at 16 (a chain of new objects, each a `/Length` compressed in a container whose own `/Length` names the next, or a stream whose `/Length` names the next stream, overflowed the stack: a dev build's 2 MiB thread within 800 hops); (5) object-stream members are parsed within their own extent (up to the next distinct offset), an entry whose offset repeats an earlier entry's is skipped (so a repeated object number can resolve to its earlier entry), and offsets are processed in index order | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding`; (4) `inspect::tests::compressed_object_self_container_does_not_recurse`, `inspect::tests::compressed_object_mutual_containers_do_not_recurse`, `inspect::tests::object_stream_length_cycle_does_not_recurse`, `inspect::tests::compressed_object_container_chain_does_not_recurse`, `inspect::tests::compressed_length_chain_does_not_recurse`, `inspect::tests::plain_stream_length_chain_does_not_recurse`, `inspect::tests::compressed_objects_and_indirect_stream_length_still_load`, `inspect::tests::compressed_length_in_a_container_with_an_indirect_length_still_loads`; (5) `inspect::tests::object_stream_member_extents_bound_owned_strings`, `inspect::tests::object_stream_member_extents_preserve_index_order_and_whitespace` |
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::tests::jpx_stream_bit_depth_boundaries_do_not_panic`, `raster::tests::jpx_32_bit_image_does_not_prevent_other_page_content_from_rendering`, `raster::tests::jpx_gray_rgb_and_alpha_keep_eight_bit_samples`, `raster::tests::jpx_streams_reject_out_of_range_bit_depths`; (7) `raster::tests::decompression_bombs_stop_at_the_stream_limit`, `raster::tests::page_contents_and_nested_forms_share_one_budget`; (8) `inspect::tests::jspdf_high_precision_mediabox_does_not_overflow_dimensions`, `object::number::tests::high_precision_float_does_not_overflow_mantissa` (hayro-syntax); (9) `object::dict::lazy_index_tests`, `raster::tests::large_resource_indexes_preserve_pixels_and_text`; (10) `data::tests`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`, `raster::tests::shared_parser_is_lazy_and_initializes_once_under_contention`; (11) `data::tests::panicking_initializer_wakes_waiters_and_prevents_retries`, `xref::tests::concurrent_cold_object_stream_reads_all_succeed`; (12) `util::tests::segment_list_rejects_out_of_range_indices`; (13) `raster::tests::user_unit_scales_the_page_and_everything_on_it` |
+102 -17
View File
@@ -289,6 +289,28 @@ impl XRef {
Some(f(&mut cache))
}
/// PdfCraft patch: (hayro-syntax (22)) run `f` on this PDF's parsed object stream indexes
/// ([`ObjectStreamIndexes`]). `None` when there is none to use (the dummy xref, or a no_std
/// cache already borrowed), and the caller then parses the index without it. A poisoned lock
/// is still used: entries are complete `Arc`s inserted in one step, so a panic elsewhere
/// cannot have left one half-written.
fn with_object_stream_indexes<T>(
&self,
f: impl FnOnce(&mut ObjectStreamIndexes) -> T,
) -> Option<T> {
let Inner::Some(repr) = &self.0 else {
return None;
};
#[cfg(feature = "std")]
let mut indexes = repr
.object_stream_indexes
.lock()
.unwrap_or_else(|poison| poison.into_inner());
#[cfg(not(feature = "std"))]
let mut indexes = repr.object_stream_indexes.try_borrow_mut().ok()?;
Some(f(&mut indexes))
}
fn new(
data: PdfData,
xref_map: XrefMap,
@@ -310,6 +332,7 @@ impl XRef {
trailer_data,
password: password.to_vec(),
dict_cache: Arc::new(Mutex::new(DictCache::default())),
object_stream_indexes: Arc::new(Mutex::new(ObjectStreamIndexes::default())),
})));
// We read the trailer twice, once to determine the encryption used and then a second
@@ -622,10 +645,35 @@ impl XRef {
return None;
}
let stream = self.get_with::<Stream<'_>>(obj_stream_id, &ctx)?;
// PdfCraft patch: (hayro-syntax (22)) the stream's index of `objnum offset` pairs
// is parsed once and kept, as its decoded data is: parsed again for every object
// fetched from it, reading all of a stream of N objects took N² steps (one
// Firefox page kept 65,542 objects in one stream and took 37 s to draw).
let data = repr.data.get_with(obj_stream_id, &ctx)?;
let object_stream = ObjectStream::new(stream, data, &ctx)?;
object_stream.get(index)
let known = self
.with_object_stream_indexes(|indexes| indexes.get(&obj_stream_id).cloned())
.flatten();
let offsets = match known {
// A stream whose pairs don't parse stays unreadable, as it was.
Some(offsets) => offsets?,
None => {
let stream = self.get_with::<Stream<'_>>(obj_stream_id, &ctx)?;
// `/N` and `/First` may be references that can't be followed while
// another stream loads: reading them fails without being remembered.
let num_objects = stream.dict().get::<usize>(N)?;
let first_offset = stream.dict().get::<usize>(FIRST)?;
let parsed =
read_object_stream_index(num_objects, first_offset, data).map(Arc::new);
self.with_object_stream_indexes(|indexes| {
indexes
.entry(obj_stream_id)
.or_insert_with(|| parsed.clone())
.clone()
})
.unwrap_or(parsed)?
}
};
ObjectStream::with_offsets(data, &ctx, offsets).get(index)
}
}
}
@@ -698,6 +746,7 @@ impl TrailerData {
#[derive(Debug, Clone)]
struct SomeRepr {
dict_cache: Arc<Mutex<DictCache>>,
object_stream_indexes: Arc<Mutex<ObjectStreamIndexes>>,
data: Arc<Data>,
map: Arc<RwLock<MapRepr>>,
metadata: Arc<Metadata>,
@@ -1067,34 +1116,62 @@ fn get_decryptor(trailer_dict: &Dict<'_>, password: &[u8]) -> Result<Decryptor,
}
}
/// PdfCraft patch: (hayro-syntax (22)) each object stream's parsed `(object number, offset)`
/// pairs ([`read_object_stream_index`]) by the stream's identifier, `None` for pairs that don't
/// parse. Never cleared, like the decoded stream data they index (`Data`), which a repair keeps
/// too. Bounded by that data: every pair takes at least three of its bytes.
type ObjectStreamIndexes = FxHashMap<ObjectIdentifier, Option<Arc<Vec<(u32, usize)>>>>;
/// The `(object number, offset)` pairs at the start of an object stream's decoded `data`, the
/// offsets measured from the start of `data`. `None` when the pairs don't parse. Reads no
/// further than the data holds, whatever `/N` claims.
fn read_object_stream_index(
num_objects: usize,
first_offset: usize,
data: &[u8],
) -> Option<Vec<(u32, usize)>> {
let mut r = Reader::new(data);
let mut offsets = vec![];
for _ in 0..num_objects {
r.skip_white_spaces_and_comments();
// Skip object number
let obj_num = r.read_without_context::<u32>()?;
r.skip_white_spaces_and_comments();
let relative_offset = r.read_without_context::<usize>()?;
// PdfCraft patch: (hayro-syntax (22)) an offset past `usize` is malformed (it wrapped).
offsets.push((obj_num, first_offset.checked_add(relative_offset)?));
}
Some(offsets)
}
struct ObjectStream<'a> {
data: &'a [u8],
ctx: ReaderContext<'a>,
offsets: Vec<(u32, usize)>,
offsets: Arc<Vec<(u32, usize)>>,
}
impl<'a> ObjectStream<'a> {
fn new(inner: Stream<'_>, data: &'a [u8], ctx: &ReaderContext<'a>) -> Option<Self> {
let num_objects = inner.dict().get::<usize>(N)?;
let first_offset = inner.dict().get::<usize>(FIRST)?;
let offsets = read_object_stream_index(num_objects, first_offset, data)?;
let mut r = Reader::new(data);
let mut offsets = vec![];
for _ in 0..num_objects {
r.skip_white_spaces_and_comments();
// Skip object number
let obj_num = r.read_without_context::<u32>()?;
r.skip_white_spaces_and_comments();
let relative_offset = r.read_without_context::<usize>()?;
offsets.push((obj_num, first_offset + relative_offset));
}
Some(Self::with_offsets(data, ctx, Arc::new(offsets)))
}
/// PdfCraft patch: (hayro-syntax (22)) an object stream whose index is already parsed.
fn with_offsets(
data: &'a [u8],
ctx: &ReaderContext<'a>,
offsets: Arc<Vec<(u32, usize)>>,
) -> Self {
let mut ctx = ctx.clone();
ctx.set_in_object_stream(true);
Some(Self { data, ctx, offsets })
Self { data, ctx, offsets }
}
fn get<T>(&self, index: u32) -> Option<T>
@@ -1369,4 +1446,12 @@ mod tests {
let mut reader = Reader::new(data);
assert!(read_xref_table_trailer(&mut reader, &ReaderContext::dummy()).is_none());
}
/// PdfCraft patch: (hayro-syntax (22)) an object's offset past `usize` is malformed: a plain
/// `+` there panics in a debug build and wraps in a release one.
#[test]
fn an_object_stream_offset_past_usize_is_malformed_not_a_panic() {
assert_eq!(read_object_stream_index(1, usize::MAX, b"5 1"), None);
assert_eq!(read_object_stream_index(1, 10, b"5 1"), Some(vec![(5, 11)]));
}
}
+2
View File
@@ -15,3 +15,5 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] }
sha2 = "0.11"
toml = "1"
# The demo-PDF time stamp goes through chrono (no hand-rolled calendar code).
chrono = { workspace = true }
-1
View File
@@ -4,7 +4,6 @@
"auth-event-ef-open.pdf": "open-error",
"bug1020226.pdf": "open-error",
"bug1782186.pdf": "open-error",
"bug1978317.pdf": "timeout",
"encrypted-attachment.pdf": "open-error",
"issue15590.pdf": "open-error",
"issue15893_reduced.pdf": "open-error",
+31 -12
View File
@@ -75,22 +75,33 @@ impl Stamp {
}
pub fn from_unix(secs: i64) -> Self {
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (h, mi, s) = (rem / 3600, rem % 3600 / 60, rem % 60);
// Civil-from-days (Howard Hinnant's algorithm).
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
let (y, m, d) = Self::civil_from_days(days);
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
Stamp { unix: secs, pdf: format!("D:{y:04}{m:02}{d:02}{h:02}{mi:02}{s:02}Z"), iso: format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}Z") }
}
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
///
/// Past `NaiveDate`'s ±262143-year range the old hand-rolled converter still produced a date,
/// which both stamps formatted directly; a `from_timestamp` epoch fallback would change it.
/// The Gregorian calendar repeats every 400 years (146_097 days), so chrono converts the day
/// within one cycle and whole cycles go back into the year (an `i128` so the extremes can't
/// overflow), keeping chrono as the only calendar logic. 1970-01-01 is `num_days_from_ce` 719_163.
fn civil_from_days(days: i64) -> (i64, u32, u32) {
use chrono::Datelike;
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
Some(d) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
}
None => (1970, 1, 1), // unreachable
}
}
/// The same moment shifted by `minutes` (used to order threaded replies).
pub fn plus_minutes(&self, minutes: i64) -> Self {
Self::from_unix(self.unix + minutes * 60)
@@ -429,6 +440,14 @@ mod tests {
assert_eq!(s.iso, "2026-09-21T14:13:20Z");
assert_eq!(s.plus_minutes(0).pdf, s.pdf);
assert_eq!(s.plus_minutes(60 * 24).iso, "2026-09-22T14:13:20Z");
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(Stamp::from_unix(253_402_300_800).iso, "10000-01-01T00:00:00Z");
assert_eq!(Stamp::from_unix(253_402_300_800).pdf, "D:100000101000000Z");
// Past chrono's ±262143-year range the proleptic Gregorian date is kept, not the epoch.
// Pinned to the old hand-rolled output (no year clamp — a negative year keeps its sign).
assert_eq!(Stamp::from_unix(10_000_000_000_000).iso, "318857-05-20T17:46:40Z");
assert_eq!(Stamp::from_unix(10_000_000_000_000).pdf, "D:3188570520174640Z");
assert_eq!(Stamp::from_unix(-10_000_000_000_000).iso, "-314918-08-13T06:13:20Z");
}
#[test]
+24 -1
View File
@@ -15,12 +15,21 @@ pub fn read(manifest: &str) -> Result<String, String> {
Ok(v)
}
/// `manifest` with the workspace version replaced; everything else is byte-for-byte unchanged.
/// `manifest` with the workspace version replaced, together with the matching `version` of every
/// path dependency in `[workspace.dependencies]` (the workspace's own crates, which must agree
/// with it or `cargo update` refuses them); everything else is byte-for-byte unchanged.
pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
validate(new)?;
let old = read(manifest)?;
let (idx, _) = find_line(manifest)?;
let (old_pin, new_pin) = (format!("version = \"{old}\""), format!("version = \"{new}\""));
let mut out = String::with_capacity(manifest.len() + 8);
let mut in_deps = false;
for (i, line) in manifest.split_inclusive('\n').enumerate() {
let t = line.trim();
if t.starts_with('[') {
in_deps = t == "[workspace.dependencies]";
}
if i == idx {
let eol = if line.ends_with("\r\n") {
"\r\n"
@@ -30,6 +39,8 @@ pub fn replace(manifest: &str, new: &str) -> Result<String, String> {
""
};
out.push_str(&format!("version = \"{new}\"{eol}"));
} else if in_deps && line.contains("path = \"") && line.contains(&old_pin) {
out.push_str(&line.replace(&old_pin, &new_pin));
} else {
out.push_str(line);
}
@@ -131,6 +142,18 @@ mod tests {
assert!(out.contains("foo = { version = \"1\" }"));
}
#[test]
fn moves_the_workspace_crates_pins_with_it() {
let manifest = "[workspace.package]\nversion = \"0.1.0\"\n\n[workspace.dependencies]\n\
own = { path = \"crates/own\", version = \"0.1.0\" }\n\
dep = { version = \"0.1.0\" }\n\
other = { path = \"crates/other\", version = \"0.2\" }\n";
let out = replace(manifest, "0.2.0").unwrap();
assert!(out.contains("own = { path = \"crates/own\", version = \"0.2.0\" }"), "{out}");
assert!(out.contains("dep = { version = \"0.1.0\" }"), "a registry dependency keeps its version");
assert!(out.contains("other = { path = \"crates/other\", version = \"0.2\" }"));
}
#[test]
fn keeps_crlf_line_endings() {
let crlf = MANIFEST.replace('\n', "\r\n");