Merge remote-tracking branch 'origin/main' into tmp339

This commit is contained in:
Brandon Thomas 2026-10-09 21:26:44 -04:00
commit 75ba05cf49
23 changed files with 5742 additions and 126 deletions

View File

@ -4,18 +4,19 @@
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 (258)
## In this repository (259)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `crates/ui-egui/src/i18n/zh-hans.tsv` | Simplified Chinese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/it.tsv` | Italian 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 | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |

View File

@ -46,13 +46,13 @@ sha256 = "e336657e76e498264691f4c55af84ae69af803bae2564e26f87a2bbad31fdf8e"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
title = "Brazilian Portuguese interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
author = "André Cardoso (andrecards), PdfCraft contributors"
source = "Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors"
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 = "ca56752387255e66e027eeb6efcac0434a6467997f63e6cfba629a27dd316c9a"
sha256 = "972ca1974635243d199fc6b3d2c77ab78ff5553674231f324bdbff9575035fd7"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@ -98,6 +98,17 @@ kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1d891722c4bb69968eb3fca2c479ba9cf0d68fd6b2f0f3a5975f154246bdde0e"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
title = "Italian interface translation catalog"
author = "PdfCraft contributors"
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f0cdcfafe37d2be636ede1cef251fc5028ad233e020be8614727a676cc93283d"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
title = "Russian interface translation catalog"

66
Cargo.lock generated
View File

@ -3543,7 +3543,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@ -3560,7 +3560,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@ -3570,7 +3570,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@ -3581,7 +3581,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"base64 0.22.1",
"image",
@ -3598,7 +3598,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-automation",
@ -3612,18 +3612,18 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
version = "0.4.0"
version = "0.5.0"
[[package]]
name = "pdfcraft-content"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"pdfcraft-crypt",
@ -3635,7 +3635,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"image",
@ -3649,7 +3649,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"aes",
"md-5",
@ -3661,7 +3661,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"pdfcraft-annot",
@ -3675,7 +3675,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"chrono",
"icu_properties",
@ -3710,14 +3710,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"flate2",
"proptest",
@ -3727,7 +3727,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@ -3736,7 +3736,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@ -3748,18 +3748,18 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
version = "0.4.0"
version = "0.5.0"
[[package]]
name = "pdfcraft-js"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"boa_engine",
]
[[package]]
name = "pdfcraft-measure"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@ -3771,14 +3771,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@ -3788,7 +3788,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"image",
"pdfcraft-annot",
@ -3801,7 +3801,7 @@ dependencies = [
[[package]]
name = "pdfcraft-organize"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@ -3811,11 +3811,11 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
version = "0.4.0"
version = "0.5.0"
[[package]]
name = "pdfcraft-preflight"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-cos",
"pdfcraft-model",
@ -3823,7 +3823,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@ -3833,7 +3833,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@ -3847,7 +3847,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"bytemuck",
"flate2",
@ -3865,7 +3865,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"aes",
"aws-lc-rs",
@ -3899,7 +3899,7 @@ dependencies = [
[[package]]
name = "pdfcraft-ui-egui"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"arboard",
"base64 0.22.1",
@ -3938,7 +3938,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
@ -3950,7 +3950,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"base64 0.22.1",
"image",
@ -3964,7 +3964,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.4.0"
version = "0.5.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",

View File

@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
version = "0.4.0"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@ -13,36 +13,36 @@ repository = "https://github.com/storytold/pdfcraft"
[workspace.dependencies]
# internal
pdfcraft-geom = { path = "crates/geom", version = "0.4.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.4.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.4.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.4.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.4.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.4.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.4.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.4.0" }
pdfcraft-model = { path = "crates/model", version = "0.4.0" }
pdfcraft-content = { path = "crates/content", version = "0.4.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.4.0" }
pdfcraft-print = { path = "crates/print", version = "0.4.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.4.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.4.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.4.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.4.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.4.0" }
pdfcraft-js = { path = "crates/js", version = "0.4.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.4.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.4.0" }
pdfcraft-export = { path = "crates/export", version = "0.4.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.4.0" }
pdfcraft-create = { path = "crates/create", version = "0.4.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.4.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.4.0" }
pdfcraft-render = { path = "crates/render", version = "0.4.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.4.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.4.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.4.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.4.0", default-features = false }
pdfcraft-geom = { path = "crates/geom", version = "0.5.0" }
pdfcraft-filters = { path = "crates/filters", version = "0.5.0" }
pdfcraft-cos = { path = "crates/cos", version = "0.5.0" }
pdfcraft-crypt = { path = "crates/crypt", version = "0.5.0" }
pdfcraft-organize = { path = "crates/organize", version = "0.5.0" }
pdfcraft-measure = { path = "crates/measure", version = "0.5.0" }
pdfcraft-annot = { path = "crates/annot", version = "0.5.0" }
pdfcraft-fonts = { path = "crates/fonts", version = "0.5.0" }
pdfcraft-model = { path = "crates/model", version = "0.5.0" }
pdfcraft-content = { path = "crates/content", version = "0.5.0" }
pdfcraft-redact = { path = "crates/redact", version = "0.5.0" }
pdfcraft-print = { path = "crates/print", version = "0.5.0" }
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.5.0" }
pdfcraft-sign = { path = "crates/sign", version = "0.5.0" }
pdfcraft-optimize = { path = "crates/optimize", version = "0.5.0" }
pdfcraft-a11y = { path = "crates/a11y", version = "0.5.0" }
pdfcraft-ocr = { path = "crates/ocr", version = "0.5.0" }
pdfcraft-js = { path = "crates/js", version = "0.5.0" }
pdfcraft-compare = { path = "crates/compare", version = "0.5.0" }
pdfcraft-preflight = { path = "crates/preflight", version = "0.5.0" }
pdfcraft-export = { path = "crates/export", version = "0.5.0" }
pdfcraft-edit = { path = "crates/edit", version = "0.5.0" }
pdfcraft-create = { path = "crates/create", version = "0.5.0" }
pdfcraft-forms = { path = "crates/forms", version = "0.5.0" }
pdfcraft-xfa = { path = "crates/xfa", version = "0.5.0" }
pdfcraft-render = { path = "crates/render", version = "0.5.0" }
pdfcraft-engine = { path = "crates/engine", version = "0.5.0" }
pdfcraft-platform = { path = "crates/platform", version = "0.5.0" }
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.5.0" }
pdfcraft-automation = { path = "crates/automation", version = "0.5.0", default-features = false }
# pdf
hayro = "0.7.1"

View File

@ -81,6 +81,7 @@ The gaps that matter most, in priority order. Agents: prefer these over adding P
5. **Pro workflows.** Signatures with timestamps and LTV (M9), OCR beyond Latin (M10), Office import/export (M10), preflight and PDF/A/X/UA (M11), XFA (M12).
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, German, Spanish, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Japanese, Russian, Ukrainian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
6. **1.0 polish (M14).** Code-signed installers, localization (the catalog system is in place and French, Spanish, Italian, Japanese, Russian and both Chinese catalogs cover the UI; Czech and Brazilian Portuguese still cover menus only), keyboard-only operation, a full screen-reader audit.
Decided against for now (owner, 2026-10-05): self-installing updates and an update check at start. Help ▸ Check for updates is manual only.
@ -108,6 +109,7 @@ Hours are for a single agent (low–high). "Done" is the estimated fraction of t
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 23% | 155–295 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export. Missing: autotag, Tags/Order/Content panels, Reading Order tool, alt-text workflow, compare, geospatial/3D measurement, search index, XFA |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 33–66 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Hungarian and Ukrainian catalogs have ≈1,800–2,100 entries each; Simplified Chinese, French, German, Hungarian and Ukrainian coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Ukrainian locales (`uk`, `uk_UA`) follow the Ukrainian one; its certificate and accessibility wording has a dictionary-checked follow-up. #307 (bounded large-PDF memory): share resource indexes and native parser metadata, inspect structure lazily, bound scan/full-save caches and raster storage, and reuse signature discovery safely. Three Linux trials retain full-save workflow RSS at 1.06 GiB (previously 3.69 GiB); rebased engine navigation uses 821 MiB (previously 997 MiB). With the RSS guard removed, the unmodified baseline was kernel OOM-killed in a 15 GiB container; rebased optimized inspection completed at 497.7 MiB in 1.78 s. Strict Clippy, 1,336 workspace tests and 30 WASM crate checks pass. The larger-host follow-up is closed. File-backed input, global memory budgets and Windows validation remain pending. Missing: more catalogs filled in, installers, performance budgets |
| M14 | 1.0 polish: performance, localization, installers | 120–250 | 5% | 115–240 | Done: PhotoCraft's translation system (`i18n/`: TSV catalogs, `tl!`, command-id and plural entries, system-language detection, strict catalog tests); every dialog, panel and notice goes through `tl!`; Japanese, Traditional Chinese, Simplified Chinese, Russian, Spanish, French and Italian catalogs have ≈1,800 entries each; Simplified Chinese and French coverage is checked against the UI source; Czech and Brazilian Portuguese for menus. French locales (`fr`, `fr_FR`, `fr_CA`) follow the French catalog; Italian locales (`it`, `it_IT`) the Italian one. Missing: more catalogs filled in, installers, performance budgets |
| | **Total (original plan sizing)** | **2,085–3,840** | **≈ 35%** | **≈ 1,350–2,500 at the planned rate; ≈ 600–1,100 at the measured rate** | |
**Overall progress: about 30–35% of the effort (51.0% of features shipped).** The viewer and the core are far ahead of the editing features, because the viewer was built first so progress could be seen; rendering itself is still borrowed from `hayro`. See [Honest assessment](#honest-assessment-2026-10-05).
@ -133,6 +135,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
- **2026-10-09 (M3, #171):** A browser startup `?file=` download opens in a background tab once the user has started working, so it cannot switch the active document or the next Save. Deliberate opens still activate their tab; Home, Combine files, workspace choices and another file's password prompt are preserved. Initial-view geometry is applied to the background document, and its script notices wait until its tab is selected. Synthetic shell tests cover the race, saving the local edit, tab switching, encrypted arrivals, errors and converted text. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (never-crash, #378):** On Windows the window is drawn on the GPU that drives the primary display, as on Linux: `EnumDisplayDevices` (through the safe `winsafe` crate) says which adapter shows each desktop display, and `pick_adapter` matches it to the wgpu adapters by PCI ids. Before, the low-power preference chose a Ryzen integrated GPU with no monitor on a desktop whose monitors hang off an NVIDIA card, and presenting across adapters took the AMD display driver down for two minutes. The log now names the GPU chosen. Synthetic tests for the Windows device-id parser and the primary-display rule; a live test on the running machine. Overall unchanged.
- **2026-10-09 (page preview resolution):** The page view draws whole pages at twice the screen density when that bitmap still fits, then filters it down, so type and fine lines keep more detail. Print preview, page thumbnails, alternate-text figures and the page behind a moving signature are rendered at the size they are shown instead of being stretched up from a small image. Milestone and effort estimates unchanged.
- **2026-10-09 (M5, line endings):** Lines, connected lines and callouts draw all ten standard line-ending styles. `comment_add` accepts `endings`, and Comment properties can change them in one undo step. An unknown name is left undrawn, and a non-finite direction is rejected. Cloudy borders remain polygon-only, so `comment.line-endings` stays partial. Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M6, field rotation):** Regenerated form appearances honour `/MK /R` (0, 90, 180, 270). Filling or restyling a rotated field keeps the text, check mark and button caption turned, and Field Properties ▸ Position and `form_set_props` `rotation` set it, swapping width and height around the field's center when the axis changes. `form.appearance-regeneration` stays partial (Helvetica metrics are still approximate). Overall estimate unchanged (about 30–35%).
- **2026-10-09 (M2, JPX sample normalization):** JPEG 2000 rescaling uses bounded integer arithmetic for supported 1–32-bit samples, including the 32-bit boundary that previously shifted past the integer width. Zero-sized rows are refused before scaling. Small contributor-original codestreams exercise decoding and rendering; endpoint and standard-depth regressions cover the same helper. Overall estimate unchanged (about 30–35%).

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@ const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三",
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language):
/// the interface languages, in the same order and with the same names.
pub const DATE_LANGUAGES: [DateLanguage; 14] = [
pub const DATE_LANGUAGES: [DateLanguage; 15] = [
DateLanguage {
code: "en",
name: "English",
@ -219,6 +219,15 @@ pub const DATE_LANGUAGES: [DateLanguage; 14] = [
days: ["неділя", "понеділок", "вівторок", "середа", "четвер", "пʼятниця", "субота"],
days_short: ["нд", "пн", "вт", "ср", "чт", "пт", "сб"],
},
DateLanguage {
code: "it",
name: "Italiano",
months: ["gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre"],
months_with_day: None,
months_short: ["gen", "feb", "mar", "apr", "mag", "giu", "lug", "ago", "set", "ott", "nov", "dic"],
days: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
days_short: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
},
];
/// The date language for `code` (any case), or `None` when there's none.
@ -527,7 +536,7 @@ mod tests {
assert_eq!(s.today_text(None, None).as_deref(), Ok("terça-feira 14 novembro"));
assert_eq!(s.today_text(Some("d mmmm"), Some("uk")).as_deref(), Ok("14 листопада"), "Ukrainian months after a day");
assert_eq!(s.today_text(Some("dddd d mmmm"), Some("de")).as_deref(), Ok("Dienstag 14 November"));
assert!(s.set_date_language(Some("it")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert!(s.set_date_language(Some("xx")).unwrap_err().contains("zh-hant"), "only the interface languages");
assert_eq!(s.date_language(), Some("pt-br"), "an unknown language keeps the previous one");
s.set_date_language(None).unwrap();
assert_eq!(s.date_language(), None);

View File

@ -31,6 +31,8 @@ impl ExternalKey for WindowsKey {
PublicKey::P384(_) => p384::ecdsa::Signature::from_slice(&raw)
.map(|s| s.to_der().as_bytes().to_vec())
.map_err(|e| SignError::Crypto(format!("CNG returned an invalid P-384 signature: {e}"))),
// `identities` lists only RSA, P-256 and P-384 store keys; anything else can't sign here.
_ => Err(SignError::Unsupported("signing with this key type through the Windows certificate store".into())),
}
}
}
@ -67,6 +69,7 @@ pub fn identities() -> Result<Vec<DigitalId>, SignError> {
PublicKey::Rsa { .. } => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Rsa),
PublicKey::P256(_) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == 256),
PublicKey::P384(_) => key.algorithm_group().is_ok_and(|a| a == AlgorithmGroup::Ecdsa) && key.bits().is_ok_and(|bits| bits == 384),
_ => false,
};
if !supported {
continue;

View File

@ -423,14 +423,15 @@ impl std::fmt::Debug for AltDraft {
}
}
/// Render a figure's area for the dialog (at most 360 × 220 px).
/// Render a figure's area for the dialog, at the preview pane's device resolution.
fn figure_picture(ctx: &egui::Context, doc: &pdfcraft_engine::Document, f: &pdfcraft_engine::a11y::Figure) -> Option<egui::TextureHandle> {
let (page, b) = (f.page?, f.bbox?);
let info = doc.info.pages.get(page)?;
let (u, v) = (info.user_to_view(b[0] as f32, b[1] as f32), info.user_to_view(b[2] as f32, b[3] as f32));
let (x0, y0, x1, y1) = (u[0].min(v[0]), u[1].min(v[1]), u[0].max(v[0]), u[1].max(v[1]));
let (w, h) = ((x1 - x0).max(1.0), (y1 - y0).max(1.0));
let scale = (360.0 / w).min(220.0 / h).clamp(0.05, 8.0);
let ppp = ctx.pixels_per_point().max(1.0);
let scale = ((380.0 * ppp) / w).min((220.0 * ppp) / h).clamp(0.05, 32.0);
let tile = pdfcraft_render::Tile {
x: (x0 * scale).floor().max(0.0) as u32,
y: (y0 * scale).floor().max(0.0) as u32,

View File

@ -30,10 +30,15 @@ const STALE_TAG: u64 = u64::MAX;
/// Pages whose raster would exceed this many device pixels on a side are drawn in tiles.
const TILE_THRESHOLD: f32 = 4096.0;
const TILE: u32 = 1024;
/// Longest side of the low-resolution backdrop drawn under tiles.
const BASE_SIDE: f32 = 2048.0;
const THUMB_W: f32 = 132.0;
const THUMB_H: f32 = 176.0;
/// Longest side of the backdrop drawn under tiles, and of a live page preview (signature drag).
pub(crate) const BASE_SIDE: f32 = 2048.0;
/// Print-preview rasters (distinct from thumbnails and from organize-grid renders).
const PRINT_TAG: u64 = 1 << 60;
/// Thumbnail slot before density scaling. The pages panel draws at most 150 logical points and
/// the organize grid about 146; when the byte budget allows, density reaches the screen so those
/// images are sampled down instead of stretched.
const THUMB_W: f32 = 240.0;
const THUMB_H: f32 = 320.0;
const THUMB_BYTES: usize = 24 * 1024 * 1024;
const PAGE_BYTES: usize = 128 * 1024 * 1024;
const TILE_BYTES: usize = 128 * 1024 * 1024;
@ -272,6 +277,8 @@ pub struct DocView {
frame_visible: HashSet<usize>,
/// Thumbnails that are out of date (still shown until their replacement arrives).
stale_thumbs: HashSet<usize>,
/// Print dialog: the current sheet's pages at the preview pane's device resolution.
print_pages: HashMap<usize, (u64, TextureHandle)>,
/// Sharp tiles of large pages: (page, scale tag, tile x, tile y) → texture. Tiles of an
/// earlier zoom stay, drawn stretched, until those of the current one cover the page.
tiles: HashMap<(usize, u64, u32, u32), TextureHandle>,
@ -468,6 +475,7 @@ impl DocView {
frame_queue: Vec::new(),
frame_visible: HashSet::new(),
stale_thumbs: HashSet::new(),
print_pages: HashMap::new(),
tiles: HashMap::new(),
texts: HashMap::new(),
text_failed: HashSet::new(),
@ -602,6 +610,9 @@ impl DocView {
p.tag = STALE_TAG;
}
self.stale_thumbs.extend(self.thumbs.keys().copied());
for slot in self.print_pages.values_mut() {
slot.0 = STALE_TAG;
}
// Pages may have moved: a sharp render of another page would be worse than a soft one.
self.grid_pages.clear();
self.tiles.clear();
@ -626,6 +637,9 @@ impl DocView {
if self.thumbs.contains_key(&page) {
self.stale_thumbs.insert(page);
}
if let Some(slot) = self.print_pages.get_mut(&page) {
slot.0 = STALE_TAG;
}
self.tiles.retain(|(p, _, _, _), _| *p != page);
self.texts.remove(&page);
self.text_failed.remove(&page);
@ -659,11 +673,17 @@ impl DocView {
(!quads.is_empty()).then_some((s.page, quads))
}
/// A page's thumbnail texture, when rendered (the print preview uses them).
/// A page's thumbnail texture, when rendered.
pub(crate) fn thumb_id(&self, page: usize) -> Option<egui::TextureId> {
self.thumbs.get(&page).map(|t| t.tex.id())
}
/// The print preview's picture of `page`: the sheet raster when it has arrived, otherwise the
/// thumbnail.
pub(crate) fn page_preview(&self, page: usize) -> Option<egui::TextureId> {
self.print_pages.get(&page).map(|(_, tex)| tex.id()).or_else(|| self.thumb_id(page))
}
pub(crate) fn page_text(&self, page: usize) -> Option<Arc<PageText>> {
self.texts.get(&page).cloned()
}
@ -1073,6 +1093,7 @@ impl DocView {
self.tiles.clear();
self.thumbs.clear();
self.grid_pages.clear();
self.print_pages.clear();
self.stale_thumbs.clear();
self.waiting_since.clear();
// Signature previews own a separate renderer and image/background textures.
@ -1124,6 +1145,13 @@ impl DocView {
grid.push(*req);
continue;
}
// Print-preview rasters: a few sheets at the pane's size (the dialog bounds them).
if req.tag & PRINT_TAG != 0 {
if !self.errors.contains_key(&req.page) {
queue.push(*req);
}
continue;
}
// On-screen work is always admitted (the viewport bounds it); the limits only
// decide how much prefetch fits beside it.
let visible = self.frame_visible.contains(&req.page);
@ -1255,6 +1283,9 @@ impl DocView {
if let Some(t) = r.request.tile {
let tex = ctx.load_texture(format!("tile-{:?}-{page}-{}-{}", self.id, t.x, t.y), img, TextureOptions::LINEAR);
self.tiles.insert((page, r.request.tag, t.x / TILE, t.y / TILE), tex);
} else if r.request.tag & PRINT_TAG != 0 {
let tex = ctx.load_texture(format!("print-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.print_pages.insert(page, (r.request.tag, tex));
} else if r.request.tag & GRID_TAG != 0 {
let tex = ctx.load_texture(format!("grid-{:?}-{page}", self.id), img, TextureOptions::LINEAR);
self.grid_pages.insert(page, (r.width, tex));
@ -1277,8 +1308,9 @@ impl DocView {
// An on-screen page or tile is always taken (the viewport bounds it): it may evict
// off-screen rasters but never another on-screen one, and is never refused.
let visible = req.tag & THUMB_TAG == 0 && self.frame_visible.contains(&req.page);
// Organize-grid renders are bounded by the cells in view (organize_grid).
if req.tag & GRID_TAG != 0 {
// Organize-grid renders are bounded by the cells in view (organize_grid). Print
// previews are the sheets around the one on screen.
if req.tag & (GRID_TAG | PRINT_TAG) != 0 {
return true;
}
if let Some(t) = req.tile {
@ -1370,6 +1402,21 @@ impl DocView {
// blurs every line and glyph (#260).
self.zoom * PT * ppp
}
/// Queue sharp rasters for the print preview. Pages that leave the nearby sheets are dropped.
pub(crate) fn queue_print_previews(&mut self, pages: &[(usize, f32)]) {
self.print_pages.retain(|page, _| pages.iter().any(|(p, _)| p == page));
for &(page, scale) in pages {
if self.errors.contains_key(&page) || !scale.is_finite() || scale <= 0.0 {
continue;
}
let tag = scale_tag(scale) | PRINT_TAG;
let fresh = self.print_pages.get(&page).is_some_and(|(have, _)| *have == tag);
if !fresh {
self.frame_queue.push(RenderRequest { page, kind: RequestKind::Pixels, tile: None, scale, tag });
}
}
}
}
/// The request tag for a raster at `scale`: equal tags mean the same scale (to 1/65536).
@ -1651,6 +1698,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages.")));
return;
}
// The print dialog queues its own rasters later in the frame. Closing it drops them.
if app.dialog != Some(crate::Dialog::Print) {
app.views[index].print_pages.clear();
}
// The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled.
let preparing = app.is_preparing();
// Edit a PDF: added text and images can be selected, moved and edited.
@ -3863,6 +3914,18 @@ trailer << /Root 1 0 R >>
v.single_page_wheel(egui::MouseWheelUnit::Line, dy, egui::TouchPhase::Move, at, true)
}
#[test]
fn thumbnails_cover_the_panel_on_a_high_dpi_screen() {
// Pages panel slot is at most 150 logical points. At 2 px/pt a letter page's thumbnail
// must be at least that wide, so the panel samples it down instead of stretching it.
let info = thumbnail_info(1);
let mut v = DocView::new(DocId(1), &info, ViewDefaults::default());
v.need_thumbnail(0, true);
let queue = v.prepare_render_queue(&info, 2.0);
let scale = queue.first().expect("one thumbnail").scale;
assert!(612.0 * scale >= 150.0 * 2.0, "{scale}");
}
#[test]
fn notches_turn_pages_and_stop_at_the_ends() {
let mut v = view(3, PageLayout::Single);

View File

@ -263,6 +263,11 @@ impl CommentPrefs {
self.styles.iter().find(|(t, _)| *t == tool).map(|(_, s)| s.clone()).unwrap_or_default()
}
/// Every tool with its current default style (the persisted settings read this).
pub fn styles(&self) -> impl Iterator<Item = (CommentTool, &Style)> {
self.styles.iter().map(|(t, s)| (*t, s))
}
pub fn set_color(&mut self, tool: CommentTool, c: Rgb) {
if let Some((_, s)) = self.styles.iter_mut().find(|(t, _)| *t == tool) {
s.color = c;

View File

@ -774,10 +774,18 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
let Some(doc) = app.session.get(id) else { return };
let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
let rasters = crate::print_ui::preview_rasters(
&app.print_draft,
&sizes,
&labels,
ui.ctx().pixels_per_point(),
ui.ctx().input(|i| i.max_texture_side) as f32,
);
let view = &mut app.views[i];
view.queue_print_previews(&rasters);
let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| {
view.need_thumbnail(p, true);
view.thumb_id(p)
view.page_preview(p)
});
print_go = go;
close = go || cancel;

File diff suppressed because it is too large Load Diff

View File

@ -100,7 +100,7 @@ fn plural_ukrainian(n: u64) -> usize {
}
/// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 14] = [
pub static LANGUAGES: [LangInfo; 15] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
@ -127,6 +127,8 @@ pub static LANGUAGES: [LangInfo; 14] = [
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
// Ukrainian; every `uk-*` locale (`uk-UA`, `uk_UA.UTF-8`) resolves here.
LangInfo { code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_ukrainian, catalog: OnceLock::new() },
// Italian; every `it-*` locale (`it-IT`) resolves here.
LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), plural: plural_one_other, catalog: OnceLock::new() },
];
impl LangInfo {
@ -719,6 +721,37 @@ mod tests {
assert!(missing.is_empty(), "untranslated Japanese UI literals: {missing:#?}");
}
/// Italian translates every registered command and every All tools group, section and item.
#[test]
fn italian_covers_commands_and_catalogue() {
let it = Lang::from_code("it").expect("it registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(it, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(it, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(it, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(it, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(it, item.label), "missing item: {}", item.label);
}
}
}
}
/// New tl!("literal") labels must not silently fall back to English in Italian.
#[test]
fn italian_covers_ui_literals() {
let it = Lang::from_code("it").expect("it registered");
let literals = ui_literals();
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(it, label)).collect();
assert!(missing.is_empty(), "untranslated Italian UI literals: {missing:#?}");
}
/// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop)
/// and with BIZ UDPGothic Regular alone (the web build's only Japanese face).
#[test]
@ -818,6 +851,78 @@ mod tests {
assert_eq!(restored.language, "pt-br");
}
/// Brazilian Portuguese translates every registered command and every All tools group, section and item.
#[test]
fn brazilian_portuguese_covers_commands_and_catalogue() {
let pt = Lang::from_code("pt-br").expect("pt-br registered");
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(pt, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(pt, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(pt, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(pt, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(pt, item.label), "missing item: {}", item.label);
}
}
}
}
/// Check direct lookups and multiline literals as well as ordinary tl!("literal") calls,
/// following the complete-catalog check for Simplified Chinese.
#[test]
fn brazilian_portuguese_covers_ui_literals() {
let pt = Lang::from_code("pt-br").expect("pt-br registered");
let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
let mut literals = std::collections::BTreeSet::new();
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(dir).expect("UI source directory") {
let path = entry.expect("UI source entry").path();
if path.is_dir() {
if path.file_name().is_some_and(|name| name != "i18n") {
stack.push(path);
}
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n").replace("crate::i18n::t(", "tl!(");
let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
while let Some((_, after)) = rest.split_once("tl!(") {
let after = after.trim_start();
let Some(after) = after.strip_prefix('"') else {
rest = after;
continue;
};
let mut escaped = false;
let end = after
.char_indices()
.find_map(|(i, c)| {
if c == '"' && !escaped {
return Some(i);
}
escaped = c == '\\' && !escaped;
None
})
.expect("closed tl! literal");
let (raw, tail) = after.split_at(end);
let closing = tail.strip_prefix('"').expect("closing quote").trim_start();
let closing = closing.strip_prefix(',').unwrap_or(closing).trim_start();
if closing.starts_with(')') {
let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes");
literals.insert(label);
}
rest = tail.strip_prefix('"').expect("closing quote");
}
}
}
}
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(pt, label)).collect();
assert!(missing.is_empty(), "untranslated Brazilian Portuguese UI literals: {missing:#?}");
}
#[test]
fn spanish_is_registered() {
let es = Lang::from_code("es").expect("es registered");

File diff suppressed because it is too large Load Diff

View File

@ -101,6 +101,7 @@ pub(crate) fn date_text_for_pdf(session: &Session) -> Result<String, String> {
/// The longest author name kept (Preferences ▸ Identity, restored settings).
pub(crate) const MAX_AUTHOR_CHARS: usize = 200;
pub mod portable;
mod protect;
mod recovery;
@ -624,6 +625,25 @@ impl Default for PdfCraftApp {
}
}
/// A restored colour: an `[r, g, b]` array of finite numbers, each clamped to 0–1 (restored
/// settings are untrusted). Anything else (a string, a wrong length, a null or a non-finite
/// number) is refused whole, so the caller keeps its default.
fn rgb_triple(v: &serde_json::Value) -> Option<[f64; 3]> {
let a = v.as_array()?;
if a.len() != 3 {
return None;
}
let mut out = [0.0f64; 3];
for (slot, x) in out.iter_mut().zip(a.iter()) {
let n = x.as_f64()?;
if !n.is_finite() {
return None;
}
*slot = n.clamp(0.0, 1.0);
}
Some(out)
}
impl PdfCraftApp {
pub fn new() -> Self {
Self {
@ -1273,6 +1293,19 @@ impl PdfCraftApp {
// Null follows the interface language.
"date_language": self.session.date_language(),
"author": self.comment_prefs.author,
// Per-tool comment defaults (colours, opacity, widths), so Make Current
// Properties Default survives a restart (#340).
"comment_styles": self
.comment_prefs
.styles()
.map(|(t, s)| serde_json::json!({
"tool": t.command(),
"color": s.color,
"opacity": s.opacity,
"width": s.width,
"fill": s.fill,
}))
.collect::<Vec<_>>(),
// Drawn signatures keep their original form (older settings read the same).
"signature": match &self.signature { Some(fill_sign::SavedSig::Drawn(s)) => Some(s), _ => None },
"signature_text": match &self.signature { Some(fill_sign::SavedSig::Typed(t)) => Some(t), _ => None },
@ -1341,6 +1374,34 @@ impl PdfCraftApp {
if let Some(author) = v["author"].as_str().map(str::trim).filter(|a| !a.is_empty()) {
self.comment_prefs.author = author.chars().take(MAX_AUTHOR_CHARS).collect();
}
// Per-tool comment styles (#340): each field goes through its clamping setter, so a
// malformed or hostile entry is clamped or ignored rather than trusted.
if let Some(styles) = v["comment_styles"].as_array() {
for e in styles {
let Some(tool) = e["tool"].as_str().and_then(crate::comments::CommentTool::from_command) else { continue };
if let Some(c) = rgb_triple(&e["color"]) {
self.comment_prefs.set_color(tool, c);
}
if let Some(o) = e["opacity"].as_f64() {
self.comment_prefs.set_opacity(tool, o);
}
if let Some(w) = e["width"].as_f64() {
self.comment_prefs.set_width(tool, w);
}
// `"fill": null` is a saved "no fill", which clears a tool's default fill; a
// missing or malformed fill keeps it.
let fill = match e.get("fill") {
Some(serde_json::Value::Null) => Some(None),
Some(v) => rgb_triple(v).map(Some),
None => None,
};
if let Some(fill) = fill {
let mut s = self.comment_prefs.style(tool);
s.fill = fill;
self.comment_prefs.set_style(tool, s);
}
}
}
if let Ok(s) = serde_json::from_value::<Vec<Vec<[f32; 2]>>>(v["signature"].clone())
&& s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite())))
{

View File

@ -9,6 +9,12 @@ use pdfcraft_engine::print::{self, Binding, BookletSubset, Content, Layout, Orie
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
/// The preview pane, and the paper inside it (12 pt inset on each side).
const PREVIEW_W: f32 = 320.0;
const PREVIEW_H: f32 = 380.0;
const PREVIEW_INSET: f32 = 12.0;
pub(crate) const PREVIEW_BOX: (f32, f32) = (PREVIEW_W - 2.0 * PREVIEW_INSET, PREVIEW_H - 2.0 * PREVIEW_INSET);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Which {
All,
@ -269,6 +275,60 @@ impl PdfCraftApp {
}
}
/// The longest side, in pixels, of one page's print-preview raster (also kept within the GPU's
/// `max_texture_side`): an Actual-size print of a large page would otherwise ask for a raster
/// bigger than a texture can be.
pub(crate) const PREVIEW_MAX_SIDE: f32 = 4096.0;
/// The most bytes of print-preview rasters kept at once. The current sheet comes first, then the
/// next and the previous one; a neighbour that doesn't fit isn't rendered ahead.
pub(crate) const PREVIEW_BYTES: f64 = 128.0 * 1024.0 * 1024.0;
/// Pages on the current sheet and its neighbours, each with the device pixels per point that
/// fill the preview pane (so the picture is not a stretched thumbnail), nearest sheet first and
/// within [`PREVIEW_MAX_SIDE`] (and `max_side`, the GPU's texture limit) and [`PREVIEW_BYTES`].
pub(crate) fn preview_rasters(d: &PrintDraft, sizes: &[(f64, f64)], labels: &[String], ppp: f32, max_side: f32) -> Vec<(usize, f32)> {
let Ok(settings) = d.settings(sizes.len(), labels) else { return Vec::new() };
let Ok(sheets) = print::layout(sizes, &settings) else { return Vec::new() };
if sheets.is_empty() {
return Vec::new();
}
let ppp = if ppp.is_finite() { ppp.max(1.0) } else { 1.0 };
let side = if max_side.is_finite() && max_side >= 1.0 { max_side.min(PREVIEW_MAX_SIDE) } else { PREVIEW_MAX_SIDE };
let i = d.sheet.min(sheets.len() - 1);
// Nearest first: the sheet on screen, then the next one, then the previous one.
let order = [Some(i), i.checked_add(1).filter(|n| *n < sheets.len()), i.checked_sub(1)];
let mut out: Vec<(usize, f32, f64)> = Vec::new();
let mut bytes = 0.0f64;
for sheet in order.into_iter().flatten().filter_map(|s| sheets.get(s)) {
let (sw, sh) = (sheet.size.0 as f32, sheet.size.1 as f32);
if !(sw.is_finite() && sh.is_finite()) || sw < 1.0 || sh < 1.0 {
continue;
}
let k = (PREVIEW_BOX.0 / sw).min(PREVIEW_BOX.1 / sh);
for pl in &sheet.placed {
let [a, b, _, _, _, _] = pl.matrix.0;
let placed = (a.hypot(b) as f32) * k * ppp;
let Some(&(pw, ph)) = sizes.get(pl.page) else { continue };
let long_pt = pw.max(ph) as f32;
if !placed.is_finite() || placed < 0.05 || !long_pt.is_finite() || long_pt < 1.0 {
continue;
}
let scale = placed.min(64.0).min(side / long_pt);
let cost = (pw * f64::from(scale)).ceil() * (ph * f64::from(scale)).ceil() * 4.0;
if let Some(slot) = out.iter_mut().find(|(page, _, _)| *page == pl.page) {
if scale > slot.1 && bytes - slot.2 + cost <= PREVIEW_BYTES {
bytes += cost - slot.2;
(slot.1, slot.2) = (scale, cost);
}
} else if out.len() < 48 && (out.is_empty() || bytes + cost <= PREVIEW_BYTES) {
bytes += cost;
out.push((pl.page, scale, cost));
}
}
}
out.into_iter().map(|(page, scale, _)| (page, scale)).collect()
}
/// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them.
/// Only choices away from the printer's defaults are kept, and sent with the job.
fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) {
@ -567,15 +627,15 @@ pub(crate) fn body(
ui.add_space(12.0);
// Preview.
ui.vertical(|ui| {
ui.set_width(320.0);
let (area, _) = ui.allocate_exact_size(vec2(320.0, 380.0), egui::Sense::hover());
ui.set_width(PREVIEW_W);
let (area, _) = ui.allocate_exact_size(vec2(PREVIEW_W, PREVIEW_H), egui::Sense::hover());
ui.painter().rect_filled(area, 6.0, t.hover);
match (&settings, sheets.get(d.sheet)) {
(Err(e), _) => {
ui.put(area.shrink(16.0), egui::Label::new(egui::RichText::new(e).color(t.text_muted)).wrap());
}
(Ok(_), Some(sheet)) => {
let k = ((area.width() - 24.0) / sheet.size.0 as f32).min((area.height() - 24.0) / sheet.size.1 as f32);
let k = (PREVIEW_BOX.0 / sheet.size.0 as f32).min(PREVIEW_BOX.1 / sheet.size.1 as f32);
let paper = Rect::from_center_size(area.center(), vec2(sheet.size.0 as f32 * k, sheet.size.1 as f32 * k));
ui.painter().rect_filled(paper, 0.0, Color32::WHITE);
ui.painter().rect_stroke(paper, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
@ -668,3 +728,52 @@ pub(crate) fn body(
});
(go, cancel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_preview_rasters_stay_within_the_texture_limit() {
// Actual size on a 14,400 pt (200 in) page would ask for a raster far past any texture.
let d = PrintDraft { size: SizeMode::Actual, ..PrintDraft::default() };
let sizes = vec![(14_400.0, 14_400.0); 2];
let labels = vec!["1".into(), "2".into()];
for max_side in [2048.0, 8192.0, 16384.0, f32::NAN, 0.0] {
for (page, scale) in preview_rasters(&d, &sizes, &labels, 4.0, max_side) {
let long = 14_400.0 * scale;
let cap = if max_side.is_finite() && max_side >= 1.0 { max_side.min(PREVIEW_MAX_SIDE) } else { PREVIEW_MAX_SIDE };
assert!(long <= cap + 1.0, "page {page} at {max_side}: {long} px");
}
}
}
#[test]
fn print_preview_rasters_keep_the_current_sheet_within_the_byte_budget() {
// Many large pages: the current sheet always gets its raster; neighbours only while the
// budget lasts, and the total stays within it.
let d = PrintDraft { sheet: 5, ..PrintDraft::default() };
let sizes = vec![(2_000.0, 2_000.0); 12];
let labels: Vec<String> = (1..=12).map(|n| n.to_string()).collect();
let rasters = preview_rasters(&d, &sizes, &labels, 4.0, 16384.0);
assert_eq!(rasters.first().map(|r| r.0), Some(5), "the sheet on screen comes first: {rasters:?}");
let total: f64 = rasters.iter().map(|(_, s)| (2_000.0 * f64::from(*s)).ceil().powi(2) * 4.0).sum();
assert!(total <= PREVIEW_BYTES || rasters.len() == 1, "{total} bytes for {rasters:?}");
}
#[test]
fn print_preview_is_sharper_than_a_thumbnail_on_a_high_dpi_screen() {
let d = PrintDraft::default();
let sizes = vec![(612.0, 792.0); 3];
let labels = vec!["1".into(), "2".into(), "3".into()];
let rasters = preview_rasters(&d, &sizes, &labels, 2.0, 8192.0);
let scale = rasters.iter().find(|(page, _)| *page == 0).map(|(_, s)| *s).unwrap();
// A 132 pt thumbnail at 2× is about 0.43 px/pt. The preview pane needs roughly twice that.
assert!(scale > 0.7, "letter page in the preview at 2 px/pt: {scale}");
assert!(rasters.iter().any(|(page, _)| *page == 1), "the next sheet is ready");
let mut later = d.clone();
later.sheet = 2;
let rasters = preview_rasters(&later, &sizes, &labels, 2.0, 8192.0);
assert!(rasters.iter().any(|(page, _)| *page == 2));
}
}

View File

@ -60,9 +60,10 @@ impl SignatureDrag {
}
}
let (Some((_, page, _)), Some(layers)) = (self.key, self.layers.as_mut()) else { return };
// The image stays sharp at every zoom; bound the extra background raster to 2048².
// The image stays sharp at every zoom. The background matches the page view up to one
// whole-page texture; past that the view tiles and this preview stays at that cap.
let Some(p) = doc.info.pages.get(page) else { return };
let scale = scale.min(2048.0 / p.width.max(p.height).max(1.0));
let scale = scale.min(crate::canvas::BASE_SIDE / p.width.max(p.height).max(1.0));
let tag = (scale * 1000.0) as u64;
if layers.tag != Some(tag) {
layers.renderer.set_queue(vec![RenderRequest { page, scale, tag, ..Default::default() }]);

View File

@ -537,6 +537,13 @@ fn make_current_properties_default() {
h.run_steps(3);
let st = h.state().comment_prefs.style(pdfcraft_ui_egui::comments::CommentTool::Rectangle);
assert_eq!((st.color, st.opacity, st.width), ([0.0, 0.47, 0.84], 0.5, 5.0));
// The default survives a restart (#340): a fresh app reads the persisted settings and its
// next rectangle still takes the style.
let json = h.state().persist();
let mut fresh = PdfCraftApp::new();
fresh.restore(&json);
let st = fresh.comment_prefs.style(pdfcraft_ui_egui::comments::CommentTool::Rectangle);
assert_eq!((st.color, st.opacity, st.width), ([0.0, 0.47, 0.84], 0.5, 5.0), "the tool default persisted");
// The next rectangle takes it.
h.state_mut().set_option("quick", "square").unwrap();
drag_pt(&mut h, (160.0, 100.0), (260.0, 40.0));

View File

@ -162,7 +162,7 @@ fn ui_fonts_work_without_craft_fonts() {
theme::install_fonts(&ctx);
}
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian) are drawn entirely by the app's own faces
/// Latin- and Cyrillic-script catalogs (Czech, Brazilian Portuguese, German, Spanish, French, Russian, Bulgarian, Hungarian, Ukrainian, Italian) are drawn entirely by the app's own faces
/// (Inter, JetBrains Mono): no letter falls through to egui's defaults or a CJK fallback. (egui's
/// `has_glyphs` can't answer this: with only the primary face it is also the replacement face.)
#[test]
@ -179,6 +179,7 @@ fn primary_ui_fonts_cover_latin_and_cyrillic_catalogs() {
("bg", include_str!("../src/i18n/bg.tsv")),
("hu", include_str!("../src/i18n/hu.tsv")),
("uk", include_str!("../src/i18n/uk.tsv")),
("it", include_str!("../src/i18n/it.tsv")),
] {
let mut text = String::from("áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽãõçâêôàÃÕÇÂÊÔÀñÑüÜäÄöÖőűß„“¿¡«»…ґҐєЄіІїЇ");
for line in catalog.lines().filter(|l| !l.starts_with('#')) {

View File

@ -380,6 +380,52 @@ fn persistence_round_trips_and_tolerates_garbage() {
assert_eq!(b.theme, pdfcraft_ui_egui::theme::ThemeKind::Dark);
}
/// A tool's saved "no fill" (`"fill": null`) clears a fill it had as its default; a missing or
/// malformed fill keeps it (#340).
#[test]
fn a_saved_no_fill_clears_a_comment_tool_default_fill() {
use pdfcraft_ui_egui::comments::CommentTool;
let filled = |a: &mut PdfCraftApp| {
let mut s = a.comment_prefs.style(CommentTool::Rectangle);
s.fill = Some([0.2, 0.4, 0.6]);
a.comment_prefs.set_style(CommentTool::Rectangle, s);
};
let mut a = PdfCraftApp::new();
filled(&mut a);
a.restore(r#"{"comment_styles":[{"tool":"comment.square","fill":null}]}"#);
assert_eq!(a.comment_prefs.style(CommentTool::Rectangle).fill, None, "null clears the fill");
let mut b = PdfCraftApp::new();
filled(&mut b);
b.restore(r#"{"comment_styles":[{"tool":"comment.square","color":[0,0,1]},{"tool":"comment.square","fill":"red"}]}"#);
assert_eq!(b.comment_prefs.style(CommentTool::Rectangle).fill, Some([0.2, 0.4, 0.6]), "missing or malformed keeps it");
// A round trip keeps a cleared fill cleared.
let mut c = PdfCraftApp::new();
c.restore(&a.persist());
assert_eq!(c.comment_prefs.style(CommentTool::Rectangle).fill, None);
}
/// Persisted per-tool comment styles go through their clamping setters: hostile or malformed
/// values are clamped or ignored, never trusted (#340).
#[test]
fn comment_style_settings_clamp_and_refuse_hostile_values() {
use pdfcraft_ui_egui::comments::CommentTool;
let mut a = PdfCraftApp::new();
a.restore(
r#"{"comment_styles":[
{"tool":"comment.highlight","color":[2,-1,0.5],"opacity":99,"width":-4},
{"tool":"comment.note","color":"yellow","opacity":"none"},
{"tool":"comment.note","color":[0.1,null,0.3]},
{"tool":"bogus.command","color":[0,0,0]}
]}"#,
);
// Out-of-range numbers are clamped into range.
let st = a.comment_prefs.style(CommentTool::Highlight);
assert_eq!((st.color, st.opacity, st.width), ([1.0, 0.0, 0.5], 1.0, 0.5));
// A malformed colour is refused whole: the tool keeps its default.
let note = a.comment_prefs.style(CommentTool::Note);
assert_eq!(note.color, [1.0, 0.82, 0.0], "the default colour stays");
}
#[test]
fn zoom_keeps_the_point_under_the_cursor_still() {
let mut h = harness(|app| {

View File

@ -1,8 +1,8 @@
# Interface language
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar** or **Українська**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська** or **Italiano**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged.
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
**Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
The control channel exposes the setting through `ui.set`:
@ -10,17 +10,21 @@ The control channel exposes the setting through `ui.set`:
{"method":"ui.set","params":{"key":"language","value":"ja"}}
```
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu` or `uk`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk` or `it`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian and Ukrainian translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Italian translate them (about 1,800 entries each); Czech and Brazilian Portuguese cover the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
Dialogs, panels, menus, notices and history labels go through the catalog (`tl!`). Japanese, Traditional Chinese, Simplified Chinese, Russian, Bulgarian, German, Spanish, French, Telugu, Hungarian, Ukrainian and Brazilian Portuguese translate them (about 1,800 entries each); Czech covers the menus so far. Untranslated labels use English. Error details that come from the engine or the operating system are shown as they are, inside a translated frame ("操作「…」に失敗しました: …"). The command palette matches the translated label, the English label and the command id. Vertical Japanese PDF rendering is an existing viewer feature; this change does not add vertical text editing.
The Ukrainian catalog has 2,047 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French, Hungarian and Italian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André Cardoso posted in [#288](https://github.com/storytold/pdfcraft/issues/288), the labels added since, and a review of the existing wording. Portuguese, like French, treats 0 and 1 as singular (`1 página`, `2 páginas`).
Japanese interface text uses BIZ UDPGothic from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input that every release includes (`CRAFT_FONTS_DIR`; see the README). A build made without it has no Japanese face, so Japanese labels show replacement boxes. Chinese is drawn with the same craft-fonts CJK faces (in Simplified Chinese the `Hans` faces come first, so a line never mixes faces with different baselines), so a character none of them has shows a replacement box (and every Chinese character does in a build without craft-fonts). Russian, Bulgarian, Czech, Brazilian Portuguese, German, Spanish, French and Hungarian need only the bundled Latin faces (tested in `tests/fonts.rs`). Hungarian uses the same one/other plural rule as English: 1 is singular, and every other count — including 0 — is the other form. After a numeral the noun stays singular (`1 oldal`, `2 oldal`). Telugu is drawn with a craft-fonts `Telu` face (Noto Sans Telugu, kept in the web build too), whose conjuncts egui shapes; a build without one shows Telugu labels as replacement boxes, and the release-pinned craft-fonts input has no Telugu face yet.
There is no right-to-left interface language yet. File names and document titles in Arabic or Hebrew are drawn with a craft-fonts `Arab` face when the build input has one; on desktop, a character no embedded face has is drawn with one font already installed on the machine (`PDFCRAFT_SYSTEM_FONTS=0` turns this off). The tab strip, the recent-files list and Properties show such names in display order; other places (bookmarks, comments, search results) have the glyphs but may order mixed-direction text wrongly.
Japanese, Simplified Chinese, French and German coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Simplified Chinese also checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140).
Japanese, Simplified Chinese, French, German and Brazilian Portuguese coverage tests, following PhotoCraft's complete-catalog approach, check every registered command, All tools label and `tl!("literal")` in the UI source. Simplified Chinese also checks direct `i18n::t` calls and multiline literals, plus the About tabs, contributor name modes, sort options, table headers and model columns. Generated history labels, contributor summaries and diagnostics are tested with user-supplied names and error details. Catalog coverage does not guarantee glyph coverage: the release-pinned craft-fonts input currently has no `Hans` face, and the web build embeds only BIZ UDPGothic Regular. Chinese font delivery remains separate work, as discussed in [#112](https://github.com/storytold/pdfcraft/pull/112) and [#140](https://github.com/storytold/pdfcraft/pull/140).
German labels keep nouns capitalized, including tool names inserted into notices. The certificate viewer uses the `certificate` context for `Subject` (the certificate holder), while document properties keep the plain translation (the topic). Search hints reserve room for the shortcut, and Home cards limit their title and description to the available rows; hovering shows the full text when it is shortened.

View File

@ -3819,7 +3819,7 @@ status = "shipped"
commands = ["comment.list"]
tools = []
evidence = ["crates/ui-egui/tests/comments.rs::make_current_properties_default"]
notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus)"
notes = "colour, opacity and line width become the tool's defaults (canvas and Comments panel menus) and survive a restart: the per-tool styles are persisted with the settings and restored through their clamping setters (#340)"
[[feature]]
id = "comment.author-identity"
@ -9819,7 +9819,7 @@ status = "partial"
commands = ["app.preferences"]
tools = []
evidence = ["crates/ui-egui/src/i18n/mod.rs::windows_display_language_resolves_chinese_scripts", "crates/ui-egui/src/i18n/mod.rs::neutral_locale_does_not_override_display_language", "crates/ui-egui/src/i18n/mod.rs::windows_display_language_query_returns_a_locale_tag", "crates/ui-egui/src/i18n/mod.rs::language_persists_and_invalid_input_keeps_current_language", "crates/ui-egui/src/i18n/mod.rs::bundled_catalogs_are_consistent", "crates/ui-egui/src/i18n/mod.rs::japanese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::simplified_chinese_history_and_diagnostics_preserve_user_values", "crates/ui-egui/tests/control.rs::simplified_chinese_signature_prompts_and_errors_keep_document_state", "crates/ui-egui/tests/control.rs::language_switch_preserves_document_and_command_ids", "crates/ui-egui/tests/control.rs::japanese_controls_and_search_keep_command_ids", "crates/ui-egui/tests/control.rs::japanese_dialogs_errors_and_custom_action_names", "crates/ui-egui/src/i18n/mod.rs::french_is_registered", "crates/ui-egui/src/i18n/mod.rs::french_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::french_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::french_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::german_certificate_subject_has_a_distinct_context", "crates/ui-egui/src/widgets.rs::search_placeholder_never_overlaps_the_shortcut", "crates/ui-egui/src/home.rs::translated_card_description_leaves_room_for_the_action", "crates/ui-egui/tests/control.rs::german_preferences_and_search_keep_command_ids", "crates/ui-egui/src/i18n/mod.rs::telugu_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::telugu_labels_have_glyphs", "crates/ui-egui/src/i18n/mod.rs::hungarian_is_registered", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::hungarian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::hungarian_history_and_diagnostics_preserve_user_values", "crates/ui-egui/src/i18n/mod.rs::ukrainian_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::ukrainian_integer_plurals_cover_teens_and_large_counts", "crates/ui-egui/src/i18n/mod.rs::ukrainian_calendar_uses_month_case_for_dates", "crates/ui-egui/src/i18n/mod.rs::ukrainian_catalog_does_not_leave_english_ui_text", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::ukrainian_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::ukrainian_history_and_diagnostics_preserve_user_values"]
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. Engine/OS error details stay untranslated. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it and ui.state expose it. TSV catalogs per language (same system as PhotoCraft); every dialog, panel and notice goes through tl!. Japanese, Traditional Chinese, Simplified Chinese, German, Spanish, French and Hungarian cover commands, dialogs, panels, shortcuts and history labels; Czech and Brazilian Portuguese the menus. French locales (fr, fr_FR, fr_CA, fr_BE) select the French catalog, German locales (de, de_DE, de_AT, de_CH) the German one, Hungarian locales (hu, hu_HU) the Hungarian one. German search hints reserve shortcut space, Home cards bound translated text, certificate Subject has its own context, and inserted tool names keep their capitalization. Engine/OS error details stay untranslated. Auto detects the Windows display language after explicitly named supported locale overrides; neutral C/POSIX launcher locales are skipped on Windows, with Chinese script/region mapping and English fallback. Missing: Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases, a full localization audit."
[[feature]]
id = "misc.prefs-reading"