M14: Arabic interface language (#692)

* M14: Arabic interface language

A 2,136-entry Arabic catalog (six CLDR plural forms, Arabic month and
weekday names), selected by `ar` and every `ar-*` locale. egui joins
Arabic letters but places words left to right, so the catalog is put
into display order when it loads (bidi::display_rtl); placeholders,
keyboard shortcuts and caption colons stay in place. The command
search matches a multi-word Arabic query word by word.

The craft-fonts pin moves to the revision with Noto Sans Arabic.
crates/fonts/build.rs now refuses to embed Adobe type designs, so the
Noto Sans CJK SC in that revision is never embedded (AGENTS.md §1.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M14: Arabic catalog follows the {key} shortcut labels and has Redaction code

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Abdelhaq El Amraoui
2026-10-10 06:36:54 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent ebc5ccd4c0
commit 1c33e6f851
15 changed files with 2661 additions and 42 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- name: Install Linux system dependencies (egui/winit/rfd) - name: Install Linux system dependencies (egui/winit/rfd)
+5 -5
View File
@@ -77,7 +77,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
@@ -136,7 +136,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
@@ -195,7 +195,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- name: Install build and packaging tools - name: Install build and packaging tools
run: | run: |
@@ -303,7 +303,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- name: Build and package in a FreeBSD VM (tar.gz) - name: Build and package in a FreeBSD VM (tar.gz)
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build). # vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
@@ -349,7 +349,7 @@ jobs:
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with: with:
repository: storytold/craft-fonts repository: storytold/craft-fonts
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 ref: 8dcdacd5153e64560d109541a47d806f26f048c0
path: craft-fonts path: craft-fonts
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
+3 -2
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). 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 (259) ## In this repository (260)
| Asset | Title | Author | Licence | Source | Used for | | Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---| |---|---|---|---|---|---|
@@ -22,6 +22,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/ja.tsv` | Japanese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs | | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ar.tsv` | Arabic interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs | | `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
@@ -314,4 +315,4 @@ Not in this repository and never downloaded by it: compiled in only when the bui
| Input | Option | Title | Author | Licence | Source | Attribution | Used for | | Input | Option | Title | Author | Licence | Source | Attribution | Used for |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); the web build embeds only BIZ UDPGothic Regular | | `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1) |
+14 -3
View File
@@ -164,6 +164,17 @@ kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs" usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "51c933a8018726368dfaa55a82e8bc99a5684f80825bfdb6b581f03c5015908f" sha256 = "51c933a8018726368dfaa55a82e8bc99a5684f80825bfdb6b581f03c5015908f"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
title = "Arabic 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 = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "bb0808c269078ca6b63d7b2f2b8d1e058e12c89dde3ad6f28c848f6467e89671"
[[asset]] [[asset]]
path = "assets/icons/accessibility.svg" path = "assets/icons/accessibility.svg"
title = "Lucide icon \"accessibility\"" title = "Lucide icon \"accessibility\""
@@ -3212,13 +3223,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
[[build_input]] [[build_input]]
name = "craft-fonts" name = "craft-fonts"
option = "CRAFT_FONTS_DIR" option = "CRAFT_FONTS_DIR"
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho" title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; Arabic font: Noto Sans Arabic"
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU)" author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), The Noto Project Authors (Noto Sans Arabic)"
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)" source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
licence = "OFL-1.1" licence = "OFL-1.1"
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md" attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
kind = "font" kind = "font"
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); the web build embeds only BIZ UDPGothic Regular" usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds only BIZ UDPGothic Regular and Noto Sans Arabic. The checkout also holds Noto Sans CJK SC, which crates/fonts/build.rs never embeds (AGENTS.md §1.1)"
[[asset]] [[asset]]
path = "crates/sign/tests/data/x509-rsa-sha1.pdf" path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
+2 -1
View File
@@ -109,7 +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 | | 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 | | 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, 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 | | 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. Arabic (`ar`, every `ar-*` locale) has a full catalog whose labels are reordered for display; the layout itself is still left to right. Missing: a right-to-left layout for Arabic, 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** | | | | **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). **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).
@@ -134,6 +134,7 @@ M0 → M1 → M2 → M3 → M4 must happen in order. After M4, M5–M12 can run
- **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%). - **2026-10-10 (M14, side panel width):** The side panel keeps the width it is dragged to. The Bookmarks search row was 2 px wider than the panel, and a resizable egui panel keeps the width its content used, so every repaint (any pointer move) widened it up to 520 px. The Comments reply row did the same in languages with a longer "Post" (Russian, Spanish, …); Post now sits under a full-width reply field. Tests hold both panels' widths steady across 30 repaints. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged. - **2026-10-10 (M8):** Redaction codes. Redaction properties offers "Custom text" or "Redaction code" under "Use overlay text": pick the U.S. FOIA (5 U.S.C. 552(b)) or U.S. Privacy Act (552a) set and tick codes, and new marks carry them as overlay text in the set's order ("(b)(6), (b)(7)(C)"). `redact_mark` takes `code_set` and `codes`, rejecting unknown sets and codes with the allowed list. Tests cover the code sets, the tool end to end (mark, apply, text) and the dialog; the new label is translated in all 13 UI catalogs. `protect.redaction-codes` is partial: Edit Codes, custom sets and their import/export are still missing. Estimates unchanged.
- **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%). - **2026-10-10 (M14, platform shortcut labels):** View ▸ Zoom and Page navigation, the rail, find-bar, toolbar and bookmark tooltips, and Help ▸ Keyboard shortcuts write shortcuts for the platform (`Ctrl+1`, `Ctrl+Shift++` off macOS) instead of hard-coded ⌘. Labels follow egui's detected OS, so the web build in a Linux or Windows browser shows Ctrl too. Fit visible is listed once in View. Translated tooltips take a `{key}` placeholder. Overall estimate unchanged (about 30–35%).
- **2026-10-10 (M14):** Arabic interface language: a 2,136-entry catalog (`ar.tsv`, six CLDR plural forms, Arabic month and weekday names for dates), selected by `ar` and every `ar-*` locale. egui joins Arabic letters but places words left to right, so the catalog is put into display order when it loads (`bidi::display_rtl`: placeholders, keyboard shortcuts, edge spaces and caption colons are kept in place), and the command search matches a multi-word Arabic query word by word. Arabic glyphs come from the craft-fonts `Arab` face (kept in the web build); the CI and release pin of craft-fonts moves to the revision that has it, and `crates/fonts/build.rs` now refuses to embed the Noto Sans CJK SC that revision also carries (AGENTS.md §1.1). Not done: the layout is not mirrored, a wrapped Arabic paragraph breaks at the wrong end, and text typed into fields is not reordered; these need bidi support in the text layout itself.
- **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%). - **2026-10-10 (M6, #95):** Prepare a form edits shared General, Appearance and applicable Options properties in one atomic undo step. Explicit opt-in controls keep mixed values unchanged; partial appearance patches preserve custom font resources, each widget's style, indirect dictionary contents and unknown keys. Ctrl/Command+A selects the current page; rectangle selection and the Fields panel support multi-selection, with panel double-click opening properties. CLI/MCP `form_set_props` accepts a bounded, unique `fields` list. Regression tests cover save/reopen, undo/redo, locked-field rollback, stale dialogs, invalid arguments and selection. Names, positions, choices/defaults and script tabs remain individual GUI edits. Integrated upstream 0.5.0 and completed Portuguese/Italian catalogs without losing translator credit; shared-property labels now cover all 14 bundled translations. Kept the new Italian interface and Fill & Sign date-language registries aligned, including accented date output; the About-dialog regression waits for the larger contributor list to settle before clicking. Milestone and overall effort estimates unchanged (about 30–35%).
- **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%). - **2026-10-09 (M5, line-ending labels):** Comment properties names each line ending in words ("Reverse open arrow", "Butt", …) and those labels are translated. The PDF `/LE` token is unchanged. Overall estimate unchanged (about 30–35%).
+15 -1
View File
@@ -51,9 +51,12 @@ const CJK_MONTHS: [&str; 12] = ["1月", "2月", "3月", "4月", "5月", "6月",
const ZH_MONTHS: [&str; 12] = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"]; const ZH_MONTHS: [&str; 12] = ["一月", "二月", "三月", "四月", "五月", "六月", "七月", "八月", "九月", "十月", "十一月", "十二月"];
const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; const ZH_DAYS: [&str; 7] = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const AR_MONTHS: [&str; 12] = ["يناير", "فبراير", "مارس", "أبريل", "مايو", "يونيو", "يوليو", "أغسطس", "سبتمبر", "أكتوبر", "نوفمبر", "ديسمبر"];
const AR_DAYS: [&str; 7] = ["الأحد", "الاثنين", "الثلاثاء", "الأربعاء", "الخميس", "الجمعة", "السبت"];
/// The languages month and weekday names can be written in (Preferences ▸ Date format ▸ Language): /// 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. /// the interface languages, in the same order and with the same names.
pub const DATE_LANGUAGES: [DateLanguage; 15] = [ pub const DATE_LANGUAGES: [DateLanguage; 16] = [
DateLanguage { DateLanguage {
code: "en", code: "en",
name: "English", name: "English",
@@ -228,6 +231,16 @@ pub const DATE_LANGUAGES: [DateLanguage; 15] = [
days: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"], days: ["domenica", "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato"],
days_short: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"], days_short: ["dom", "lun", "mar", "mer", "gio", "ven", "sab"],
}, },
// Arabic has no shorter written forms of its month and weekday names.
DateLanguage {
code: "ar",
name: "العربية",
months: AR_MONTHS,
months_with_day: None,
months_short: AR_MONTHS,
days: AR_DAYS,
days_short: AR_DAYS,
},
]; ];
/// The date language for `code` (any case), or `None` when there's none. /// The date language for `code` (any case), or `None` when there's none.
@@ -507,6 +520,7 @@ mod tests {
assert_eq!(in_lang("dddd d mmmm yyyy", "it"), "sabato 7 marzo 2026"); assert_eq!(in_lang("dddd d mmmm yyyy", "it"), "sabato 7 marzo 2026");
assert_eq!(in_lang("ddd d mmm", "it"), "sab 7 mar"); assert_eq!(in_lang("ddd d mmm", "it"), "sab 7 mar");
assert_eq!(in_lang("dddd, d mmmm", "te"), "శనివారం, 7 మార్చి"); assert_eq!(in_lang("dddd, d mmmm", "te"), "శనివారం, 7 మార్చి");
assert_eq!(in_lang("dddd, d mmmm", "ar"), "السبت, 7 مارس");
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日"); assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "ja"), "2026年3月7日 土曜日");
assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六"); assert_eq!(in_lang("yyyy\\年mmmmd\\日 dddd", "zh-hans"), "2026年三月7日 星期六");
assert_eq!(in_lang("ddd", "zh-hant"), "週六"); assert_eq!(in_lang("ddd", "zh-hant"), "週六");
+10
View File
@@ -27,6 +27,10 @@ fn main() {
} }
} }
/// Family-name prefixes of the faces AGENTS.md §1.1 forbids: Adobe's Source families and Noto
/// CJK, which is Source Han under another name. The test in `src/craft.rs` has the same list.
const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
/// One `CraftFont { .. }` initialiser per manifest line. /// One `CraftFont { .. }` initialiser per manifest line.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> { fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt"); let manifest = dir.join("fonts/manifest.txt");
@@ -39,6 +43,12 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let [family, style, file, scripts, ..] = f.as_slice() else { let [family, style, file, scripts, ..] = f.as_slice() else {
return Err(format!("malformed manifest line: {line}")); return Err(format!("malformed manifest line: {line}"));
}; };
// AGENTS.md §1.1 bars Adobe's type designs whatever their licence, so a checkout that
// carries one (craft-fonts has Noto Sans CJK, which is Source Han) never embeds it.
if BARRED_FAMILIES.iter().any(|barred| family.starts_with(barred)) {
println!("cargo::warning=craft-fonts: not embedding {family} {style} (AGENTS.md §1.1)");
continue;
}
// Arabic interface faces are small, so the web build keeps them too. // Arabic interface faces are small, so the web build keeps them too.
let arabic = scripts.split(',').any(|s| s.trim() == "Arab"); let arabic = scripts.split(',').any(|s| s.trim() == "Arab");
// Telugu faces too, for the Telugu interface. // Telugu faces too, for the Telugu interface.
+14
View File
@@ -237,6 +237,20 @@ mod tests {
assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count()); assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count());
} }
/// Family-name prefixes `build.rs` never embeds (its own `BARRED_FAMILIES`): Adobe's type
/// designs, which includes Noto CJK (Source Han under another name).
const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans", "Noto Sans CJK", "Noto Serif CJK"];
/// Whatever the craft-fonts checkout holds, no Adobe type design is embedded (AGENTS.md §1.1).
#[test]
fn barred_families_are_never_embedded() {
for face in CRAFT_FONTS {
assert!(!BARRED_FAMILIES.iter().any(|barred| face.family.starts_with(barred)), "{} is embedded", face.name());
}
assert!(BARRED_FAMILIES.iter().any(|barred| "Noto Sans CJK SC".starts_with(barred)));
assert!(!BARRED_FAMILIES.iter().any(|barred| "Noto Sans Arabic".starts_with(barred)));
}
#[test] #[test]
fn craft_fonts_are_optional_and_consistent() { fn craft_fonts_are_optional_and_consistent() {
// Holds with or without CRAFT_FONTS_DIR. // Holds with or without CRAFT_FONTS_DIR.
+200 -3
View File
@@ -5,6 +5,9 @@
//! word join and run right to left, but it places the runs themselves left to right in logical //! word join and run right to left, but it places the runs themselves left to right in logical
//! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display //! order: `واحد اثنين.pdf` would show its two words swapped. [`visual`] puts the runs in display
//! order and leaves the letters of each word alone for the shaper. //! order and leaves the letters of each word alone for the shaper.
//!
//! [`display_rtl`] does the same for the labels of a right-to-left interface language (the Arabic
//! catalog), whose base direction is right to left.
use std::borrow::Cow; use std::borrow::Cow;
@@ -20,8 +23,14 @@ pub fn visual(text: &str) -> Cow<'_, str> {
if !text.chars().any(is_rtl_script) { if !text.chars().any(is_rtl_script) {
return Cow::Borrowed(text); return Cow::Borrowed(text);
} }
let info = BidiInfo::new(text, Some(Level::ltr()));
let mut out = String::with_capacity(text.len()); let mut out = String::with_capacity(text.len());
push_visual(&mut out, text, Level::ltr());
Cow::Owned(out)
}
/// `text` in display order for the paragraph direction `base`, appended to `out`.
fn push_visual(out: &mut String, text: &str, base: Level) {
let info = BidiInfo::new(text, Some(base));
for para in &info.paragraphs { for para in &info.paragraphs {
let (levels, runs) = info.visual_runs(para, para.range.clone()); let (levels, runs) = info.visual_runs(para, para.range.clone());
for run in runs { for run in runs {
@@ -29,13 +38,125 @@ pub fn visual(text: &str) -> Cow<'_, str> {
// Runs lie on character boundaries; a missing slice is skipped, never a panic. // Runs lie on character boundaries; a missing slice is skipped, never a panic.
let Some(piece) = text.get(run) else { continue }; let Some(piece) = text.get(run) else { continue };
if rtl { if rtl {
push_rtl_run(&mut out, piece); push_rtl_run(out, piece);
} else { } else {
out.extend(piece.chars().filter(|c| !is_bidi_control(*c))); out.extend(piece.chars().filter(|c| !is_bidi_control(*c)));
} }
} }
} }
Cow::Owned(out) }
/// A label of a right-to-left interface language, in logical order, rearranged for egui: each
/// line reads right to left, with Latin words and numbers inside it left to right.
///
/// - `{name}` placeholders stay whole and unmirrored, so [`crate::i18n::fmt`] still fills them.
/// What it inserts is drawn left to right at that place.
/// - Spaces and separator dots at either end stay at that end: the code appends to a label on
/// its right (`"Issued by: "` + a name), as the interface is still laid out left to right.
/// - A short caption's closing colon stays on the right too, next to the widget it captions.
///
/// Text without right-to-left characters comes back unchanged. Like [`visual`], this is for
/// painting one line; a label egui wraps breaks at the wrong end.
pub fn display_rtl(text: &str) -> String {
if !text.chars().any(is_rtl_script) {
return text.to_owned();
}
let mut out = String::with_capacity(text.len());
for (i, line) in text.split('\n').enumerate() {
if i > 0 {
out.push('\n');
}
push_rtl_line(&mut out, line);
}
out
}
/// The most words a caption may have for its closing colon to stay on the right.
const CAPTION_WORDS: usize = 3;
/// First of the private-use characters that stand for placeholders while a line is reordered.
/// They are strong left-to-right, like the names they replace.
const PLACEHOLDER_BASE: u32 = 0xE000;
/// Placeholders protected per line; a line with more keeps the rest as plain text.
const MAX_PLACEHOLDERS: usize = 64;
fn push_rtl_line(out: &mut String, line: &str) {
let edge = |c: char| c.is_whitespace() || c == '·';
let core = line.trim_start_matches(edge);
let lead = line.get(..line.len().saturating_sub(core.len())).unwrap_or_default();
let trimmed = core.trim_end_matches(edge);
let tail = core.get(trimmed.len()..).unwrap_or_default();
let mut core = trimmed;
let mut colon = "";
if let Some(caption) = core.strip_suffix(':')
&& caption.split_whitespace().count() <= CAPTION_WORDS
{
core = caption;
colon = ":";
}
// A line that already uses the stand-in characters is reordered with its placeholders as text.
let reserved = core.chars().any(|c| (PLACEHOLDER_BASE..PLACEHOLDER_BASE.saturating_add(MAX_PLACEHOLDERS as u32)).contains(&u32::from(c)));
let (masked, names) = if reserved { (core.to_owned(), Vec::new()) } else { mask_placeholders(core) };
let mut visual = String::with_capacity(masked.len());
push_visual(&mut visual, &masked, Level::rtl());
out.push_str(lead);
for c in visual.chars() {
let index = u32::from(c).checked_sub(PLACEHOLDER_BASE).and_then(|i| usize::try_from(i).ok());
match index.and_then(|i| names.get(i)) {
Some(name) => out.push_str(name),
None => out.push(c),
}
}
out.push_str(colon);
out.push_str(tail);
}
/// `text` with each `{name}` placeholder and each keyboard shortcut (`⇧⌘+`) replaced by one
/// stand-in character, and what they replaced, in order. A shortcut's symbols have no direction
/// of their own, so left alone they would be drawn in reverse.
fn mask_placeholders(text: &str) -> (String, Vec<&str>) {
let mut masked = String::with_capacity(text.len());
let mut names: Vec<&str> = Vec::new();
let mut rest = text;
while names.len() < MAX_PLACEHOLDERS
&& let Some((start, end)) = next_atom(rest)
{
let (Some(before), Some(atom), Some(after)) = (rest.get(..start), rest.get(start..end), rest.get(end..)) else { break };
// `names.len()` is below MAX_PLACEHOLDERS, so the sum is a private-use scalar.
let Some(stand_in) = u32::try_from(names.len()).ok().and_then(|i| char::from_u32(PLACEHOLDER_BASE.saturating_add(i))) else { break };
masked.push_str(before);
masked.push(stand_in);
names.push(atom);
rest = after;
}
masked.push_str(rest);
(masked, names)
}
/// The byte range of the first placeholder or keyboard shortcut in `text`.
fn next_atom(text: &str) -> Option<(usize, usize)> {
let placeholder = text.find('{').and_then(|open| {
let len = text.get(open..)?.find('}')?;
Some((open, open.saturating_add(len).saturating_add(1)))
});
let shortcut = text.find(is_modifier_key).map(|at| {
// The shortcut is the stretch around the symbol up to a space, a bracket or script text.
let outside = |c: char| c.is_whitespace() || is_rtl_script(c) || matches!(c, '(' | ')' | '[' | ']' | '{' | '}');
let start = text
.get(..at)
.and_then(|before| before.rfind(outside).and_then(|i| before.get(i..)?.chars().next().map(|c| i.saturating_add(c.len_utf8()))))
.unwrap_or(0);
let end = text.get(at..).and_then(|after| after.find(outside)).map_or(text.len(), |i| at.saturating_add(i));
(start, end)
});
match (placeholder, shortcut) {
(Some(p), Some(s)) => Some(if s.0 < p.0 { s } else { p }),
(p, s) => p.or(s),
}
}
/// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃.
fn is_modifier_key(c: char) -> bool {
matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}')
} }
/// One right-to-left run, in display order. Stretches of right-to-left script stay in logical /// One right-to-left run, in display order. Stretches of right-to-left script stay in logical
@@ -127,6 +248,82 @@ mod tests {
assert_eq!(visual("\u{200F}واحد\u{061C}"), "واحد"); assert_eq!(visual("\u{200F}واحد\u{061C}"), "واحد");
} }
#[test]
fn rtl_labels_read_right_to_left() {
// No right-to-left characters: unchanged.
for s in ["", "Save as…", "{n} pages", "日本語"] {
assert_eq!(display_rtl(s), s);
}
assert_eq!(display_rtl("حفظ"), "حفظ");
// "Save as…": the second word and the ellipsis end up on the left.
assert_eq!(display_rtl("حفظ باسم…"), "…باسم حفظ");
// Latin words and numbers keep their own order inside the line.
assert_eq!(display_rtl("إنشاء PDF من ملف"), "ملف من PDF إنشاء");
assert_eq!(display_rtl("تصدير إلى Microsoft Word الآن"), "الآن Microsoft Word إلى تصدير");
assert_eq!(display_rtl("صالح لمدة 5 سنوات"), "سنوات 5 لمدة صالح");
// Keyboard shortcuts are not reversed.
assert_eq!(display_rtl("طباعة (⌘P)"), "(⌘P) طباعة");
assert_eq!(display_rtl("تدوير العرض (⇧⌘+)"), "(⇧⌘+) العرض تدوير");
assert_eq!(display_rtl("التمرير مع ⌘ أيضًا"), "أيضًا ⌘ مع التمرير");
assert_eq!(display_rtl("نص (عادي)"), "(عادي) نص");
}
#[test]
fn rtl_labels_keep_placeholders_whole() {
// Two placeholders around a colon stay together, left to right: a file name and its error.
assert_eq!(display_rtl("تعذّر فتح {name}: {e}"), "{name}: {e} فتح تعذّر");
assert_eq!(display_rtl("فشل {label}، السبب {e}"), "{e} السبب ،{label} فشل");
assert_eq!(display_rtl("{n} صفحات"), "صفحات {n}");
assert_eq!(display_rtl("الصفحة {p} من {n}"), "{n} من {p} الصفحة");
// Adjacent placeholders keep their order, as numbers around a slash would.
assert_eq!(display_rtl("جارٍ البحث… {s}/{p}"), "{s}/{p} …البحث جارٍ");
// Braces that are not a placeholder are ordinary punctuation.
assert_eq!(display_rtl("حرف } واحد"), "واحد { حرف");
let many = format!("ا {}", "{a} ".repeat(200));
assert_eq!(display_rtl(&many).matches("{a}").count(), 200);
}
#[test]
fn rtl_labels_keep_their_edges() {
// Edge spaces and separator dots stay where the code concatenates.
assert_eq!(display_rtl(" (صفحة {p})"), " ({p} صفحة)");
assert_eq!(display_rtl(" · محمي بكلمة مرور"), " · مرور بكلمة محمي");
assert_eq!(display_rtl("سلسلة المفاتيح · "), "المفاتيح سلسلة · ");
// A caption's colon stays next to its widget; a sentence's colon ends the sentence.
assert_eq!(display_rtl("الاسم:"), "الاسم:");
assert_eq!(display_rtl("صادرة عن: "), "عن صادرة: ");
assert_eq!(display_rtl("اختر المعرّف الرقمي الذي تريد استخدامه:"), ":استخدامه تريد الذي الرقمي المعرّف اختر");
// Each line is its own paragraph.
assert_eq!(display_rtl("تعذّر عرض الصفحة.\n{e}"), ".الصفحة عرض تعذّر\n{e}");
}
#[test]
fn rtl_labels_never_panic_on_odd_input() {
let long = "ا {n} ".repeat(20_000);
for s in [
"ا{",
"ا}",
"ا{}",
"ا{{{",
"}ا{",
"ا\u{E000}{n}",
"⌘",
"ا ⌘",
"⌘ا⌘{",
"ا (⇧⌘",
"\u{064B}:",
":",
"ا:",
" : ا : ",
"ا\n\n\nب",
"\u{202E}ا{n}",
long.as_str(),
] {
let v = display_rtl(s);
assert!(v.chars().count() <= s.chars().count(), "{:?}", s.chars().take(12).collect::<String>());
}
}
#[test] #[test]
fn odd_input_never_panics() { fn odd_input_never_panics() {
let long = "ا ب ".repeat(20_000); let long = "ا ب ".repeat(20_000);
File diff suppressed because it is too large Load Diff
+9
View File
@@ -155,6 +155,15 @@ impl Catalog {
(c, errors) (c, errors)
} }
/// Rewrite every translation (each plural form on its own), e.g. into display order for a
/// right-to-left language. Keys are untouched.
pub fn map_translations(&mut self, f: impl Fn(&str) -> String) {
let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.ids));
for translation in tables.flat_map(HashMap::values_mut).chain(self.plurals.values_mut().flatten()) {
*translation = f(translation);
}
}
pub fn plain(&self, s: &str) -> Option<&str> { pub fn plain(&self, s: &str) -> Option<&str> {
self.plain.get(s).map(String::as_str) self.plain.get(s).map(String::as_str)
} }
+197 -19
View File
@@ -44,6 +44,9 @@ pub struct LangInfo {
/// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's /// Plural form index for a count (English: 0 = one, 1 = other; Japanese: always 0). A catalog's
/// `@plural` entries list one form per index. /// `@plural` entries list one form per index.
pub plural: fn(u64) -> usize, pub plural: fn(u64) -> usize,
/// The language is written right to left. egui lays text out left to right, so the catalog's
/// translations are put into display order when it loads ([`crate::bidi::display_rtl`]).
pub rtl: bool,
catalog: OnceLock<Catalog>, catalog: OnceLock<Catalog>,
} }
@@ -99,36 +102,69 @@ fn plural_ukrainian(n: u64) -> usize {
} }
} }
/// Arabic (CLDR `ar`, integer counts): zero (0), one (1), two (2), few (3–10, 103–110),
/// many (11–99, 111–199), other (100–102, 200–202).
fn plural_arabic(n: u64) -> usize {
match (n, n % 100) {
(0, _) => 0,
(1, _) => 1,
(2, _) => 2,
(_, 3..=10) => 3,
(_, 11..=99) => 4,
_ => 5,
}
}
/// The registry. English first: it is the fallback and the source language. /// The registry. English first: it is the fallback and the source language.
pub static LANGUAGES: [LangInfo; 15] = [ pub static LANGUAGES: [LangInfo; 16] = [
LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, catalog: OnceLock::new() }, LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, catalog: OnceLock::new() }, LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`). // Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` locales resolve here (see `candidates`).
LangInfo { code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), plural: plural_none, catalog: OnceLock::new() }, LangInfo {
code: "zh-hans", name: "简体中文", source: include_str!("zh-hans.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new()
},
// Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` // Traditional Chinese in the vocabulary used in Taiwan; `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*`
// locales all resolve here (see `candidates`). // locales all resolve here (see `candidates`).
LangInfo { code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), plural: plural_none, catalog: OnceLock::new() }, LangInfo {
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, catalog: OnceLock::new() }, code: "zh-hant", name: "繁體中文", source: include_str!("zh-hant.tsv"), plural: plural_none, rtl: false, catalog: OnceLock::new()
},
LangInfo { code: "cs", name: "Čeština", source: include_str!("cs.tsv"), plural: plural_cs, rtl: false, catalog: OnceLock::new() },
// Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`). // Brazilian Portuguese; `pt`, `pt-BR` and `pt-PT` locales all resolve here (see `candidates`).
LangInfo { code: "pt-br", name: "Português (Brasil)", source: include_str!("pt-br.tsv"), plural: plural_pt, catalog: OnceLock::new() }, LangInfo {
code: "pt-br",
name: "Português (Brasil)",
source: include_str!("pt-br.tsv"),
plural: plural_pt,
rtl: false,
catalog: OnceLock::new(),
},
// German (informal "du"); every `de-*` locale (`de-DE`, `de-AT`, `de-CH` ...) resolves here. // German (informal "du"); every `de-*` locale (`de-DE`, `de-AT`, `de-CH` ...) resolves here.
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_one_other, catalog: OnceLock::new() }, LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Spanish (European vocabulary); every `es-*` locale (`es-ES`, `es-MX`, `es-419` ...) resolves here. // Spanish (European vocabulary); every `es-*` locale (`es-ES`, `es-MX`, `es-419` ...) resolves here.
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_one_other, catalog: OnceLock::new() }, LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// French; every `fr-*` locale (`fr-FR`, `fr-CA`, `fr-BE` ...) resolves here. // French; every `fr-*` locale (`fr-FR`, `fr-CA`, `fr-BE` ...) resolves here.
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, catalog: OnceLock::new() }, LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), plural: plural_fr, rtl: false, catalog: OnceLock::new() },
// Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here. // Russian; every `ru-*` locale (`ru-RU`, `ru-BY`, `ru-KZ` ...) resolves here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, catalog: OnceLock::new() }, LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), plural: plural_russian, rtl: false, catalog: OnceLock::new() },
// Bulgarian; every `bg-*` locale (`bg-BG`) resolves here. // Bulgarian; every `bg-*` locale (`bg-BG`) resolves here.
LangInfo { code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, catalog: OnceLock::new() }, LangInfo {
code: "bg", name: "Български", source: include_str!("bg.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
},
// Telugu; every `te-*` locale (`te-IN`) resolves here. // Telugu; every `te-*` locale (`te-IN`) resolves here.
LangInfo { code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, catalog: OnceLock::new() }, LangInfo {
code: "te", name: "తెలుగు", source: include_str!("te.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new()
},
// Hungarian; every `hu-*` locale (`hu-HU`) resolves here. // Hungarian; every `hu-*` locale (`hu-HU`) resolves here.
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_one_other, catalog: OnceLock::new() }, LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Ukrainian; every `uk-*` locale (`uk-UA`, `uk_UA.UTF-8`) resolves here. // 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() }, LangInfo {
code: "uk", name: "Українська", source: include_str!("uk.tsv"), plural: plural_ukrainian, rtl: false, catalog: OnceLock::new()
},
// Italian; every `it-*` locale (`it-IT`) resolves here. // 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() }, LangInfo { code: "it", name: "Italiano", source: include_str!("it.tsv"), plural: plural_one_other, rtl: false, catalog: OnceLock::new() },
// Arabic (Modern Standard, Western digits); every `ar-*` locale (`ar-MA`, `ar-EG`, `ar-SA` ...)
// resolves here. Right to left: see `LangInfo::rtl`.
LangInfo { code: "ar", name: "العربية", source: include_str!("ar.tsv"), plural: plural_arabic, rtl: true, catalog: OnceLock::new() },
]; ];
impl LangInfo { impl LangInfo {
@@ -139,10 +175,13 @@ impl LangInfo {
fn catalog(&self) -> &Catalog { fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| { self.catalog.get_or_init(|| {
let (catalog, errors) = Catalog::parse(self.source, self.plural_forms()); let (mut catalog, errors) = Catalog::parse(self.source, self.plural_forms());
for error in errors { for error in errors {
log::warn!("{} interface catalog: {error}; that entry shows in English", self.code); log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
} }
if self.rtl {
catalog.map_translations(crate::bidi::display_rtl);
}
catalog catalog
}) })
} }
@@ -193,6 +232,12 @@ impl Lang {
self.0.name self.0.name
} }
/// Whether the language is written right to left. Its translations come back from the
/// lookups in display order (see [`LangInfo::rtl`]).
pub fn rtl(self) -> bool {
self.0.rtl
}
fn catalog(self) -> &'static Catalog { fn catalog(self) -> &'static Catalog {
self.0.catalog() self.0.catalog()
} }
@@ -602,8 +647,14 @@ mod tests {
#[test] #[test]
fn broken_catalog_falls_back_to_english() { fn broken_catalog_falls_back_to_english() {
static BROKEN: LangInfo = static BROKEN: LangInfo = LangInfo {
LangInfo { code: "broken", name: "Broken test catalog", source: "malformed\n\\", plural: plural_none, catalog: OnceLock::new() }; code: "broken",
name: "Broken test catalog",
source: "malformed\n\\",
plural: plural_none,
rtl: false,
catalog: OnceLock::new(),
};
assert_eq!(tr(Lang(&BROKEN), "File"), "File"); assert_eq!(tr(Lang(&BROKEN), "File"), "File");
assert_eq!(trn(Lang(&BROKEN), 2, "{n} page", "{n} pages"), "2 pages"); assert_eq!(trn(Lang(&BROKEN), 2, "{n} page", "{n} pages"), "2 pages");
} }
@@ -615,6 +666,7 @@ mod tests {
name: "Test catalog", name: "Test catalog",
source: "\tLight\tPlain light\nweight\tLight\tThin\n@id\tfile.open\tOpen dialog…\n@plural\t{n} page|{n} pages\tOne page: {n}|Many pages: {n}\n", source: "\tLight\tPlain light\nweight\tLight\tThin\n@id\tfile.open\tOpen dialog…\n@plural\t{n} page|{n} pages\tOne page: {n}|Many pages: {n}\n",
plural: plural_one_other, plural: plural_one_other,
rtl: false,
catalog: OnceLock::new(), catalog: OnceLock::new(),
}; };
let l = Lang(&TEST); let l = Lang(&TEST);
@@ -2047,6 +2099,132 @@ mod tests {
set_current(Lang::EN); set_current(Lang::EN);
} }
fn arabic() -> Lang {
Lang::from_code("ar").expect("ar registered")
}
#[test]
fn arabic_is_registered_and_persists() {
let ar = arabic();
assert_eq!(ar.name(), "العربية");
assert!(ar.rtl());
assert!(Lang::all().filter(|l| l.rtl()).eq([ar]), "Arabic is the only right-to-left language");
assert_eq!(normalize_pref("AR"), Some("ar"));
for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] {
assert_eq!(lang_from_tag(tag), Some(ar), "{tag}");
}
assert_eq!(first_supported("ar-MA\r\nen-US"), Some(ar));
assert_eq!(tr(ar, "File"), "ملف");
assert_eq!(tr(ar, "Bookmarks"), "المرجعية الإشارات");
assert_eq!(tr_ctx(ar, "comment menu", "Edit"), "تحرير");
assert_eq!(tr_ctx(ar, "certificate", "Subject"), "الشهادة صاحب");
// Unknown strings and user text are never reordered by a lookup.
assert_eq!(tr(ar, "تقرير نهائي {n}.pdf"), "تقرير نهائي {n}.pdf");
let mut app = crate::PdfCraftApp::default();
app.set_option("language", "AR").unwrap();
assert_eq!(app.language, "ar");
let mut restored = crate::PdfCraftApp::default();
restored.restore(&app.persist());
assert_eq!(restored.language, "ar");
assert!(restored.set_option("language", "xx").is_err());
assert_eq!(restored.language, "ar");
}
/// Lookups return Arabic in display order: egui draws the words left to right as given, and
/// joins the letters of each word itself.
#[test]
fn arabic_lookups_are_in_display_order() {
let ar = arabic();
// "حفظ باسم…" as typed; the ellipsis ends the phrase on the left.
assert_eq!(tr(ar, "Save as…"), "…باسم حفظ");
// Latin names keep their own direction inside the phrase.
assert_eq!(tr(ar, "About PdfCraft"), "PdfCraft حول");
// A caption's colon stays beside the widget on its right.
assert_eq!(tr(ar, "Name:"), "الاسم:");
assert_eq!(tr(ar, "Issued by: "), "عن صادرة: ");
// Placeholders survive and are filled in place.
assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "{name}: {e} فتح تعذّر");
assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "a {e}.pdf: x فتح تعذّر");
assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "12 من 3 الصفحة");
set_current(ar);
assert_eq!(command_label("Undo Insert pages from a.pdf"), "a.pdf من صفحات إدراج عن التراجع");
assert_eq!(menu_label("file.saveAs", "Save as…"), "…باسم حفظ");
set_current(Lang::EN);
}
#[test]
fn arabic_plurals_follow_the_six_cldr_forms() {
let ar = arabic();
assert_eq!(ar.0.plural_forms(), 6);
let form = |n| (ar.0.plural)(n);
assert_eq!([0, 1, 2].map(form), [0, 1, 2]);
assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]);
assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]);
assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]);
assert_eq!(trn(ar, 1, "{n} page", "{n} pages"), "صفحة 1");
assert_eq!(trn(ar, 2, "{n} page", "{n} pages"), "صفحتان 2");
assert_eq!(trn(ar, 5, "{n} page", "{n} pages"), "صفحات 5");
assert_eq!(trn(ar, 11, "{n} field", "{n} fields"), "حقلًا 11");
assert_eq!(trn(ar, 100, "{n} field", "{n} fields"), "حقل 100");
}
/// Arabic translates every registered command, menu and All tools label.
#[test]
fn arabic_covers_commands_and_catalogue() {
let ar = arabic();
for command in pdfcraft_engine::commands::COMMANDS {
assert!(has(ar, command.label), "missing command: {}", command.label);
if let Some(menu) = command.menu {
assert!(has(ar, menu), "missing menu: {menu}");
}
}
for group in pdfcraft_engine::catalog::TOOL_GROUPS {
assert!(has(ar, group.label), "missing group: {}", group.label);
for section in group.sections {
assert!(has(ar, section.title), "missing section: {}", section.title);
for item in section.items {
assert!(has(ar, item.label), "missing item: {}", item.label);
}
}
}
}
/// Arabic translates every `tl!("…")` literal in the UI source.
#[test]
fn arabic_covers_ui_literals() {
let literals = ui_literals();
assert!(literals.len() > 900, "source scan found only {} literals", literals.len());
let missing: Vec<_> = literals.iter().filter(|label| !has(arabic(), label)).collect();
assert!(missing.is_empty(), "untranslated Arabic UI literals: {missing:#?}");
}
/// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface
/// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps
/// it; Arabic labels must not need a Japanese face).
#[test]
fn arabic_labels_have_glyphs() {
let faces: Vec<String> = pdfcraft_fonts::ui_arabic_fonts().iter().map(|f| f.name()).collect();
if faces.is_empty() {
eprintln!("skipping Arabic glyph checks: build with CRAFT_FONTS_DIR and an Arabic face to run them");
return;
}
let ar = arabic();
let (entries, _) = parse_entries(ar.0.source, ar.0.plural_forms());
let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(ar.name().chars()).filter(|c| !c.is_control()).collect();
let mut arabic_only = crate::theme::font_definitions();
for family in arabic_only.families.values_mut() {
family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || faces.contains(name));
}
use egui::epaint::text::{Fonts, TextOptions};
for (build, defs) in [("all faces", crate::theme::font_definitions()), ("Arabic face only", arabic_only)] {
let mut fonts = Fonts::new(TextOptions::default(), defs);
for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] {
let missing: std::collections::BTreeSet<char> = labels.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect();
assert!(missing.is_empty(), "{build}: {id:?} lacks Arabic label glyphs {missing:?}");
}
}
}
/// Every bundled catalog is well-formed and consistent with its sources. /// Every bundled catalog is well-formed and consistent with its sources.
#[test] #[test]
fn bundled_catalogs_are_consistent() { fn bundled_catalogs_are_consistent() {
+8
View File
@@ -25,6 +25,14 @@ fn score(hay: &str, needle: &str) -> Option<usize> {
if let Some(p) = h.find(needle) { if let Some(p) = h.find(needle) {
return Some(p); return Some(p);
} }
// A right-to-left language's labels are in display order (their words run the other way)
// while the query is in typing order, so several words match one by one.
if crate::i18n::current().rtl() && needle.contains(char::is_whitespace) {
let first = needle.split_whitespace().map(|word| h.find(word)).try_fold(usize::MAX, |first, p| p.map(|p| first.min(p)));
if let Some(first) = first {
return Some(first);
}
}
// Subsequence match as a fallback. // Subsequence match as a fallback.
let mut it = h.chars(); let mut it = h.chars();
needle.chars().all(|c| it.any(|x| x == c)).then_some(100) needle.chars().all(|c| it.any(|x| x == c)).then_some(100)
+22 -5
View File
@@ -1,8 +1,8 @@
# Interface language # 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 **Italiano**. The change applies immediately and persists between launches. Command ids, document contents and file names are unchanged. Choose **Menu > Edit > Preferences…** (Command-comma on macOS, Ctrl-comma elsewhere) **> Interface language** and select **Auto**, **English**, **日本語**, **简体中文**, **繁體中文**, **Русский**, **Български**, **Čeština**, **Português (Brasil)**, **Deutsch**, **Español**, **Français**, **తెలుగు**, **Magyar**, **Українська**, **Italiano** or **العربية**. 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, 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. **Auto** (the default) follows the system language: `LC_ALL`, `LC_MESSAGES` or `LANG`, then the preferred-languages list on macOS. Any Portuguese locale (`pt_BR`, `pt_PT`) uses the Brazilian catalog, any French locale (`fr`, `fr_FR`, `fr_CA`, `fr_BE`) the French one, any German locale (`de`, `de_DE`, `de_AT`, `de_CH`) the German one, and Chinese locales for Taiwan, Hong Kong and Macau (`zh_TW`, `zh_HK`, `zh_MO`, `zh-Hant`) the Traditional Chinese one; other Chinese locales (`zh`, `zh_CN`, `zh_SG`, `zh-Hans`) the Simplified Chinese one. Telugu locales (`te`, `te_IN`) use the Telugu catalog, Bulgarian locales (`bg`, `bg_BG`) the Bulgarian one, and Hungarian locales (`hu`, `hu_HU`) the Hungarian one, and Ukrainian locales (`uk`, `uk_UA`) the Ukrainian one, and Italian locales (`it`, `it_IT`, `it_CH`) the Italian one, and Arabic locales (`ar`, `ar_MA`, `ar_EG`, `ar_SA`, …) the Arabic one. A system language without a catalog shows English. On Windows, **Auto** uses the Windows display languages, in preference order, when no supported locale is selected by those environment variables. Neutral `C`/`POSIX` locale values (including `C.UTF-8`) inherited from a launcher do not override the Windows display language; explicitly named supported locales such as `fr_FR.UTF-8` still do. The query asks Windows directly (`GetUserPreferredUILanguages`, through the `sys-locale` crate) without starting a process, and is cached for the process. If detection fails or the display language has no catalog, PdfCraft shows English.
The control channel exposes the setting through `ui.set`: The control channel exposes the setting through `ui.set`:
@@ -10,9 +10,9 @@ The control channel exposes the setting through `ui.set`:
{"method":"ui.set","params":{"key":"language","value":"ja"}} {"method":"ui.set","params":{"key":"language","value":"ja"}}
``` ```
The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`), 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. The value is `auto` or a language code (`en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it`, `ar`), in any case. `ui.state` reports `language` as written in the preferences (`auto`, `en`, `ja`, `zh-hans`, `zh-hant`, `ru`, `bg`, `cs`, `pt-br`, `de`, `es`, `fr`, `te`, `hu`, `uk`, `it` or `ar`). Unknown values return an error without changing the current setting. Preferences saved before this setting existed follow the system language.
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, Italian and Arabic 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. 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. 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.
@@ -22,7 +22,24 @@ The Brazilian Portuguese catalog has 2,000 entries: the ~1,826-entry gist André
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. 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. ## Arabic and right-to-left text
The Arabic catalog has 2,136 entries in Modern Standard Arabic with Western digits. Counts use the six [CLDR Arabic forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#ar) (`0`, `1`, `2`, `3–10`, `11–99`, the rest), and dates use the Arabic names of the Gregorian months.
egui joins the letters of an Arabic word and runs them right to left, but it places the words of a line left to right in the order they are written. So catalog entries are written in logical (typing) order, and `i18n` puts each one into display order when the catalog loads (`bidi::display_rtl`): the line reads right to left, Latin words and numbers inside it read left to right, `{name}` placeholders and keyboard shortcuts stay whole, spaces at either end stay where the code concatenates, and a short caption keeps its colon on the right, beside the widget it captions. Lookups (`tl!`, `tr`, `trn`) therefore return display-order text for Arabic; `i18n::fmt` inserts values as they are. The command search matches a query of several Arabic words word by word.
What Arabic does not have yet:
- **The layout is not mirrored.** Panels, menus, toolbars and alignment are as in the left-to-right languages, with Arabic labels in them.
- **A wrapped paragraph breaks at the wrong end.** A label too long for its line continues on the next line with its *first* words, so wrapped descriptions and tooltips read from the last line up. Single-line labels are right.
- **Text you type into a field** (search, comments, form values) has joined letters but its words run left to right while you edit it. What is stored is correct.
- Labels built by joining translated pieces in code (a list such as `A · B · C`) keep the code's left-to-right order.
These need bidirectional support in the text layout itself, not in the catalog.
Arabic glyphs come from the craft-fonts `Arab` face (Noto Sans Arabic, kept in the web build too). A build without craft-fonts shows Arabic as replacement boxes on the web; on desktop it falls back to one installed font (below). The craft-fonts revision pinned for CI and releases (`.github/workflows/`) has the Arabic face. That revision also holds Noto Sans CJK SC, which `crates/fonts/build.rs` never embeds: `AGENTS.md` §1.1 bars it, so Chinese still has no face of its own.
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, 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). 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).
+2 -2
View File
@@ -9818,8 +9818,8 @@ milestone = "M14"
status = "partial" status = "partial"
commands = ["app.preferences"] commands = ["app.preferences"]
tools = [] 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"] 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::arabic_is_registered_and_persists", "crates/ui-egui/src/i18n/mod.rs::arabic_lookups_are_in_display_order", "crates/ui-egui/src/i18n/mod.rs::arabic_plurals_follow_the_six_cldr_forms", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_commands_and_catalogue", "crates/ui-egui/src/i18n/mod.rs::arabic_covers_ui_literals", "crates/ui-egui/src/i18n/mod.rs::arabic_labels_have_glyphs", "crates/ui-egui/src/bidi.rs::rtl_labels_keep_placeholders_whole", "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/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." notes = "Preferences switches Auto (system language)/English/Japanese/Simplified Chinese/Traditional Chinese/Czech/Brazilian Portuguese/German/Spanish/French/Telugu/Hungarian/Ukrainian/Italian/Arabic and persists; ui.set language auto|en|ja|zh-hans|zh-hant|cs|pt-br|de|es|fr|te|hu|uk|it|ar 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. Arabic (ar and every ar-* locale) covers commands, dialogs and panels with the six CLDR plural forms; its labels are put into display order when the catalog loads, because egui joins Arabic letters but lays words out left to right. Missing: a right-to-left layout (Arabic labels sit in the left-to-right layout, a wrapped Arabic paragraph breaks at the wrong end, and text typed into a field is not reordered), Czech and Brazilian Portuguese dialogs, Chinese and Telugu font delivery in releases (the pinned craft-fonts revision has an Arabic face; its Noto Sans CJK SC is never embedded, AGENTS.md §1.1), a full localization audit."
[[feature]] [[feature]]
id = "misc.prefs-reading" id = "misc.prefs-reading"