mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-12 09:21:17 +00:00
Merge main into fix/undo-in-open-text-editor
Main moved to egui 0.37 (#4591) and renamed string ids to Id::unique. Conflict in edit_text_ui.rs: the paragraph box keeps the shared input id, now Id::unique; the added-text box's shared id follows.
This commit is contained in:
commit
c779488c79
233 files changed
+15957
-3309
No files matched your search
@@ -32,6 +32,9 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Windows font notice regressions
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-font-notices.ps1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -73,7 +76,7 @@ jobs:
|
||||
# the org's 5 macOS slots are kept for release builds, and release.yml builds macOS.
|
||||
os: ${{ (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'full-ci')) && '["ubuntu-24.04","windows-latest"]' || '["ubuntu-24.04"]' }}
|
||||
# Same craft-fonts commit as release.yml and freebsd.yml.
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
# egui/winit/rfd, and the independent oracles (crates/oracle).
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: pdfcraft
|
||||
app-title: PdfCraft
|
||||
craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
+9
-7
@@ -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 the SHA-256 of every asset but the translation catalogs, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (288)
|
||||
## In this repository (289)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -28,6 +28,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | 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; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ckb.tsv` | Central Kurdish (Sorani) 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/fa.tsv` | Persian (Farsi) 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; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed | Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | Turkish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Turkish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/lt.tsv` | Lithuanian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels and user-facing messages; original catalog reviewed by a native Lithuanian-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/hi.tsv` | Hindi 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Hindi UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
@@ -297,14 +298,15 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `crates/ui-egui/src/i18n/th.tsv` | Thai 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; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Thai UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
| `crates/ui-egui/src/i18n/ca.tsv` | Catalan interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, written from their meaning; no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 secured-document notice labels are contributor-original AI-assisted translations from English, not separately native-reviewed; the 2026-10-22 reply-count label is an AI-assisted translation from English, not separately native-reviewed | Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
|
||||
|
||||
## Compiled in through dependencies (5)
|
||||
## Compiled in through dependencies (6)
|
||||
|
||||
| Crate | File | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.36.2 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/Ubuntu-Light.ttf` | Ubuntu Light | Canonical Ltd. / Dalton Maag | Ubuntu-font-1.0 | https://design.ubuntu.com/font | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/Hack-Regular.ttf` | Hack Regular | Source Foundry Authors (derived from Bitstream Vera / DejaVu) | MIT AND Bitstream-Vera | https://github.com/source-foundry/Hack | egui fallback fonts (eframe feature default_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/NotoEmoji-Regular.ttf` | Noto Emoji (monochrome) | Google Inc. | OFL-1.1 | https://github.com/googlefonts/noto-emoji | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/emoji-icon-font.ttf` | emoji-icon-font | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | egui monochrome fallback font (egui feature monochrome_emoji_fonts) |
|
||||
| `epaint_default_fonts` 0.37.0 | `fonts/egui-icons.ttf` | egui icons (monochrome emoji-icon-font subset) | John Slegers | MIT | https://github.com/jslegers/emoji-icon-font | Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE. |
|
||||
| `hayro-interpret` 0.7.0 | `src/font/generated/metrics.rs` | Standard-14 font metrics and encodings (as Rust tables) (non-visual data, AGENTS.md §1.1) | Adobe (Core 14 AFM metrics, PDF specification encodings); tables by The Hayro Authors | MIT OR Apache-2.0 | ISO 32000-2 Annex D; Adobe Core14 AFM files (via vendored hayro-interpret) | Glyph widths and encodings for non-embedded standard fonts. Non-visual data, allowed by AGENTS.md §1.1 |
|
||||
|
||||
## Downloaded at build time (22)
|
||||
@@ -342,4 +344,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 |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| `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 leaves out for size |
|
||||
| `craft-fonts` | `CRAFT_FONTS_DIR` | Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic | The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic) | OFL-1.1 | https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9) | 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); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1 |
|
||||
+32
-10
@@ -212,6 +212,16 @@ licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Central Kurdish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/fa.tsv"
|
||||
title = "Persian (Farsi) interface translation catalog"
|
||||
author = "PdfCraft contributors"
|
||||
source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-25 initial catalog covering the current 2,776-entry key union, AI-assisted translations from English, not separately native-reviewed"
|
||||
licence = "MIT OR Apache-2.0"
|
||||
licence_file = "LICENSE-MIT"
|
||||
kind = "translation"
|
||||
usage = "Persian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/ui-egui/src/i18n/tr.tsv"
|
||||
title = "Turkish interface translation catalog"
|
||||
@@ -2429,7 +2439,7 @@ sha256 = "f225e0b044875b8fa6375b6b5ad935f0d99fcc1ad03ecf9f0baae2cedbfca4d2"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/Ubuntu-Light.ttf"
|
||||
title = "Ubuntu Light"
|
||||
author = "Canonical Ltd. / Dalton Maag"
|
||||
@@ -2441,7 +2451,7 @@ sha256 = "80307b8da7649aa4ee4d484b232140e3ce1ec0ca093073d3c53c8f5a5ced7a70"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/Hack-Regular.ttf"
|
||||
title = "Hack Regular"
|
||||
author = "Source Foundry Authors (derived from Bitstream Vera / DejaVu)"
|
||||
@@ -2453,28 +2463,40 @@ sha256 = "15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/NotoEmoji-Regular.ttf"
|
||||
title = "Noto Emoji (monochrome)"
|
||||
author = "Google Inc."
|
||||
source = "https://github.com/googlefonts/noto-emoji"
|
||||
licence = "OFL-1.1"
|
||||
kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
|
||||
sha256 = "415dc6290378574135b64c808dc640c1df7531973290c4970c51fdeb849cb0c5"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.36.2"
|
||||
version = "0.37.0"
|
||||
path = "fonts/emoji-icon-font.ttf"
|
||||
title = "emoji-icon-font"
|
||||
author = "John Slegers"
|
||||
source = "https://github.com/jslegers/emoji-icon-font"
|
||||
licence = "MIT"
|
||||
kind = "font"
|
||||
usage = "egui fallback fonts (eframe feature default_fonts)"
|
||||
usage = "egui monochrome fallback font (egui feature monochrome_emoji_fonts)"
|
||||
sha256 = "f426bec371f484646d002717e2c555a95cd5145e635d83a48bbef83f3abf18ca"
|
||||
|
||||
[[bundled]]
|
||||
crate = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
path = "fonts/egui-icons.ttf"
|
||||
title = "egui icons (monochrome emoji-icon-font subset)"
|
||||
author = "John Slegers"
|
||||
source = "https://github.com/jslegers/emoji-icon-font"
|
||||
licence = "MIT"
|
||||
kind = "font"
|
||||
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
|
||||
sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
|
||||
|
||||
[[asset]]
|
||||
path = "vendor/hayro-cmap/assets/cmaps.brotli"
|
||||
title = "Adobe CMap resources (predefined CJK CMaps)"
|
||||
@@ -3362,13 +3384,13 @@ sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e"
|
||||
[[build_input]]
|
||||
name = "craft-fonts"
|
||||
option = "CRAFT_FONTS_DIR"
|
||||
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), The Noto Project Authors (Noto Sans Arabic)"
|
||||
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml)"
|
||||
title = "Japanese fonts: BIZ UDPGothic (Regular, Bold), Shippori Mincho, BIZ UDMincho; native Simplified Chinese font: LXGW WenKai Lite; Arabic font: Noto Sans Arabic"
|
||||
author = "The BIZ UDGothic and BIZ UDMincho Project Authors (Morisawa Inc.), The Shippori Mincho Project Authors (FONTDASU), LXGW and the original Klee Project Authors / Fontworks (LXGW WenKai Lite), The Noto Project Authors (Noto Sans Arabic)"
|
||||
source = "https://github.com/storytold/craft-fonts (pinned by commit in .github/workflows/release.yml and .github/workflows/ci.yml); LXGW WenKai Lite from https://github.com/lxgw/LxgwWenKai-Lite/releases/tag/v1.530 (commit b712c83d98acf977bacfb7fb010207802808bda9)"
|
||||
licence = "OFL-1.1"
|
||||
attribution = "https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md"
|
||||
kind = "font"
|
||||
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 leaves out for size"
|
||||
usage = "Japanese fallback in the interface (BIZ UDPGothic first) and Japanese glyphs in edited PDF text (Shippori Mincho); Simplified Chinese interface fallback on native builds only (LXGW WenKai Lite Regular, font SHA-256 f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea, OFL SHA-256 0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e, read from fonts/simplified-chinese-manifest.txt and never embedded on wasm); Arabic interface text, file names and document titles (Noto Sans Arabic); the web build embeds BIZ UDPGothic Regular and the pinned small Arabic, Hebrew, Thai and Devanagari faces; the selected craft-fonts revision contains no Telugu face. Other non-Japanese CJK faces retain the general 4 MiB cap; the reviewed native-only LXGW fallback has a separate 16 MiB cap. Open-source OFL releases are permitted under current AGENTS.md §1.1"
|
||||
|
||||
[[asset]]
|
||||
path = "crates/sign/tests/data/x509-rsa-sha1.pdf"
|
||||
|
||||
Generated
+487
-344
File diff suppressed because it is too large.
Load diff
+67
-43
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.0"
|
||||
version = "0.14.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,47 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "f58bc1a3df490992d633393d0898af0588ce882c" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "666c358065261a2816b376fa3bb10538dbf53040" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.13.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.13.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.13.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.13.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.13.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.13.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.13.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
@@ -75,11 +79,13 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
egui = "0.37"
|
||||
egui_extras = { version = "0.37", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
rfd = "0.17"
|
||||
# Monotonic time for logic-only work on native and web (`App::logic` runs without a UI pass).
|
||||
web-time = "1.1"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
@@ -118,6 +124,24 @@ hayro-cmap = { path = "vendor/hayro-cmap" }
|
||||
hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
winit = { path = "vendor/winit" }
|
||||
egui-wgpu = { path = "vendor/egui-wgpu" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
# earlier patches and their source guards, not path patches; the other `vendor/` entries above
|
||||
# remain real path patches. See vendor/README.md and the migration plan.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
@@ -58,3 +58,21 @@ dependencies change (plan/adr/0002, 0006).
|
||||
Vendored (vendor/, patched; see vendor/README.md):
|
||||
- hayro 0.7.1 — Apache-2.0 OR MIT, Copyright the hayro authors (licence files kept in vendor/hayro).
|
||||
- hayro-interpret 0.7.0 — Apache-2.0 OR MIT, Copyright the hayro authors (LICENSE-APACHE, LICENSE-MIT kept in vendor/hayro-interpret).
|
||||
|
||||
Bundled egui font icons
|
||||
|
||||
The pinned egui dependency includes egui-icons.ttf, a small subset of John Slegers'
|
||||
emoji-icon-font, through default_fonts. The parent emoji-icon-font.ttf face is
|
||||
included through monochrome_emoji_fonts. Their upstream project is
|
||||
https://github.com/jslegers/emoji-icon-font; both use the same exact copyright
|
||||
and permission notice below.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -332,15 +332,13 @@ pdfcraft-cli ui --control ~/.pdfcraft-control.json inspect focused=true
|
||||
pdfcraft-cli ui --control ~/.pdfcraft-control.json quit
|
||||
```
|
||||
|
||||
`ui.dock` arranges the `tools` and `inspector` panels; `canvas` is the protected document view.
|
||||
Each per-panel request requires a `panel` ID and an `operation`: `open`, `close`,
|
||||
`activate`, `float` with `rect: [x, y, width, height]`, or `move` with `target` and
|
||||
`zone: center|left|right|top|bottom`. For example,
|
||||
`{"operation":"float","panel":"tools","rect":[100,100,320,440]}` floats Tools.
|
||||
`ui.dock.reset` restores the default
|
||||
arrangement. The control channel accepts these methods directly; `ui.command` also accepts
|
||||
`{"id":"ui.dock","params":{...}}`. Saved preferences retain groups, active tabs, visibility
|
||||
and floating geometry. Invalid requests return an error without changing the document or layout.
|
||||
`ui.panel` opens, closes or sizes a side panel: `{"panel":"tools"|"side","open":true|false,"width":320}`.
|
||||
`tools` is the Tools panel and `side` the panel the rail opens (reopening it shows the one open last).
|
||||
`width` is clamped to the panel's limits (Tools 240–520, side panel 260–520 points). `ui.dock.reset`
|
||||
resets the side panels: Tools open on the left, default widths. The control channel accepts these
|
||||
methods directly; `ui.command` also accepts `{"id":"ui.panel","params":{...}}`. Saved preferences keep
|
||||
the Tools side, the open panels and their widths. Invalid requests return an error without changing
|
||||
the document or the panels.
|
||||
|
||||
|
||||
`resize` requests a native inner-window size in logical points (each dimension must be between 320 and 8192); the window manager may constrain it. `state` reports the observed `window.width`, `window.height` and `window.pixels_per_point`. `focus` accepts exactly one widget `id` or unique enabled, focusable `label`; subsequent `key` requests traverse or activate the actual widgets. `inspect` includes focusability and accepts a `focused` filter. `quit` takes no parameters: clean documents exit normally, while unsaved edits or typing open the existing Save/Don't save/Cancel prompt and return `quitting: false, needs_confirmation: true`. It never silently discards work. Window resizing and application quit are native-only.
|
||||
@@ -595,13 +593,4 @@ Forks and modified versions must remove them.
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tools and Inspector panels can be dragged into tab groups, split beside the PDF, or floated within the app window. Read and full-screen modes keep their existing focused document view. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
Advanced `ui.dock` operations use the same validated transaction as pointer gestures:
|
||||
`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and
|
||||
`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`),
|
||||
`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null.
|
||||
A center `move` can include `before` (panel ID or null to append) for tab order.
|
||||
The equivalent `{"action": ...}` form accepts a serialized shared docking action;
|
||||
this is also the form emitted by the renderer. Protected-canvas and app-specific panel
|
||||
rules apply equally to both forms.
|
||||
The Tools panel sits on the left, or on the right with Preferences ▸ Tools panel side. The rail stays at the right edge and opens one side panel beside it. Drag a panel's inner edge to resize it; widths are remembered. F4 shows or hides the side panel and Shift+F4 the Tools panel (also in the View menu); View ▸ Reset side panels restores the default arrangement.
|
||||
+8
-4
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M8, #3205 follow-up: labelled Italian international phone numbers with a slash match whole; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: Word table cells use the widths of their own grid columns; estimates unchanged)
|
||||
@@ -165,10 +165,10 @@ Portuguese and Spanish vs the current union). Detail and script notes: [localiza
|
||||
| Language | Code | Strings | Status |
|
||||
|---|---|---|---|
|
||||
| English | `en` | 100% | full |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (no Hans UI face in releases, #826) |
|
||||
| Simplified Chinese | `zh-hans` | 97.4% | partial (native LXGW WenKai Lite; no Hans face in the web build, #826) |
|
||||
| Spanish | `es` | 100% of current union | partial (dialogs / tooltips; no help) |
|
||||
| Hindi | `hi` | 0% | none |
|
||||
| Arabic | `ar` | 100% | partial (layout not mirrored) |
|
||||
| Arabic | `ar` | 100% | partial (logical catalog, explicit RTL labels, mirrored stock chrome; native/browser/IME workflows unqualified) |
|
||||
| French | `fr` | 99.3% | partial |
|
||||
| Portuguese (Brazil) | `pt-br` | 100% of current union | partial |
|
||||
| Indonesian | `id` | 0% | none |
|
||||
@@ -188,7 +188,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
| Rank | Milestone | Estimate (h) |
|
||||
|---|---|---|
|
||||
| 0 | Convert and share: Create PDF from Office, Word export that keeps layout (#773) | 32–55 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, Chinese UI face (#826) | 50–90 |
|
||||
| 1 | Backlog and platform: crash and wrong-result reports, Chinese web UI face (#826) | 50–90 |
|
||||
| 2 | Fidelity harness against Acrobat and the renderer decision (M2; owner decision on #841) | 110–190 |
|
||||
| 3 | Editing existing content (M7) | 100–180 |
|
||||
| 4 | OCR languages and Office export/import (M10) | 95–175 |
|
||||
@@ -199,6 +199,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
|
||||
- **2026-10-23 (M7, #4280):** Edit text names each standard stand-in font after its face (`PCEdTimesBold`, ...) instead of one shared `PCEdHelv`. Rewriting a bold paragraph whose subset lacks an accent no longer repoints the paragraphs set earlier in a regular stand-in at Times-Bold, and the editor's frame stays on the paragraph being edited. Pages saved by older builds keep their shared name and are not repaired. Estimates unchanged.
|
||||
@@ -674,6 +676,8 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-25 · minor:** M14 localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Estimates unchanged.
|
||||
|
||||
**2026-10-15 · minor:** M14/#2383 candidate: GPU device requests clamp unsupported limits while retaining the adapter texture extent. Synthetic V3D/downlevel regressions and Windows GPU-required tests pass; Raspberry Pi hardware remains unverified. Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|
||||
@@ -21,7 +21,7 @@ pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-preflight = { workspace = true }
|
||||
pdfcraft-automation = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
|
||||
[dev-dependencies]
|
||||
# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
|
||||
|
||||
@@ -12,7 +12,7 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "glow", "persistence", "accesskit"] }
|
||||
egui = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -11,15 +11,13 @@ workspace = true
|
||||
[dependencies]
|
||||
# Help ▸ Check for updates (issue #28): one HTTPS request to GitHub, over rustls with the OS's
|
||||
# trusted roots. No bundled root list (webpki-roots and webpki-root-certs are CDLA-licensed).
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls-no-provider", "_ring"] }
|
||||
rustls-native-certs = "0.8"
|
||||
pdfcraft-ui-egui = { workspace = true }
|
||||
# `build_info`: the version and compile-time build label for `--version` and the log.
|
||||
pdfcraft-engine = { workspace = true }
|
||||
# wgpu draws; OpenGL (glow) is the fallback when wgpu can't start on an old or unusual GPU or
|
||||
# driver (#461, #435, #392), and can be chosen with PDFCRAFT_RENDERER=gl.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links"] }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "glow", "accesskit", "wayland", "x11", "persistence", "links", "clipboard"] }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
serde_json = { workspace = true }
|
||||
# Records from every crate go to standard error and the log file (src/logging.rs).
|
||||
log = { workspace = true }
|
||||
@@ -31,9 +29,6 @@ chrono = { workspace = true }
|
||||
# crate registers the handler (Objective-C interop needs unsafe, which our workspace forbids)
|
||||
# behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
# ⌘Q asks about unsaved changes like the close button (#1136): an app menu whose Quit asks the
|
||||
# window to close instead of winit's, which ends the process without asking.
|
||||
craft-quit = { workspace = true, features = ["menu"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is
|
||||
@@ -71,3 +66,6 @@ uds_windows = "=1.2.1"
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for pdfcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
+89
-47
@@ -41,7 +41,6 @@ mod reopen;
|
||||
mod settings;
|
||||
mod single_instance;
|
||||
mod text_scale;
|
||||
mod updates;
|
||||
#[cfg(test)]
|
||||
#[path = "windows_manifest.rs"]
|
||||
mod windows_manifest;
|
||||
@@ -195,6 +194,11 @@ fn rejoin_split_paths(args: Vec<String>, exists: impl Fn(&str) -> bool) -> Vec<S
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::PDFCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
std::process::exit(i32::from(result.is_err()));
|
||||
}
|
||||
// First, so the panic hook and every start-up warning are recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
// Last-resort guard (AGENTS.md §4): commands, edits, opens and saves catch panics and report
|
||||
@@ -357,6 +361,9 @@ fn native_options(integrated: bool, app_frame: bool, renderer: eframe::Renderer)
|
||||
let persistence_path = settings_dir().map(|d| d.join("app.ron"));
|
||||
// Set explicitly, so the renderer never depends on which one eframe defaults to.
|
||||
let mut native = eframe::NativeOptions { viewport, persistence_path, renderer, ..Default::default() };
|
||||
// PdfCraft installs its own last-resort system face (`PDFCRAFT_SYSTEM_FONTS`); eframe's
|
||||
// provider would read every installed face, including proprietary ones, into every UI pass.
|
||||
native.system_font_fallback = false;
|
||||
if renderer == eframe::Renderer::Wgpu {
|
||||
configure_gpu(&mut native);
|
||||
}
|
||||
@@ -416,16 +423,17 @@ fn app_creator<'a>(
|
||||
{
|
||||
app.motion.source = Some(motion::source());
|
||||
}
|
||||
app.update_source = Some(std::sync::Arc::new(updates::latest_release));
|
||||
app.set_startup_update_check(true);
|
||||
app.printer_source = Some(pdfcraft_ui_egui::system_printers());
|
||||
app.os_key_store_ids = cfg!(any(target_os = "macos", target_os = "windows"));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
app.os_events = Some(apple_events.connect(&cc.egui_ctx));
|
||||
// winit's Quit (⌘Q) ends the process without asking about unsaved changes (#1136). This
|
||||
// menu's Quit closes the window instead, so `guard_quit` asks, as for the close button.
|
||||
if let Err(e) = craft_quit::install_app_menu(&cc.egui_ctx, "PdfCraft") {
|
||||
log::warn!("couldn't install the app menu, so ⌘Q quits without asking to save: {e}");
|
||||
// The native menu bar. Its Quit (⌘Q) closes the window rather than ending the process as
|
||||
// winit's does (#1136), so `guard_quit` asks about unsaved changes, as for the close
|
||||
// button.
|
||||
if !app.install_native_menu(&cc.egui_ctx) {
|
||||
log::warn!("couldn't install the macOS menu bar; the menus stay in the window");
|
||||
}
|
||||
}
|
||||
if let Some(server) = instance {
|
||||
@@ -795,6 +803,41 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The lockfile package block for `name`, or a panic naming the missing package.
|
||||
fn locked_package(root: &std::path::Path, name: &str) -> String {
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).expect("Cargo.lock");
|
||||
lock.split("[[package]]")
|
||||
.find(|p| p.contains(&format!("\nname = \"{name}\"\n")))
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| panic!("{name} is not in Cargo.lock"))
|
||||
}
|
||||
|
||||
/// Interim graph check for the window stack while the reviewed fork revision is not yet
|
||||
/// published. The old `vendor/winit` / `vendor/egui-wgpu` path patches were removed from the
|
||||
/// published manifest; local integration supplies the reviewed worktrees through an external
|
||||
/// Cargo config patch. A git source must name the public storytold fork; a path source is the
|
||||
/// local integration graph. Behaviour (surface fitting, error scopes, scroll phases) is tested
|
||||
/// directly elsewhere; the retained vendor copies keep provenance until the fork revision is
|
||||
/// pinned. No public revision is invented here.
|
||||
fn assert_window_stack_graph(root: &std::path::Path, name: &str, version: &str) -> String {
|
||||
let package = locked_package(root, name);
|
||||
assert!(package.contains(&format!("\nversion = \"{version}\"\n")), "{name} must resolve to {version}: {package}");
|
||||
if let Some(source) = package.lines().find_map(|line| line.strip_prefix("source = ")) {
|
||||
assert!(source.contains("github.com/storytold/"), "{name} must come from the reviewed storytold fork once published, not {source}");
|
||||
assert!(
|
||||
source.split(|c: char| !c.is_ascii_hexdigit()).any(|part| part.len() == 40),
|
||||
"{name} must pin the fork's 40-character revision, not {source}"
|
||||
);
|
||||
}
|
||||
// A path patch has no `source =` in Cargo.lock. The local integration config is the only
|
||||
// allowed path source; a machine-local path must never be in the published manifest,
|
||||
// which the root-manifest assertion below checks. A path entry carries no remote identity
|
||||
// to verify, so this test does not claim the resolved code is the published fork.
|
||||
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).expect("root Cargo.toml");
|
||||
assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} must not be a published path patch");
|
||||
package
|
||||
}
|
||||
|
||||
use pdfcraft_ui_egui::control::Endpoint;
|
||||
|
||||
fn endpoint() -> std::io::Result<Endpoint> {
|
||||
@@ -1063,28 +1106,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
|
||||
/// does not path-patch the vendor copy, and that copy stays as provenance for the surface-fit patch.
|
||||
#[test]
|
||||
fn egui_wgpu_carries_the_surface_size_fit() {
|
||||
// Issue #577: egui-wgpu 0.36.2 configures a window's surface at the window's size, and
|
||||
fn vendor_provenance_egui_wgpu_surface_fit_identity() {
|
||||
// Issue #577: egui-wgpu configured a window's surface at the window's size, and
|
||||
// `Surface::configure` panics when that's beyond the device's `max_texture_dimension_2d`
|
||||
// (emilk/egui#8361). vendor/egui-wgpu fits it within the limit. A dependency bump that
|
||||
// resolves egui-wgpu from crates.io again, or a re-vendored copy without the patch, would
|
||||
// bring the crash back: re-apply the patch, or drop the copy once an egui release has a
|
||||
// fix (vendor/README.md).
|
||||
// (emilk/egui#8361). The reviewed egui 0.37 worktree carries E2 (`surface_fit`); the
|
||||
// behavioural test above exercises it. The old 0.36 vendor copy is retained for
|
||||
// provenance only and is no longer a published path patch, so reading it would not prove
|
||||
// what the app runs.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
|
||||
assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
|
||||
// `surface_fit` is tested above; these keep it in the paths that size a surface.
|
||||
let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
|
||||
for patch in [
|
||||
"let (width, height, render_scale) = surface_fit(window_width, window_height, max_side);",
|
||||
"let (fit_width, fit_height, _) = surface_fit(width, height, self.max_surface_side());",
|
||||
"pixels_per_point: pixels_per_point * surface_state.render_scale,",
|
||||
"old_state.window_width,\n old_state.window_height,",
|
||||
] {
|
||||
assert!(painter.contains(patch), "vendor/egui-wgpu lost its surface size patch: {patch}");
|
||||
}
|
||||
assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
|
||||
assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1140,24 +1174,20 @@ mod tests {
|
||||
assert!(matches!(caught, Err(wgpu::Error::Validation { .. })), "outer: {caught:?}");
|
||||
}
|
||||
|
||||
/// Regression test: the lockfile resolves egui-wgpu 0.37.0 through an allowed source, the root manifest
|
||||
/// does not path-patch the vendor copy, and that copy stays as provenance for the catch-errors patch.
|
||||
#[test]
|
||||
fn egui_wgpu_returns_a_surface_it_cannot_configure_as_an_error() {
|
||||
// Issue #519: `catch_errors` is tested above; this keeps it around the first configure of
|
||||
// every new window's surface, and its error reaching eframe as `WgpuError` (eframe then
|
||||
// returns `Error::Wgpu`, which `main` retries with OpenGL). Without it a failed configure
|
||||
// panics before the app is created, and the OpenGL retry never runs.
|
||||
fn vendor_provenance_egui_wgpu_catch_errors_identity() {
|
||||
// Issue #519: E3 (`catch_errors`) is tested above against a real device. The reviewed 0.37
|
||||
// worktree carries it around the first configure of every new window's surface, and its
|
||||
// error reaches eframe as `WgpuError` (eframe then returns `Error::Wgpu`, which `main`
|
||||
// retries with OpenGL). Without it a failed configure panics before the app is created.
|
||||
// `catch_errors` is exercised against a real device in the test above and below; the
|
||||
// graph check makes sure the app runs the reviewed 0.37 worktree (the retained vendor
|
||||
// copy is not read as proof of behaviour).
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let egui_wgpu = lock.split("[[package]]").find(|p| p.contains("\nname = \"egui-wgpu\"\n")).expect("egui-wgpu is in Cargo.lock");
|
||||
assert!(!egui_wgpu.contains("\nsource = "), "egui-wgpu must resolve to vendor/egui-wgpu, not:{egui_wgpu}");
|
||||
let painter = std::fs::read_to_string(root.join("vendor/egui-wgpu/src/winit.rs")).unwrap().replace("\r\n", "\n");
|
||||
let add_surface = painter.split("async fn add_surface(").nth(1).and_then(|s| s.split("\n fn ").next()).expect("add_surface");
|
||||
for patch in [
|
||||
"let installed = catch_errors(&device, || {\n self.install_surface(surface, viewport_id, size.width, size.height, false);\n })",
|
||||
"return Err(crate::WgpuError::ConfigureSurface(error));",
|
||||
] {
|
||||
assert!(add_surface.contains(patch), "vendor/egui-wgpu lost its surface configure patch: {patch}");
|
||||
}
|
||||
assert_window_stack_graph(&root, "egui-wgpu", "0.37.0");
|
||||
assert!(root.join("vendor/egui-wgpu/src/winit.rs").exists(), "the vendor copy and its provenance stay until the fork revision is pinned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1191,6 +1221,8 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: winit resolves to 0.30.13 without a published vendor path patch, the retained copy
|
||||
/// still carries the W1 Windows 11 monitor-scale fix, and its upstream base is recorded.
|
||||
#[test]
|
||||
fn winit_carries_the_windows_11_monitor_scale_fix() {
|
||||
// Issue #324: winit 0.30.13 as released nudges a window dragged onto a monitor with another
|
||||
@@ -1199,13 +1231,23 @@ mod tests {
|
||||
// dependency bump that resolves winit from crates.io again, or a re-vendored copy without
|
||||
// the patch, would silently bring the bug back: re-apply the patch, or drop the copy once a
|
||||
// winit 0.30 release has the fix (vendor/README.md).
|
||||
// W1 (and W2–W13) were audited against the reviewed winit worktree: the vendor rows in
|
||||
// `vendor/README.md` match the fork's PATCHES.md entries W1–W13. The vendor source is no
|
||||
// longer a published path patch, so this checks the actual graph, not the old copy.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let lock = std::fs::read_to_string(root.join("Cargo.lock")).unwrap();
|
||||
let winit = lock.split("[[package]]").find(|p| p.contains("\nname = \"winit\"\n")).expect("winit is in Cargo.lock");
|
||||
assert!(!winit.contains("\nsource = "), "winit must resolve to vendor/winit, not:{winit}");
|
||||
let dpi_changed = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
|
||||
let patch = "if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {\n new_outer_rect = suggested_rect;";
|
||||
assert!(dpi_changed.contains(patch), "vendor/winit lost its WM_DPICHANGED patch");
|
||||
assert_window_stack_graph(&root, "winit", "0.30.13");
|
||||
let event_loop = std::fs::read_to_string(root.join("vendor/winit/src/platform_impl/windows/event_loop.rs")).unwrap();
|
||||
// The audited W1 hunk itself, not merely the file's presence: Windows 11 applies the
|
||||
// rect Windows suggests, while Windows 10 keeps the old adjustment.
|
||||
for marker in [
|
||||
"PdfCraft patch: from Windows 11 (build 22000) on, apply the rect Windows suggests",
|
||||
"if !WIN10_BUILD_VERSION.is_some_and(|build| build < 22000) {",
|
||||
"new_outer_rect = suggested_rect;",
|
||||
] {
|
||||
assert!(event_loop.contains(marker), "vendor/winit lost the W1 monitor-scale patch: {marker}");
|
||||
}
|
||||
let provenance = std::fs::read_to_string(root.join("vendor/winit/.cargo_vcs_info.json")).unwrap();
|
||||
assert!(provenance.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base provenance");
|
||||
}
|
||||
|
||||
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Asks GitHub for the latest PdfCraft release (Help ▸ Check for updates, issue #28).
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use pdfcraft_ui_egui::updates::{RELEASES_PAGE, Release};
|
||||
|
||||
const LATEST: &str = "https://api.github.com/repos/storytold/pdfcraft/releases/latest";
|
||||
|
||||
/// The latest release. The answer is untrusted: its size is capped, and only a page under
|
||||
/// [`RELEASES_PAGE`] is ever offered for download (anything else falls back to that list).
|
||||
pub fn latest_release() -> Result<Release, String> {
|
||||
let agent = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(Duration::from_secs(10)))
|
||||
.tls_config(ureq::tls::TlsConfig::builder().root_certs(os_roots()?).build())
|
||||
.build()
|
||||
.new_agent();
|
||||
let mut response = agent
|
||||
.get(LATEST)
|
||||
.header("Accept", "application/vnd.github+json")
|
||||
.header("User-Agent", concat!("PdfCraft/", env!("CARGO_PKG_VERSION")))
|
||||
// Read error statuses ourselves: a 403 from GitHub usually means its anonymous
|
||||
// rate limit ran out, and the retry time rides along in a response header.
|
||||
.config()
|
||||
.http_status_as_error(false)
|
||||
.build()
|
||||
.call()
|
||||
.map_err(|e| format!("couldn't reach GitHub ({e})"))?;
|
||||
let status = response.status().as_u16();
|
||||
if status == 403 {
|
||||
let reset = response.headers().get("x-ratelimit-reset").and_then(|v| v.to_str().ok());
|
||||
return Err(rate_limited(reset, now_unix()));
|
||||
}
|
||||
if !(200..300).contains(&status) {
|
||||
return Err(format!("couldn't reach GitHub (http status: {status})"));
|
||||
}
|
||||
let body = response.body_mut().with_config().limit(1 << 20).read_to_string().map_err(|e| format!("unreadable answer ({e})"))?;
|
||||
parse(&body)
|
||||
}
|
||||
|
||||
/// The certificate authorities the operating system trusts.
|
||||
fn os_roots() -> Result<ureq::tls::RootCerts, String> {
|
||||
let found = rustls_native_certs::load_native_certs();
|
||||
let certs: Vec<ureq::tls::Certificate<'static>> = found.certs.iter().map(|c| ureq::tls::Certificate::from_der(c.as_ref()).to_owned()).collect();
|
||||
if certs.is_empty() {
|
||||
return Err("no trusted certificates found on this system".into());
|
||||
}
|
||||
Ok(ureq::tls::RootCerts::new_with_certs(&certs))
|
||||
}
|
||||
|
||||
/// The current Unix time in seconds (0 when the clock gives nothing usable).
|
||||
fn now_unix() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// What to tell the user when GitHub answers 403: its anonymous quota (60 checks an hour,
|
||||
/// shared by everyone behind the same address) ran out. `reset` is the `x-ratelimit-reset`
|
||||
/// header, the Unix time the quota refills; `now` is the current Unix time. Anything
|
||||
/// surprising (no header, not a number, already past) falls back to asking for patience.
|
||||
fn rate_limited(reset: Option<&str>, now: u64) -> String {
|
||||
const FALLBACK: &str = "GitHub is limiting update checks for now (shared hourly quota); try again later";
|
||||
let wait = reset.and_then(|r| r.trim().parse::<u64>().ok()).and_then(|reset| reset.checked_sub(now)).filter(|&wait| wait > 0);
|
||||
match wait {
|
||||
Some(wait) => {
|
||||
let minutes = wait.div_ceil(60).max(1);
|
||||
let plural = if minutes == 1 { "" } else { "s" };
|
||||
format!("GitHub is limiting update checks for now (shared hourly quota); try again in about {minutes} minute{plural}")
|
||||
}
|
||||
None => FALLBACK.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(body: &str) -> Result<Release, String> {
|
||||
let v: serde_json::Value = serde_json::from_str(body).map_err(|e| format!("unreadable answer ({e})"))?;
|
||||
let version = v["tag_name"].as_str().filter(|t| !t.is_empty() && t.len() <= 64).ok_or("no release found")?.to_string();
|
||||
let url = v["html_url"]
|
||||
.as_str()
|
||||
.filter(|u| u.strip_prefix(RELEASES_PAGE).is_some_and(|rest| rest.starts_with('/') && !rest.contains(['?', '#', '\\'])))
|
||||
.unwrap_or(RELEASES_PAGE)
|
||||
.to_string();
|
||||
Ok(Release { version, url })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn answers_are_read_and_only_our_release_pages_are_offered() {
|
||||
let r = parse(r#"{"tag_name":"v0.2.0","html_url":"https://github.com/storytold/pdfcraft/releases/tag/v0.2.0"}"#).unwrap();
|
||||
assert_eq!(r, Release { version: "v0.2.0".into(), url: "https://github.com/storytold/pdfcraft/releases/tag/v0.2.0".into() });
|
||||
for elsewhere in ["https://example.com/pdfcraft.exe", "https://github.com/storytold/pdfcraft/releases.evil/x", "javascript:alert(1)"] {
|
||||
let r = parse(&format!(r#"{{"tag_name":"v9.9.9","html_url":"{elsewhere}"}}"#)).unwrap();
|
||||
assert_eq!(r.url, RELEASES_PAGE, "{elsewhere}");
|
||||
}
|
||||
assert!(parse(r#"{"message":"Not Found"}"#).is_err());
|
||||
assert!(parse("<html>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limit_messages_name_the_wait() {
|
||||
let limited = "GitHub is limiting update checks for now (shared hourly quota); try again in about 20 minutes";
|
||||
assert_eq!(rate_limited(Some("1800001200"), 1800000000), limited);
|
||||
// Rounds up: 61 seconds out reads as 2 minutes.
|
||||
assert_eq!(
|
||||
rate_limited(Some("1800000061"), 1800000000),
|
||||
"GitHub is limiting update checks for now (shared hourly quota); try again in about 2 minutes"
|
||||
);
|
||||
assert_eq!(
|
||||
rate_limited(Some("1800000060"), 1800000000),
|
||||
"GitHub is limiting update checks for now (shared hourly quota); try again in about 1 minute"
|
||||
);
|
||||
// No header, not a number, or already past: ask for patience instead of a time.
|
||||
let later = "GitHub is limiting update checks for now (shared hourly quota); try again later";
|
||||
for bad in [None, Some(""), Some("soon"), Some("1799999999"), Some("1800000000")] {
|
||||
assert_eq!(rate_limited(bad, 1800000000), later);
|
||||
}
|
||||
}
|
||||
|
||||
/// Live: asks GitHub over TLS with the OS's roots (`cargo test -p pdfcraft -- --ignored`).
|
||||
#[test]
|
||||
#[ignore = "needs network access"]
|
||||
fn github_answers_with_the_latest_release() {
|
||||
let r = latest_release().unwrap();
|
||||
assert!(pdfcraft_ui_egui::updates::is_newer(&r.version, "0.0.0"), "{r:?}");
|
||||
assert!(r.url.starts_with(RELEASES_PAGE), "{r:?}");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod event {
|
||||
pub use winit::event::TouchPhase;
|
||||
}
|
||||
|
||||
mod vendor_graph;
|
||||
mod winit_macos_emoji_picker;
|
||||
mod winit_macos_ime_commit;
|
||||
mod winit_macos_ime_discard;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Dependency-graph verification for the egui 0.37 window stack.
|
||||
//!
|
||||
//! The twelve `winit_*` modules next to this one read the retained `vendor/winit` /
|
||||
//! `vendor/egui-wgpu` copies. They are provenance for W1–W13 and E2+E3, not proof of what the app
|
||||
//! runs: the root manifest pins both crates to immutable storytold git revisions, and the retained
|
||||
//! copies are not path patches. These tests check the resolved graph and the retained evidence
|
||||
//! instead of treating the copies as the runtime source. The winit fork's own W17 tests own patch
|
||||
//! behaviour.
|
||||
|
||||
/// Return the workspace root above the PdfCraft app crate.
|
||||
fn root() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Every `[[package]]` block in `Cargo.lock` whose `name` is exactly `name`.
|
||||
fn locked_packages(name: &str) -> Vec<String> {
|
||||
let lock = std::fs::read_to_string(root().join("Cargo.lock")).expect("Cargo.lock");
|
||||
let needle = format!("name = \"{name}\"");
|
||||
lock.split("[[package]]").filter(|package| package.lines().any(|line| line.trim() == needle.as_str())).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// The one resolved package called `name`. A duplicate name is a lockfile problem, not something
|
||||
/// to resolve by taking the first match.
|
||||
fn locked(name: &str) -> String {
|
||||
let mut packages = locked_packages(name);
|
||||
assert_eq!(packages.len(), 1, "Cargo.lock must contain exactly one `{name}` package, found {}", packages.len());
|
||||
packages.remove(0)
|
||||
}
|
||||
|
||||
/// The resolved package must be the exact canonical storytold git source at the reviewed
|
||||
/// 40-character revision, including the `Cargo.lock` URL and fragment. A path source (no `source`
|
||||
/// line) is refused: it would make the graph depend on a developer's checkout. A published path
|
||||
/// patch is refused as well.
|
||||
fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
|
||||
let package = locked(name);
|
||||
let expected_version = format!("version = \"{version}\"");
|
||||
assert!(package.lines().any(|line| line.trim() == expected_version.as_str()), "{name} must resolve to {version}: {package}");
|
||||
let sources: Vec<&str> = package.lines().filter_map(|line| line.trim().strip_prefix("source = ")).collect();
|
||||
assert_eq!(sources.len(), 1, "{name} must have exactly one `source` in Cargo.lock; a local path source is not accepted: {package}");
|
||||
let expected_source = format!("\"git+{url}?rev={rev}#{rev}\"");
|
||||
assert_eq!(sources[0], expected_source.as_str(), "{name} must resolve to the exact canonical git URL, revision and fragment, not {}", sources[0]);
|
||||
|
||||
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
|
||||
assert!(!manifest.contains(&format!("{name} = {{ path = \"vendor/")), "vendor/{name} cannot stay a published path patch");
|
||||
}
|
||||
|
||||
/// Regression test: the resolved window stack points at the reviewed forks and the retained vendor provenance
|
||||
/// files remain.
|
||||
#[test]
|
||||
fn vendor_provenance_window_stack_graph_is_resolved() {
|
||||
assert_resolved("winit", "0.30.13", "https://github.com/storytold/winit.git", "a517b3846ee8457d84ee4975110bfda6797c44dc");
|
||||
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "6d518fc799681571963842f95b55a6b68431b15a");
|
||||
// Retained provenance: the audited copies and their recorded W1–W13 / E2+E3 rows.
|
||||
let readme = std::fs::read_to_string(root().join("vendor/README.md")).expect("vendor/README.md");
|
||||
for marker in ["W1–W13", "surface_fit", "catch_errors", "e9809ef54b18499bb4f2cac945719ecc2a61061b"] {
|
||||
assert!(readme.contains(marker), "vendor/README.md lost window-stack provenance: {marker}");
|
||||
}
|
||||
let vcs = std::fs::read_to_string(root().join("vendor/winit/.cargo_vcs_info.json")).expect("vendor/winit provenance");
|
||||
assert!(vcs.contains("e9809ef54b18499bb4f2cac945719ecc2a61061b"), "vendor/winit keeps its upstream base");
|
||||
for path in ["vendor/winit/src/platform_impl", "vendor/egui-wgpu/src/winit.rs"] {
|
||||
assert!(root().join(path).exists(), "retain {path} as provenance for the pinned fork");
|
||||
}
|
||||
}
|
||||
|
||||
/// Renderer, security and PostScript vendors are unrelated to the UI port and must stay path patches.
|
||||
#[test]
|
||||
fn renderer_and_security_vendors_remain() {
|
||||
let manifest = std::fs::read_to_string(root().join("Cargo.toml")).expect("root Cargo.toml");
|
||||
for vendor in [
|
||||
"vendor/hayro",
|
||||
"vendor/hayro-interpret",
|
||||
"vendor/hayro-syntax",
|
||||
"vendor/hayro-cmap",
|
||||
"vendor/hayro-jbig2",
|
||||
"vendor/hayro-postscript",
|
||||
"vendor/lopdf",
|
||||
"vendor/vello_common",
|
||||
] {
|
||||
assert!(manifest.contains(&format!("path = \"{vendor}\"")), "{vendor} must stay a path patch");
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (8) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (8) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit commits text inserted outside a key press and leaves `selectedRange`
|
||||
/// unmarked.
|
||||
#[test]
|
||||
fn winit_commits_macos_text_inserted_outside_a_key_press() {
|
||||
fn vendor_provenance_winit_macos_text_inserted_outside_a_key_press() {
|
||||
// The macOS emoji picker inserts its text with `insertText` outside any key press and without
|
||||
// a composition. winit 0.30.13 reports `insertText` only as part of a key press or a
|
||||
// composition, so an emoji picked into a PdfCraft text field was dropped. vendor/winit commits
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (6) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (6) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit commits a composition that ended without marked text and keeps the
|
||||
/// marked-text underline.
|
||||
#[test]
|
||||
fn winit_commits_macos_compositions_that_end_without_marked_text() {
|
||||
fn vendor_provenance_winit_macos_compositions_that_end_without_marked_text() {
|
||||
// winit 0.30.13 treats `insertText` as an IME commit only while marked text is left. Input
|
||||
// methods that end a composition with `unmarkText` first lost the committed text, and Apple
|
||||
// Korean, which clears its marked text before inserting the key typed after a syllable (`한`
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (7) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (7) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: disallowing IME in the vendored winit discards the macOS input method's marked text.
|
||||
#[test]
|
||||
fn winit_discards_the_macos_composition_when_ime_is_disallowed() {
|
||||
fn vendor_provenance_winit_discards_the_macos_composition_when_ime_is_disallowed() {
|
||||
// egui disallows IME when a text field loses focus. winit 0.30.13 then forgets the marked text
|
||||
// but doesn't tell the input method, which keeps the composition and continues it once IME is
|
||||
// allowed again: the abandoned syllable comes back in the next field. vendor/winit calls
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (10) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (10) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit retries the first Apple Korean key once so it starts a composition.
|
||||
#[test]
|
||||
fn winit_retries_the_first_apple_korean_key_once() {
|
||||
fn vendor_provenance_winit_retries_the_first_apple_korean_key_once() {
|
||||
// On the first key in a freshly started app, Apple Korean can answer with `insertText` and the
|
||||
// raw compatibility jamo instead of starting a composition, so typing g k s in PdfCraft gave
|
||||
// ㅎㅏㄴ instead of 한. vendor/winit interprets that one key again before sending it to the
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (9) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (9) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit sends a key committed through macOS IME only once.
|
||||
#[test]
|
||||
fn winit_sends_a_key_committed_through_macos_ime_only_once() {
|
||||
fn vendor_provenance_winit_sends_a_key_committed_through_macos_ime_only_once() {
|
||||
// Apple Korean answers Space with no syllable in progress with `setMarkedText(" ")`,
|
||||
// `insertText(" ")` and `setMarkedText("")`. The last call resets winit 0.30.13's IME state, so
|
||||
// the key went to the app both as `Ime::Commit(" ")` and as `KeyboardInput` with text " ", and
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! vendor/winit patch (12) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (12) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit lets title-bar drags reach the app while the empty tab strip still
|
||||
/// moves the window.
|
||||
#[test]
|
||||
fn winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
fn vendor_provenance_winit_lets_macos_title_bar_drags_reach_the_app() {
|
||||
// On macOS the tab strip is the title bar: a transparent title bar over a full-size content
|
||||
// view. NSView answers YES to `mouseDownCanMoveWindow` for a view that isn't opaque, so AppKit
|
||||
// moved the whole window for a drag that began on a document tab, and tabs could not be
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
|
||||
@@ -16,6 +18,7 @@ fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run three wheel frames through egui and return each frame's zoom and smooth-scroll deltas.
|
||||
fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let ctx = egui::Context::default();
|
||||
let mut previous = event::TouchPhase::Ended;
|
||||
@@ -27,7 +30,13 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
let (unit, dy) = if discrete { (MouseWheelUnit::Line, 1.0) } else { (MouseWheelUnit::Point, 24.0) };
|
||||
let input = RawInput {
|
||||
time: Some(frame as f64 / 60.0),
|
||||
events: vec![Event::MouseWheel { unit, delta: vec2(0.0, dy), phase: egui_phase(phase), modifiers }],
|
||||
events: vec![Event::MouseWheel {
|
||||
unit,
|
||||
delta: vec2(0.0, dy),
|
||||
phase: egui_phase(phase),
|
||||
modifiers,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(input, |ui| seen.push(ui.input(|i| (i.zoom_delta(), i.smooth_scroll_delta)))).textures_delta.clear();
|
||||
@@ -35,8 +44,10 @@ fn wheel_trace(source: AxisSource, discrete: bool) -> Vec<(f32, egui::Vec2)> {
|
||||
seen
|
||||
}
|
||||
|
||||
/// Regression test: releasing Ctrl after a pixel-wheel zoom makes the next wheel event scroll instead of
|
||||
/// zooming.
|
||||
#[test]
|
||||
fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
fn vendor_provenance_releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
for source in [AxisSource::Wheel, AxisSource::WheelTilt, AxisSource::Continuous] {
|
||||
let seen = wheel_trace(source, false);
|
||||
assert!(seen[1].0 > 1.0, "Ctrl must zoom: {source:?}");
|
||||
@@ -45,16 +56,18 @@ fn releasing_ctrl_after_pixel_wheel_zoom_returns_to_scrolling() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression test: discrete wheels follow the modifiers of the current event, just like pixel wheels.
|
||||
#[test]
|
||||
fn discrete_wheels_still_follow_current_modifiers() {
|
||||
fn vendor_provenance_discrete_wheels_still_follow_current_modifiers() {
|
||||
let seen = wheel_trace(AxisSource::Wheel, true);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
assert_eq!(seen[2].0, 1.0);
|
||||
assert!(seen[2].1.y > 0.0);
|
||||
}
|
||||
|
||||
/// Regression test: a finger gesture keeps its Ctrl zoom through momentum until the gesture ends.
|
||||
#[test]
|
||||
fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
fn vendor_provenance_finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
// A finger scroll's momentum may arrive after Ctrl is released.
|
||||
let seen = wheel_trace(AxisSource::Finger, false);
|
||||
assert!(seen[1].0 > 1.0);
|
||||
@@ -64,8 +77,9 @@ fn finger_gestures_keep_their_modifier_until_the_gesture_ends() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, Some(AxisSource::Finger), false, false), event::TouchPhase::Started);
|
||||
}
|
||||
|
||||
/// Regression test: wheel sources without a phase keep the existing start, move and end policy.
|
||||
#[test]
|
||||
fn unknown_sources_keep_the_existing_phase_policy() {
|
||||
fn vendor_provenance_unknown_sources_keep_the_existing_phase_policy() {
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, false, false), event::TouchPhase::Started);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Started, None, false, false), event::TouchPhase::Moved);
|
||||
assert_eq!(scroll::phase(event::TouchPhase::Ended, None, true, false), event::TouchPhase::Moved);
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (2) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (2) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit keeps a Wayland seat's text input until the seat itself is removed.
|
||||
#[test]
|
||||
fn winit_keeps_the_wayland_text_input_until_the_seat_goes() {
|
||||
fn vendor_provenance_winit_keeps_the_wayland_text_input_until_the_seat_goes() {
|
||||
// winit 0.30.13 destroys a Wayland seat's text input whenever the seat loses any capability (a
|
||||
// touch screen or tablet unplugged, a remote-desktop session ending) and recreates it only when
|
||||
// a capability is added, so IME stops working in a running PdfCraft until it is restarted.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (4) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (4) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit maximizes a hidden Windows window only when it is shown.
|
||||
#[test]
|
||||
fn winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
|
||||
fn vendor_provenance_winit_maximizes_a_hidden_windows_window_only_when_it_is_shown() {
|
||||
// eframe creates PdfCraft's window hidden, restores the saved maximized state and shows the
|
||||
// window once its first frame is drawn. winit 0.30.13 applies the maximized state with
|
||||
// `ShowWindow(SW_MAXIMIZE)`, which shows a hidden window, so a window closed maximized came back
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (3) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (3) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit reads the Windows IME cursor only when the composition reports it.
|
||||
#[test]
|
||||
fn winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
|
||||
fn vendor_provenance_winit_reads_the_windows_ime_cursor_only_when_it_is_reported() {
|
||||
// winit 0.30.13 reads `GCS_CURSORPOS` for every composition without a target clause, whether
|
||||
// or not `WM_IME_COMPOSITION` says the IME set it. The Microsoft Korean IME never sets it, so
|
||||
// the composition's cursor read as 0, before the syllable being composed. vendor/winit puts it
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
/// Regression test: the vendored winit keeps the X11 server-DPI fallback and the desktop file does not
|
||||
/// override scaling.
|
||||
#[test]
|
||||
fn winit_carries_the_x11_server_dpi_fallback() {
|
||||
fn vendor_provenance_winit_carries_the_x11_server_dpi_fallback() {
|
||||
// The behavior tests live beside the policy in winit (util::randr::tests).
|
||||
// This guards PdfCraft's vendored copy, which workspace tests do not test itself.
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! vendor/winit patch (5) (vendor/README.md).
|
||||
//! Provenance-only: reads the retained `vendor/` copy, not the resolved dependency, so it is
|
||||
//! not runtime coverage. `vendor_graph.rs` checks the resolved graph identity.
|
||||
//! Retained vendor provenance for winit patch (5) (vendor/README.md); `vendor_graph.rs` checks the resolved graph.
|
||||
|
||||
/// Regression test: the vendored winit focuses the X11 input context it replaces.
|
||||
#[test]
|
||||
fn winit_focuses_the_x11_input_context_it_replaces() {
|
||||
fn vendor_provenance_winit_focuses_the_x11_input_context_it_replaces() {
|
||||
// egui allows IME when a text field gains focus and disallows it when the field loses focus.
|
||||
// On X11, winit 0.30.13 replaces the window's input context each time and leaves focusing the
|
||||
// new one to the next focus event, which never comes while PdfCraft's window keeps the focus:
|
||||
|
||||
@@ -17,7 +17,7 @@ pdfcraft-platform = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
base64 = { version = "0.22", optional = true }
|
||||
base64 = { version = "0.23", optional = true }
|
||||
|
||||
|
||||
[features]
|
||||
|
||||
@@ -188,8 +188,10 @@ impl Automation {
|
||||
i.flip_v = !i.flip_v;
|
||||
}
|
||||
if let Some(c) = rect_arg(a, "crop")? {
|
||||
if !c.iter().all(|v| (0.0..0.5).contains(v)) {
|
||||
return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)"));
|
||||
if !pdfcraft_engine::AddedImage::crop_valid(c) {
|
||||
return Err(bad(
|
||||
"crop takes fractions from 0 to under 1 (left, bottom, right, top); left and right together, and bottom and top together, trim at most 0.95",
|
||||
));
|
||||
}
|
||||
i.crop = c;
|
||||
}
|
||||
|
||||
@@ -174,6 +174,12 @@ impl Automation {
|
||||
self
|
||||
}
|
||||
|
||||
/// Multiply the time XFA form scripts may take (tests). See [`Session::with_xfa_time_scale`].
|
||||
pub fn with_xfa_time_scale(mut self, factor: u32) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_xfa_time_scale(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// The clock and time zone of a host without its own. See [`Session::with_wall_clock`].
|
||||
pub fn with_wall_clock(mut self, clock: fn() -> i64, utc_offset: fn() -> i64) -> Self {
|
||||
self.session = std::mem::take(&mut self.session).with_wall_clock(clock, utc_offset);
|
||||
|
||||
@@ -627,7 +627,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
"image": { "type": "string", "description": "Replace the picture with this file." },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
@@ -956,7 +956,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t(
|
||||
"doc_header_footer",
|
||||
"Add header and footer",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
"Add a header and/or footer to pages. Text boxes: header_left/center/right, footer_left/center/right. Tokens: <<1>> page number, <<n>> page count, <<1 of n>>, <<Page 1 of n>>, <<1/n>>, dates <<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>> <<dd.mm.yyyy>> (and more), Bates <<Bates Number#6#1#PREFIX#SUFFIX>>. shrink: true scales the page content down evenly into the room the header and footer leave free, so they don't cover it. font: Helvetica (default), Times or Courier. replace: true swaps out existing ones (Update). Undoable.",
|
||||
)
|
||||
.cmd("edit.header_footer")
|
||||
.with(schema(
|
||||
|
||||
@@ -252,6 +252,19 @@ fn doc_info_reports_the_open_destination() {
|
||||
"{open}"
|
||||
);
|
||||
assert_eq!(open["destination"]["zoom"].as_f64(), Some(2.0));
|
||||
// The initial view reads the named destination's page, and choosing page 1 replaces it (ISO
|
||||
// 32000-2 §12.3.2.4): one undo step, kept by the save.
|
||||
let page = |a: &mut Automation, doc: u64| ok(a, "doc_info", json!({ "doc": doc }))["open_destination"]["page"].clone();
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc }))["initial_view"]["page"], 2);
|
||||
assert_eq!(ok(&mut a, "doc_initial_view", json!({ "doc": doc, "page": 1 }))["initial_view"]["page"], 1);
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 2);
|
||||
ok(&mut a, "edit_redo", json!({ "doc": doc }));
|
||||
assert_eq!(page(&mut a, doc), 1);
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "named-page1.pdf" }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "named-page1.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page(&mut a, saved), 1);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "script.pdf" }))["doc"].as_u64().unwrap();
|
||||
assert!(ok(&mut a, "doc_info", json!({ "doc": doc }))["open_destination"].is_null());
|
||||
}
|
||||
@@ -4471,6 +4484,12 @@ fn adding_content_through_tools() {
|
||||
assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}");
|
||||
// Image tools: rotate, flip, crop, replace.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] }));
|
||||
// One side may trim most of the picture; together an axis keeps at least 5 %.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.7, 0, 0, 0] }));
|
||||
assert!(matches!(
|
||||
a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "crop": [0.6, 0, 0.4, 0] })),
|
||||
Err(ToolError::InvalidArgs(_))
|
||||
));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" }));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true);
|
||||
assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_))));
|
||||
@@ -5842,6 +5861,28 @@ fn signing_with_an_image_typed_or_drawn_graphic_through_tools() {
|
||||
assert!(inked(60, 110), "the strokes are drawn");
|
||||
}
|
||||
|
||||
/// Signing a laid-out XFA form's signature box through the tools signs the field named by its
|
||||
/// SOM path, which is how another viewer, laying the form out again, finds the signature; the
|
||||
/// signed file reopens with the signature intact and on that field.
|
||||
#[test]
|
||||
fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() {
|
||||
let dir = workdir("xfa-sign");
|
||||
std::fs::write(dir.join("form.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
ok(&mut a, "sign_id_create", json!({ "name": "Ada Lovelace", "key": "p256", "password": "secret12", "path": "ada.p12" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
let som = "form[0].page1[0].sign[0]";
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
assert!(fields["fields"].as_array().unwrap().iter().any(|f| f["name"] == som), "{fields}");
|
||||
ok(&mut a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "field": som, "out": "signed.pdf" }));
|
||||
let signed = ok(&mut a, "doc_open", json!({ "path": "signed.pdf" }))["doc"].as_u64().unwrap();
|
||||
let list = ok(&mut a, "sign_list", json!({ "doc": signed }));
|
||||
let s = list["signatures"].as_array().unwrap().iter().find(|s| s["signed"] == true).cloned().unwrap_or_else(|| panic!("{list}"));
|
||||
assert_eq!(s["field"], som, "{s}");
|
||||
assert_eq!(s["status"], "unknown", "intact, the signer not trusted: {s}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digital_ids_signing_and_validation_through_tools() {
|
||||
let dir = workdir("signing");
|
||||
@@ -6848,12 +6889,79 @@ BT /F1 11 Tf 20 20 Td (Total due: 150.00) Tj ET";
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// #4158: a word set in italic inside a sentence ("Before *Chevron* after.") reaches Word as an
|
||||
/// italic run of the sentence's paragraph, not as a paragraph of its own between two halves.
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_within_their_budgets() {
|
||||
fn exporting_to_word_keeps_an_italic_word_inside_its_sentence() {
|
||||
let dir = workdir("italic-run-word");
|
||||
let content = "BT /F1 12 Tf 72 700 Td (Before ) Tj /F2 12 Tf (Chevron) Tj /F1 12 Tf ( after.) Tj ET \
|
||||
BT /F1 12 Tf 72 640 Td (A paragraph further down.) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic >>".into(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(dir.join("opinion.pdf"), pdf).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "opinion.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_office", json!({ "doc": doc, "path": "opinion.docx" }));
|
||||
let zip = std::fs::read(dir.join("opinion.docx")).unwrap();
|
||||
let mut at = 0;
|
||||
let mut xml = String::new();
|
||||
while zip[at..].starts_with(b"PK\x03\x04") {
|
||||
let size = u32::from_le_bytes(zip[at + 18..at + 22].try_into().unwrap()) as usize;
|
||||
let len = u16::from_le_bytes(zip[at + 26..at + 28].try_into().unwrap()) as usize;
|
||||
let data = at + 30 + len;
|
||||
if &zip[at + 30..data] == b"word/document.xml" {
|
||||
std::io::Read::read_to_string(&mut flate2::read::DeflateDecoder::new(&zip[data..data + size]), &mut xml).unwrap();
|
||||
break;
|
||||
}
|
||||
at = data + size;
|
||||
}
|
||||
// The paragraphs that hold text, each as its runs: (text, italic).
|
||||
let paragraphs: Vec<Vec<(String, bool)>> = xml
|
||||
.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:r>")
|
||||
.skip(1)
|
||||
.filter_map(|r| Some((r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0.to_string(), r.contains("<w:i/>"))))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|runs| !runs.is_empty())
|
||||
.collect();
|
||||
let sentence = paragraphs.iter().find(|p| p.iter().any(|(t, _)| t.contains("Before"))).unwrap_or_else(|| panic!("{xml}"));
|
||||
assert_eq!(sentence.len(), 3, "one paragraph, three runs: {xml}");
|
||||
assert_eq!(sentence.iter().map(|(t, _)| t.as_str()).collect::<String>(), "Before Chevron after.", "{xml}");
|
||||
assert_eq!(sentence.iter().map(|(_, i)| *i).collect::<Vec<_>>(), [false, true, false], "{xml}");
|
||||
assert_eq!(paragraphs.len(), 2, "{xml}");
|
||||
}
|
||||
|
||||
/// Hundreds of calculations run on opening and again after a change through the tools. The
|
||||
/// time limits are raised so that a busy machine does not stop them (the engine's tests check
|
||||
/// that the limits stop them).
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_open_and_recalculate_through_tools() {
|
||||
let dir = workdir("xfa-many");
|
||||
let (sections, rows) = (10, 20);
|
||||
std::fs::write(dir.join("many.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows))).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let started = std::time::Instant::now();
|
||||
let opened = ok(&mut a, "doc_open", json!({ "path": "many.pdf" }));
|
||||
let open_took = started.elapsed();
|
||||
@@ -7276,7 +7384,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
let dir = workdir("xfa-print-buttons");
|
||||
let template = pdfcraft_xfa::fixtures::repeated_print_button_template();
|
||||
std::fs::write(dir.join("print.pdf"), pdfcraft_xfa::fixtures::shell(&template)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the click scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "print.pdf" }))["doc"].as_u64().unwrap();
|
||||
let check = |a: &mut Automation, doc| {
|
||||
let fields = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
@@ -7301,7 +7410,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
std::fs::write(dir.join("scripted.pdf"), pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template())).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
// Raised time limits: a busy machine does not stop the scripts this checks.
|
||||
let mut a = auto(&dir).with_xfa_time_scale(100);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "scripted.pdf" }))["doc"].as_u64().unwrap();
|
||||
let field = |a: &mut Automation, n: &str| {
|
||||
let f = ok(a, "form_fields", json!({ "doc": doc }));
|
||||
|
||||
@@ -149,7 +149,8 @@ pub struct AddedImage {
|
||||
pub rotation: u8,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top.
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top (see
|
||||
/// [`AddedImage::crop_valid`]).
|
||||
pub crop: [f64; 4],
|
||||
}
|
||||
|
||||
@@ -158,12 +159,43 @@ impl AddedImage {
|
||||
AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] }
|
||||
}
|
||||
|
||||
/// The least of the image's width and of its height that a crop keeps.
|
||||
pub const MIN_CROP_KEEP: f64 = 0.05;
|
||||
|
||||
/// How far a sum of two sides may pass the most a crop trims: `0.05 + 0.9` is
|
||||
/// `0.9500000000000001` in floating point and still keeps 5 %.
|
||||
const CROP_ROUNDING: f64 = 1e-9;
|
||||
|
||||
/// Whether `crop` trims every side by a fraction from 0 to under 1 and keeps at least
|
||||
/// [`AddedImage::MIN_CROP_KEEP`] across each axis: one side may trim 95 % when its opposite
|
||||
/// trims nothing.
|
||||
pub fn crop_valid(crop: [f64; 4]) -> bool {
|
||||
let [l, b, r, t] = crop;
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
crop.iter().all(|v| v.is_finite() && (0.0..1.0).contains(v)) && l + r <= most + Self::CROP_ROUNDING && b + t <= most + Self::CROP_ROUNDING
|
||||
}
|
||||
|
||||
/// `crop` brought into [`AddedImage::crop_valid`] range, for one read from a file: each side
|
||||
/// in range, and an axis that trims too much keeps its two sides' proportion.
|
||||
pub fn clamp_crop(crop: [f64; 4]) -> [f64; 4] {
|
||||
let most = 1.0 - Self::MIN_CROP_KEEP;
|
||||
let [l, b, r, t] = crop.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 });
|
||||
// Scaled from the halves, whose sum can't overflow, so the pair keeps its proportion;
|
||||
// a pair within the limit keeps each side within it too.
|
||||
let fit = |a: f64, c: f64| {
|
||||
let half = a / 2.0 + c / 2.0;
|
||||
if half > (most + Self::CROP_ROUNDING) / 2.0 { (a / 2.0 / half * most, c / 2.0 / half * most) } else { (a, c) }
|
||||
};
|
||||
let ((l, r), (b, t)) = (fit(l, r), fit(b, t));
|
||||
[l, b, r, t]
|
||||
}
|
||||
|
||||
/// The matrix from image space (the unit square) to display space: crop, flip, rotate,
|
||||
/// then fill the box.
|
||||
fn matrix(&self) -> pdfcraft_content::Matrix {
|
||||
use pdfcraft_content::Matrix as M;
|
||||
let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45));
|
||||
let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05));
|
||||
let [l, b, r, t] = Self::clamp_crop(self.crop);
|
||||
let (cw, ch) = (1.0 - l - r, 1.0 - b - t);
|
||||
let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]);
|
||||
if self.flip_h {
|
||||
m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0]));
|
||||
@@ -1225,6 +1257,21 @@ pub fn delete_content(doc: &mut Document, page: usize, index: usize) -> Result<(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Leave only item `index` on `page`: its contents become the item's own stream and its
|
||||
/// annotations go, so a render of the page (on a transparent backdrop) is the item's pixels
|
||||
/// alone. For previews on a copy of the document; the page's resources stay as they are.
|
||||
pub fn isolate_content(doc: &mut Document, page: usize, index: usize) -> Result<(), EditError> {
|
||||
let a = find(doc, page, index)?;
|
||||
let p = page_list(doc).swap_remove(page);
|
||||
// An item's stream starts from the page's initial graphics state: `place_tagged` closes
|
||||
// everything drawn before it, so it draws the same on its own.
|
||||
doc.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), Object::Array(vec![Object::Ref(a.obj)]));
|
||||
d.remove(b"Annots");
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Type 3 fonts an Arabic item's stream shows text with. Each item has its own (`draw_shaped`
|
||||
/// names them so), so they go when the item is replaced or deleted.
|
||||
fn own_fonts(doc: &Document, obj: ObjRef) -> Vec<Vec<u8>> {
|
||||
|
||||
@@ -539,6 +539,11 @@ pub(crate) fn selectable_images(
|
||||
/// Find the saved graphics-state scope containing just this image and its crop. Applying
|
||||
/// the transform before that scope moves the actual clipping path as well as the pixels.
|
||||
/// A shared clip must never be moved along with unrelated artwork or text.
|
||||
///
|
||||
/// Under a clip set outside every scope that holds only this image (a report or table drawing
|
||||
/// each picture as `q re W n cm Do Q` inside a section's clip), no scope contains every active
|
||||
/// clip. The outermost scope holding only the image is used then: it contains all of the
|
||||
/// image's private clips, which move with it, while the outer clip is shared and stays.
|
||||
fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usize, Matrix)>, EditError> {
|
||||
struct Scope {
|
||||
start: usize,
|
||||
@@ -556,6 +561,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
let mut text = false;
|
||||
let mut at_image = None;
|
||||
let mut isolated = None;
|
||||
let mut outermost = None;
|
||||
for (index, op) in ops.iter().enumerate() {
|
||||
match op.op.as_slice() {
|
||||
b"q" => {
|
||||
@@ -566,9 +572,13 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
}
|
||||
b"Q" => {
|
||||
if let Some(scope) = stack.pop() {
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_clip && !scope.inherited_path {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
if scope.selected && !scope.other_paint && !scope.inherited_path {
|
||||
if !scope.inherited_clip {
|
||||
// Prefer the innermost scope that contains every active clipping path.
|
||||
isolated.get_or_insert((scope.start, index, scope.matrix));
|
||||
}
|
||||
// Scopes close inner first, so the last one recorded is the outermost.
|
||||
outermost = Some((scope.start, index, scope.matrix));
|
||||
}
|
||||
matrix = scope.matrix;
|
||||
clipped = scope.inherited_clip;
|
||||
@@ -611,7 +621,7 @@ fn image_transform_scope(ops: &[Op], target: usize) -> Result<Option<(usize, usi
|
||||
if !clipped {
|
||||
return Ok(Some((target, target, placement)));
|
||||
}
|
||||
Ok(isolated)
|
||||
Ok(isolated.or(outermost))
|
||||
}
|
||||
|
||||
fn transform_image(
|
||||
|
||||
@@ -57,7 +57,9 @@ pub struct HeaderFooter {
|
||||
/// Left, centre and right header text, then left, centre and right footer text. Tokens:
|
||||
/// `<<1>>` page number, `<<n>>` page count, `<<1 of n>>`, `<<1/n>>`, `<<Page 1>>`,
|
||||
/// `<<Page 1 of n>>`, dates `<<m/d/yyyy>>`, `<<mm/dd/yyyy>>`, `<<d/m/yyyy>>`, `<<yyyy-mm-dd>>`,
|
||||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||||
/// `<<yyyy/mm/dd>>`, `<<yyyy/m/d>>`, `<<m/d/yy>>`, `<<mmmm d, yyyy>>`, the dotted day-first
|
||||
/// dates of German-speaking and much of Central and Eastern Europe `<<dd.mm.yyyy>>`,
|
||||
/// `<<d.m.yyyy>>`, `<<dd.mm.yy>>`, `<<d.m.yy>>`, and Bates numbers `<<Bates Number#6#1#PRE#SUF>>`
|
||||
/// (digits, start, prefix, suffix).
|
||||
pub text: [String; 6],
|
||||
/// The standard face the text is drawn in (#3754): Helvetica (the default), Times or Courier.
|
||||
@@ -297,6 +299,10 @@ pub fn expand(template: &str, page_number: u32, count: usize, bates: u64, cx: &C
|
||||
"d/m/yy" => format!("{d}/{m}/{:02}", y.rem_euclid(100)),
|
||||
"d/m/yyyy" => format!("{d}/{m}/{y}"),
|
||||
"dd/mm/yyyy" => format!("{d:02}/{m:02}/{y}"),
|
||||
"d.m.yy" => format!("{d}.{m}.{:02}", y.rem_euclid(100)),
|
||||
"d.m.yyyy" => format!("{d}.{m}.{y}"),
|
||||
"dd.mm.yy" => format!("{d:02}.{m:02}.{:02}", y.rem_euclid(100)),
|
||||
"dd.mm.yyyy" => format!("{d:02}.{m:02}.{y}"),
|
||||
"yyyy-mm-dd" => format!("{y}-{m:02}-{d:02}"),
|
||||
"yyyy/mm/dd" => format!("{y}/{m:02}/{d:02}"),
|
||||
"yyyy/m/d" => format!("{y}/{m}/{d}"),
|
||||
@@ -1038,8 +1044,8 @@ pub use text::{
|
||||
};
|
||||
pub mod added;
|
||||
pub use added::{
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, list_added, scan_markers,
|
||||
update_content,
|
||||
Added, AddedImage, AddedText, Align, Content, Family, MarkerScan, add_content, delete_content, first_undrawable, isolate_content, list_added,
|
||||
scan_markers, update_content,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -75,6 +75,31 @@ fn an_image_turns_by_any_angle_about_its_centre_and_saves_the_matrix() {
|
||||
assert!(images::turn_by_degrees(r0, f64::NAN).is_none() && images::turn_by_degrees(r0, f64::INFINITY).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_added_image_crop_keeps_some_of_each_axis() {
|
||||
use crate::added::AddedImage;
|
||||
assert!(AddedImage::crop_valid([0.7, 0.0, 0.0, 0.95]));
|
||||
assert!(AddedImage::crop_valid([0.5, 0.45, 0.45, 0.5]));
|
||||
// 0.05 + 0.9 rounds to just above 0.95: still the 5 % kept, accepted and left as it is.
|
||||
assert!(AddedImage::crop_valid([0.05, 0.0, 0.9, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.05, 0.0, 0.9, 0.0]), [0.05, 0.0, 0.9, 0.0]);
|
||||
assert!(!AddedImage::crop_valid([0.06, 0.0, 0.9, 0.0]), "more than 95 % across");
|
||||
assert!(!AddedImage::crop_valid([0.6, 0.0, 0.4, 0.0]), "nothing left across");
|
||||
assert!(!AddedImage::crop_valid([-0.1, 0.0, 0.0, 0.0]));
|
||||
assert!(!AddedImage::crop_valid([f64::NAN, 0.0, 0.0, 0.0]));
|
||||
assert_eq!(AddedImage::clamp_crop([0.7, 0.0, 0.0, 0.0]), [0.7, 0.0, 0.0, 0.0], "a valid crop stays as it is");
|
||||
// From a file: too much across keeps the two sides' proportion; nonsense trims nothing.
|
||||
let [l, b, r, t] = AddedImage::clamp_crop([0.6, f64::INFINITY, 0.6, -1.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12 && b == 0.0 && t == 0.0);
|
||||
// Sides beyond the whole image keep their proportion too, and huge ones don't overflow.
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.5, 0.0]);
|
||||
assert!((l - 0.7125).abs() < 1e-12 && (r - 0.2375).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([f64::MAX, 0.0, f64::MAX, 0.0]);
|
||||
assert!((l - 0.475).abs() < 1e-12 && (r - 0.475).abs() < 1e-12, "{l} {r}");
|
||||
let [l, _, r, _] = AddedImage::clamp_crop([1.5, 0.0, 0.0, 0.0]);
|
||||
assert!((l - 0.95).abs() < 1e-12 && r == 0.0, "one side alone is capped at the limit");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_expand() {
|
||||
let c = cx();
|
||||
@@ -82,6 +107,7 @@ fn tokens_expand() {
|
||||
assert_eq!(expand("<<1 of n>> · <<Page 1>> · <<1/n>>", 2, 5, 0, &c), "2 of 5 · Page 2 · 2/5");
|
||||
assert_eq!(expand("<<m/d/yyyy>> <<yyyy-mm-dd>> <<mmmm d, yyyy>>", 1, 1, 0, &c), "10/1/2026 2026-10-01 October 1, 2026");
|
||||
assert_eq!(expand("<<yyyy/mm/dd>> <<yyyy/m/d>>", 1, 1, 0, &c), "2026/10/01 2026/10/1");
|
||||
assert_eq!(expand("<<dd.mm.yyyy>> <<d.m.yyyy>> <<dd.mm.yy>> <<d.m.yy>>", 1, 1, 0, &c), "01.10.2026 1.10.2026 01.10.26 1.10.26");
|
||||
assert_eq!(expand("<<Bates Number#6#100#ABC#-X>>", 1, 1, 104, &c), "ABC000104-X");
|
||||
assert_eq!(expand("keep <<unknown>> and <<unclosed", 1, 1, 0, &c), "keep <<unknown>> and <<unclosed");
|
||||
assert_eq!(bates_start("x <<Bates Number#6#100#A#B>>"), Some(100));
|
||||
@@ -983,6 +1009,28 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
||||
assert!(marks_present(&doc).is_empty());
|
||||
}
|
||||
|
||||
/// #4464: a page left with one added item draws that item and nothing else.
|
||||
#[test]
|
||||
fn an_isolated_item_is_all_that_is_left_on_its_page() {
|
||||
let mut doc = fixture();
|
||||
let first = AddedText { rect: [72.0, 600.0, 300.0, 700.0], text: "First".into(), size: 14.0, ..AddedText::default() };
|
||||
let second = AddedText { rect: [72.0, 400.0, 300.0, 500.0], text: "Second".into(), ..first.clone() };
|
||||
add_content(&mut doc, 0, &Content::Text(first)).unwrap();
|
||||
add_content(&mut doc, 0, &Content::Text(second)).unwrap();
|
||||
let before = streams(&doc, 0).join("\n");
|
||||
assert!(before.contains("(First) Tj") && before.contains("(Second) Tj"), "{before}");
|
||||
assert!(isolate_content(&mut doc, 0, 2).is_err(), "no third item");
|
||||
assert!(isolate_content(&mut doc, 9, 0).is_err(), "no such page");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
doc.update_dict(page.obj, |d| d.set(b"Annots".to_vec(), Object::Array(Vec::new()))).unwrap();
|
||||
isolate_content(&mut doc, 0, 1).unwrap();
|
||||
let after = streams(&doc, 0).join("\n");
|
||||
assert!(after.contains("(Second) Tj") && !after.contains("(First) Tj"), "{after}");
|
||||
let page = pdfcraft_model::pages(&doc).swap_remove(0);
|
||||
assert!(page.dict.get(b"Annots").is_none(), "no annotations are drawn over it");
|
||||
assert_eq!(list_added(&doc).len(), 1);
|
||||
}
|
||||
|
||||
/// Edit ▸ Paste (#2636): where a pasted item lands.
|
||||
#[test]
|
||||
fn a_paste_lands_where_the_copy_was_offset_per_paste_and_stays_on_the_page() {
|
||||
@@ -4978,6 +5026,40 @@ fn cropped_image_resizing_moves_private_clips_and_keeps_shared_ones() {
|
||||
assert_eq!(text::text_lines(&doc, 0).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// Report generators draw each picture as `q re W n cm Do Q` inside a section's clip that the
|
||||
/// section's text shares. Resizing the picture scales its own clip with it (the enlarged picture
|
||||
/// used to show only through its old box) while the section's clip and text stay put.
|
||||
#[test]
|
||||
fn resizing_an_image_under_a_shared_clip_moves_its_own_clip() {
|
||||
// The picture's own clip as wide as the picture, and wider (both seen in one report).
|
||||
for clip_width in [61.2, 85.0] {
|
||||
let mut doc = image_page_with(&format!(
|
||||
"q 20 20 560 760 re W n q 162 79 {clip_width} 28.3 re W n 61.2 0 0 28.3 162 79 cm /Im0 Do Q BT /F1 12 Tf 30 50 Td (Section text) Tj ET Q"
|
||||
));
|
||||
let before = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
assert!(close(before.visible_rect.unwrap(), [162.0, 79.0, 223.2, 107.3]), "{:?}", before.visible_rect);
|
||||
let caption = text::text_lines(&doc, 0).unwrap();
|
||||
let target = [162.0, 79.0, 284.4, 135.6];
|
||||
let transform = images::rect_to_rect(before.visible_rect.unwrap(), target);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(transform)).unwrap();
|
||||
doc = reopen(&doc);
|
||||
let after = images::page_images(&doc, 0).unwrap()[0].clone();
|
||||
let near = |a: [f64; 4], b: [f64; 4]| a.iter().zip(b).all(|(x, y)| (x - y).abs() < 0.05);
|
||||
assert!(near(after.visible_rect.unwrap(), target), "clip {clip_width}: the whole enlarged picture shows, {:?}", after.visible_rect);
|
||||
assert_eq!(after.object, before.object, "pixels are never resampled or replaced");
|
||||
assert_eq!(
|
||||
text::text_lines(&doc, 0).unwrap().iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
caption.iter().map(|l| (&l.text, l.rect, l.baseline())).collect::<Vec<_>>(),
|
||||
"the section's text stays where it was"
|
||||
);
|
||||
// The section's clip is shared, so it still bounds the picture: past it, nothing shows.
|
||||
let past = images::rect_to_rect(after.visible_rect.unwrap(), [500.0, 79.0, 700.0, 135.6]);
|
||||
images::change_image(&mut doc, 0, 0, &images::ImageChange::Transform(past)).unwrap();
|
||||
let clipped = images::page_images(&reopen(&doc), 0).unwrap()[0].visible_rect.unwrap();
|
||||
assert!(clipped[2] <= 580.05, "the section's clip stays in force: {clipped:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_image_crop_with_intervening_state_resizes_across_streams() {
|
||||
let variants: Vec<Vec<&[u8]>> = vec![
|
||||
|
||||
@@ -231,7 +231,23 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("view.previous_view", "Previous view", None, Some(Shortcut::cmd("[")), Document, "chevron-left"),
|
||||
c("view.next_view", "Next view", None, Some(Shortcut::cmd("]")), Document, "chevron-right"),
|
||||
c("view.focus_page_input", "Go to page…", VIEW, Some(Shortcut::cmd_shift("N")), Document, "text-cursor-input"),
|
||||
c("ui.dock.reset", "Reset panel layout", VIEW, None, Nothing, "settings"),
|
||||
c(
|
||||
"view.side_panel",
|
||||
"Show or hide the side panel",
|
||||
VIEW,
|
||||
Some(Shortcut { command: false, shift: false, mac_ctrl: false, key: "F4" }),
|
||||
Nothing,
|
||||
"panel-right",
|
||||
),
|
||||
c(
|
||||
"view.tools_pane",
|
||||
"Show or hide the Tools panel",
|
||||
VIEW,
|
||||
Some(Shortcut { command: false, shift: true, mac_ctrl: false, key: "F4" }),
|
||||
Nothing,
|
||||
"panel-left",
|
||||
),
|
||||
c("ui.dock.reset", "Reset side panels", VIEW, None, Nothing, "settings"),
|
||||
c("view.theme", "Switch light / dark theme", None, None, Nothing, "moon"),
|
||||
c("view.theme.system", "Use system setting", None, None, Nothing, "settings"),
|
||||
c("view.theme.light", "Light gray", None, None, Nothing, "sun"),
|
||||
|
||||
@@ -220,7 +220,10 @@ impl crate::Document {
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
.map(|b| {
|
||||
let f = b.base_font.to_ascii_lowercase();
|
||||
// `text` is trimmed; the editor text keeps the source's own spaces.
|
||||
let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)];
|
||||
pdfcraft_export::Block {
|
||||
space_around,
|
||||
text: b.text,
|
||||
rect: visible_rect(b.rect),
|
||||
size: b.size * unit,
|
||||
|
||||
@@ -63,7 +63,7 @@ const CKB_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 static DATE_LANGUAGES: [DateLanguage; 34] = [
|
||||
pub static DATE_LANGUAGES: [DateLanguage; 35] = [
|
||||
DateLanguage {
|
||||
code: "en",
|
||||
name: "English",
|
||||
@@ -267,6 +267,17 @@ pub static DATE_LANGUAGES: [DateLanguage; 34] = [
|
||||
days: CKB_DAYS,
|
||||
days_short: CKB_DAYS,
|
||||
},
|
||||
// Persian (Farsi); Gregorian month and weekday names, right to left like Arabic and Kurdish.
|
||||
// Persian does not change the month form after a day number, so `months_with_day` is `None`.
|
||||
DateLanguage {
|
||||
code: "fa",
|
||||
name: "فارسی",
|
||||
months: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
|
||||
months_with_day: None,
|
||||
months_short: ["ژانویه", "فوریه", "مارس", "آوریل", "مه", "ژوئن", "ژوئیه", "اوت", "سپتامبر", "اکتبر", "نوامبر", "دسامبر"],
|
||||
days: ["یکشنبه", "دوشنبه", "سهشنبه", "چهارشنبه", "پنجشنبه", "جمعه", "شنبه"],
|
||||
days_short: ["یک", "دو", "سه", "چه", "پن", "جم", "شن"],
|
||||
},
|
||||
DateLanguage {
|
||||
code: "pl",
|
||||
name: "Polski",
|
||||
|
||||
+83
-30
@@ -2461,6 +2461,8 @@ struct EditCtx {
|
||||
xfa_datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A form script ran too long and was abandoned: the document's scripts go off.
|
||||
xfa_ran_away: bool,
|
||||
/// What the XFA scripts' time limits are multiplied by ([`Session::with_xfa_time_scale`]).
|
||||
xfa_time_scale: u32,
|
||||
/// The edit took the XFA out of the form: what went with it, for the document's warnings.
|
||||
xfa_removed: Vec<String>,
|
||||
/// Pages in the document, for `xfa.layout.pageCount()`.
|
||||
@@ -2495,6 +2497,7 @@ impl EditCtx {
|
||||
xfa_out: Default::default(),
|
||||
xfa_datasets: None,
|
||||
xfa_ran_away: false,
|
||||
xfa_time_scale: 1,
|
||||
xfa_removed: Vec::new(),
|
||||
pages: 0,
|
||||
date: now.map(pdfcraft_cos::pdf_date),
|
||||
@@ -2677,7 +2680,8 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
|
||||
},
|
||||
Edit::XfaEvent { som, activity } => {
|
||||
let tpl = cx.xfa.clone().ok_or_else(|| EditError::Invalid("this is not a laid-out XFA form, or JavaScript is off".into()))?;
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run =
|
||||
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
|
||||
let ran = xfa::on_event(doc, &tpl, som, activity, &mut run);
|
||||
cx.xfa_datasets = run.datasets;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
@@ -3070,16 +3074,24 @@ fn renderer_password(bytes: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The document as it may be written to a file (#3926): a copy without the values of text fields
|
||||
/// with the Password flag, or `cos` itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231
|
||||
/// (a note to the Password flag): "it is imperative that PDF processors never store the value of
|
||||
/// the text field in the PDF file if this flag is set". The open document keeps them in memory.
|
||||
/// The document as it may be written to a file (#3926, #3944): a copy without the values of text
|
||||
/// fields with the Password flag (or XFA `<passwordEdit/>` fields in the datasets), or `cos`
|
||||
/// itself when it holds none. ISO 32000-2 §12.7.5.3, Table 231 (a note to the Password flag):
|
||||
/// "it is imperative that PDF processors never store the value of the text field in the PDF file
|
||||
/// if this flag is set". The open document keeps them in memory.
|
||||
fn writable(cos: &pdfcraft_cos::Document) -> Result<std::borrow::Cow<'_, pdfcraft_cos::Document>, EditError> {
|
||||
if !pdfcraft_forms::has_password_values(cos) {
|
||||
let has_form_pw = pdfcraft_forms::has_password_values(cos);
|
||||
let has_xfa_pw = pdfcraft_xfa::has_password_values(cos);
|
||||
if !has_form_pw && !has_xfa_pw {
|
||||
return Ok(std::borrow::Cow::Borrowed(cos));
|
||||
}
|
||||
let mut copy = cos.clone();
|
||||
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
if has_form_pw {
|
||||
pdfcraft_forms::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
if has_xfa_pw {
|
||||
pdfcraft_xfa::clear_password_values(&mut copy).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
Ok(std::borrow::Cow::Owned(copy))
|
||||
}
|
||||
|
||||
@@ -3229,6 +3241,9 @@ pub struct Session {
|
||||
date_format: Option<String>,
|
||||
/// Preferences ▸ Date format ▸ Language, when not following the interface language.
|
||||
date_language: Option<String>,
|
||||
/// What the XFA scripts' time limits are multiplied by (`None`: 1); see
|
||||
/// [`Session::with_xfa_time_scale`].
|
||||
xfa_time_scale: Option<u32>,
|
||||
}
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
@@ -3237,6 +3252,7 @@ pub struct OpenContext {
|
||||
now: Option<i64>,
|
||||
js_off: bool,
|
||||
trust: Arc<TrustStore>,
|
||||
xfa_time_scale: u32,
|
||||
}
|
||||
|
||||
/// A fully prepared document that has not yet been registered in a session.
|
||||
@@ -3473,7 +3489,8 @@ fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
/// fields take the data's values.
|
||||
fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, calculate: bool) -> Result<(), EditError> {
|
||||
if let Some(tpl) = cx.xfa.clone() {
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run =
|
||||
xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false, time_scale: cx.xfa_time_scale };
|
||||
let r = guard(|| xfa::on_data_replaced(doc, &tpl, calculate, &mut run))
|
||||
.unwrap_or_else(|m| Err(format!("laying the form out again failed unexpectedly ({m})")));
|
||||
cx.xfa_datasets = run.datasets;
|
||||
@@ -3516,9 +3533,14 @@ fn xfa_after_data_change(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx, cal
|
||||
fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
let mut changed = Vec::new();
|
||||
// The first field of each name, as a lookup by name would find it.
|
||||
let mut by_name = std::collections::HashMap::new();
|
||||
for f in &fields {
|
||||
by_name.entry(f.name.as_str()).or_insert(f);
|
||||
}
|
||||
let mut updates = Vec::new();
|
||||
for (name, value) in pdfcraft_xfa::read_values(doc, &data) {
|
||||
let Some(f) = fields.iter().find(|f| f.name == name) else { continue };
|
||||
let Some(f) = by_name.get(name.as_str()) else { continue };
|
||||
let new = match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) if matches!(f.kind, pdfcraft_forms::FieldKind::Combo | pdfcraft_forms::FieldKind::List) => {
|
||||
// Saved values, one per line for a multi-select list; shown text stands for
|
||||
@@ -3536,13 +3558,14 @@ fn xfa_values_from_datasets(doc: &mut pdfcraft_cos::Document) -> Result<Vec<Stri
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => (f.value.first() != sel.as_ref()).then_some(FieldValue::Radio(sel)),
|
||||
pdfcraft_xfa::FieldData::None => None,
|
||||
};
|
||||
if let Some(v) = new
|
||||
&& pdfcraft_forms::set_value(doc, &name, &v).is_ok()
|
||||
{
|
||||
changed.push(name);
|
||||
if let Some(v) = new {
|
||||
updates.push((name, v));
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
// All at once: one by one, each value would read the whole form again, which took minutes
|
||||
// on a form with thousands of fields.
|
||||
let Ok(results) = pdfcraft_forms::set_values(doc, &updates) else { return Ok(Vec::new()) };
|
||||
Ok(updates.into_iter().zip(results).filter(|(_, r)| r.is_ok()).map(|((name, _), _)| name).collect())
|
||||
}
|
||||
|
||||
/// Validate the signature fields of `cos` (written as `bytes`).
|
||||
@@ -3561,6 +3584,17 @@ impl Session {
|
||||
self
|
||||
}
|
||||
|
||||
/// Multiply the time an XFA form's scripts may take by `factor` (tests): the 3 s the
|
||||
/// scripts of an opening or a change share, the 5 s of a click's, the 10 s the script
|
||||
/// engine has to start, and each script's own 1 s or 3 s. A test that checks what the
|
||||
/// scripts do passes a large factor, so that a busy machine does not stop them. On native
|
||||
/// builds, 0 stops every script before it starts; the wasm build has no wall clock, so
|
||||
/// there the factor has no effect.
|
||||
pub fn with_xfa_time_scale(mut self, factor: u32) -> Self {
|
||||
self.xfa_time_scale = Some(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// A real clock for a host that has neither a clock nor a time zone of its own (a
|
||||
/// `wasm32-unknown-unknown` build: `now()` would be empty and `local_utc_offset()` is 0, so
|
||||
/// Fill & Sign stamps 1 Jan 1970 and `/ModDate` is never written). `clock` is the UTC time in
|
||||
@@ -3633,7 +3667,7 @@ impl Session {
|
||||
|
||||
/// Snapshot the session state that affects open-time scripts, dates and signature trust.
|
||||
pub fn open_context(&self) -> OpenContext {
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone() }
|
||||
OpenContext { now: self.now(), js_off: self.js_off, trust: self.trust.clone(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Prepare a document without adding it to this session.
|
||||
@@ -3762,7 +3796,13 @@ impl Session {
|
||||
let mut work = cos.clone();
|
||||
let pages = info.pages.len();
|
||||
let ran = guard(|| {
|
||||
let mut run = xfa::XfaRun { page_count: pages, out: &mut script_output, datasets: None, ran_away: false };
|
||||
let mut run = xfa::XfaRun {
|
||||
page_count: pages,
|
||||
out: &mut script_output,
|
||||
datasets: None,
|
||||
ran_away: false,
|
||||
time_scale: context.xfa_time_scale,
|
||||
};
|
||||
let changes = xfa::on_open(&mut work, &tpl, &mut run);
|
||||
if run.ran_away {
|
||||
ran_away = true;
|
||||
@@ -3946,6 +3986,7 @@ impl Session {
|
||||
let now = self.now();
|
||||
let today = self.today();
|
||||
let js_off = self.js_off;
|
||||
let xfa_time_scale = self.xfa_time_scale.unwrap_or(1);
|
||||
let doc = self.doc_mut(id)?;
|
||||
doc.edit_notices.clear();
|
||||
let name = doc.name.clone();
|
||||
@@ -3955,6 +3996,7 @@ impl Session {
|
||||
if !js_off {
|
||||
cx.xfa = doc.xfa_template.clone();
|
||||
}
|
||||
cx.xfa_time_scale = xfa_time_scale;
|
||||
cx.pages = doc.info.pages.len();
|
||||
cx.today = today;
|
||||
let reason = doc.read_only_reason.clone().unwrap_or_default();
|
||||
@@ -4052,7 +4094,13 @@ impl Session {
|
||||
let mut reset = false;
|
||||
collect(&edit, &mut changed, &mut reset);
|
||||
if !changed.is_empty() || reset {
|
||||
let mut run = xfa::XfaRun { page_count: cx.pages, out: &mut cx.xfa_out, datasets: cx.xfa_datasets, ran_away: false };
|
||||
let mut run = xfa::XfaRun {
|
||||
page_count: cx.pages,
|
||||
out: &mut cx.xfa_out,
|
||||
datasets: cx.xfa_datasets,
|
||||
ran_away: false,
|
||||
time_scale: cx.xfa_time_scale,
|
||||
};
|
||||
// Every changed field's scripts, then the calculations once (after a reset,
|
||||
// only those).
|
||||
let ran = guard(|| xfa::on_changes(&mut next, &tpl, &changed, &mut run))
|
||||
@@ -4516,11 +4564,15 @@ impl Session {
|
||||
// An encrypted file needs the password it is protected with now (the owner
|
||||
// password when known, so saving never downgrades this session's rights).
|
||||
let keys = editor.keys.clone();
|
||||
// The saved file carries no Password-field values (#3943); the open document keeps them.
|
||||
// The saved file carries no Password-field values (#3943, #3944); the open document keeps them.
|
||||
let typed = pdfcraft_forms::password_values(&editor.cos);
|
||||
editor.cos =
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), keys.reopen.as_deref()).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
pdfcraft_forms::restore_password_values(&mut editor.cos, &typed).map_err(|e| EditError::Reopen(e.to_string()))?;
|
||||
if !typed.is_empty() && doc.info.xfa.is_some() {
|
||||
let mut datasets = None;
|
||||
let _ = xfa_sync_datasets(&mut editor.cos, doc.xfa_template.as_deref(), &mut datasets);
|
||||
}
|
||||
editor.saved = bytes;
|
||||
editor.saved_keys = keys;
|
||||
editor.unsaved_file = false;
|
||||
@@ -5125,17 +5177,18 @@ impl Session {
|
||||
let mut out = Vec::new();
|
||||
for d in &mut self.docs {
|
||||
if d.dirty && d.generation != d.snapshot_generation {
|
||||
// The working bytes hold typed Password-field values (#3926): a recovery file is
|
||||
// written from a copy without them, and none is written if that copy fails.
|
||||
let bytes = match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
// The working bytes hold typed Password-field values (#3926, #3944): a recovery file
|
||||
// is written from a copy without them, and none is written if that copy fails.
|
||||
let bytes =
|
||||
match d.editor.as_ref().filter(|e| pdfcraft_forms::has_password_values(&e.cos) || pdfcraft_xfa::has_password_values(&e.cos)) {
|
||||
Some(e) => match writable(&e.cos)
|
||||
.and_then(|c| write_incremental(&c, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string())))
|
||||
{
|
||||
Ok(b) => Arc::new(b),
|
||||
Err(_) => continue,
|
||||
},
|
||||
None => d.bytes.clone(),
|
||||
};
|
||||
d.snapshot_generation = d.generation;
|
||||
out.push(RecoverySnapshot {
|
||||
doc: d.id,
|
||||
|
||||
@@ -87,8 +87,9 @@ impl OptimizeJob {
|
||||
|
||||
fn work(self, progress: &mut dyn FnMut(OptimizeStage) -> bool) -> Result<Optimized, EditError> {
|
||||
let OptimizeJob { mut cos, settings, discard, opts } = self;
|
||||
// Like every file written, without the values of Password-flag fields (#3926).
|
||||
// Like every file written, without the values of Password-flag fields (#3926, #3944).
|
||||
pdfcraft_forms::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
pdfcraft_xfa::clear_password_values(&mut cos).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let mut step = |stage| if progress(stage) { Ok(()) } else { Err(EditError::Cancelled) };
|
||||
step(OptimizeStage::Discarding)?;
|
||||
let discarded = if discard.is_empty() { Vec::new() } else { pdfcraft_redact::sanitize::remove_hidden(&mut cos, &discard)? };
|
||||
|
||||
+253
-8
@@ -2284,6 +2284,82 @@ fn a_signed_file_carries_no_password_field_value() {
|
||||
assert!(!appears_in_pdf(&signed, "*******"), "nor the typed length");
|
||||
}
|
||||
|
||||
/// #3944: a value typed into an XFA `<passwordEdit/>` field stays in the open document (both the
|
||||
/// widget and the live XFA datasets, even across multiple edits and across Save + `mark_saved`),
|
||||
/// but is written to no file — neither on the widget nor in the XFA datasets stream.
|
||||
#[test]
|
||||
fn xfa_password_field_values_are_not_saved_in_datasets_or_widgets() {
|
||||
let xfa_pdf = pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(""));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("xfa.pdf", None, Arc::new(xfa_pdf), None).unwrap();
|
||||
let pin_name = s.get(id).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
|
||||
|
||||
// Edit the password twice so an earlier edit's datasets stream could not linger in the overlay.
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret1".into()) }).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("hunter2".into()) }).unwrap();
|
||||
|
||||
let field = s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).cloned().unwrap();
|
||||
assert!(field.has(pdfcraft_forms::flags::PASSWORD));
|
||||
assert_eq!(field.value, vec!["hunter2".to_string()], "the open document keeps the value");
|
||||
assert!(appears_in_pdf(&s.get(id).unwrap().bytes, "hunter2"), "the working bytes hold the live datasets");
|
||||
assert!(!appears_in_pdf(&s.get(id).unwrap().bytes, "secret1"), "the first edit's datasets stream was replaced in place");
|
||||
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved, leak), "Save leaked {leak}");
|
||||
assert!(!appears_in_pdf(&s.save_full_bytes(id).unwrap(), leak), "save_full_bytes leaked {leak}");
|
||||
assert!(!appears_in_pdf(&s.reduced_bytes(id).unwrap().0, leak), "reduced_bytes leaked {leak}");
|
||||
}
|
||||
let snaps = s.autosave_snapshots();
|
||||
assert_eq!(snaps.len(), 1);
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&snaps[0].bytes, leak), "autosave snapshot leaked {leak}");
|
||||
}
|
||||
|
||||
// Reopening the saved file in a new session leaves the password field empty (it does not
|
||||
// repopulate from the XFA datasets on open).
|
||||
let mut t = Session::new();
|
||||
let reopened = t.open("saved.pdf", None, saved.clone(), None).unwrap();
|
||||
assert!(
|
||||
t.get(reopened).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value.iter().all(|v| v.is_empty()),
|
||||
"reopened XFA form must have an empty password field"
|
||||
);
|
||||
|
||||
// Recording the save keeps the typed password in the open document (and its live datasets),
|
||||
// while a subsequent save still omits it.
|
||||
s.mark_saved(id, saved, Some("saved.pdf".into())).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == pin_name).unwrap().value, ["hunter2"]);
|
||||
assert!(!s.get(id).unwrap().dirty);
|
||||
for leak in ["hunter2", "secret1", "*******"] {
|
||||
assert!(!appears_in_pdf(&s.save_bytes(id).unwrap(), leak), "save after mark_saved leaked {leak}");
|
||||
}
|
||||
|
||||
// Undo past `mark_saved` and edit again: the datasets stream in the restored overlay is
|
||||
// overwritten in place rather than left behind.
|
||||
s.undo(id).unwrap();
|
||||
s.apply(id, Edit::SetFieldValue { name: pin_name.clone(), value: FieldValue::Text("secret3".into()) }).unwrap();
|
||||
let saved_after_undo = s.save_bytes(id).unwrap();
|
||||
for leak in ["hunter2", "secret1", "secret3", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved_after_undo, leak), "save after undo leaked {leak}");
|
||||
}
|
||||
|
||||
// A relayout (`rerender`) after typing a password frees the replaced generated widget and its
|
||||
// masked appearance stream so neither remains in the overlay when saving.
|
||||
let relayout_xml = pdfcraft_xfa::fixtures::fields_template("").replace(
|
||||
r#"<subform name="page1" layout="tb">"#,
|
||||
r#"<subform name="page1" layout="tb"><field name="grow" w="2in"><ui><textEdit multiLine="1"/></ui></field>"#,
|
||||
);
|
||||
let id2 = s.open("relayout.pdf", None, Arc::new(pdfcraft_xfa::fixtures::shell(&relayout_xml)), None).unwrap();
|
||||
let pin2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("pin")).unwrap().name.clone();
|
||||
let grow2 = s.get(id2).unwrap().form.iter().find(|f| f.name.contains("grow")).unwrap().name.clone();
|
||||
s.apply(id2, Edit::SetFieldValue { name: pin2, value: FieldValue::Text("s3cr3t!".into()) }).unwrap();
|
||||
s.apply(id2, Edit::SetFieldValue { name: grow2, value: FieldValue::Text("line1\nline2\nline3\nline4\nline5".into()) }).unwrap();
|
||||
let saved_relayout = s.save_bytes(id2).unwrap();
|
||||
for leak in ["s3cr3t!", "*******"] {
|
||||
assert!(!appears_in_pdf(&saved_relayout, leak), "save after relayout leaked {leak}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A value its field's font can't draw is kept, and the edit that drew it reports it in
|
||||
/// `edit_notices` (the interface shows it, tools return it): not a later unrelated edit, not a
|
||||
/// failed one, and not a value that can be drawn. The form (an inline AcroForm here) asks other
|
||||
@@ -3574,8 +3650,12 @@ fn added_images_rotate_flip_and_crop_as_drawn() {
|
||||
assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped");
|
||||
img.flip_h = false;
|
||||
img.crop = [0.0, 0.0, 0.5, 0.0];
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
|
||||
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
|
||||
// More than half from one side: only the blue right part is left.
|
||||
img.crop = [0.7, 0.0, 0.0, 0.0];
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
|
||||
assert!(blue(colour_at(&s, 53, 150)) && blue(colour_at(&s, 130, 150)), "70 % trimmed from the left: blue fills the box to its left edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5504,6 +5584,74 @@ fn static_xfa_forms_take_their_values_from_the_datasets_and_write_them_back() {
|
||||
assert!(!d2.info.warnings.iter().any(|w| w.contains("taken from this form's XFA data")), "nothing was rewritten");
|
||||
}
|
||||
|
||||
/// A static XFA form whose values are only in its datasets takes every one on opening, as
|
||||
/// filling the fields one at a time would leave it: values, appearances and the note; a
|
||||
/// read-only field keeps its own value.
|
||||
#[test]
|
||||
fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
|
||||
let n = 60;
|
||||
let original = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("many.pdf", None, original.clone(), None).expect("opens");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.info.xfa, Some(pdfcraft_render::Xfa::Static));
|
||||
assert!(!doc.dirty && doc.bytes.starts_with(&original[..]), "the values are an appended revision");
|
||||
// The read-only first field keeps its own (empty) value, and is not named.
|
||||
let note = doc.info.warnings.iter().find(|w| w.contains("taken from this form's XFA data")).expect("a warning");
|
||||
assert!(note.starts_with("59 form field values") && note.contains("form1[0].page1[0].f1[0]") && note.ends_with("and 54 more"), "{note}");
|
||||
assert!(!note.contains("f0[0]"), "{note}");
|
||||
// The same values filled one at a time.
|
||||
let mut one_by_one = pdfcraft_cos::Document::open(original).unwrap();
|
||||
for i in 0..n {
|
||||
let r = pdfcraft_forms::set_value(&mut one_by_one, &format!("form1[0].page1[0].f{i}[0]"), &FieldValue::Text(format!("v{i}")));
|
||||
assert_eq!(r.is_err(), i == 0, "field {i}: {r:?}");
|
||||
}
|
||||
let opened = &doc.editor.as_ref().unwrap().cos;
|
||||
let shown = |d: &pdfcraft_cos::Document| -> Vec<(String, Vec<String>, String)> {
|
||||
pdfcraft_forms::fields(d)
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let w = f.widgets.first().map(|w| w.obj).unwrap();
|
||||
let ap = d.get(w).as_dict().and_then(|w| w.get(b"AP").cloned()).map(|a| d.resolve(&a));
|
||||
let n = ap.and_then(|ap| ap.as_dict().and_then(|a| a.get(b"N").cloned())).map(|n| d.resolve(&n));
|
||||
let look = match n.as_deref() {
|
||||
Some(pdfcraft_cos::Object::Stream(st)) => String::from_utf8_lossy(&st.decoded().unwrap()).into_owned(),
|
||||
_ => String::new(),
|
||||
};
|
||||
(f.name, f.value, look)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let got = shown(opened);
|
||||
assert_eq!(got.len(), n);
|
||||
assert_eq!(got, shown(&one_by_one));
|
||||
assert!(got[0].1.is_empty());
|
||||
assert!(got.iter().enumerate().skip(1).all(|(i, (_, v, ap))| *v == [format!("v{i}")] && ap.contains(&format!("(v{i})"))));
|
||||
}
|
||||
|
||||
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
|
||||
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
|
||||
/// times the fields now takes at most about eight times as long (it took about fifty).
|
||||
#[test]
|
||||
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
|
||||
let time = |n: usize| {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let small = time(100);
|
||||
let large = time(800);
|
||||
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
|
||||
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
|
||||
}
|
||||
|
||||
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
|
||||
/// viewers write and read it, and comes back from there when the form is opened again. It used
|
||||
/// to be written as an ISO date whatever the picture said.
|
||||
@@ -5587,6 +5735,8 @@ fn xfa_data_no_field_binds_to_survives_an_edit_and_save() {
|
||||
assert!(doc.xfa_warnings.iter().any(|w| w.contains("structured content")));
|
||||
}
|
||||
|
||||
/// An XFA form's choice lists, signature and picture fields open as their kinds of field, take
|
||||
/// values that scripts read, and are saved into the datasets.
|
||||
#[test]
|
||||
fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
let data = "<form><page1><country>Japan</country><langs>English\nSpanish</langs></page1></form>";
|
||||
@@ -5606,7 +5756,8 @@ fn xfa_choice_lists_signature_and_picture_fields_open_fill_script_and_save() {
|
||||
assert_eq!(langs.value, vec!["English".to_string(), "Spanish".to_string()]);
|
||||
assert!(field(&s, "other").has(pdfcraft_forms::flags::EDIT));
|
||||
assert!(field(&s, "pin").has(pdfcraft_forms::flags::PASSWORD));
|
||||
assert_eq!(field(&s, "sign").kind, K::Signature);
|
||||
// The signature field is named by its SOM path, as another viewer finds a signature.
|
||||
assert_eq!(field(&s, "form[0].page1[0].sign[0]").kind, K::Signature);
|
||||
// Scripts read a choice list's saved value.
|
||||
assert_eq!(field(&s, "summary").value, vec!["Country: JP".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Choosing another item recalculates; the list takes several values; a typed value is kept
|
||||
@@ -5764,7 +5915,8 @@ fn xfa_data_import_on_a_static_form_replaces_every_value_and_pictures_reach_its_
|
||||
#[test]
|
||||
fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
// Raised time limits: a busy machine does not stop the calculations this checks.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let id = s.open("rows.pdf", None, bytes, None).expect("opens");
|
||||
let names = |s: &Session| s.get(id).unwrap().form.iter().map(|f| f.name.clone()).collect::<Vec<_>>();
|
||||
let value = |s: &Session, n: &str| s.get(id).unwrap().form.iter().find(|f| f.name == n).map(|f| f.value.clone()).unwrap_or_default();
|
||||
@@ -5793,16 +5945,19 @@ fn xfa_data_import_adds_and_removes_rows_of_a_dynamic_form() {
|
||||
assert!(String::from_utf8_lossy(&out).contains("<item>only</item>"));
|
||||
}
|
||||
|
||||
/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine within
|
||||
/// the opening's budget, and a change recalculates them within the event's.
|
||||
/// Hundreds of calculations, in JavaScript and FormCalc, run on opening in one engine, and a
|
||||
/// change recalculates them in one more. The time limits are raised so that a busy machine
|
||||
/// does not stop them; the next test checks that the limits stop them.
|
||||
#[test]
|
||||
fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
fn hundreds_of_xfa_calculations_run_in_one_engine() {
|
||||
let (sections, rows) = (10, 20);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(100);
|
||||
let engines = pdfcraft_js::xfa_session::engines_started();
|
||||
let started = std::time::Instant::now();
|
||||
let id = s.open("many.pdf", None, bytes, None).expect("opens");
|
||||
let elapsed = started.elapsed();
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 1, "the scripts of the opening share one engine");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped") || e.contains("stopped")), "{:?}", out.errors);
|
||||
let form = &s.get(id).unwrap().form;
|
||||
@@ -5815,9 +5970,10 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
let expect = (rows * (rows + 1)).to_string();
|
||||
assert!(sums.iter().all(|f| f.value == vec![expect.clone()]), "{:?}", sums.iter().map(|f| f.value.clone()).collect::<Vec<_>>());
|
||||
eprintln!("{} calculate scripts on open in {elapsed:?}", sections * rows + sections);
|
||||
// A change recalculates them all again, well within the event budget.
|
||||
// A change recalculates them all again, in one more engine.
|
||||
let started = std::time::Instant::now();
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started() - engines, 2, "the scripts of the change share one engine");
|
||||
let out = s.take_js_output(id);
|
||||
assert!(!out.errors.iter().any(|e| e.contains("were skipped")), "{:?}", out.errors);
|
||||
assert_eq!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().value, vec!["200".to_string()]);
|
||||
@@ -5825,6 +5981,28 @@ fn hundreds_of_xfa_calculations_run_in_one_engine_within_the_open_budget() {
|
||||
eprintln!("recalculated after a change in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// The time the scripts of an opening share stops the calculations it has no time for, and
|
||||
/// says how many were skipped; a change's scripts are stopped the same way.
|
||||
#[test]
|
||||
fn the_xfa_open_budget_stops_the_calculations_it_has_no_time_for() {
|
||||
let (sections, rows) = (10, 20);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::many_calculations_template(sections, rows)));
|
||||
// A time scale of 0 leaves no time at all: every script is skipped before it starts.
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000).with_xfa_time_scale(0);
|
||||
let engines = pdfcraft_js::xfa_session::engines_started();
|
||||
let id = s.open("many.pdf", None, bytes, None).expect("opens");
|
||||
let skipped = sections * rows + sections;
|
||||
let stopped = format!("The form's scripts were stopped: the scripts took more than 0 seconds; {skipped} more were skipped");
|
||||
assert_eq!(s.take_js_output(id).errors, vec![stopped.clone()]);
|
||||
let totals = |s: &Session| s.get(id).unwrap().form.iter().filter(|f| f.name.starts_with("total")).map(|f| f.value.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(totals(&s).len(), sections * rows);
|
||||
assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s));
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("100".into()) }).unwrap();
|
||||
assert!(s.take_js_output(id).errors.iter().any(|e| e.starts_with("The form's scripts were stopped: the scripts took more than 0 seconds")));
|
||||
assert!(totals(&s).iter().all(Vec::is_empty), "no total was calculated: {:?}", totals(&s));
|
||||
assert_eq!(pdfcraft_js::xfa_session::engines_started(), engines, "no script ran, so no engine started");
|
||||
}
|
||||
|
||||
/// The scripts of one event share the form as the engine holds it: a failed script's
|
||||
/// half-done writes reach nobody, and a script reads the saved value a choice list was given
|
||||
/// by its shown text.
|
||||
@@ -8474,3 +8652,70 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
@@ -89,4 +89,17 @@ impl Document {
|
||||
Ok(TextEditPreview { page, bytes, config: self.config.clone() })
|
||||
})?
|
||||
}
|
||||
|
||||
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
|
||||
/// so the page under its first place doesn't come along. Read-only, like
|
||||
/// [`Self::added_content_preview`].
|
||||
pub fn added_content_alone(&self, page: usize, index: usize) -> Result<TextEditPreview, String> {
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let mut cos = editor.cos.clone();
|
||||
pdfcraft_edit::isolate_content(&mut cos, page, index).map_err(|e| e.to_string())?;
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(TextEditPreview { page, bytes, config: RenderConfig { transparent: true, ..self.config.clone() } })
|
||||
})?
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ pub(crate) struct XfaRun<'a> {
|
||||
pub datasets: Option<pdfcraft_cos::ObjRef>,
|
||||
/// A script ran past its time limit and was abandoned: the form's scripts should stay off.
|
||||
pub ran_away: bool,
|
||||
/// What every time limit of the scripts is multiplied by (the event's budget, the engine's
|
||||
/// start and each script's own): 1, unless the session was given another
|
||||
/// ([`crate::Session::with_xfa_time_scale`]).
|
||||
pub time_scale: u32,
|
||||
}
|
||||
|
||||
/// The live form node `n`, with its descendants, as the script engine's snapshot of it.
|
||||
@@ -277,6 +281,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
fn new(doc: &'a mut pdfcraft_cos::Document, tpl: &'a Template, run: &'a mut XfaRun<'b>, budget_ms: u64) -> Self {
|
||||
let data = pdfcraft_xfa::data_of(doc);
|
||||
let ov = pdfcraft_xfa::overrides(doc);
|
||||
let budget_ms = budget_ms.saturating_mul(u64::from(run.time_scale));
|
||||
Runner {
|
||||
doc,
|
||||
tpl,
|
||||
@@ -311,7 +316,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
/// The script engine for this event.
|
||||
fn session(&mut self) -> &mut XfaSession {
|
||||
let doc_info = XfaDoc { file_name: String::new(), page: 0, page_count: self.run.page_count };
|
||||
self.session.get_or_insert_with(|| XfaSession::new(doc_info))
|
||||
let scale = self.run.time_scale;
|
||||
self.session.get_or_insert_with(|| XfaSession::new(doc_info).with_time_scale(scale))
|
||||
}
|
||||
|
||||
/// Give the object at `som` the value `text` in the data and in the live form (refreshed
|
||||
@@ -661,7 +667,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
event,
|
||||
root,
|
||||
limits: limits_for(&ev.activity),
|
||||
timeout: timeout_for(&ev.activity),
|
||||
timeout: timeout_for(&ev.activity).saturating_mul(self.run.time_scale),
|
||||
};
|
||||
let o = self.session().run(script);
|
||||
if o.abandoned {
|
||||
@@ -893,7 +899,7 @@ pub(crate) fn on_event(doc: &mut pdfcraft_cos::Document, tpl: &Template, som: &s
|
||||
r.run(&ev, "")?;
|
||||
}
|
||||
// The calculations that follow get a budget of their own.
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS);
|
||||
r.clock = Clock::start(OPEN_BUDGET_MS.saturating_mul(u64::from(r.run.time_scale)));
|
||||
r.calculates()?;
|
||||
r.finish().map(|_| ())
|
||||
}
|
||||
|
||||
+339
-33
@@ -2,7 +2,9 @@
|
||||
//!
|
||||
//! The engine reduces each page to [`Page`]: paragraphs (text, box, size, bold/italic) and
|
||||
//! images (encoded bytes and box), in reading order. The writers turn that into a flowing
|
||||
//! document: headings are the paragraphs set larger than the body text. Word keeps pages with
|
||||
//! document: headings are the paragraphs set larger than the body text, and a block that
|
||||
//! continues another on its line (an italic word, a link) is a run of that block's paragraph.
|
||||
//! Word keeps pages with
|
||||
//! rotated text, or text overlapped by artwork or watermark-sized text, as positioned, editable
|
||||
//! shapes to avoid reflowing overlays; other pages flow with images inline.
|
||||
|
||||
@@ -30,6 +32,10 @@ pub struct Block {
|
||||
pub color: [f64; 3],
|
||||
/// Counterclockwise baseline angle in user space (degrees).
|
||||
pub rotation: f64,
|
||||
/// Whether the source had a word space just before (`[0]`) and just after (`[1]`) `text`,
|
||||
/// which `text` leaves out. A block that continues this one on its line is a run of the same
|
||||
/// paragraph, and these say whether a space separates them (#4158).
|
||||
pub space_around: [bool; 2],
|
||||
}
|
||||
|
||||
/// An image of a page.
|
||||
@@ -81,7 +87,8 @@ pub struct Table {
|
||||
|
||||
/// What goes into the output, in order.
|
||||
enum Item<'a> {
|
||||
Para(&'a Block, u8),
|
||||
/// A paragraph's runs (at least one) and its heading level.
|
||||
Para(Vec<Run<'a>>, u8),
|
||||
/// Owned: tables are detected per page inside [`items`], so they cannot borrow.
|
||||
Table(Table),
|
||||
Img(&'a Image),
|
||||
@@ -149,6 +156,12 @@ const CONTINUATION_PROBE: usize = 64;
|
||||
/// [`INLINE_GAP`] later, or a point earlier for rounding), so it is the same line of text running
|
||||
/// on (a link inside a sentence, a superscript), not a cell of its own.
|
||||
fn continuations(blocks: &[Block]) -> Vec<bool> {
|
||||
continued_from(blocks).into_iter().map(|from| from.is_some()).collect()
|
||||
}
|
||||
|
||||
/// Per block, the block it continues on the same line (see [`continuations`]): of those it could
|
||||
/// continue, the one that ends nearest to where it starts.
|
||||
fn continued_from(blocks: &[Block]) -> Vec<Option<usize>> {
|
||||
let mut by_end: Vec<(f64, usize)> = blocks.iter().enumerate().map(|(i, b)| (b.rect[2], i)).collect();
|
||||
by_end.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
blocks
|
||||
@@ -162,11 +175,88 @@ fn continuations(blocks: &[Block]) -> Vec<bool> {
|
||||
.iter()
|
||||
.take_while(|(end, _)| *end <= b.rect[0] + 1.0)
|
||||
.take(CONTINUATION_PROBE)
|
||||
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi))
|
||||
.any(|a| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
|
||||
.filter_map(|(_, ai)| blocks.get(*ai).filter(|_| *ai != bi).map(|a| (*ai, a)))
|
||||
.filter(|(_, a)| a.rect[3].min(b.rect[3]) - a.rect[1].max(b.rect[1]) > 0.5 * (a.rect[3] - a.rect[1]).min(b.rect[3] - b.rect[1]))
|
||||
.min_by(|(_, a), (_, c)| (a.rect[2] - b.rect[0]).abs().total_cmp(&(c.rect[2] - b.rect[0]).abs()))
|
||||
.map(|(ai, _)| ai)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A gap between two runs wider than this (as a fraction of the font size) is a word space the
|
||||
/// text left out. It is the gap the reading-block grouping uses for the same purpose.
|
||||
const WORD_GAP: f64 = 0.12;
|
||||
|
||||
/// One run of a paragraph: a block, and the text it contributes (its own, plus the word space
|
||||
/// before the next run when the source had one there or the page shows a gap, but the text has
|
||||
/// none).
|
||||
pub(crate) struct Run<'a> {
|
||||
pub(crate) block: &'a Block,
|
||||
pub(crate) text: String,
|
||||
}
|
||||
|
||||
/// A page's paragraphs, each a list of runs from left to right along its line. A block that
|
||||
/// continues another on the same line ([`continued_from`]: an italic case name, a link, a
|
||||
/// superscript) is a run in its own style inside that block's paragraph, not a paragraph of its
|
||||
/// own (#4158). Blocks marked in `skip` (table cells, a slide title) are left out and join
|
||||
/// nothing. Paragraphs come in the order of their first runs in `blocks`.
|
||||
pub(crate) fn paragraphs<'a>(blocks: &'a [Block], skip: &[bool]) -> Vec<Vec<Run<'a>>> {
|
||||
let skipped = |i: usize| skip.get(i).copied().unwrap_or(false);
|
||||
let from = continued_from(blocks);
|
||||
// Left to right. A block joins only one that starts strictly further left (in `total_cmp`'s
|
||||
// order, NaN included), so that one is visited first and has its paragraph already, and the
|
||||
// links cannot form a cycle.
|
||||
let mut order: Vec<(f64, usize)> = blocks.iter().enumerate().filter(|(i, _)| !skipped(*i)).map(|(i, b)| (b.rect[0], i)).collect();
|
||||
order.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
|
||||
// Each block's paragraph, named by its first block, and each paragraph's blocks in order.
|
||||
let mut head: Vec<usize> = (0..blocks.len()).collect();
|
||||
let mut members: Vec<Vec<usize>> = vec![Vec::new(); blocks.len()];
|
||||
for &(x0, bi) in &order {
|
||||
let Some(b) = blocks.get(bi) else { continue };
|
||||
let joined = from
|
||||
.get(bi)
|
||||
.copied()
|
||||
.flatten()
|
||||
.filter(|ai| !skipped(*ai))
|
||||
.filter(|ai| blocks.get(*ai).is_some_and(|a| a.rect[0].total_cmp(&x0).is_lt() && (a.rotation - b.rotation).abs() <= 0.1))
|
||||
.and_then(|ai| head.get(ai).copied());
|
||||
let h = joined.unwrap_or(bi);
|
||||
if let Some(slot) = head.get_mut(bi) {
|
||||
*slot = h;
|
||||
}
|
||||
if let Some(m) = members.get_mut(h) {
|
||||
m.push(bi);
|
||||
}
|
||||
}
|
||||
members
|
||||
.into_iter()
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| {
|
||||
let mut runs: Vec<Run<'a>> = Vec::with_capacity(m.len());
|
||||
for b in m.into_iter().filter_map(|bi| blocks.get(bi)) {
|
||||
if let Some(prev) = runs.last_mut()
|
||||
&& !prev.text.ends_with(char::is_whitespace)
|
||||
&& !b.text.starts_with(char::is_whitespace)
|
||||
&& (prev.block.space_around[1] || b.space_around[0] || b.rect[0] - prev.block.rect[2] > b.size * WORD_GAP)
|
||||
{
|
||||
prev.text.push(' ');
|
||||
}
|
||||
runs.push(Run { block: b, text: b.text.clone() });
|
||||
}
|
||||
runs
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A paragraph's heading level: the lowest of its runs', so body text that runs on after a bold
|
||||
/// lead-in on the same line keeps the paragraph body text. A marker set smaller than the first run
|
||||
/// (a footnote number, "st" in "1st") does not count, so a heading keeps its footnote marker.
|
||||
fn para_level(runs: &[Run], body: f64) -> u8 {
|
||||
let Some(first) = runs.first() else { return 0 };
|
||||
let marker = |r: &Run| r.block.size < first.block.size * 0.9 && r.block.text.trim().chars().count() <= 3;
|
||||
runs.iter().filter(|r| !marker(r)).map(|r| level(r.block, body)).min().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// A cell is short: taller blocks are body text (or a multi-column layout), not table cells.
|
||||
fn is_cell_like(b: &Block) -> bool {
|
||||
(b.rect[3] - b.rect[1]) <= b.size * 5.0 && !b.text.trim().is_empty()
|
||||
@@ -374,12 +464,14 @@ fn items(pages: &[Page]) -> Vec<Item<'_>> {
|
||||
}
|
||||
let (tables, consumed) = tables(&p.blocks);
|
||||
// Blocks and images by their top edge, top to bottom.
|
||||
let mut parts: Vec<(f64, f64, Item)> = p
|
||||
.blocks
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(bi, _)| !consumed.get(*bi).copied().unwrap_or(false))
|
||||
.map(|(_, b)| (b.rect[3], b.rect[0], Item::Para(b, level(b, body))))
|
||||
// A paragraph sits where its first run does: its other runs follow along the same line.
|
||||
let mut parts: Vec<(f64, f64, Item)> = paragraphs(&p.blocks, &consumed)
|
||||
.into_iter()
|
||||
.filter_map(|runs| {
|
||||
let first = runs.first()?.block;
|
||||
let lvl = para_level(&runs, body);
|
||||
Some((first.rect[3], first.rect[0], Item::Para(runs, lvl)))
|
||||
})
|
||||
.collect();
|
||||
parts.extend(tables.into_iter().map(|t| (t.rect[3], t.rect[0], Item::Table(t))));
|
||||
parts.extend(p.images.iter().map(|im| (im.rect[3], im.rect[0], Item::Img(im))));
|
||||
@@ -434,15 +526,26 @@ table{{border-collapse:collapse;margin:1em 0}}td,th{{border:1px solid #999;paddi
|
||||
);
|
||||
for it in items(pages) {
|
||||
match it {
|
||||
Item::Para(b, lvl) => {
|
||||
let mut t = esc(&b.text);
|
||||
if b.italic {
|
||||
t = format!("<em>{t}</em>");
|
||||
Item::Para(runs, lvl) => {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
let mut t = String::new();
|
||||
for r in &runs {
|
||||
let mut run = esc(&r.text);
|
||||
if r.block.italic {
|
||||
run = format!("<em>{run}</em>");
|
||||
}
|
||||
if r.block.bold && lvl == 0 {
|
||||
run = format!("<strong>{run}</strong>");
|
||||
}
|
||||
// The paragraph has its first run's colour; a run in another colour says so.
|
||||
let colour = rgb8(r.block.color);
|
||||
if colour != rgb8(first.color) {
|
||||
let [red, green, blue] = colour;
|
||||
run = format!("<span style=\"color:#{red:02X}{green:02X}{blue:02X}\">{run}</span>");
|
||||
}
|
||||
t.push_str(&run);
|
||||
}
|
||||
if b.bold && lvl == 0 {
|
||||
t = format!("<strong>{t}</strong>");
|
||||
}
|
||||
let style = hex(b.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
let style = hex(first.color).map_or(String::new(), |h| format!(" style=\"color:#{h}\""));
|
||||
match lvl {
|
||||
0 => s.push_str(&format!("<p{style}>{t}</p>\n")),
|
||||
l => s.push_str(&format!("<h{l}{style}>{t}</h{l}>\n")),
|
||||
@@ -711,7 +814,6 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
let mut media: Vec<(String, &Image)> = Vec::new();
|
||||
// Word needs a paragraph between adjacent tables and after the last one in the body.
|
||||
let mut after_table = false;
|
||||
let run = |b: &Block| run_xml(&b.text, b.size, b.bold, b.italic, b.color);
|
||||
let mut shape_id = 0usize;
|
||||
for (page_index, page) in pages.iter().enumerate() {
|
||||
let (pw, _) = word_page_size(page);
|
||||
@@ -722,13 +824,18 @@ pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
} else {
|
||||
for it in items(std::slice::from_ref(page)) {
|
||||
match &it {
|
||||
Item::Para(b, lvl) => {
|
||||
Item::Para(runs, lvl) => {
|
||||
let style = match lvl {
|
||||
1 => "<w:pPr><w:pStyle w:val=\"Heading1\"/></w:pPr>",
|
||||
2 => "<w:pPr><w:pStyle w:val=\"Heading2\"/></w:pPr>",
|
||||
_ => "",
|
||||
};
|
||||
body.push_str(&format!("<w:p>{style}{}</w:p>", run(b)));
|
||||
body.push_str("<w:p>");
|
||||
body.push_str(style);
|
||||
for r in runs {
|
||||
body.push_str(&run_xml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
||||
}
|
||||
body.push_str("</w:p>");
|
||||
}
|
||||
Item::Table(t) => {
|
||||
if after_table {
|
||||
@@ -873,7 +980,7 @@ pub fn rtf(pages: &[Page]) -> String {
|
||||
};
|
||||
for it in &items {
|
||||
match it {
|
||||
Item::Para(b, _) => note(b.color),
|
||||
Item::Para(runs, _) => runs.iter().for_each(|r| note(r.block.color)),
|
||||
Item::Table(t) => t.rows.iter().flatten().for_each(|c| note(c.color)),
|
||||
Item::Img(_) | Item::PageBreak => {}
|
||||
}
|
||||
@@ -894,16 +1001,32 @@ pub fn rtf(pages: &[Page]) -> String {
|
||||
}
|
||||
for it in items {
|
||||
match it {
|
||||
Item::Para(b, _) => {
|
||||
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
|
||||
if b.bold {
|
||||
fmt.push_str("\\b");
|
||||
Item::Para(runs, _) => {
|
||||
// The first run's formatting is the paragraph's; each later run is a group that
|
||||
// turns on or off what differs from it.
|
||||
let fmt = |b: &Block, later: bool| {
|
||||
let mut fmt = format!("\\fs{}", (b.size * 2.0).round() as i64);
|
||||
if b.bold {
|
||||
fmt.push_str("\\b");
|
||||
} else if later {
|
||||
fmt.push_str("\\b0");
|
||||
}
|
||||
if b.italic {
|
||||
fmt.push_str("\\i");
|
||||
} else if later {
|
||||
fmt.push_str("\\i0");
|
||||
}
|
||||
let colour = cf(b.color);
|
||||
fmt.push_str(if colour.is_empty() && later { "\\cf0" } else { &colour });
|
||||
fmt
|
||||
};
|
||||
let mut runs = runs.iter();
|
||||
let Some(first) = runs.next() else { continue };
|
||||
s.push_str(&format!("{{\\pard{} {}", fmt(first.block, false), rtf_text(&first.text)));
|
||||
for r in runs {
|
||||
s.push_str(&format!("{{{} {}}}", fmt(r.block, true), rtf_text(&r.text)));
|
||||
}
|
||||
if b.italic {
|
||||
fmt.push_str("\\i");
|
||||
}
|
||||
fmt.push_str(&cf(b.color));
|
||||
s.push_str(&format!("{{\\pard{fmt} {}\\par}}\n", rtf_text(&b.text)));
|
||||
s.push_str("\\par}\n");
|
||||
}
|
||||
Item::Table(t) => {
|
||||
let mut edges = t.cols.clone();
|
||||
@@ -957,6 +1080,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
})
|
||||
.collect();
|
||||
Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
|
||||
@@ -995,6 +1119,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -1098,6 +1223,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.8; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
p.images = page().images;
|
||||
let xml = part(&docx(&[p], "Invoice"), "word/document.xml");
|
||||
@@ -1184,6 +1310,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
let xml = part(&docx(&[p.clone()], "Receipt\u{1}"), "word/document.xml");
|
||||
assert!(xml.contains(">Total 4.50\tGBP</w:t>"), "{xml}");
|
||||
@@ -1237,6 +1364,7 @@ mod tests {
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
});
|
||||
let r = rtf(&[p]);
|
||||
assert!(r.starts_with("{\\rtf1") && r.ends_with('}'));
|
||||
@@ -1254,6 +1382,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
Page {
|
||||
width: 612.0,
|
||||
@@ -1277,6 +1406,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
],
|
||||
images: Vec::new(),
|
||||
@@ -1329,7 +1459,16 @@ mod tests {
|
||||
.map(|i| {
|
||||
let (row, col) = (i / 2, i / 2 + i % 2);
|
||||
let (x, y) = (10.0 + col as f64 * 20.0, 10_000.0 - row as f64 * 14.0);
|
||||
Block { text: "x".into(), rect: [x, y, x + 8.0, y + 12.0], size: 11.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
Block {
|
||||
text: "x".into(),
|
||||
rect: [x, y, x + 8.0, y + 12.0],
|
||||
size: 11.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let started = std::time::Instant::now();
|
||||
@@ -1356,6 +1495,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1370,7 +1510,16 @@ mod tests {
|
||||
}
|
||||
|
||||
fn block(t: &str, x0: f64, y0: f64, x1: f64, y1: f64) -> Block {
|
||||
Block { text: t.into(), rect: [x0, y0, x1, y1], size: 12.0, bold: false, italic: false, color: [0.0; 3], rotation: 0.0 }
|
||||
Block {
|
||||
text: t.into(),
|
||||
rect: [x0, y0, x1, y1],
|
||||
size: 12.0,
|
||||
bold: false,
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
}
|
||||
}
|
||||
|
||||
/// #2806: a sentence whose link is set in another colour or font runs on as separate blocks
|
||||
@@ -1400,6 +1549,8 @@ mod tests {
|
||||
};
|
||||
let h = html(std::slice::from_ref(&p), "Regulations");
|
||||
assert!(!h.contains("<table>"), "running text became a table: {h}");
|
||||
// The link is a run of its sentence, in its own colour (#4158).
|
||||
assert!(h.contains("<p>6.3. 2. All content shown at <span style=\"color:#0563C1\">http</span>"), "{h}");
|
||||
for b in &p.blocks {
|
||||
assert!(h.contains(&b.text), "{} was lost: {h}", b.text);
|
||||
}
|
||||
@@ -1487,6 +1638,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let p = Page {
|
||||
width: 612.0,
|
||||
@@ -1541,4 +1693,158 @@ mod tests {
|
||||
let rels = part(&zip, "word/_rels/document.xml.rels");
|
||||
assert_eq!(rels.matches("relationships/image\"").count(), MAX_MEDIA);
|
||||
}
|
||||
|
||||
/// The flowing paragraphs of a Word document (table cells left out), each as its runs' text
|
||||
/// and whether the run is italic.
|
||||
fn word_paragraphs(xml: &str) -> Vec<Vec<(String, bool)>> {
|
||||
let body = xml.split("<w:tbl>").map(|s| s.split_once("</w:tbl>").map_or(s, |(_, after)| after)).collect::<String>();
|
||||
body.split("<w:p>")
|
||||
.skip(1)
|
||||
.map(|p| {
|
||||
let p = p.split_once("</w:p>").map_or(p, |(inside, _)| inside);
|
||||
p.split("<w:r>")
|
||||
.skip(1)
|
||||
.filter_map(|r| {
|
||||
let text = r.split_once("<w:t xml:space=\"preserve\">")?.1.split_once("</w:t>")?.0;
|
||||
Some((text.to_string(), r.contains("<w:i/>")))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|runs| !runs.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn runs(r: &[(&str, bool)]) -> Vec<(String, bool)> {
|
||||
r.iter().map(|(t, i)| (t.to_string(), *i)).collect()
|
||||
}
|
||||
|
||||
/// "Before *Chevron* after." on one line: the roman text, the italic case name and the roman
|
||||
/// text after it, the way reading blocks split a sentence at its style changes (#4158). The
|
||||
/// italic face stands a little taller than the roman one around it.
|
||||
fn italic_sentence() -> Vec<Block> {
|
||||
vec![
|
||||
block("Before", 72.0, 700.0, 110.0, 712.0),
|
||||
Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.6) },
|
||||
block("after.", 161.0, 700.0, 190.0, 712.0),
|
||||
]
|
||||
}
|
||||
|
||||
/// #4158: a run in another style inside a sentence stays in that sentence's paragraph, as a
|
||||
/// run in its own style, in every Office format.
|
||||
#[test]
|
||||
fn an_italic_word_stays_in_its_sentence() {
|
||||
let mut blocks = italic_sentence();
|
||||
blocks.push(block("The next line is a paragraph of its own.", 72.0, 684.0, 300.0, 696.0));
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("The next line is a paragraph of its own.", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
let h = html(std::slice::from_ref(&p), "Opinion");
|
||||
assert!(h.contains("<p>Before <em>Chevron </em>after.</p>"), "{h}");
|
||||
let r = rtf(std::slice::from_ref(&p));
|
||||
assert!(r.contains("{\\pard\\fs24 Before {\\fs24\\b0\\i\\cf0 Chevron }{\\fs24\\b0\\i0\\cf0 after.}\\par}"), "{r}");
|
||||
// The longest line is the slide's title; the sentence is one paragraph of the body.
|
||||
let pxml = part(&pptx(std::slice::from_ref(&p), "Opinion"), "ppt/slides/slide1.xml");
|
||||
assert!(
|
||||
pxml.contains("<a:p><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>Before </a:t></a:r><a:r><a:rPr sz=\"1200\" i=\"1\"></a:rPr><a:t>Chevron </a:t></a:r><a:r><a:rPr sz=\"1200\"></a:rPr><a:t>after.</a:t></a:r></a:p>"),
|
||||
"{pxml}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #4158: the runs keep their order along the line whatever order the blocks come in, and the
|
||||
/// paragraph keeps its place on the page, though the italic run stands taller than the first.
|
||||
#[test]
|
||||
fn a_sentence_keeps_its_reading_order() {
|
||||
let mut blocks = vec![block("Then the next line.", 72.0, 684.0, 200.0, 696.0)];
|
||||
blocks.extend(italic_sentence().into_iter().rev());
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Opinion"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("Then the next line.", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The text of a block leaves out the spaces at its ends, and the box of a run often takes in
|
||||
/// its trailing space, so runs can touch. The source's own space still separates them; runs
|
||||
/// that touch with no space between them (a superscript) stay joined.
|
||||
#[test]
|
||||
fn runs_keep_the_spaces_the_source_had_between_them() {
|
||||
let blocks = vec![
|
||||
Block { space_around: [false, true], ..block("Before", 72.0, 700.0, 113.0, 712.0) },
|
||||
Block { italic: true, ..block("Chevron", 113.0, 700.0, 158.0, 712.0) },
|
||||
Block { space_around: [true, false], ..block("after.", 158.0, 700.0, 190.0, 712.0) },
|
||||
block("E = mc", 72.0, 660.0, 110.0, 672.0),
|
||||
Block { size: 8.0, ..block("2", 110.0, 665.0, 115.0, 673.0) },
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Spaces"), "word/document.xml");
|
||||
assert_eq!(
|
||||
word_paragraphs(&xml),
|
||||
vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)]), runs(&[("E = mc", false), ("2", false)])],
|
||||
"{xml}"
|
||||
);
|
||||
}
|
||||
/// Text turned another way (a label up the margin) is not a run of the line it touches.
|
||||
#[test]
|
||||
fn turned_text_is_not_a_run_of_the_line_beside_it() {
|
||||
let blocks = vec![block("Before", 72.0, 700.0, 110.0, 712.0), Block { rotation: 90.0, ..block("SIDE", 111.0, 690.0, 123.0, 720.0) }];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let h = html(std::slice::from_ref(&p), "Turned");
|
||||
assert!(h.contains("<p>Before</p>") && h.contains("<p>SIDE</p>"), "{h}");
|
||||
}
|
||||
/// A block that starts where the line above ends is the next line, not a run of it.
|
||||
#[test]
|
||||
fn a_block_on_the_next_line_starts_a_paragraph() {
|
||||
let blocks = vec![
|
||||
block("The first line ends here", 72.0, 700.0, 190.0, 712.0),
|
||||
Block { italic: true, ..block("and the next starts there.", 190.5, 690.0, 320.0, 702.0) },
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Lines"), "word/document.xml");
|
||||
assert_eq!(word_paragraphs(&xml), vec![runs(&[("The first line ends here", false)]), runs(&[("and the next starts there.", true)])], "{xml}");
|
||||
}
|
||||
|
||||
/// A superscript beside a table cell stays in the table: table detection consumes it before
|
||||
/// paragraphs are formed, and a sentence after the table still keeps its italic run.
|
||||
#[test]
|
||||
fn table_cells_are_not_runs_of_a_paragraph() {
|
||||
let mut blocks = vec![block("PLACE", 100.0, 700.0, 140.0, 712.0), block("PRIZE (euros)", 300.0, 700.0, 380.0, 712.0)];
|
||||
for (n, (row, sup)) in [("1", "st"), ("2", "nd"), ("3", "rd")].iter().enumerate() {
|
||||
let y = 680.0 - 20.0 * n as f64;
|
||||
blocks.push(block(row, 100.0, y, 106.0, y + 12.0));
|
||||
blocks.push(Block { size: 8.0, ..block(sup, 106.0, y + 5.0, 113.0, y + 13.0) });
|
||||
blocks.push(block(&format!("{},000", 30 - 5 * n), 300.0, y, 340.0, y + 12.0));
|
||||
}
|
||||
blocks.extend(italic_sentence().into_iter().map(|b| Block { rect: [b.rect[0], b.rect[1] - 200.0, b.rect[2], b.rect[3] - 200.0], ..b }));
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Prizes"), "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:tbl>").count(), 1, "{xml}");
|
||||
for cell in ["st", "nd", "rd", "30,000"] {
|
||||
assert_eq!(xml.matches(&format!("preserve\">{cell}</w:t>")).count(), 1, "{cell}: {xml}");
|
||||
}
|
||||
// The cells are all in the table: the only flowing paragraph is the sentence.
|
||||
assert_eq!(word_paragraphs(&xml), vec![runs(&[("Before ", false), ("Chevron ", true), ("after.", false)])], "{xml}");
|
||||
}
|
||||
|
||||
/// A bold lead-in followed by body text on its line is body text, not a heading; a heading
|
||||
/// with a smaller footnote marker after it stays a heading.
|
||||
#[test]
|
||||
fn a_paragraph_is_a_heading_only_when_its_text_is() {
|
||||
let blocks = vec![
|
||||
Block { size: 24.0, ..block("Annual Report", 72.0, 740.0, 250.0, 764.0) },
|
||||
Block { size: 14.0, ..block("1", 251.0, 752.0, 258.0, 766.0) },
|
||||
Block { bold: true, ..block("Summary", 72.0, 700.0, 125.0, 712.0) },
|
||||
block("the year closed with more members than it opened with, and fewer debts.", 128.0, 700.0, 540.0, 712.0),
|
||||
];
|
||||
let p = Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None };
|
||||
let xml = part(&docx(std::slice::from_ref(&p), "Report"), "word/document.xml");
|
||||
assert_eq!(xml.matches("<w:pStyle w:val=\"Heading1\"/>").count(), 1, "{xml}");
|
||||
assert!(!xml.contains("Heading2"), "the lead-in made its paragraph a heading: {xml}");
|
||||
assert_eq!(word_paragraphs(&xml).len(), 2, "{xml}");
|
||||
}
|
||||
}
|
||||
+26
-12
@@ -1,11 +1,12 @@
|
||||
//! PowerPoint presentation (.pptx, Office Open XML PresentationML): slides per page,
|
||||
//! largest text as slide title, remaining paragraphs as body text, images and tables between them.
|
||||
|
||||
use crate::{Block, Image, Page, Table, Zip, esc, hex};
|
||||
use crate::{Image, Page, Run, Table, Zip, esc, hex};
|
||||
|
||||
/// Items on a slide in reading order.
|
||||
enum SlideBodyItem<'a> {
|
||||
Para(&'a Block),
|
||||
/// A paragraph's runs (at least one).
|
||||
Para(Vec<Run<'a>>),
|
||||
Table(Table),
|
||||
Img(&'a Image),
|
||||
}
|
||||
@@ -136,11 +137,11 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
.map(|(i, _)| i);
|
||||
|
||||
let mut items: Vec<(f64, f64, SlideBodyItem)> = Vec::new();
|
||||
for (bi, b) in p.blocks.iter().enumerate() {
|
||||
if Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
items.push((b.rect[3], b.rect[0], SlideBodyItem::Para(b)));
|
||||
// The title is a shape of its own, so it joins no paragraph.
|
||||
let skip: Vec<bool> = (0..p.blocks.len()).map(|bi| Some(bi) == title_idx || consumed.get(bi).copied().unwrap_or(false)).collect();
|
||||
for runs in crate::paragraphs(&p.blocks, &skip) {
|
||||
let Some(first) = runs.first().map(|r| r.block) else { continue };
|
||||
items.push((first.rect[3], first.rect[0], SlideBodyItem::Para(runs)));
|
||||
}
|
||||
for t in tables {
|
||||
items.push((t.rect[3], t.rect[0], SlideBodyItem::Table(t)));
|
||||
@@ -173,11 +174,16 @@ pub fn pptx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
SlideBodyItem::Para(_) => {
|
||||
let mut paras_xml = String::new();
|
||||
let mut est_h_pt = 0.0;
|
||||
while let Some((_, _, SlideBodyItem::Para(b))) = items.get(i) {
|
||||
let run = run_drawingml(&b.text, b.size, b.bold, b.italic, b.color);
|
||||
paras_xml.push_str(&format!("<a:p>{run}</a:p>"));
|
||||
let lines = (b.text.len() as f64 / 60.0).ceil().max(1.0);
|
||||
est_h_pt += (b.size.max(10.0) * 1.3) * lines + 6.0;
|
||||
while let Some((_, _, SlideBodyItem::Para(runs))) = items.get(i) {
|
||||
paras_xml.push_str("<a:p>");
|
||||
for r in runs {
|
||||
paras_xml.push_str(&run_drawingml(&r.text, r.block.size, r.block.bold, r.block.italic, r.block.color));
|
||||
}
|
||||
paras_xml.push_str("</a:p>");
|
||||
let len = runs.iter().map(|r| r.text.len()).fold(0usize, usize::saturating_add);
|
||||
let size = runs.iter().map(|r| r.block.size).fold(10.0, f64::max);
|
||||
let lines = (len as f64 / 60.0).ceil().max(1.0);
|
||||
est_h_pt += (size * 1.3) * lines + 6.0;
|
||||
i += 1;
|
||||
}
|
||||
let box_h = height_emu(est_h_pt);
|
||||
@@ -354,6 +360,7 @@ xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" type=\"blank\"
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Block;
|
||||
use crate::tests::part;
|
||||
|
||||
fn sample_page() -> Page {
|
||||
@@ -369,6 +376,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.1, 0.2, 0.3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
Block {
|
||||
text: "Revenue grew 25% year over year across all regions.".into(),
|
||||
@@ -378,6 +386,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
Block {
|
||||
text: "Operational expenses remained flat.".into(),
|
||||
@@ -387,6 +396,7 @@ mod tests {
|
||||
italic: true,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
],
|
||||
images: vec![Image {
|
||||
@@ -510,6 +520,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let table_page = Page {
|
||||
width: 612.0,
|
||||
@@ -611,6 +622,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let mut blocks = vec![huge("Title", f64::MAX, 500.0)];
|
||||
for k in 0..40 {
|
||||
@@ -682,6 +694,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
};
|
||||
let p = Page {
|
||||
width: 720.0,
|
||||
@@ -696,6 +709,7 @@ mod tests {
|
||||
italic: false,
|
||||
color: [0.0; 3],
|
||||
rotation: 0.0,
|
||||
space_around: [false; 2],
|
||||
},
|
||||
// Table where one cell has a very large number (e.g. 36 pt callout in the table)
|
||||
cell("Metric", 72.0, 400.0, 100.0, 12.0),
|
||||
|
||||
+94
-17
@@ -2,7 +2,9 @@
|
||||
//! The recipe is craft-fonts' `docs/integration.md`; unset, `CRAFT_FONTS` is empty. It only reads
|
||||
//! the local checkout (no network). On wasm32 it embeds only the UI faces (BIZ UDPGothic Regular
|
||||
//! and any `Arab`, `Telu`, `Hebr`, `Thai` or `Deva` face), to keep the web build within hosting
|
||||
//! limits (Cloudflare Pages: 25 MiB per file).
|
||||
//! limits (Cloudflare Pages: 25 MiB per file). Native builds also read the optional
|
||||
//! `fonts/simplified-chinese-manifest.txt` (LXGW WenKai Lite); it is never embedded on wasm.
|
||||
//! `CRAFT_FONTS_REQUIRED` makes both the checkout and that native manifest required.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
@@ -10,18 +12,21 @@ use std::path::PathBuf;
|
||||
#[path = "src/cjk_scripts.rs"]
|
||||
mod cjk_scripts;
|
||||
|
||||
/// Build-script entry point: embed craft-fonts from `CRAFT_FONTS_DIR`, make a missing or invalid input fatal
|
||||
/// when `CRAFT_FONTS_REQUIRED` is set, and write `craft_fonts.rs` to `OUT_DIR`.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
match craft_fonts(&dir, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) if required => println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
} else if required {
|
||||
println!("cargo::error=CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing");
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
@@ -30,26 +35,66 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
/// The main manifest, plus the native-only Simplified Chinese manifest. The latter is optional
|
||||
/// for ordinary builds and required when `CRAFT_FONTS_REQUIRED` is set, so a required build can
|
||||
/// never pass the Chinese UI tests only by skipping them.
|
||||
fn craft_fonts(dir: &std::path::Path, required: bool) -> Result<String, String> {
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = manifest(dir, &dir.join("fonts/manifest.txt"), wasm, false)?;
|
||||
if !wasm {
|
||||
let chinese = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", chinese.display());
|
||||
match chinese.try_exists() {
|
||||
Ok(false) if required => {
|
||||
return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display()));
|
||||
}
|
||||
Ok(false) => {}
|
||||
Ok(true) => match manifest(dir, &chinese, false, required) {
|
||||
Ok(entries) => out.push_str(&entries),
|
||||
// A broken optional addition must never discard the main Japanese/symbol/RTL faces.
|
||||
Err(e) if required => return Err(e),
|
||||
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"),
|
||||
},
|
||||
Err(e) if required => return Err(format!("{}: {e}", chinese.display())),
|
||||
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display()),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
///
|
||||
/// `strict_cap` is set for the required native Simplified Chinese manifest: a face that does not
|
||||
/// fit its cap must fail the build rather than be silently left out, so a required build cannot
|
||||
/// pass without the Chinese face it promises.
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool, strict_cap: bool) -> Result<String, String> {
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let mut out = String::new();
|
||||
// A required native Chinese manifest must add the Hans face itself, not merely some other
|
||||
// CJK face that happens to fit: otherwise CRAFT_FONTS_REQUIRED would pass while the promised
|
||||
// Simplified Chinese fallback is still absent.
|
||||
let mut has_hans = false;
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// Whole CJK faces aren't embedded for size; interface CJK comes from cut faces once the app
|
||||
// moves to craft-fonts-build (storytold/craft-libs#213).
|
||||
// Keep the upstream general CJK size cap. The separately pinned native Chinese UI
|
||||
// face has its own bound; web builds retain the smaller general limit. A required
|
||||
// Chinese face over its cap fails the build instead of being silently omitted.
|
||||
let cjk = scripts.split(',').any(cjk_scripts::is_cjk_script);
|
||||
if cjk
|
||||
&& !cjk_scripts::WHOLE_CJK_FAMILIES.contains(family)
|
||||
&& std::fs::metadata(dir.join(file)).is_ok_and(|m| m.len() > cjk_scripts::MAX_OTHER_CJK_BYTES)
|
||||
{
|
||||
continue;
|
||||
if cjk && let Ok(metadata) = std::fs::metadata(dir.join(file)) {
|
||||
match cjk_scripts::cjk_size_decision(family, metadata.len(), wasm, strict_cap) {
|
||||
cjk_scripts::CjkSizeDecision::Embed => {}
|
||||
cjk_scripts::CjkSizeDecision::Skip => continue,
|
||||
cjk_scripts::CjkSizeDecision::Reject => {
|
||||
let cap = cjk_scripts::cjk_cap_bytes(family, wasm);
|
||||
return Err(format!(
|
||||
"{file}: the {family} face is larger than the {cap}-byte native Chinese UI cap; CRAFT_FONTS_REQUIRED cannot embed it"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Arabic interface faces are small, so the web build keeps them too.
|
||||
let arabic = scripts.split(',').any(|s| s.trim() == "Arab");
|
||||
@@ -68,6 +113,7 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
if wasm && !web_face {
|
||||
continue;
|
||||
}
|
||||
has_hans |= cjk_scripts::has_script_tag(scripts, "Hans");
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
@@ -78,5 +124,36 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
if strict_cap && !has_hans {
|
||||
return Err(format!(
|
||||
"{}: CRAFT_FONTS_REQUIRED is set but the manifest emits no Hans face (a Simplified Chinese face is required)",
|
||||
manifest.display()
|
||||
));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manifest_tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test: a required native-font manifest must declare at least one Hans face, while optional
|
||||
/// input accepts another script and a missing asset still fails.
|
||||
#[test]
|
||||
fn required_native_manifest_rejects_non_hans_faces_after_parsing() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-required-hans-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("face.ttf"), b"synthetic manifest fixture").unwrap();
|
||||
let path = root.join("manifest.txt");
|
||||
for scripts in ["Hant,Latn", "Hans-HK,Latn", "Latn"] {
|
||||
std::fs::write(&path, format!("Fixture | Regular | face.ttf | {scripts}\n")).unwrap();
|
||||
assert!(manifest(&root, &path, false, true).unwrap_err().contains("no Hans face"), "{scripts}");
|
||||
assert!(manifest(&root, &path, false, false).unwrap().contains("CraftFont {"), "optional input stays usable");
|
||||
}
|
||||
std::fs::write(&path, "Fixture | Regular | face.ttf | Latn, Hans\n").unwrap();
|
||||
assert!(manifest(&root, &path, false, true).unwrap().contains("CraftFont {"));
|
||||
std::fs::remove_file(root.join("face.ttf")).unwrap();
|
||||
assert!(manifest(&root, &path, false, true).is_err(), "a missing Hans asset cannot satisfy required input");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@ pub const WHOLE_CJK_FAMILIES: [&str; 3] = ["Shippori Mincho", "BIZ UDPGothic", "
|
||||
/// Any other CJK face over this size is left out.
|
||||
pub const MAX_OTHER_CJK_BYTES: u64 = 4 << 20;
|
||||
|
||||
/// Reviewed native-only UI fallback from the separate Chinese manifest.
|
||||
/// It is never included in the browser payload. Other CJK families keep the 4 MiB cap.
|
||||
pub const NATIVE_UI_CJK_FAMILIES: [&str; 1] = ["LXGW WenKai Lite"];
|
||||
pub const MAX_NATIVE_UI_CJK_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// ISO 15924 codes of the Han, Japanese and Korean scripts, Hangul on its own included.
|
||||
const CJK_SCRIPTS: [&str; 6] = ["Hani", "Hans", "Hant", "Jpan", "Kore", "Hang"];
|
||||
|
||||
@@ -17,3 +22,76 @@ pub fn is_cjk_script(tag: &str) -> bool {
|
||||
let code = tag.trim().split('-').next().unwrap_or_default();
|
||||
CJK_SCRIPTS.contains(&code)
|
||||
}
|
||||
|
||||
/// Whether a manifest's comma-separated script list contains `script` exactly, ignoring spaces.
|
||||
/// The interface and document lookups use exact tags (`CraftFont::covers`), so a regional tag
|
||||
/// such as `Hans-HK` is not a `Hans` entry.
|
||||
pub fn has_script_tag(scripts: &str, script: &str) -> bool {
|
||||
scripts.split(',').any(|tag| tag.trim() == script)
|
||||
}
|
||||
|
||||
/// What `build.rs` does with one CJK face from a manifest.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum CjkSizeDecision {
|
||||
/// The face fits its cap (or is a Japanese family embedded whole).
|
||||
Embed,
|
||||
/// An optional face over its cap: leave it out and keep the rest.
|
||||
Skip,
|
||||
/// A required face over its cap: fail the build rather than silently omit it.
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// The size cap that applies to `family` in this build.
|
||||
pub fn cjk_cap_bytes(family: &str, wasm: bool) -> u64 {
|
||||
if !wasm && NATIVE_UI_CJK_FAMILIES.contains(&family) { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES }
|
||||
}
|
||||
|
||||
/// Decide whether a CJK face is embedded, skipped, or rejected.
|
||||
///
|
||||
/// The Japanese document/UI families are embedded whole. Every other CJK family uses its cap;
|
||||
/// `required` marks the native Simplified Chinese manifest of a `CRAFT_FONTS_REQUIRED` build,
|
||||
/// where an over-cap face must fail instead of quietly disappearing.
|
||||
pub fn cjk_size_decision(family: &str, bytes: u64, wasm: bool, required: bool) -> CjkSizeDecision {
|
||||
if WHOLE_CJK_FAMILIES.contains(&family) {
|
||||
return CjkSizeDecision::Embed;
|
||||
}
|
||||
if bytes <= cjk_cap_bytes(family, wasm) {
|
||||
CjkSizeDecision::Embed
|
||||
} else if required {
|
||||
CjkSizeDecision::Reject
|
||||
} else {
|
||||
CjkSizeDecision::Skip
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Check that a required Chinese manifest needs an exact `Hans` script tag and does not accept
|
||||
/// Traditional or region-suffixed tags.
|
||||
#[test]
|
||||
fn a_required_chinese_manifest_needs_an_exact_hans_entry() {
|
||||
assert!(has_script_tag("Hans,Latn", "Hans"));
|
||||
assert!(has_script_tag(" Latn , Hans ", "Hans"));
|
||||
assert!(!has_script_tag("Hant,Latn", "Hans"), "Traditional Chinese is not the required Simplified face");
|
||||
assert!(!has_script_tag("Hans-HK,Latn", "Hans"), "the UI looks for the exact Hans tag");
|
||||
assert!(!has_script_tag("Hani,Jpan,Kore", "Hans"));
|
||||
}
|
||||
|
||||
/// Check that a native Chinese face over its cap is rejected when required, skipped when optional, and
|
||||
/// uses the web cap on the web.
|
||||
#[test]
|
||||
fn a_required_native_chinese_face_over_its_cap_is_rejected_not_skipped() {
|
||||
let at_cap = MAX_NATIVE_UI_CJK_BYTES;
|
||||
assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap, false, true), CjkSizeDecision::Embed);
|
||||
assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, true), CjkSizeDecision::Reject);
|
||||
assert_eq!(cjk_size_decision("LXGW WenKai Lite", at_cap + 1, false, false), CjkSizeDecision::Skip);
|
||||
// The web build keeps the smaller general cap even for the native Chinese family.
|
||||
assert_eq!(cjk_size_decision("LXGW WenKai Lite", MAX_OTHER_CJK_BYTES + 1, true, true), CjkSizeDecision::Reject);
|
||||
// Japanese document/UI families are embedded whole.
|
||||
assert_eq!(cjk_size_decision("BIZ UDPGothic", u64::MAX, false, true), CjkSizeDecision::Embed);
|
||||
assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", false), MAX_NATIVE_UI_CJK_BYTES);
|
||||
assert_eq!(cjk_cap_bytes("LXGW WenKai Lite", true), MAX_OTHER_CJK_BYTES);
|
||||
}
|
||||
}
|
||||
@@ -315,12 +315,13 @@ mod tests {
|
||||
}
|
||||
|
||||
/// `build.rs` embeds the Japanese CJK faces whole and any other CJK face only up to
|
||||
/// `MAX_OTHER_CJK_BYTES`, so a checkout with a large Chinese or Korean face (craft-fonts has the
|
||||
/// `MAX_OTHER_CJK_BYTES` (the explicitly pinned native-only LXGW UI fallback has a separate
|
||||
/// 16 MiB cap), so a checkout with a large Chinese or Korean face (craft-fonts has the
|
||||
/// 16.4 MB Noto Sans CJK SC) leaves it out. The four Japanese faces of the pinned craft-fonts
|
||||
/// are 24.1 MB.
|
||||
#[test]
|
||||
fn cjk_faces_follow_the_size_rule() {
|
||||
use crate::cjk_scripts::{MAX_OTHER_CJK_BYTES, WHOLE_CJK_FAMILIES, is_cjk_script};
|
||||
use crate::cjk_scripts::{MAX_NATIVE_UI_CJK_BYTES, MAX_OTHER_CJK_BYTES, NATIVE_UI_CJK_FAMILIES, WHOLE_CJK_FAMILIES, is_cjk_script};
|
||||
// A tag can carry a region after the code: craft-fonts tags Noto Sans HK UI `Hant-HK`.
|
||||
assert!(["Hant-HK", "Hans", "Jpan", "Hang"].iter().all(|s| is_cjk_script(s)));
|
||||
assert!(!["Latn", "Arab", "Hebr"].iter().any(|s| is_cjk_script(s)));
|
||||
@@ -328,7 +329,10 @@ mod tests {
|
||||
let (whole, other): (Vec<_>, Vec<_>) = cjk.partition(|f| WHOLE_CJK_FAMILIES.contains(&f.family));
|
||||
for face in &other {
|
||||
let len = face.bytes.len();
|
||||
assert!(u64::try_from(len).is_ok_and(|n| n <= MAX_OTHER_CJK_BYTES), "{} is embedded at {len} bytes", face.name());
|
||||
let native_ui = NATIVE_UI_CJK_FAMILIES.contains(&face.family);
|
||||
assert!(!cfg!(target_arch = "wasm32") || !native_ui, "native UI face in web payload");
|
||||
let cap = if native_ui { MAX_NATIVE_UI_CJK_BYTES } else { MAX_OTHER_CJK_BYTES };
|
||||
assert!(u64::try_from(len).is_ok_and(|n| n <= cap), "{} is embedded at {len} bytes", face.name());
|
||||
}
|
||||
let bytes: usize = whole.iter().map(|f| f.bytes.len()).sum();
|
||||
assert!(bytes < 25_000_000, "{bytes} bytes of Japanese faces");
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
//! Required manifest checks execute the same parser as the production build script.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../build.rs"]
|
||||
mod build_script;
|
||||
@@ -14,6 +14,8 @@ set_value(&mut doc, "name", &FieldValue::Text("Ada".into()))?;
|
||||
set_value(&mut doc, "agree", &FieldValue::Check(true))?;
|
||||
set_value(&mut doc, "size", &FieldValue::Radio(Some("L".into())))?;
|
||||
set_value(&mut doc, "toppings", &FieldValue::Choice(vec!["Ham".into()]))?;
|
||||
// Many values: as set_value one by one, but reading the form once and recalculating once.
|
||||
let results: Vec<Result<(), FormError>> = set_values(&mut doc, &[("name".into(), FieldValue::Text("Ada".into()))])?;
|
||||
reset(&mut doc, None)?; // Clear form (back to /DV)
|
||||
|
||||
// Prepare a form
|
||||
@@ -58,7 +60,9 @@ let cells: Vec<table::Cell> = grid.cells_for(names)?; // "Table1_1", "Table
|
||||
drawing while the objects it was drawn from are the same (`FieldAppearanceCache`), so typing in
|
||||
one field redraws that field, not the form. Check boxes and radio buttons with no appearance for
|
||||
their state are drawn from `/MK` (border, background, and when on the `/MK /CA` ZapfDingbats
|
||||
symbol in the `/DA` colour), and push buttons with none from `/MK`, `/BS` and their caption.
|
||||
symbol in the `/DA` colour), and with `/NeedAppearances` so is every one that has an `/MK`,
|
||||
over the appearance the file carries; push buttons with none are drawn from `/MK`, `/BS` and
|
||||
their caption.
|
||||
- Check boxes and radio buttons switch `/V` and each widget's `/AS`. Widgets with no appearances
|
||||
get PdfCraft's own (a drawn check mark or dot; no symbol font needed).
|
||||
- Imported `/MaxLen 0` is treated as unlimited (also clearing an inherited limit), while the
|
||||
|
||||
+12
-1
@@ -844,7 +844,10 @@ fn group(int: &str, sep: char) -> String {
|
||||
fn number_text(v: f64, decimals: u8, sep: u8) -> String {
|
||||
// Round half away from zero, as JavaScript's toFixed does for these values.
|
||||
let p = 10f64.powi(i32::from(decimals));
|
||||
let s = format!("{:.*}", decimals as usize, (v.abs() * p).round() / p);
|
||||
let scaled = v.abs() * p;
|
||||
// Past 2^53 every value is already whole, and scaling could overflow to infinity.
|
||||
let r = if scaled.is_finite() { scaled.round() / p } else { v.abs() };
|
||||
let s = format!("{:.*}", decimals as usize, r);
|
||||
let (int, frac) = s.split_once('.').map_or((s.as_str(), ""), |(a, b)| (a, b));
|
||||
let (g, d) = match sep {
|
||||
1 => (None, '.'),
|
||||
@@ -2060,4 +2063,12 @@ mod undelimited_tests {
|
||||
assert_eq!(parse_date("99999999999999999999", "yyyymmdd"), None);
|
||||
assert_eq!(parse_date("2026 99999999999", "yyyy"), None, "an unreadable number is left over, not ignored");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_finite_numbers_do_not_print_inf() {
|
||||
let n = format_value(&Format::Number { decimals: 2, sep: 1, neg: 0, currency: String::new(), prepend: true }, "1e307");
|
||||
assert!(n.len() > 300 && n.ends_with(".00") && !n.contains("inf"), "got {n}");
|
||||
let p = format_value(&Format::Percent { decimals: 2, sep: 1 }, "1e306");
|
||||
assert!(!p.contains("inf"), "got {p}");
|
||||
}
|
||||
}
|
||||
@@ -480,7 +480,7 @@ fn frame(doc: &Document, wd: &Dict, w: f64, h: f64) -> (String, f64) {
|
||||
|
||||
/// Whether `wd` is a check box or radio button PdfCraft laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`, written by the XFA layout).
|
||||
fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
pub(crate) fn is_round(doc: &Document, wd: &Dict) -> bool {
|
||||
matches!(wd.get(b"PCXfaRound").map(|o| doc.resolve(o)).as_deref(), Some(Object::Bool(true)))
|
||||
}
|
||||
|
||||
@@ -1029,9 +1029,10 @@ fn check_mark(style: crate::author::CheckStyle, cx: f64, cy: f64, s: f64, color:
|
||||
}
|
||||
|
||||
/// How a check box or radio button widget `w` of field `f` looks in state on (`on`) or Off when
|
||||
/// the file has no appearance for that state: its `/MK` background and border ([`frame`]), and
|
||||
/// when on, its caption `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes
|
||||
/// and radio buttons may use too) as a ZapfDingbats character, as in the check box example of
|
||||
/// the file has no appearance for that state, or leaves its appearances to the viewer
|
||||
/// (`/NeedAppearances`): its `/MK` background and border ([`frame`]), and when on, its caption
|
||||
/// `/MK /CA` (ISO 32000-2 Table 192: the normal caption, which check boxes and radio buttons may
|
||||
/// use too) as a ZapfDingbats character, as in the check box example of
|
||||
/// 12.7.5.2.3: `4` (a check mark) by default, `l` (a dot) for a radio button. The character is
|
||||
/// drawn in the `/DA` colour, at the `/DA` size or, for an automatic size (`0 Tf`), filling most
|
||||
/// of the box. ZapfDingbats is one of the standard 14 fonts, so no font program is needed.
|
||||
|
||||
@@ -963,6 +963,7 @@ pub fn add_field(doc: &mut Document, page: usize, rect: [f64; 4], kind: &NewFiel
|
||||
rotation: 0,
|
||||
caption: None,
|
||||
list: None,
|
||||
round: true,
|
||||
};
|
||||
let ap = appearance::check_box_states(doc, &w, FieldKind::Radio, export, "/ZaDb 0 Tf 0 g");
|
||||
doc.update_dict(widget, |d| d.set(b"AP".to_vec(), Object::Dict(ap)))?;
|
||||
|
||||
+126
-14
@@ -118,6 +118,9 @@ pub struct Widget {
|
||||
pub caption: Option<String>,
|
||||
/// List boxes: where the options are drawn, so a click can pick the one under the pointer.
|
||||
pub list: Option<appearance::ListRows>,
|
||||
/// Drawn round: a radio button, or a check box laid out for a round XFA check button
|
||||
/// (`/PCXfaRound true`).
|
||||
pub round: bool,
|
||||
}
|
||||
|
||||
/// A terminal form field.
|
||||
@@ -570,7 +573,21 @@ pub fn fields_with_adopted(doc: &Document) -> Fields {
|
||||
Fields { items, adopted_page_fields }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Whole-form readings ([`enumerate`]) on this thread.
|
||||
static FORM_READS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times this thread has read the whole form.
|
||||
#[cfg(test)]
|
||||
fn form_reads() -> usize {
|
||||
FORM_READS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
fn enumerate(doc: &Document) -> (Vec<Field>, usize) {
|
||||
#[cfg(test)]
|
||||
FORM_READS.with(|n| n.set(n.get() + 1));
|
||||
let af = acroform(doc);
|
||||
let mut page_of = std::collections::HashMap::new();
|
||||
let mut annot_index = std::collections::HashMap::new();
|
||||
@@ -882,6 +899,7 @@ fn walk(
|
||||
})
|
||||
.flatten(),
|
||||
list: (kind == FieldKind::List).then(|| appearance::list_rows_of(doc, inh.da.as_deref().unwrap_or("/Helv 0 Tf 0 g"), Some(d), wd)),
|
||||
round: kind == FieldKind::Radio || (kind == FieldKind::CheckBox && appearance::is_round(doc, wd)),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -931,23 +949,93 @@ pub fn set_value_with(doc: &mut Document, name: &str, value: &FieldValue, script
|
||||
if all.is_empty() {
|
||||
return Err(FormError::NoForm);
|
||||
}
|
||||
let f = all.iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let named: Vec<usize> = all.iter().enumerate().filter(|(_, f)| f.name == name).map(|(i, _)| i).collect();
|
||||
fill(doc, &all, &named, name, value, scripts)?;
|
||||
recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fill several fields, as [`set_value`] fills each in turn, but reading the form once and
|
||||
/// recalculating once, after the last value. Filling fields one by one reads the whole form twice
|
||||
/// per field, which takes minutes on a form with thousands of fields.
|
||||
///
|
||||
/// Returns each value's result, in order. The outer `Err` is the recalculation failing, after
|
||||
/// which, as with [`set_value`], no value counts as filled (though each was written).
|
||||
pub fn set_values(doc: &mut Document, values: &[(String, FieldValue)]) -> Result<Vec<Result<(), FormError>>, FormError> {
|
||||
let mut scripts = NoScripts;
|
||||
let mut form = NamedFields::read(doc);
|
||||
// The field objects this batch wrote since the form was last read.
|
||||
let mut written = std::collections::HashSet::new();
|
||||
let mut results = Vec::with_capacity(values.len());
|
||||
let mut filled = false;
|
||||
for (name, value) in values {
|
||||
if form.all.is_empty() {
|
||||
results.push(Err(FormError::NoForm));
|
||||
continue;
|
||||
}
|
||||
// A field written earlier in the batch (named twice, or a twin's name) is read again,
|
||||
// so this write starts from what the earlier one left, as it would one by one.
|
||||
if form.objs(name).any(|o| written.contains(&o)) {
|
||||
form = NamedFields::read(doc);
|
||||
written.clear();
|
||||
}
|
||||
let named = form.named(name);
|
||||
let result = fill(doc, &form.all, named, name, value, &mut scripts);
|
||||
written.extend(form.objs(name));
|
||||
filled |= result.is_ok();
|
||||
results.push(result);
|
||||
}
|
||||
if filled {
|
||||
recalculate_with(doc, &mut scripts)?;
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// The form's fields, with the positions of each fully qualified name in them (in field order).
|
||||
struct NamedFields {
|
||||
all: Vec<Field>,
|
||||
by_name: std::collections::HashMap<String, Vec<usize>>,
|
||||
}
|
||||
|
||||
impl NamedFields {
|
||||
fn read(doc: &Document) -> Self {
|
||||
let all = fields(doc);
|
||||
let mut by_name: std::collections::HashMap<String, Vec<usize>> = std::collections::HashMap::new();
|
||||
for (i, f) in all.iter().enumerate() {
|
||||
by_name.entry(f.name.clone()).or_default().push(i);
|
||||
}
|
||||
NamedFields { all, by_name }
|
||||
}
|
||||
|
||||
fn named(&self, name: &str) -> &[usize] {
|
||||
self.by_name.get(name).map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// The field objects named `name`.
|
||||
fn objs<'a>(&'a self, name: &str) -> impl Iterator<Item = ObjRef> + 'a {
|
||||
self.named(name).iter().filter_map(|&i| self.all.get(i)).map(|f| f.obj)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `value` into the field `name`, without recalculating: `named` are the positions in
|
||||
/// `all` (the form as read before this write) of the fields with that name, in field order.
|
||||
fn fill(doc: &mut Document, all: &[Field], named: &[usize], name: &str, value: &FieldValue, scripts: &mut dyn Scripts) -> Result<(), FormError> {
|
||||
let f = named.first().and_then(|&i| all.get(i)).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
if f.read_only() {
|
||||
return Err(FormError::ReadOnly(name.into()));
|
||||
}
|
||||
write_value(doc, f, value, scripts)?;
|
||||
author::form_fonts_as_objects(doc, &all)?;
|
||||
author::form_fonts_as_objects(doc, all)?;
|
||||
// Fields with the same fully qualified name are one field (§12.7.3.1). Writers that repeat a
|
||||
// name as separate field dictionaries, instead of widgets under one parent, get the value
|
||||
// the first one took after its scripts, without running them again.
|
||||
let twins: Vec<&Field> = all.iter().filter(|t| t.name == name && t.obj != f.obj && t.kind == f.kind).collect();
|
||||
let twins: Vec<&Field> = named.iter().filter_map(|&i| all.get(i)).filter(|t| t.obj != f.obj && t.kind == f.kind).collect();
|
||||
if !twins.is_empty() {
|
||||
let taken = fields(doc).into_iter().find(|x| x.obj == f.obj).map(|x| x.value).unwrap_or_default();
|
||||
for t in twins {
|
||||
share_value(doc, f.obj, t, &taken, scripts)?;
|
||||
}
|
||||
}
|
||||
recalculate_with(doc, scripts)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1253,6 +1341,7 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
[one] if matches!(f.kind, FieldKind::Text | FieldKind::Combo) => scripting::formatted(doc, f, one, scripts),
|
||||
_ => None,
|
||||
};
|
||||
let in_place = f.kind == FieldKind::Text && f.has(flags::PASSWORD);
|
||||
let mut undrawn = false;
|
||||
for w in &f.widgets {
|
||||
let (stream, missing) = match &shown {
|
||||
@@ -1260,7 +1349,23 @@ fn redraw(doc: &mut Document, f: &Field, values: &[String], scripts: &mut dyn Sc
|
||||
None => appearance::field_appearance_drawn(doc, f, w, values, true),
|
||||
};
|
||||
undrawn |= missing.is_some();
|
||||
let ap = doc.add(Object::Stream(stream));
|
||||
let existing_ap = in_place
|
||||
.then(|| {
|
||||
doc.get(w.obj)
|
||||
.as_dict()
|
||||
.and_then(|d| d.get(b"AP").map(|a| doc.resolve(a)))
|
||||
.and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()))
|
||||
.and_then(|n| n.as_ref())
|
||||
.filter(|r| doc.is_edited(r.num) && matches!(&*doc.get(*r), Object::Stream(_)))
|
||||
})
|
||||
.flatten();
|
||||
let ap = match existing_ap {
|
||||
Some(r) => {
|
||||
doc.set(r, Object::Stream(stream));
|
||||
r
|
||||
}
|
||||
None => doc.add(Object::Stream(stream)),
|
||||
};
|
||||
let mut fresh = Dict::new();
|
||||
fresh.set(b"N".to_vec(), Object::Ref(ap));
|
||||
let apd = merged_appearance(doc, w.obj, &fresh);
|
||||
@@ -1315,7 +1420,7 @@ fn password_fields(doc: &Document) -> Vec<Field> {
|
||||
/// again only makes the answer cautious. It reads only `/Ff`, `/Kids` and `/Parent`, so a save
|
||||
/// or recovery snapshot of a form without such a field doesn't build every [`Field`]. Like
|
||||
/// [`fields`], it looks at the form's field tree and at the widgets on the pages, which may be
|
||||
/// missing from `/Fields`. Each dictionary is visited once and at most 32 levels deep.
|
||||
/// missing from `/Fields`. Each dictionary is visited once and at most 64 levels deep.
|
||||
fn has_password_flag(doc: &Document) -> bool {
|
||||
let sets_it = |r: ObjRef| doc.get(r).as_dict().and_then(|d| field_flags(doc, d)).is_some_and(|ff| ff & flags::PASSWORD != 0);
|
||||
let top = acroform(doc).and_then(|af| af.get(b"Fields").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())).unwrap_or_default();
|
||||
@@ -1323,7 +1428,7 @@ fn has_password_flag(doc: &Document) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
while let Some((o, depth)) = stack.pop() {
|
||||
let Object::Ref(r) = o else { continue };
|
||||
if depth > 32 || !seen.insert(r) {
|
||||
if depth > 64 || !seen.insert(r) {
|
||||
continue;
|
||||
}
|
||||
if sets_it(r) {
|
||||
@@ -1363,11 +1468,11 @@ pub fn restore_password_values(doc: &mut Document, values: &[(String, String)])
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 32 deep and without repeats.
|
||||
/// `r` and its field ancestors (`/Parent`), nearest first, at most 64 deep and without repeats.
|
||||
fn field_chain(doc: &Document, r: ObjRef) -> Vec<ObjRef> {
|
||||
let mut chain = vec![r];
|
||||
let mut cur = r;
|
||||
while chain.len() < 32 {
|
||||
while chain.len() < 64 {
|
||||
let Some(Object::Ref(parent)) = doc.get(cur).as_dict().and_then(|d| d.get(b"Parent").cloned()) else { break };
|
||||
if chain.contains(&parent) {
|
||||
break;
|
||||
@@ -1448,8 +1553,9 @@ pub fn need_appearances(doc: &Document) -> bool {
|
||||
/// writer left the appearances to the viewer), and otherwise each widget without a normal
|
||||
/// appearance (`/AP /N`), as typing the value would draw it (#3470). Check boxes and radio buttons
|
||||
/// with no normal appearance for their state (`/AS`) are drawn from their `/MK` background, border
|
||||
/// and caption, and push buttons with none from their `/MK` and `/BS` (Table 192). `doc` is not
|
||||
/// changed: saving writes the file as it was.
|
||||
/// and caption, and with `/NeedAppearances` every one that has an `/MK` is, over its own
|
||||
/// appearance; push buttons with none are drawn from their `/MK` and `/BS` (Table 192). `doc` is
|
||||
/// not changed: saving writes the file as it was.
|
||||
pub fn with_field_appearances(doc: &Document) -> Option<Document> {
|
||||
with_field_appearances_cached(doc, &mut FieldAppearanceCache::default())
|
||||
}
|
||||
@@ -1467,19 +1573,25 @@ pub fn with_field_appearances_cached(doc: &Document, cache: &mut FieldAppearance
|
||||
.and_then(|d| d.get(b"AP"))
|
||||
.and_then(|ap| doc.resolve(ap).as_dict().and_then(|ap| ap.get(b"N").map(|n| doc.resolve(n))));
|
||||
// A check box or radio button shows the appearance of its state (12.7.5.2.3): `/AS`,
|
||||
// else a check box's value, else Off. An Off state may go without one (it shows
|
||||
// nothing); an on state needs one.
|
||||
// else the value when it names this widget's on state (any check box value when the
|
||||
// widget names none), else Off. An Off state may go without one (it shows nothing);
|
||||
// an on state needs one.
|
||||
let state = match f.kind {
|
||||
FieldKind::CheckBox | FieldKind::Radio => {
|
||||
let value = f.value.first().filter(|v| f.kind == FieldKind::CheckBox && !v.is_empty() && v.as_str() != "Off");
|
||||
let mine = |v: &&String| w.on_state.as_ref().map_or(f.kind == FieldKind::CheckBox, |on| on == *v);
|
||||
let value = f.value.first().filter(|v| !v.is_empty() && v.as_str() != "Off").filter(mine);
|
||||
Some(w.state.clone().or_else(|| value.cloned()).unwrap_or_else(|| "Off".into()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// With /NeedAppearances a check box or radio button that describes its look in /MK is
|
||||
// drawn from it (Table 192), its own appearance or not; one without /MK keeps its own.
|
||||
let described = || widget.as_dict().and_then(|d| d.get(b"MK")).is_some_and(|mk| doc.resolve(mk).as_dict().is_some());
|
||||
let missing = match f.kind {
|
||||
FieldKind::Text | FieldKind::Combo | FieldKind::List => normal.is_none() || all,
|
||||
FieldKind::PushButton => normal.is_none(),
|
||||
FieldKind::CheckBox | FieldKind::Radio => match (normal.as_deref(), state.as_deref()) {
|
||||
_ if all && described() => true,
|
||||
(None, _) => true,
|
||||
(Some(Object::Dict(states)), Some(s)) => s != "Off" && !states.contains(&name_bytes(s)),
|
||||
_ => false,
|
||||
|
||||
@@ -4088,6 +4088,65 @@ fn a_password_field_missing_from_the_field_list_is_still_cleared() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A password field nested beyond depth 32 (e.g. depth 35) is recognized by `has_password_flag`
|
||||
/// and `has_password_values`, matching the field tree walk limit of 64.
|
||||
#[test]
|
||||
fn a_password_field_nested_beyond_depth_32_is_recognized() {
|
||||
let target_depth = 35;
|
||||
let leaf_obj_num = 5 + target_depth;
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
];
|
||||
for d in 0..target_depth {
|
||||
let obj_num = 5 + d;
|
||||
let next_obj_num = obj_num + 1;
|
||||
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
|
||||
objs.push(format!("<< /T (node{d}) /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
|
||||
}
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /Ff 8192 /V (secret35) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
|
||||
leaf_obj_num - 1
|
||||
));
|
||||
let mut doc = document(&objs);
|
||||
assert!(has_password_flag(&doc));
|
||||
assert!(has_password_values(&doc));
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
|
||||
assert!(!has_password_values(&doc));
|
||||
}
|
||||
|
||||
/// A password field whose widget is reachable through a page's Annots and whose inherited Password
|
||||
/// flag is deeper than 32 levels is recognized by `has_password_flag`.
|
||||
#[test]
|
||||
fn a_page_widget_inheriting_password_flag_beyond_depth_32_is_recognized() {
|
||||
let target_depth = 35;
|
||||
let leaf_obj_num = 5 + target_depth;
|
||||
let mut objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /Annots [{leaf_obj_num} 0 R] >>"),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
];
|
||||
for d in 0..target_depth {
|
||||
let obj_num = 5 + d;
|
||||
let next_obj_num = obj_num + 1;
|
||||
let parent_entry = if d == 0 { String::new() } else { format!(" /Parent {} 0 R", obj_num - 1) };
|
||||
let ff_entry = if d == 0 { " /Ff 8192" } else { "" };
|
||||
objs.push(format!("<< /T (node{d}){ff_entry} /Kids [{next_obj_num} 0 R]{parent_entry} >>"));
|
||||
}
|
||||
objs.push(format!(
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (pass) /V (secret_chain) /Parent {} 0 R /Rect [50 600 150 620] /P 3 0 R >>",
|
||||
leaf_obj_num - 1
|
||||
));
|
||||
let mut doc = document(&objs);
|
||||
assert!(has_password_flag(&doc));
|
||||
assert!(has_password_values(&doc));
|
||||
assert_eq!(clear_password_values(&mut doc).unwrap(), 1);
|
||||
assert!(!has_password_values(&doc));
|
||||
}
|
||||
|
||||
/// #4018: a value that doesn't fit its box at the field's font size is reported (it is drawn cut
|
||||
/// at the box). An auto-sized field shrinks to fit, and a short or empty value fits.
|
||||
#[test]
|
||||
@@ -4300,3 +4359,221 @@ fn comb_fields_show_their_cells() {
|
||||
let plain = saved_look(&doc, "d");
|
||||
assert!(!plain.contains(" m ") && plain.contains("(A) Tj"), "{plain}");
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons with an `/MK` look and appearances of their own, in a form that
|
||||
/// sets `/NeedAppearances` (`need`) or not.
|
||||
fn mk_buttons(need: bool) -> Document {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
let widget = |n: &str, rest: &str| format!("<< /Type /Annot /Subtype /Widget /FT /Btn /T ({n}) /F 4 /P 3 0 R {rest} >>");
|
||||
let states = |on: &str| format!("/AP << /N << /{on} 15 0 R /Off 16 0 R >> >>");
|
||||
document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] >>".into(),
|
||||
format!(
|
||||
"<< /Fields [6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 12 0 R 13 0 R 14 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>"
|
||||
),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
widget("check", &format!("/V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [50 700 74 724] {}", states("Yes"))), // 6
|
||||
widget("cross", &format!("/V /Yes /AS /Yes /DA (/ZaDb 0 Tf 1 0 0 rg) /MK << /CA (8) >> /Rect [100 700 124 724] {}", states("Yes"))),
|
||||
widget("diamond", &format!("/V /Yes /AS /Yes /MK << /CA (u) /BG [1 1 0] >> /Rect [150 700 174 724] {}", states("Yes"))),
|
||||
widget("square", &format!("/V /Yes /AS /Yes /MK << /CA (n) >> /Rect [200 700 224 724] {}", states("Yes"))),
|
||||
widget("star", &format!("/V /Yes /AS /Yes /MK << /CA (H) >> /Rect [250 700 274 724] {}", states("Yes"))), // 10
|
||||
widget("off", &format!("/V /Off /AS /Off /MK << /BC [0 0 0] /CA (4) >> /Rect [300 700 324 724] {}", states("Yes"))),
|
||||
widget("radio", &format!("/Ff 32768 /V /B /MK << /BC [0 0 1] /CA (l) >> /Rect [350 700 374 724] {}", states("B"))),
|
||||
widget("plain", &format!("/V /Yes /AS /Yes /Rect [400 700 424 724] {}", states("Yes"))),
|
||||
widget(
|
||||
"odd",
|
||||
&format!(
|
||||
"/V /Yes /AS /Yes /DA (/ZaDb nan Tf 1 x rg) /MK << /CA 42 /BC [(no) /Name] /BG 7 >> /BS << /W -3 >> /Rect [450 700 474 724] {}",
|
||||
states("Yes")
|
||||
),
|
||||
),
|
||||
"<< /Length 24 >>\nstream\n0 g 4 4 16 16 re f\nendstream".into(), // 15: a filled square
|
||||
"<< /Length 4 >>\nstream\nq\nQ\nendstream".into(),
|
||||
])
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows each check box and radio button with an `/MK` drawn from it,
|
||||
/// over the appearance the file carries: the caption's ZapfDingbats symbol in the `/DA` colour
|
||||
/// when on, only the border when off. A widget with no `/MK` keeps its own; the file is untouched.
|
||||
#[test]
|
||||
fn need_appearances_draws_check_boxes_and_radio_buttons_from_their_captions() {
|
||||
let doc = mk_buttons(true);
|
||||
let copy = with_field_appearances(&doc).expect("buttons to draw");
|
||||
let all = fields(©);
|
||||
for (name, state, symbol, colour) in [
|
||||
("check", "Yes", "(4) Tj", "0 g"),
|
||||
("cross", "Yes", "(8) Tj", "1 0 0 rg"),
|
||||
("diamond", "Yes", "(u) Tj", "0 g"),
|
||||
("square", "Yes", "(n) Tj", "0 g"),
|
||||
("star", "Yes", "(H) Tj", "0 g"),
|
||||
("radio", "B", "(l) Tj", "0 g"),
|
||||
] {
|
||||
let shown = state_ap(©, &field(&all, name).widgets[0], state);
|
||||
assert!(shown.contains(symbol) && shown.contains(&format!("{colour}\n")), "{name}: {shown}");
|
||||
assert!(!shown.contains("4 4 16 16 re f"), "{name} is not the file's filled square: {shown}");
|
||||
}
|
||||
let check = state_ap(©, &field(&all, "check").widgets[0], "Yes");
|
||||
assert!(check.contains("0 0 0 RG\n1 w\n0.5 0.5 23 23 re S"), "the /BC border: {check}");
|
||||
assert!(state_ap(©, &field(&all, "diamond").widgets[0], "Yes").contains("1 1 0 rg\n0 0 24 24 re f"), "the /BG background");
|
||||
assert!(state_ap(©, &field(&all, "radio").widgets[0], "B").contains("0 0 1 RG"), "the radio's own border");
|
||||
let off = state_ap(©, &field(&all, "off").widgets[0], "Off");
|
||||
assert!(off.contains("re S") && !off.contains("Tj"), "off: only the border: {off}");
|
||||
// Nonsense in /MK, /DA and /BS: the default check, no border, and no panic.
|
||||
let odd = state_ap(©, &field(&all, "odd").widgets[0], "Yes");
|
||||
assert!(odd.contains("(4) Tj") && !odd.contains("re S"), "{odd}");
|
||||
let own = |d: &Document, name: &str| d.get(field(&fields(d), name).widgets[0].obj).as_dict().and_then(|w| w.get(b"AP").cloned());
|
||||
assert_eq!(own(©, "plain"), own(&doc, "plain"), "a box without /MK keeps its own look");
|
||||
assert_eq!(state_ap(&doc, &field(&fields(&doc), "check").widgets[0], "Yes").trim_end(), "0 g 4 4 16 16 re f", "the file is untouched");
|
||||
}
|
||||
|
||||
/// Without `/NeedAppearances` the file's own check box and radio button appearances are shown
|
||||
/// as they are, `/MK` or not: no copy is needed.
|
||||
#[test]
|
||||
fn without_need_appearances_buttons_keep_their_own_appearances() {
|
||||
assert!(with_field_appearances(&mk_buttons(false)).is_none());
|
||||
}
|
||||
|
||||
/// A form for filling many values at once: two summed fields and their calculated total, a
|
||||
/// name two field dictionaries share, a check box, a radio group, a list and a read-only field.
|
||||
fn batch_form() -> Document {
|
||||
let tx = |name: &str, y: u32, rest: &str| {
|
||||
format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T ({name}) /Rect [50 {y} 250 {}] /P 3 0 R {rest} >>", y + 20)
|
||||
};
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 12 0 R 13 0 R 14 0 R 17 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R 11 0 R 14 0 R 17 0 R] /CO [7 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 15 0 R >> >> >>"
|
||||
.into(),
|
||||
tx("a", 700, "/V (1)"),
|
||||
tx("b", 660, ""),
|
||||
tx("total", 620, r#"/AA << /C << /S /JavaScript /JS (AFSimple_Calculate\("SUM", new Array\("a", "b"\)\);) >> >>"#),
|
||||
tx("dup", 580, ""),
|
||||
tx("dup", 540, ""),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Btn /T (agree) /V /Off /AS /Off /Rect [300 700 315 715] /P 3 0 R >>".into(),
|
||||
"<< /FT /Btn /T (size) /Ff 49152 /Kids [12 0 R 13 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [300 650 315 665] /P 3 0 R /AP << /N << /S 16 0 R /Off 16 0 R >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 11 0 R /AS /Off /Rect [330 650 345 665] /P 3 0 R /AP << /N << /L 16 0 R /Off 16 0 R >> >> >>"
|
||||
.into(),
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Ch /T (country) /Ff 131072 /Opt [[(ca) (Canada)] [(fr) (France)]] /Rect [300 600 450 620] /P 3 0 R >>"
|
||||
.into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(),
|
||||
tx("locked", 500, "/Ff 1 /V (kept)"),
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
/// What a filled form shows: every field as read, and each widget's drawn appearance.
|
||||
fn filled(doc: &Document) -> (Vec<Field>, Vec<String>) {
|
||||
let all = fields(doc);
|
||||
let looks = all
|
||||
.iter()
|
||||
.flat_map(|f| &f.widgets)
|
||||
.map(|w| {
|
||||
let wd = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
let n = wd.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().and_then(|a| a.get(b"N").cloned()));
|
||||
match n.map(|n| doc.resolve(&n)) {
|
||||
Some(n) => match &*n {
|
||||
Object::Stream(s) => String::from_utf8_lossy(&s.decoded().unwrap_or_default()).into_owned(),
|
||||
other => format!("{other:?}").chars().filter(|c| !c.is_ascii_digit()).collect(),
|
||||
},
|
||||
None => String::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
(all, looks)
|
||||
}
|
||||
|
||||
/// Filling values in one batch leaves the form as filling them one by one does: the values,
|
||||
/// the twin of a shared name, the calculated total and every widget's appearance; a name given
|
||||
/// twice ends with its last value, and is checked against the value it took earlier in the batch;
|
||||
/// and each value fails as it would on its own.
|
||||
#[test]
|
||||
fn set_values_fills_as_set_value_does_one_by_one() {
|
||||
let values: Vec<(String, FieldValue)> = vec![
|
||||
("a".into(), FieldValue::Text("2".into())),
|
||||
("b".into(), FieldValue::Text("3".into())),
|
||||
("dup".into(), FieldValue::Text("shared".into())),
|
||||
("agree".into(), FieldValue::Check(true)),
|
||||
("size".into(), FieldValue::Radio(Some("L".into()))),
|
||||
("country".into(), FieldValue::Choice(vec!["fr".into()])),
|
||||
("nowhere".into(), FieldValue::Text("x".into())),
|
||||
("locked".into(), FieldValue::Text("changed".into())),
|
||||
("size".into(), FieldValue::Radio(Some("XL".into()))),
|
||||
("dup".into(), FieldValue::Text("again".into())),
|
||||
("a".into(), FieldValue::Text("10".into())),
|
||||
// Refused only because the batch already selected L: the group keeps one selected.
|
||||
("size".into(), FieldValue::Radio(None)),
|
||||
];
|
||||
let mut one_by_one = batch_form();
|
||||
let expected: Vec<Result<(), FormError>> = values.iter().map(|(n, v)| set_value(&mut one_by_one, n, v)).collect();
|
||||
let mut batch = batch_form();
|
||||
let results = set_values(&mut batch, &values).expect("recalculates");
|
||||
assert_eq!(results, expected);
|
||||
assert_eq!(
|
||||
results.iter().filter(|r| r.is_err()).count(),
|
||||
4,
|
||||
"the unknown name, the read-only field, the unknown option and clearing the group fail: {results:?}"
|
||||
);
|
||||
let (all, looks) = filled(&batch);
|
||||
assert_eq!((all.clone(), looks), filled(&one_by_one));
|
||||
assert_eq!(field(&all, "total").value, ["13"], "recalculated once, after the last value");
|
||||
assert!(all.iter().filter(|f| f.name == "dup").all(|f| f.value == ["again"]), "both dup fields take the last value");
|
||||
// And as saved.
|
||||
assert_eq!(filled(&reopen(&batch)), filled(&reopen(&one_by_one)));
|
||||
}
|
||||
|
||||
/// A form without fields refuses each value, and an empty batch changes nothing.
|
||||
#[test]
|
||||
fn set_values_without_a_form_or_values() {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
]);
|
||||
let r = set_values(&mut doc, &[("a".into(), FieldValue::Text("1".into()))]).unwrap();
|
||||
assert_eq!(r, vec![Err(FormError::NoForm)]);
|
||||
let mut form = batch_form();
|
||||
assert_eq!(set_values(&mut form, &[]).unwrap(), Vec::new());
|
||||
assert!(!form.is_modified());
|
||||
// Nothing filled, nothing recalculated: the total stays as the file has it.
|
||||
let r = set_values(&mut form, &[("nowhere".into(), FieldValue::Text("1".into()))]).unwrap();
|
||||
assert_eq!(r, vec![Err(FormError::NoSuchField("nowhere".into()))]);
|
||||
assert!(field(&fields(&form), "total").value.is_empty() && !form.is_modified());
|
||||
}
|
||||
|
||||
/// Filling many fields reads the form a fixed number of times, not once or twice per field
|
||||
/// (which took minutes to open a form with thousands of fields whose XFA data was filled in
|
||||
/// elsewhere).
|
||||
#[test]
|
||||
fn set_values_reads_the_form_once_not_per_value() {
|
||||
let n: u32 = 60;
|
||||
let mut objs: Vec<String> =
|
||||
vec!["<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into()];
|
||||
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 5)).collect();
|
||||
objs.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{}] >>", kids.join(" ")));
|
||||
objs.push(format!("<< /Fields [{}] /DA (/Helv 10 Tf 0 g) >>", kids.join(" ")));
|
||||
for i in 0..n {
|
||||
objs.push(format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{i}) /Rect [50 {y} 250 {top}] /P 3 0 R >>", y = 10 * i, top = 10 * i + 10));
|
||||
}
|
||||
let mut doc = document(&objs);
|
||||
let values: Vec<(String, FieldValue)> = (0..n).map(|i| (format!("f{i}"), FieldValue::Text(format!("v{i}")))).collect();
|
||||
let before = form_reads();
|
||||
let results = set_values(&mut doc, &values).unwrap();
|
||||
let reads = form_reads() - before;
|
||||
assert!(results.iter().all(Result::is_ok));
|
||||
// One reading to fill, one to recalculate.
|
||||
assert!(reads <= 2, "{reads} readings of the form for {n} values");
|
||||
assert!(fields(&doc).iter().all(|f| f.value == [format!("v{}", &f.name[1..])]));
|
||||
// A name filled again is read again, once: f0 and f1 again cost one more reading.
|
||||
let again: Vec<(String, FieldValue)> = ["f0", "f1", "f0", "f1"].iter().map(|n| (n.to_string(), FieldValue::Text("w".into()))).collect();
|
||||
let before = form_reads();
|
||||
assert!(set_values(&mut doc, &again).unwrap().iter().all(Result::is_ok));
|
||||
let reads = form_reads() - before;
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
@@ -2115,7 +2115,8 @@ impl Interp<'_> {
|
||||
let a = self.arg_num(args, 0)?;
|
||||
let places = if args.len() > 1 { self.arg_num(args, 1)?.clamp(0.0, 12.0) as i32 } else { 0 };
|
||||
let k = 10f64.powi(places);
|
||||
Val::Num((a * k).round() / k)
|
||||
let scaled = a * k;
|
||||
if scaled.is_finite() { Val::Num(scaled.round() / k) } else { Val::Num(a) }
|
||||
}
|
||||
"count" => {
|
||||
// Non-null values and objects: an empty field doesn't count, a subform does.
|
||||
@@ -2278,8 +2279,11 @@ impl Interp<'_> {
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
if b[i] == b'%'
|
||||
&& let Some(h) =
|
||||
b.get(i + 1..i + 3).and_then(|h| std::str::from_utf8(h).ok()).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
&& let Some(h) = b
|
||||
.get(i + 1..i + 3)
|
||||
.filter(|h| h.iter().all(u8::is_ascii_hexdigit))
|
||||
.and_then(|h| std::str::from_utf8(h).ok())
|
||||
.and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(h);
|
||||
i += 3;
|
||||
|
||||
@@ -1009,9 +1009,17 @@ mod formcalc {
|
||||
assert_eq!(calc(r#"Format("9.99", 2.344)"#), "2.34");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_keeps_huge_values_finite() {
|
||||
assert_eq!(calc("Round(1e300, 12) == 1e300"), "1");
|
||||
assert_eq!(calc("Round(1.2345, 2)"), "1.23");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_functions_cover_the_common_set() {
|
||||
assert_eq!(calc("Abs(-2.5)"), "2.5");
|
||||
assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b");
|
||||
assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb");
|
||||
assert_eq!(calc("Ceil(2.1)"), "3");
|
||||
assert_eq!(calc("Floor(-2.1)"), "-3");
|
||||
assert_eq!(calc("Mod(7, 3)"), "1");
|
||||
|
||||
@@ -18,6 +18,18 @@ use crate::xfa::{XfaDoc, XfaEvent, XfaNode, XfaOutcome};
|
||||
#[cfg(test)]
|
||||
pub(crate) static START_DELAY_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
std::thread_local! {
|
||||
static ENGINES_STARTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many script engine threads the sessions made on this thread have started, for
|
||||
/// diagnostics and tests: an event's scripts share one, so opening a form with hundreds of
|
||||
/// calculations starts one. Per thread, so a caller counting its own events is not disturbed
|
||||
/// by forms on other threads. Always 0 on wasm, where the scripts run on the caller's thread.
|
||||
pub fn engines_started() -> u64 {
|
||||
ENGINES_STARTED.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// What one script run needs.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Script {
|
||||
@@ -58,7 +70,7 @@ fn run_script(s: Script, doc: &XfaDoc, started: &dyn Fn()) -> XfaOutcome {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod native {
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::{Script, run_script};
|
||||
use crate::xfa::{ENGINE_START_LIMIT, XfaDoc, XfaOutcome};
|
||||
@@ -83,11 +95,20 @@ mod native {
|
||||
worker: Option<Worker>,
|
||||
/// A script ran past its limit: its thread is left to it and nothing more runs.
|
||||
dead: Option<String>,
|
||||
/// How long the engine has to be ready for a script ([`ENGINE_START_LIMIT`] by default).
|
||||
start_limit: Duration,
|
||||
}
|
||||
|
||||
impl XfaSession {
|
||||
pub fn new(doc: XfaDoc) -> XfaSession {
|
||||
XfaSession { doc, worker: None, dead: None }
|
||||
XfaSession { doc, worker: None, dead: None, start_limit: ENGINE_START_LIMIT }
|
||||
}
|
||||
|
||||
/// Multiply the time the engine has to be ready for a script by `factor` (a script's
|
||||
/// own time is [`Script::timeout`]).
|
||||
pub fn with_time_scale(mut self, factor: u32) -> XfaSession {
|
||||
self.start_limit = ENGINE_START_LIMIT.saturating_mul(factor);
|
||||
self
|
||||
}
|
||||
|
||||
/// Why nothing more runs, once a script was abandoned.
|
||||
@@ -115,11 +136,12 @@ mod native {
|
||||
}
|
||||
});
|
||||
spawned.map_err(|e| format!("the script engine could not start: {e}"))?;
|
||||
match from.recv_timeout(ENGINE_START_LIMIT) {
|
||||
super::ENGINES_STARTED.with(|n| n.set(n.get().saturating_add(1)));
|
||||
match from.recv_timeout(self.start_limit) {
|
||||
Ok(Reply::Ready) => {}
|
||||
Ok(_) => return Err("the script engine answered out of turn".into()),
|
||||
Err(RecvTimeoutError::Timeout) => {
|
||||
return Err(format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64()));
|
||||
return Err(format!("the script engine took longer than {:.0} s to start", self.start_limit.as_secs_f64()));
|
||||
}
|
||||
Err(RecvTimeoutError::Disconnected) => return Err("the script engine stopped with an internal error".into()),
|
||||
}
|
||||
@@ -129,8 +151,9 @@ mod native {
|
||||
}
|
||||
|
||||
/// Run one script, waiting at most its `timeout` from when the engine is ready for it
|
||||
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready); past that it is
|
||||
/// abandoned on its thread and the session is dead.
|
||||
/// (the engine itself gets [`ENGINE_START_LIMIT`] to be ready, unless
|
||||
/// [`XfaSession::with_time_scale`] changed it); past that it is abandoned on its thread
|
||||
/// and the session is dead.
|
||||
pub fn run(&mut self, script: Script) -> XfaOutcome {
|
||||
let abandoned = |why: String| XfaOutcome { error: Some(why), abandoned: true, ..Default::default() };
|
||||
let failed = |why: String| XfaOutcome { error: Some(why), ..Default::default() };
|
||||
@@ -138,6 +161,7 @@ mod native {
|
||||
return abandoned(why.clone());
|
||||
}
|
||||
let timeout = script.timeout;
|
||||
let start_limit = self.start_limit;
|
||||
let worker = match self.worker() {
|
||||
Ok(w) => w,
|
||||
Err(why) => return failed(why),
|
||||
@@ -148,7 +172,7 @@ mod native {
|
||||
return failed("the script engine stopped with an internal error".into());
|
||||
}
|
||||
// The engine's start, then the script's own time.
|
||||
let (mut running, mut limit) = (false, ENGINE_START_LIMIT);
|
||||
let (mut running, mut limit) = (false, start_limit);
|
||||
let mut deadline = Instant::now().checked_add(limit);
|
||||
loop {
|
||||
let left = deadline.map_or(limit, |d| d.saturating_duration_since(Instant::now()));
|
||||
@@ -167,7 +191,7 @@ mod native {
|
||||
let why = if running {
|
||||
format!("the script ran longer than {:.1} s and was abandoned", timeout.as_secs_f64())
|
||||
} else {
|
||||
format!("the script engine took longer than {:.0} s to start", ENGINE_START_LIMIT.as_secs_f64())
|
||||
format!("the script engine took longer than {:.0} s to start", start_limit.as_secs_f64())
|
||||
};
|
||||
self.dead = Some(why.clone());
|
||||
// Dropping the sender ends the thread's loop once the script is done.
|
||||
@@ -199,6 +223,11 @@ mod native {
|
||||
XfaSession { doc }
|
||||
}
|
||||
|
||||
/// No engine to start on wasm: nothing to scale.
|
||||
pub fn with_time_scale(self, _factor: u32) -> XfaSession {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn dead(&self) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -265,6 +265,15 @@ fn explicit_dest(doc: &Document, targets: &mut Targets, dest: &Object, depth: u8
|
||||
}
|
||||
}
|
||||
|
||||
/// The explicit destination array `dest` stands for: itself, a dictionary whose `/D` is one, or a
|
||||
/// named destination (a name or string, ISO 32000-2 §12.3.2.4) looked up in the `/Names /Dests`
|
||||
/// tree, then the catalog `/Dests`. `None` when a name names nothing.
|
||||
pub(crate) fn resolve_dest(doc: &Document, dest: &Object) -> Option<Vec<Object>> {
|
||||
// Names only: the page map is not needed to look one up.
|
||||
let mut targets = Targets { pages: HashMap::new(), names: None };
|
||||
explicit_dest(doc, &mut targets, dest, 0)
|
||||
}
|
||||
|
||||
/// The page index an explicit destination array names.
|
||||
fn page_of_explicit(targets: &Targets, dest: &[Object]) -> Option<usize> {
|
||||
match dest.first()? {
|
||||
|
||||
@@ -2460,10 +2460,89 @@ fn an_unchanged_opening_destination_keeps_its_position_and_name() {
|
||||
assert_eq!(crate::initial_view(&doc), crate::InitialView { hide_toolbar: true, ..v.clone() });
|
||||
// A changed magnification writes a destination of its own.
|
||||
let mut doc = doc;
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitPage, ..v }).unwrap();
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { magnification: Magnification::FitWidth, ..v }).unwrap();
|
||||
let catalog = doc.get(root).as_dict().cloned().unwrap();
|
||||
assert_ne!(catalog.get(b"OpenAction"), Some(&action));
|
||||
assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitPage);
|
||||
assert_eq!(crate::initial_view(&doc).magnification, Magnification::FitWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/// A document whose `/OpenAction` is `action`, with the named destination `there` (page 3 at
|
||||
/// 200%) in the catalog `/Dests` and `here` (page 1, fit) in a `/Names /Dests` tree.
|
||||
fn doc_opening_at(action: Object) -> Document {
|
||||
let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0));
|
||||
let page3 = Object::Ref(pdfcraft_cos::ObjRef::new(6, 0));
|
||||
let mut dests = pdfcraft_cos::Dict::new();
|
||||
dests.set(b"there".to_vec(), Object::Array(vec![page3, Object::name("XYZ"), Object::Null, Object::Null, Object::Int(2)]));
|
||||
let mut tree = pdfcraft_cos::Dict::new();
|
||||
tree.set(b"Names".to_vec(), Object::Array(vec![Object::String(PdfString::text("here")), Object::Array(vec![page1, Object::name("Fit")])]));
|
||||
let mut names = pdfcraft_cos::Dict::new();
|
||||
names.set(b"Dests".to_vec(), Object::Dict(tree));
|
||||
let mut doc = open(fixture());
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| {
|
||||
c.set(b"OpenAction".to_vec(), action);
|
||||
c.set(b"Dests".to_vec(), Object::Dict(dests));
|
||||
c.set(b"Names".to_vec(), Object::Dict(names));
|
||||
})
|
||||
.unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
fn open_action(doc: &Document) -> Option<Object> {
|
||||
doc.get(doc.root().unwrap()).as_dict().and_then(|c| c.get(b"OpenAction").cloned())
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §12.3.2.4: a named destination stands for the explicit one it names. Choosing
|
||||
/// page 1 in Document Properties on a document that opens at a named destination elsewhere
|
||||
/// writes a destination to page 1; it used to read the name as page 1 and change nothing.
|
||||
#[test]
|
||||
fn choosing_page_one_replaces_a_named_opening_destination() {
|
||||
use crate::view::Magnification;
|
||||
let page1 = Object::Ref(pdfcraft_cos::ObjRef::new(4, 0));
|
||||
let mut goto = pdfcraft_cos::Dict::new();
|
||||
goto.set(b"S".to_vec(), Object::name("GoTo"));
|
||||
goto.set(b"D".to_vec(), Object::name("there"));
|
||||
for action in [Object::Dict(goto), Object::name("there"), Object::String(PdfString::text("there"))] {
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
// The dialog shows where the document really opens.
|
||||
let v = crate::initial_view(&doc);
|
||||
assert_eq!((v.page, v.magnification), (2, Magnification::Percent(200.0)), "{action:?}");
|
||||
// Page 1 at the same magnification: an explicit destination to page 1.
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, ..v.clone() }).unwrap();
|
||||
let written = Object::Array(vec![page1.clone(), Object::name("XYZ"), Object::Null, Object::Null, Object::Real(2.0)]);
|
||||
assert_eq!(open_action(&doc), Some(written), "{action:?}");
|
||||
let reopened = save_and_reopen(&doc);
|
||||
assert_eq!(crate::initial_view(&reopened), crate::InitialView { page: 0, ..v.clone() }, "{action:?}");
|
||||
// Page 1 at the default magnification: no opening action, so the first page opens.
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 0, magnification: Magnification::Default, ..v }).unwrap();
|
||||
assert_eq!(open_action(&doc), None, "{action:?}");
|
||||
assert_eq!(crate::initial_view(&save_and_reopen(&doc)).page, 0, "{action:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A named opening destination that already goes to the chosen page and magnification is kept,
|
||||
/// and so is one that names nothing (it reads as page 1) until another page is chosen.
|
||||
#[test]
|
||||
fn a_named_opening_destination_is_kept_while_it_still_holds() {
|
||||
use crate::view::Magnification;
|
||||
let page2 = Object::Ref(pdfcraft_cos::ObjRef::new(5, 0));
|
||||
for (action, magnification) in
|
||||
[(Object::String(PdfString::text("here")), Magnification::FitPage), (Object::name("nowhere"), Magnification::Default)]
|
||||
{
|
||||
let mut doc = doc_opening_at(action.clone());
|
||||
let v = crate::initial_view(&doc);
|
||||
assert_eq!((v.page, v.magnification), (0, magnification), "{action:?}");
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { hide_toolbar: true, ..v.clone() }).unwrap();
|
||||
assert_eq!(open_action(&doc), Some(action.clone()), "{action:?}");
|
||||
crate::set_initial_view(&mut doc, &crate::InitialView { page: 1, ..v.clone() }).unwrap();
|
||||
let kind = if magnification == Magnification::FitPage { "Fit" } else { "XYZ" };
|
||||
assert_eq!(
|
||||
open_action(&doc).as_ref().and_then(Object::as_array).map(|a| (a.first().cloned(), a.get(1).cloned())),
|
||||
Some((Some(page2.clone()), Some(Object::name(kind)))),
|
||||
"{action:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,10 +118,12 @@ pub fn initial_view(doc: &Document) -> InitialView {
|
||||
};
|
||||
}
|
||||
}
|
||||
// The opening destination: an explicit array, or a GoTo action's.
|
||||
// The opening destination: an explicit array, a GoTo action's `/D`, or a named destination (a
|
||||
// name or string, §12.3.2.4) as the explicit one it names, so that the page and magnification
|
||||
// read here are where the document opens. A name that names nothing reads as page 1.
|
||||
let dest = c.get(b"OpenAction").map(|o| doc.resolve(o)).and_then(|o| match &*o {
|
||||
Object::Array(a) => Some(a.clone()),
|
||||
Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()),
|
||||
Object::Array(_) | Object::Name(_) | Object::String(_) => crate::outline::resolve_dest(doc, &o),
|
||||
Object::Dict(d) if d.name(b"S") == Some(b"GoTo") => d.get(b"D").and_then(|x| crate::outline::resolve_dest(doc, x)),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(d) = dest {
|
||||
@@ -195,9 +197,12 @@ pub fn set_initial_view(doc: &mut Document, v: &InitialView) -> Result<(), Organ
|
||||
} else {
|
||||
vp.remove(b"Direction");
|
||||
}
|
||||
// A destination, explicit or named (§12.3.2.4), or a GoTo action to one.
|
||||
let open_action_is_dest = match c.get(b"OpenAction").map(|o| doc.resolve(o)) {
|
||||
None => true,
|
||||
Some(o) => matches!(&*o, Object::Array(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo")),
|
||||
Some(o) => {
|
||||
matches!(&*o, Object::Array(_) | Object::Name(_) | Object::String(_)) || o.as_dict().is_some_and(|d| d.name(b"S") == Some(b"GoTo"))
|
||||
}
|
||||
};
|
||||
let dest = match v.magnification {
|
||||
Magnification::Default if v.page == 0 => None,
|
||||
|
||||
@@ -8,4 +8,4 @@ Synthetic tests pin both name forms, the 128-byte cut, the attempt limit and ref
|
||||
|
||||
On macOS, `write_atomically_with` copies the existing regular destination's accessible extended attributes to the open staging file after writing the data and before flushing and renaming. Finder tags, comments, binary values and empty attributes therefore survive both incremental and full saves (#2658). Save As retains attributes of an existing destination; a new destination does not inherit metadata from the source PDF. Missing files and filesystems without xattr support keep working. The metadata read does not follow the destination's symlink or block on a FIFO. macOS manages the new file's `com.apple.provenance`; it is not copied. Kernel-hidden compression attributes are not requested.
|
||||
|
||||
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
|
||||
Attribute copying is bounded: 64 KiB of names, 1024 attributes, 16 MiB per value and 64 MiB total. Each value must fill the initially queried size and retain that size in a second query after reading: a resource fork may return a partial prefix on growth rather than an error. A read/copy error or exceeded bound returns an error suggesting Save As; the original bytes and metadata stay intact and the staging file is removed. Flush errors retain the same guarantee. Tests cover exact metadata before flush and after repeated saves, empty/binary values, oversized resource forks, a simulated partial read of a growing fork and symlink isolation. Automation covers incremental/full saves, Save As and retaining an unsaved edit after metadata refusal. The staging writer also retains Unix permissions, the Windows hidden and system attributes (the staging file is created with them, so a hidden file is never published visible and a plain one never hidden, #4593) and, where supported, Windows/macOS creation dates; attribute copying runs before the creation date is restored. ACLs and hard-link identity are not preserved.
|
||||
@@ -34,7 +34,8 @@ pub fn create_staging(
|
||||
/// [`create_staging`], giving the new file the mode and group of the file `keep` describes on
|
||||
/// Unix. It is created readable and writable by its owner only and then given them, so its mode
|
||||
/// is never more open than that of the file it will replace, not even while it is still empty (a
|
||||
/// handle opened then would keep reading what is written later). The owner is the saver's.
|
||||
/// handle opened then would keep reading what is written later). The owner is the saver's. On
|
||||
/// Windows it is created with that file's [`KEPT_ATTRIBUTES`].
|
||||
fn create_staging_as(
|
||||
dir: &Path,
|
||||
name: &str,
|
||||
@@ -63,8 +64,15 @@ fn create_staging_as(
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
options.mode(0o600);
|
||||
}
|
||||
// Given as the file is created, so a hidden target's staging file is never visible.
|
||||
#[cfg(windows)]
|
||||
if let Some(keep) = keep {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
options.attributes(keep.file_attributes() & KEPT_ATTRIBUTES);
|
||||
}
|
||||
match options.open(&tmp) {
|
||||
Ok(file) => {
|
||||
#[cfg(unix)]
|
||||
if let Some(keep) = keep {
|
||||
// A volume that can't change modes (FAT, some network shares) leaves the file
|
||||
// owner-only: stricter than the file it replaces, never looser.
|
||||
@@ -78,9 +86,18 @@ fn create_staging_as(
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = keep;
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "every temporary file name tried is taken"))
|
||||
}
|
||||
|
||||
/// The Windows attributes a replaced file passes on to its staging file (#4593):
|
||||
/// `FILE_ATTRIBUTE_HIDDEN` and `FILE_ATTRIBUTE_SYSTEM`, the two that say who should see the
|
||||
/// file. A read-only file is refused before anything is staged, and the archive bit is the
|
||||
/// system's to set on a changed file; the other attributes describe how the volume stores it.
|
||||
#[cfg(windows)]
|
||||
const KEPT_ATTRIBUTES: u32 = 0x2 | 0x4;
|
||||
|
||||
/// The permissions a staging file gets to replace the file `keep` describes. The mode's group bits
|
||||
/// are meant for that file's group, so the staging file joins it first. Where it can't (the saver
|
||||
/// isn't a member), group and others get nothing: someone the group bits shut out would
|
||||
@@ -96,11 +113,6 @@ fn kept_permissions(file: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::
|
||||
mode
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn kept_permissions(_: &std::fs::File, keep: &std::fs::Metadata) -> std::fs::Permissions {
|
||||
keep.permissions()
|
||||
}
|
||||
|
||||
/// Unpredictable staging-name suffixes, so a name can't be planted in advance. Each is the
|
||||
/// standard library's keyed hash of a counter (`RandomState::hash_one`, currently SipHash-1-3),
|
||||
/// keyed from the operating system's random source. That is all a staging name needs, but it is
|
||||
@@ -119,7 +131,8 @@ pub fn staging_suffixes() -> impl Iterator<Item = u64> {
|
||||
/// a folder at the target is refused by the rename. Flushing is the staging file's own
|
||||
/// (`sync_all`); parent-directory durability after a power loss is not promised. On Unix a file
|
||||
/// that is replaced keeps its permissions and group, so a private file stays private; a new file gets the
|
||||
/// usual mode for a new file. On Windows and macOS a replaced file keeps its creation date, where
|
||||
/// usual mode for a new file. On Windows a replaced file stays hidden or system if it was, and
|
||||
/// plain if it was plain. On Windows and macOS a replaced file keeps its creation date, where
|
||||
/// the volume stores one.
|
||||
/// On macOS, accessible extended attributes of an existing regular destination are copied
|
||||
/// before flushing; a copy failure refuses the save rather than losing metadata (#2658).
|
||||
@@ -146,10 +159,14 @@ pub fn write_atomically_with(
|
||||
}
|
||||
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
|
||||
let name = path.file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("save");
|
||||
// A file that is replaced keeps its permissions (#1790) and group. Unix only: Windows is as before.
|
||||
// A file that is replaced keeps its permissions (#1790) and group on Unix, and its hidden and
|
||||
// system attributes on Windows (#4593). A save replaces a symbolic link, not the file it
|
||||
// points to, so on Windows the attributes are the link's own.
|
||||
#[cfg(unix)]
|
||||
let keep = std::fs::metadata(path).ok();
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
let keep = std::fs::symlink_metadata(path).ok();
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let keep = None;
|
||||
// A saved file is the same document: it keeps its creation date (#2658).
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
@@ -661,4 +678,69 @@ mod tests {
|
||||
assert!(std::fs::metadata(&fresh).unwrap().created().unwrap() > then);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #4593: a save replaces the file, so a hidden or system file stays hidden or system, and
|
||||
/// its staging file already is while it is written; a plain file stays plain, though the
|
||||
/// staging name starts with a dot; a new file gets no attribute from anywhere.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_replaced_file_keeps_its_hidden_and_system_attributes() {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
const HIDDEN: u32 = 0x2;
|
||||
const SYSTEM: u32 = 0x4;
|
||||
let dir = test_dir("attributes");
|
||||
let kept = |p: &Path| std::fs::metadata(p).unwrap().file_attributes() & (HIDDEN | SYSTEM);
|
||||
for (i, keep) in [0, HIDDEN, SYSTEM, HIDDEN | SYSTEM].into_iter().enumerate() {
|
||||
let target = dir.join(format!("doc{i}.pdf"));
|
||||
let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(keep).open(&target).unwrap();
|
||||
std::io::Write::write_all(&mut fixture, b"OLD").unwrap();
|
||||
drop(fixture);
|
||||
assert_eq!(kept(&target), keep, "the fixture has attributes {keep:#x}");
|
||||
for form in [StagingName::TagThenSuffix, StagingName::SuffixThenTag] {
|
||||
let mut staged = u32::MAX;
|
||||
write_atomically_with(&target, b"NEW", form, [i as u64], |f| {
|
||||
staged = f.metadata()?.file_attributes() & (HIDDEN | SYSTEM);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(staged, keep, "the staging file has the target's attributes before it is flushed");
|
||||
assert_eq!(kept(&target), keep, "the saved file keeps attributes {keep:#x}");
|
||||
assert_eq!(std::fs::read(&target).unwrap(), b"NEW");
|
||||
}
|
||||
}
|
||||
let fresh = dir.join("new.pdf");
|
||||
write_atomically(&fresh, b"NEW", StagingName::TagThenSuffix).unwrap();
|
||||
assert_eq!(kept(&fresh), 0, "a new file is neither hidden nor system");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A save replaces a symbolic link, not the file it points to, so the saved file takes the
|
||||
/// link's own attributes: a plain link to a hidden file saves as a plain file, and the file
|
||||
/// it pointed to is left as it was.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn a_replaced_link_keeps_its_own_attributes_not_its_targets() {
|
||||
use std::os::windows::fs::{MetadataExt as _, OpenOptionsExt as _};
|
||||
const HIDDEN: u32 = 0x2;
|
||||
let dir = test_dir("link-attributes");
|
||||
let referent = dir.join("referent.pdf");
|
||||
let mut fixture = std::fs::OpenOptions::new().write(true).create_new(true).attributes(HIDDEN).open(&referent).unwrap();
|
||||
std::io::Write::write_all(&mut fixture, b"OLD").unwrap();
|
||||
drop(fixture);
|
||||
let link = dir.join("link.pdf");
|
||||
if let Err(e) = std::os::windows::fs::symlink_file(&referent, &link) {
|
||||
eprintln!("skipped: creating a symbolic link needs Developer Mode or administrator rights ({e})");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
return;
|
||||
}
|
||||
assert_eq!(std::fs::symlink_metadata(&link).unwrap().file_attributes() & HIDDEN, 0, "the link itself is visible");
|
||||
write_atomically(&link, b"NEW", StagingName::TagThenSuffix).unwrap();
|
||||
let saved = std::fs::symlink_metadata(&link).unwrap();
|
||||
assert!(!saved.file_type().is_symlink(), "the save replaced the link");
|
||||
assert_eq!(saved.file_attributes() & HIDDEN, 0, "the link was visible, so the saved file is");
|
||||
assert_eq!(std::fs::read(&link).unwrap(), b"NEW");
|
||||
assert_eq!(std::fs::read(&referent).unwrap(), b"OLD", "the file the link pointed to is untouched");
|
||||
assert_ne!(std::fs::metadata(&referent).unwrap().file_attributes() & HIDDEN, 0, "and still hidden");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
+16
-13
@@ -283,8 +283,8 @@ fn spooler_arguments_and_printer_list() {
|
||||
assert_eq!(
|
||||
parse_lpstat(out),
|
||||
[
|
||||
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None },
|
||||
spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None }
|
||||
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() },
|
||||
spool::Printer { name: "Label_Writer".into(), default: false, two_sided: None, description: String::new() }
|
||||
]
|
||||
);
|
||||
assert!(parse_lpstat("lpstat: No destinations added.\nno system default destination\n").is_empty());
|
||||
@@ -327,18 +327,21 @@ fn driverless_printers_join_the_list_without_a_queue() {
|
||||
assert_eq!(
|
||||
spool::printers_parsed(queues, Some("Basement_Color\nOffice_Laser\n")),
|
||||
[
|
||||
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None },
|
||||
spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None },
|
||||
spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() },
|
||||
spool::Printer { name: "Basement_Color".into(), default: false, two_sided: None, description: String::new() },
|
||||
],
|
||||
"queues first, then the driverless destination; the queue listed twice is not doubled"
|
||||
);
|
||||
// An old spooler without `-e` (CUPS < 1.7) keeps its queues; only the driverless names are lost.
|
||||
assert_eq!(spool::printers_parsed(queues, None), [spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None }]);
|
||||
assert_eq!(
|
||||
spool::printers_parsed(queues, None),
|
||||
[spool::Printer { name: "Office_Laser".into(), default: true, two_sided: None, description: String::new() }]
|
||||
);
|
||||
// The default can itself be driverless (`lpoptions -d` names a destination no queue exists
|
||||
// for): it keeps its marker, so the dialog preselects the user's default, not the first queue.
|
||||
assert_eq!(
|
||||
spool::printers_parsed("system default destination: Basement_Color\n", Some("Basement_Color\n")),
|
||||
[spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None }],
|
||||
[spool::Printer { name: "Basement_Color".into(), default: true, two_sided: None, description: String::new() }],
|
||||
"a default destination without a queue is still the default"
|
||||
);
|
||||
assert!(spool::parse_lpstat_e("\n \n").is_empty(), "no destinations is empty, not [\"\"]");
|
||||
@@ -414,17 +417,17 @@ fn windows_printers_carry_one_default_and_keep_their_names() {
|
||||
// Windows marks the system default with a `*` no queue name may contain; names hold spaces,
|
||||
// parentheses and non-ASCII letters, and the lines end with CRLF.
|
||||
let printers = parse_windows_printers("*Microsoft Print to PDF\r\nHP LaserJet 400 (Copy 2)\r\nКопир Canon iR\r\n\r\n*XPS via USB\r\n");
|
||||
assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None });
|
||||
assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None });
|
||||
assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None });
|
||||
assert_eq!(printers[0], Printer { name: "Microsoft Print to PDF".into(), default: true, two_sided: None, description: String::new() });
|
||||
assert_eq!(printers[1], Printer { name: "HP LaserJet 400 (Copy 2)".into(), default: false, two_sided: None, description: String::new() });
|
||||
assert_eq!(printers[2], Printer { name: "Копир Canon iR".into(), default: false, two_sided: None, description: String::new() });
|
||||
// A spooler marking two defaults: the first still wins, so the dialog has one to preselect.
|
||||
assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None });
|
||||
assert_eq!(printers[3], Printer { name: "XPS via USB".into(), default: false, two_sided: None, description: String::new() });
|
||||
assert_eq!(parse_windows_printers("\r\n"), Vec::new());
|
||||
// After a tab, whether the driver reports two-sided printing; a tab inside a name stays.
|
||||
let sided = parse_windows_printers("*Office PCL\t2\r\nOffice PS\t1\r\nTab\tName\t2\r\n");
|
||||
assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true) });
|
||||
assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false) });
|
||||
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true) });
|
||||
assert_eq!(sided[0], Printer { name: "Office PCL".into(), default: true, two_sided: Some(true), description: String::new() });
|
||||
assert_eq!(sided[1], Printer { name: "Office PS".into(), default: false, two_sided: Some(false), description: String::new() });
|
||||
assert_eq!(sided[2], Printer { name: "Tab\tName".into(), default: false, two_sided: Some(true), description: String::new() });
|
||||
}
|
||||
|
||||
/// A PPD shaped like a Fiery's: installable options, multi-line PostScript in the choices, Latin-1
|
||||
|
||||
@@ -884,37 +884,14 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec<u8>],
|
||||
}
|
||||
let b = bbox.unwrap_or_default();
|
||||
changed = true;
|
||||
if scope.covered(b) {
|
||||
scope.report.paths_removed += 1;
|
||||
if clips {
|
||||
// Keep the clipping, drop the painting.
|
||||
ops.extend(body);
|
||||
ops.push(Op::new("n", vec![]));
|
||||
}
|
||||
} else if clips {
|
||||
// A clip-and-paint path can't be wrapped in q/Q without losing its clip:
|
||||
// paint it clipped, then set its clip with a no-op path.
|
||||
scope.report.paths_clipped += 1;
|
||||
if let Some(c) = clip_out(scope.rects, &gs.ctm, b) {
|
||||
let plain: Vec<Op> = body.iter().filter(|p| !p.is("W") && !p.is("W*")).cloned().collect();
|
||||
ops.push(Op::new("q", vec![]));
|
||||
ops.extend(c);
|
||||
ops.extend(plain);
|
||||
ops.push(op);
|
||||
ops.push(Op::new("Q", vec![]));
|
||||
}
|
||||
scope.report.paths_removed += 1;
|
||||
if scope.covered(b) && clips {
|
||||
// Keep the clipping, drop the painting.
|
||||
ops.extend(body);
|
||||
ops.push(Op::new("n", vec![]));
|
||||
} else {
|
||||
scope.report.paths_clipped += 1;
|
||||
if let Some(c) = clip_out(scope.rects, &gs.ctm, b) {
|
||||
ops.push(Op::new("q", vec![]));
|
||||
ops.extend(c);
|
||||
ops.extend(body);
|
||||
ops.push(op);
|
||||
ops.push(Op::new("Q", vec![]));
|
||||
}
|
||||
}
|
||||
// Partial vector redaction must not retain underlying drawing commands
|
||||
// behind clipping (F-008). Refuse partial clip retention and remove the path.
|
||||
continue;
|
||||
}
|
||||
if !path.is_empty() {
|
||||
|
||||
@@ -474,7 +474,16 @@ pub fn scan(doc: &Document) -> Vec<(Hidden, usize)> {
|
||||
+ owned(Hidden::Attachments)
|
||||
}
|
||||
Hidden::Comments => annots.iter().filter(|a| is_comment(a.name(b"Subtype").unwrap_or(b""))).count(),
|
||||
Hidden::FormFields => pdfcraft_forms::fields(doc).len(),
|
||||
Hidden::FormFields => {
|
||||
let n = pdfcraft_forms::fields(doc).len();
|
||||
if n > 0 {
|
||||
n
|
||||
} else if cat.contains(b"AcroForm") {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
Hidden::HiddenText => content_pass(&mut doc2, true, false, false).map(|(n, _)| n).unwrap_or(0),
|
||||
Hidden::HiddenLayers => {
|
||||
let off = hidden_layers(doc).len();
|
||||
@@ -510,7 +519,8 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
// Every annotation the pages list now: whatever is no longer listed at the end (flattened
|
||||
// widgets included) leaves the structure tree too.
|
||||
let listed_before = listed_annotations(doc);
|
||||
let fields = which.contains(&Hidden::FormFields) && count(Hidden::FormFields) > 0;
|
||||
let has_acroform = doc.get(root).as_dict().is_some_and(|d| d.contains(b"AcroForm"));
|
||||
let fields = which.contains(&Hidden::FormFields) && (count(Hidden::FormFields) > 0 || has_acroform);
|
||||
if fields {
|
||||
// Fields without appearances get one first, so their values stay visible.
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
@@ -631,9 +641,12 @@ pub fn remove_hidden(doc: &mut Document, which: &[Hidden]) -> Result<Vec<(Hidden
|
||||
cat.set(b"OCProperties".to_vec(), Object::Dict(oc));
|
||||
}
|
||||
}
|
||||
Hidden::FormFields => {
|
||||
cat.remove(b"AcroForm");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let n = count(h);
|
||||
let n = if h == Hidden::FormFields && fields { count(h).max(1) } else { count(h) };
|
||||
if n > 0 {
|
||||
done.push((h, n));
|
||||
}
|
||||
|
||||
+117
-2
@@ -281,14 +281,40 @@ fn vectors_are_removed_or_clipped_and_inline_images_go() {
|
||||
let mut doc = one_page(src, "", vec![]);
|
||||
mark(&mut doc, 0, &[[5.0, 5.0, 35.0, 35.0], [45.0, 45.0, 65.0, 65.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (1, 1, 1), "{r:?}");
|
||||
assert_eq!((r.paths_removed, r.paths_clipped, r.images_removed), (2, 0, 1), "{r:?}");
|
||||
let c = content(&doc, 0);
|
||||
assert!(!c.contains("10 10 20 20 re"), "{c}");
|
||||
assert!(c.contains("W*\nn\n0 0 300 300 re\nf\nQ"), "the page-size rect is clipped: {c}");
|
||||
assert!(!c.contains("300 300 re"), "the intersecting rect is removed: {c}");
|
||||
assert!(!c.contains("BI"), "{c}");
|
||||
assert!(c.contains("150 150 m"), "the line elsewhere stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_covered_vector_paths_do_not_leak_drawing_commands() {
|
||||
// A vector path with detailed drawing commands (e.g. outlined text or confidential geometry)
|
||||
// that is only partially covered by a redaction mark must be completely removed, not retained
|
||||
// behind a clipping path (F-008).
|
||||
let src = b"0 g 100 100 m 150 150 l 200 100 l h f 10 10 m 20 20 l W b 300 300 m 350 350 l S";
|
||||
let mut doc = one_page(src, "", vec![]);
|
||||
// Redaction mark covers only part of the triangle (100..200, 100..150):
|
||||
// mark is [120, 120, 160, 160], partially intersecting the triangle.
|
||||
// Also partially intersect the clip-and-stroke path [10..20, 10..20] with [15, 15, 25, 25].
|
||||
mark(&mut doc, 0, &[[120.0, 120.0, 160.0, 160.0], [15.0, 15.0, 25.0, 25.0]], "");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!(r.paths_removed, 2, "both partially intersected paths removed");
|
||||
assert_eq!(r.paths_clipped, 0, "no paths retained via clipping");
|
||||
|
||||
let c = content(&doc, 0);
|
||||
// Neither the drawing operators nor the coordinates should leak in the content stream.
|
||||
assert!(!c.contains("100 100 m"), "underlying geometry must not leak: {c}");
|
||||
assert!(!c.contains("150 150 l"), "underlying geometry must not leak: {c}");
|
||||
assert!(!c.contains("200 100 l"), "underlying geometry must not leak: {c}");
|
||||
assert!(!c.contains("10 10 m"), "partial clip geometry must not leak: {c}");
|
||||
assert!(!c.contains("20 20 l"), "partial clip geometry must not leak: {c}");
|
||||
// Unrelated non-intersecting path remains untouched.
|
||||
assert!(c.contains("300 300 m\n350 350 l\nS"), "unaffected vector path must be kept: {c}");
|
||||
}
|
||||
|
||||
/// A chain of nested forms deeper than the pass can walk was removed from the page, taking
|
||||
/// whatever else it drew with it even when nothing in it was marked. The limit now reaches as
|
||||
/// deep as the renderer and Edit text read (50), so anything a reader can see can be walked;
|
||||
@@ -2265,6 +2291,74 @@ fn unselected_pages_keep_inherited_forms() {
|
||||
assert_eq!(under(&mut saved, 1, &[[0.0, 190.0, 300.0, 220.0]]), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_resource_less_forms_keep_live_resources_after_redacting_another_page() {
|
||||
for own_resources in [false, true] {
|
||||
for still_drawn in [false, true] {
|
||||
let original = b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET";
|
||||
let mut extra = vec![
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", original),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", if still_drawn { b"/Fm Do".as_slice() } else { b"q Q".as_slice() }),
|
||||
];
|
||||
if own_resources {
|
||||
extra.push(stream(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Resources << /Font << /F1 5 0 R >> /XObject << /Fm 7 0 R /Relay 8 0 R >> >>",
|
||||
b"/Relay Do",
|
||||
));
|
||||
}
|
||||
let resources =
|
||||
if own_resources { "/XObject << /Fm 7 0 R /Relay 8 0 R /Wrapper 9 0 R >>" } else { "/XObject << /Fm 7 0 R /Relay 8 0 R >>" };
|
||||
let mut doc = one_page(b"/Fm Do", resources, extra);
|
||||
let mut page = doc.get(ObjRef::new(3, 0)).as_dict().unwrap().clone();
|
||||
let inherited = page.remove(b"Resources").unwrap();
|
||||
doc.set(ObjRef::new(3, 0), Object::Dict(page.clone()));
|
||||
let second_content =
|
||||
doc.add(Object::Stream(Stream::from_raw(Dict::new(), if own_resources { b"/Wrapper Do".to_vec() } else { b"/Relay Do".to_vec() })));
|
||||
page.set(b"Contents".to_vec(), Object::Ref(second_content));
|
||||
let second = doc.add(Object::Dict(page));
|
||||
doc.update_dict(ObjRef::new(2, 0), |d| {
|
||||
d.set(b"Resources".to_vec(), inherited);
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(ObjRef::new(3, 0)), Object::Ref(second)]));
|
||||
d.set(b"Count".to_vec(), Object::Int(2));
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let region = [[0.0, 190.0, 300.0, 220.0]];
|
||||
let surviving = if still_drawn { 6 } else { 0 };
|
||||
assert_eq!(under(&mut doc, 0, ®ion), 6, "the marked occurrence is real text");
|
||||
assert_eq!(under(&mut doc, 1, ®ion), surviving, "the relay's original nested content is visible");
|
||||
mark(&mut doc, 0, ®ion, "");
|
||||
let report = apply(&mut doc, Some(&[0])).unwrap();
|
||||
assert_eq!((report.marks, report.glyphs), (1, 6));
|
||||
assert_eq!(under(&mut doc, 0, ®ion), 0);
|
||||
assert_eq!(under(&mut doc, 1, ®ion), surviving, "redacting another page must preserve live nested content");
|
||||
|
||||
// A full saved document must retain both the reachable resource and its content,
|
||||
// without relying on an old incremental revision's otherwise-unreachable objects.
|
||||
let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(under(&mut saved, 0, ®ion), 0);
|
||||
assert_eq!(under(&mut saved, 1, ®ion), surviving);
|
||||
let page = pdfcraft_model::pages(&saved).swap_remove(1);
|
||||
let res = saved.resolve(page.dict.get(b"Resources").unwrap());
|
||||
let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
let mut holder = xo.as_dict().unwrap().clone();
|
||||
if own_resources {
|
||||
let wrapper = saved.resolve(holder.get(b"Wrapper").unwrap());
|
||||
let Object::Stream(wrapper) = &*wrapper else { panic!("a real wrapper form") };
|
||||
let res = saved.resolve(wrapper.dict.get(b"Resources").unwrap());
|
||||
let xo = saved.resolve(res.as_dict().unwrap().get(b"XObject").unwrap());
|
||||
holder = xo.as_dict().unwrap().clone();
|
||||
}
|
||||
assert_eq!(holder.contains(b"Fm"), still_drawn, "keep only resources a surviving nested form actually uses");
|
||||
if still_drawn {
|
||||
let form = saved.resolve(holder.get(b"Fm").unwrap());
|
||||
let Object::Stream(form) = &*form else { panic!("the preserved original form") };
|
||||
assert_eq!(form.decoded().unwrap(), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A page tree of `n` pages that all draw form /Fm and inherit a direct `/Resources` from the root.
|
||||
fn inheriting_pages(n: usize) -> (Document, Vec<ObjRef>) {
|
||||
let form = stream("/Type /XObject /Subtype /Form /BBox [0 0 300 300]", b"BT /F1 10 Tf 10 200 Td (SECRET) Tj ET");
|
||||
@@ -2708,6 +2802,27 @@ fn removing_form_fields_takes_hidden_widgets_and_their_values_out_of_the_file()
|
||||
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
|
||||
}
|
||||
|
||||
/// F-021: removing form fields on a document with an AcroForm dictionary (even one with no
|
||||
/// enumerable terminal fields) removes the AcroForm dictionary, processes widgets, and requires full save.
|
||||
#[test]
|
||||
fn removing_form_fields_removes_acroform_even_without_terminal_fields() {
|
||||
use crate::sanitize::{Hidden, remove_hidden, scan};
|
||||
let mut doc =
|
||||
one_page(b"BT /F1 10 Tf 10 100 Td (Visible) Tj ET", "", vec![b"<< /Type /Annot /Subtype /Widget /Rect [10 200 150 220] >>".to_vec()]);
|
||||
doc.update_dict(doc.root().unwrap(), |d| d.set(b"AcroForm".to_vec(), fields_dict(&[]))).unwrap();
|
||||
doc.update_dict(ObjRef::new(3, 0), |d| d.set(b"Annots".to_vec(), ref_array(&[7]))).unwrap();
|
||||
let counts = scan(&doc);
|
||||
assert_eq!(counts.iter().find(|c| c.0 == Hidden::FormFields).map(|c| c.1), Some(1));
|
||||
assert!(doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
|
||||
assert!(!doc.full_save_required());
|
||||
|
||||
let done = remove_hidden(&mut doc, &[Hidden::FormFields]).unwrap();
|
||||
assert_eq!(done, [(Hidden::FormFields, 1)]);
|
||||
assert!(!doc.get(doc.root().unwrap()).as_dict().is_some_and(|d| d.contains(b"AcroForm")));
|
||||
assert!(annots_of(&doc, &pdfcraft_model::pages(&doc).swap_remove(0).dict).is_empty());
|
||||
assert!(doc.full_save_required());
|
||||
}
|
||||
|
||||
/// Removing hidden text cleans the structure tree as redaction does: the elements describing the
|
||||
/// removed text lose their descriptions, and a reference into a rewritten Form points at the copy.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! A luminosity soft mask takes its values from the gray of its group's colour (ISO 32000-2
|
||||
//! §11.5.3). In a `/DeviceCMYK` group that gray is `1 - min(1, 0.3 c + 0.59 m + 0.11 y + k)`
|
||||
//! (§10.4.2.3), so 100% K masks fully, whatever profile CMYK is displayed through.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
/// The page's grey backdrop, `0.5 g`, as the renderer writes it.
|
||||
const BACKDROP: f64 = 128.0;
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
fn render(bytes: Vec<u8>) -> (Vec<u8>, u32) {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
(page.rgba.to_vec(), page.width)
|
||||
}
|
||||
|
||||
fn pixel(rgba: &[u8], width: u32, x: u32, y: u32) -> [u8; 3] {
|
||||
let offset = usize::try_from((y * width + x) * 4).unwrap();
|
||||
[rgba[offset], rgba[offset + 1], rgba[offset + 2]]
|
||||
}
|
||||
|
||||
/// Red filled over the grey backdrop under a luminosity mask whose group, in `space`, paints its
|
||||
/// left half with `paint`; its right half is the backdrop `/BC` (`bc`, when given). Returns the
|
||||
/// mask values read back from the left and the right half.
|
||||
fn mask_values(space: &str, paint: &str, bc: Option<&str>) -> (f64, f64) {
|
||||
let bc = bc.map(|bc| format!("/BC {bc}")).unwrap_or_default();
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >>".to_string(),
|
||||
stream("", "0.5 g 0 0 100 100 re f /M gs 1 0 0 rg 0 0 100 100 re f"),
|
||||
format!("<< /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R {bc} >> >>"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /CS {space} >>"),
|
||||
&format!("{paint} 0 0 50 100 re f"),
|
||||
),
|
||||
];
|
||||
let (rgba, width) = render(pdf(&objects));
|
||||
let value = |x| {
|
||||
let [r, g, b] = pixel(&rgba, width, x, 50);
|
||||
// Red over grey: red rises from the backdrop to 255 and green and blue fall to 0 as the
|
||||
// mask goes from 0 to 1.
|
||||
let from_red = (f64::from(r) - BACKDROP) / (255.0 - BACKDROP);
|
||||
let from_green = (BACKDROP - f64::from(g)) / BACKDROP;
|
||||
let from_blue = (BACKDROP - f64::from(b)) / BACKDROP;
|
||||
assert!(
|
||||
(from_red - from_green).abs() < 0.03 && (from_green - from_blue).abs() < 0.03,
|
||||
"{space} {paint}: pixel {:?} is not red over the backdrop",
|
||||
[r, g, b]
|
||||
);
|
||||
from_green
|
||||
};
|
||||
(value(25), value(75))
|
||||
}
|
||||
|
||||
fn assert_mask(space: &str, paint: &str, bc: Option<&str>, left: f64, right: f64) {
|
||||
let (got_left, got_right) = mask_values(space, paint, bc);
|
||||
assert!((got_left - left).abs() < 0.02, "{space} group, `{paint}`: mask {got_left:.3}, expected {left}");
|
||||
assert!((got_right - right).abs() < 0.02, "{space} group, /BC {bc:?}: mask {got_right:.3}, expected {right}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_ink_in_a_device_cmyk_group_masks_fully_and_no_ink_shows_fully() {
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0 k", None, 1.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0.5 k", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_process_ink_in_a_device_cmyk_group_masks_by_its_weight() {
|
||||
assert_mask("/DeviceCMYK", "1 0 0 0 k", None, 0.7, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 1 0 0 k", None, 0.41, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 1 0 k", None, 0.89, 0.0);
|
||||
assert_mask("/DeviceCMYK", "1 1 1 1 k", None, 0.0, 0.0);
|
||||
// The stroking colour too, and a colour selected with `cs`/`sc`.
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 K 0 0 0 1 k 50 w 25 0 m 25 100 l S", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "/DeviceCMYK cs 0 0 0 0.5 sc", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_cmyk_backdrop_colour_masks_by_its_gray() {
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[0 0 0 0]"), 0.0, 1.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 0 k", Some("[0 0 0 1]"), 1.0, 0.0);
|
||||
assert_mask("/DeviceCMYK", "0 0 0 1 k", Some("[1 0 0 0]"), 0.0, 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_and_rgb_groups_mask_as_before() {
|
||||
assert_mask("/DeviceGray", "0 g", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceGray", "1 g", Some("[1]"), 1.0, 1.0);
|
||||
assert_mask("/DeviceGray", "0.5 g", None, 0.5, 0.0);
|
||||
assert_mask("/DeviceRGB", "0 0 0 rg", None, 0.0, 0.0);
|
||||
assert_mask("/DeviceRGB", "1 1 1 rg", Some("[1 1 1]"), 1.0, 1.0);
|
||||
assert_mask("/DeviceRGB", "0.5 0.5 0.5 rg", None, 0.5, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_cmyk_on_the_page_keeps_its_display_colour() {
|
||||
// Only a luminosity mask in a DeviceCMYK group reads CMYK as its gray. On the page, CMYK is
|
||||
// displayed through the CMYK profile as before, where 100% K is a dark grey.
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R >>".to_string(),
|
||||
stream("", "0 0 0 1 k 0 0 100 100 re f"),
|
||||
];
|
||||
let (rgba, width) = render(pdf(&objects));
|
||||
let [r, g, b] = pixel(&rgba, width, 50, 50);
|
||||
assert!(r > 10 && g > 10 && b > 10 && r < 80, "100% K on the page: {:?}", [r, g, b]);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod cid_fallback_query;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
|
||||
@@ -18,7 +18,7 @@ pdfcraft-model = { workspace = true }
|
||||
thiserror.workspace = true
|
||||
# ASN.1 UTCTime/GeneralizedTime conversion goes through chrono (no hand-rolled calendar code).
|
||||
chrono = { workspace = true }
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
sha1 = "0.11"
|
||||
sha2 = "0.11"
|
||||
p256 = { version = "0.14", default-features = false, features = ["ecdsa", "std"] }
|
||||
|
||||
@@ -11,7 +11,11 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true }
|
||||
# macOS: a window close quits through `terminate:` (the Touch Bar crash, #1136); elsewhere a no-op.
|
||||
# The menus: native on macOS, an egui bar elsewhere, from one definition (menus.rs).
|
||||
craft-menu = { workspace = true }
|
||||
craft-keymap = { workspace = true }
|
||||
# Quit from the menus asks the window to close, so the unsaved-changes check runs; on macOS a window
|
||||
# close also quits through `terminate:` (the Touch Bar crash, #1136).
|
||||
craft-quit = { workspace = true, features = ["egui"] }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
@@ -19,28 +23,32 @@ pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
egui = { workspace = true, features = ["bytemuck"] }
|
||||
egui = { workspace = true, features = ["bytemuck", "default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "persistence", "links", "clipboard"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# UTC/calendar display (combine modified-time stamp, the form date picker) goes through chrono
|
||||
# (no hand-rolled calendar code); std-only, so it stays wasm-safe.
|
||||
chrono = { workspace = true }
|
||||
# Monotonic deadlines for work that runs in `logic` with no UI pass (native and web).
|
||||
web-time = { workspace = true }
|
||||
# Hands rendered rasters to egui as its pixels, without copying; already in the tree through egui and wgpu.
|
||||
bytemuck = { version = "1", features = ["extern_crate_alloc"] }
|
||||
|
||||
rfd = { workspace = true }
|
||||
png = "0.18"
|
||||
base64 = "0.22"
|
||||
# Right-to-left file names in the chrome (egui shapes but does not reorder); already in the tree.
|
||||
base64 = "0.23"
|
||||
# Unicode bidi paragraph segmentation for right-to-left comment wrapping; egui 0.37's epaint owns
|
||||
# display reordering. Already in the tree through egui’s epaint.
|
||||
unicode-bidi = "0.3"
|
||||
# Checks that an installed fallback font parses and has the glyphs, and reads the vertical metrics
|
||||
# that put fallback faces on the interface's baseline (#1676); the same skrifa egui uses.
|
||||
skrifa = "0.44"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
craft-update = { workspace = true }
|
||||
# Control-channel tokens.
|
||||
getrandom = "0.4"
|
||||
# Create ▸ Clipboard (images; already in the tree through egui-winit).
|
||||
@@ -79,8 +87,12 @@ js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-update = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
# Tests draw PdfCraft text without a system provider: the bundled and monochrome emoji faces must
|
||||
# be enabled on the test graph itself (a dependency's dev-features do not propagate).
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
|
||||
skrifa = "0.44"
|
||||
|
||||
@@ -301,16 +301,18 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool
|
||||
close
|
||||
}
|
||||
|
||||
/// Draw the custom-action editor: name, description and ordered steps; Save stays disabled until the name is
|
||||
/// unique and non-empty and at least one step exists.
|
||||
fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
||||
let Some((original, a)) = app.wizard.editing.as_mut() else { return false };
|
||||
ui.label(egui::RichText::new(if original.is_some() { tl!("Edit Action") } else { tl!("New Action") }).font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("action-edit").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Action name:"));
|
||||
ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name"));
|
||||
let name_label = ui.label(tl!("Action name:"));
|
||||
ui.add(crate::widgets::line(&mut a.name).desired_width(320.0).id_salt("action-name")).labelled_by(name_label.id);
|
||||
ui.end_row();
|
||||
ui.label(tl!("Description:"));
|
||||
ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description"));
|
||||
let description_label = ui.label(tl!("Description:"));
|
||||
ui.add(crate::widgets::line(&mut a.description).desired_width(320.0).id_salt("action-description")).labelled_by(description_label.id);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -324,9 +326,9 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
||||
for (i, s) in a.steps.iter_mut().enumerate() {
|
||||
ui.push_id(i, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(format!("{}. {}", i + 1, tl!(s.label())));
|
||||
let step_label = ui.label(format!("{}. {}", i + 1, tl!(s.label())));
|
||||
if let Some(arg) = s.arg_mut() {
|
||||
ui.add(crate::widgets::line(arg).desired_width(220.0));
|
||||
ui.add(crate::widgets::line(arg).desired_width(220.0)).labelled_by(step_label.id);
|
||||
}
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if ui.button(tl!("Remove")).clicked() {
|
||||
|
||||
@@ -13,9 +13,8 @@ const TILE: egui::Vec2 = vec2(148.0, 118.0);
|
||||
const MATCHES_SHOWN: usize = 2;
|
||||
|
||||
/// Whether `query` (already trimmed and lower-cased) is in `label` or its translation.
|
||||
/// A right-to-left language's translations are in display order (their words run the other way)
|
||||
/// while the query is in typing order, so there several words match one by one, as in the
|
||||
/// command palette.
|
||||
/// Translations and queries stay in logical order. For right-to-left labels, also accept
|
||||
/// multiword queries in a different word order, as in the command palette.
|
||||
fn label_hit(label: &str, translated: &str, query: &str, rtl: bool) -> bool {
|
||||
let translated = translated.to_lowercase();
|
||||
label.to_lowercase().contains(query)
|
||||
@@ -115,7 +114,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
/// short shows in full on hover.
|
||||
fn tile(ui: &mut egui::Ui, t: &Tokens, g: &ToolGroup, items: &[&'static str]) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(TILE, Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!(g.label)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
crate::home::card_lift(ui, t, rect, press);
|
||||
ui.painter().rect(rect, CornerRadius::same(10), press.fill(t.card, t.hover, t.pressed), Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
@@ -186,11 +185,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn right_to_left_labels_match_a_query_typed_in_logical_order() {
|
||||
let shown = crate::bidi::display_rtl("تنظيم الصفحات");
|
||||
assert_ne!(shown, "تنظيم الصفحات", "the catalogue reverses the words for display");
|
||||
assert!(label_hit("Organize pages", &shown, "تنظيم الصفحات", true));
|
||||
assert!(label_hit("Organize pages", &shown, "الصفحات", true), "one word");
|
||||
assert!(!label_hit("Organize pages", &shown, "تنظيم الملفات", true), "every word must be there");
|
||||
assert!(!label_hit("Organize pages", &shown, "تنظيم الصفحات", false), "left-to-right labels stay exact");
|
||||
let arabic = crate::i18n::Lang::from_code("ar").expect("Arabic is registered");
|
||||
let translated = crate::i18n::tr(arabic, "Organize pages");
|
||||
assert_eq!(translated, "تنظيم الصفحات", "the catalogue keeps logical text");
|
||||
assert!(label_hit("Organize pages", translated, "تنظيم الصفحات", true));
|
||||
assert!(label_hit("Organize pages", translated, "الصفحات", true), "one word");
|
||||
assert!(label_hit("Organize pages", translated, "الصفحات تنظيم", true), "RTL word-order fallback");
|
||||
assert!(!label_hit("Organize pages", translated, "تنظيم الملفات", true), "every word must be there");
|
||||
assert!(!label_hit("Organize pages", translated, "الصفحات تنظيم", false), "left-to-right labels stay exact");
|
||||
}
|
||||
}
|
||||
@@ -118,8 +118,8 @@ fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(The
|
||||
pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Appearance mode"));
|
||||
egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| {
|
||||
let label = ui.label(tl!("Appearance mode"));
|
||||
let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| {
|
||||
for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] {
|
||||
if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() {
|
||||
app.execute(mode_command(preference));
|
||||
@@ -127,6 +127,8 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens)
|
||||
}
|
||||
}
|
||||
});
|
||||
// The visible label names the combo box for assistive technology and tests.
|
||||
combo.response.labelled_by(label.id);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let light_active = app.theme == ThemeKind::Light;
|
||||
|
||||
+184
-50
@@ -1,18 +1,18 @@
|
||||
//! Visual order for single-line interface text that may hold right-to-left script (file names,
|
||||
//! document titles).
|
||||
//! Wrapping right-to-left text into logical lines, and test-only display-order helpers.
|
||||
//!
|
||||
//! egui shapes each font-face run with the script's own direction, so the letters of an Arabic
|
||||
//! 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 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.
|
||||
//! egui 0.37's epaint shapes text with the Unicode bidirectional algorithm (UAX #9), so
|
||||
//! production code passes it logical (typing-order) text: [`wrap`] breaks a comment into logical
|
||||
//! lines and lets epaint reorder each one. [`visual`], [`visual_auto`] and [`display_rtl`] are
|
||||
//! kept for the tests that prove the old preordering is gone; they must not be used for painting
|
||||
//! with egui 0.37, where they would reverse mixed-direction text a second time.
|
||||
|
||||
#[cfg(test)]
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
|
||||
use unicode_bidi::{BidiInfo, Level, ParagraphInfo};
|
||||
use unicode_bidi::BidiInfo;
|
||||
#[cfg(test)]
|
||||
use unicode_bidi::{Level, ParagraphInfo};
|
||||
|
||||
/// `text` rearranged for an egui call that lays out one line. Text without right-to-left
|
||||
/// characters comes back borrowed and unchanged.
|
||||
@@ -20,6 +20,7 @@ use unicode_bidi::{BidiInfo, Level, ParagraphInfo};
|
||||
/// The base direction is left to right, as the interface is: a file name keeps its extension
|
||||
/// at the end, the way the window title shows it. Use it for painting only; accessibility
|
||||
/// labels and anything stored keep the logical text.
|
||||
#[cfg(test)]
|
||||
pub fn visual(text: &str) -> Cow<'_, str> {
|
||||
if !text.chars().any(is_rtl_script) {
|
||||
return Cow::Borrowed(text);
|
||||
@@ -30,6 +31,7 @@ pub fn visual(text: &str) -> Cow<'_, str> {
|
||||
}
|
||||
|
||||
/// `text` in display order for the paragraph direction `base`, appended to `out`.
|
||||
#[cfg(test)]
|
||||
fn push_visual(out: &mut String, text: &str, base: Level) {
|
||||
let info = BidiInfo::new(text, Some(base));
|
||||
for para in &info.paragraphs {
|
||||
@@ -39,6 +41,7 @@ fn push_visual(out: &mut String, text: &str, base: Level) {
|
||||
|
||||
/// The `line` of paragraph `para` (a non-empty byte range of `text`) in display order, as egui
|
||||
/// needs it, appended to `out`.
|
||||
#[cfg(test)]
|
||||
fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &ParagraphInfo, line: Range<usize>) {
|
||||
let (levels, runs) = info.visual_runs(para, line);
|
||||
// First the true display order, every character left to right as it appears on screen.
|
||||
@@ -62,6 +65,7 @@ fn push_line(out: &mut String, text: &str, info: &BidiInfo<'_>, para: &Paragraph
|
||||
/// each such stretch goes back to logical order. What lies between them (spaces, punctuation,
|
||||
/// Latin) is drawn by the Latin face left to right and stays as it is. The stretches include
|
||||
/// Persian and Arabic-Indic digits: Arabic script to the shaper, though they read left to right.
|
||||
#[cfg(test)]
|
||||
fn push_for_shaper(out: &mut String, shown: &str) {
|
||||
let mut stretch: Vec<char> = Vec::new();
|
||||
for c in shown.chars() {
|
||||
@@ -78,7 +82,7 @@ fn push_for_shaper(out: &mut String, shown: &str) {
|
||||
/// One line of wrapped text (see [`wrap`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Line {
|
||||
/// The line in display order, for one egui call that lays out one line.
|
||||
/// The line in logical (reading) order. epaint shapes and reorders it for display.
|
||||
pub text: String,
|
||||
/// The paragraph reads right to left: the line belongs against the right edge.
|
||||
pub rtl: bool,
|
||||
@@ -89,14 +93,44 @@ pub fn has_rtl(text: &str) -> bool {
|
||||
text.chars().any(is_rtl_script)
|
||||
}
|
||||
|
||||
/// The paragraph direction for translated interface text: explicitly right to left for Arabic,
|
||||
/// Kurdish and Persian, and `TextDirection::Auto` otherwise. A label whose first strong character is
|
||||
/// Latin or a `{placeholder}` must not fall back to a left-to-right paragraph in an RTL locale.
|
||||
pub fn ui_direction() -> egui::epaint::text::TextDirection {
|
||||
if crate::i18n::current().rtl() { egui::epaint::text::TextDirection::RightToLeft } else { egui::epaint::text::TextDirection::Auto }
|
||||
}
|
||||
|
||||
/// Lay out one line of translated interface text with the locale's explicit base direction.
|
||||
pub fn layout_ui(ui: &egui::Ui, text: impl Into<String>, font: egui::FontId, color: egui::Color32) -> std::sync::Arc<egui::Galley> {
|
||||
layout_one_line(ui, text, font, color, ui_direction())
|
||||
}
|
||||
|
||||
/// Lay out one line of file or path text left to right, so an Arabic file name keeps `.pdf` at
|
||||
/// its visual end exactly as a Latin one does. The text stays logical for accessibility.
|
||||
pub fn layout_ltr(ui: &egui::Ui, text: impl Into<String>, font: egui::FontId, color: egui::Color32) -> std::sync::Arc<egui::Galley> {
|
||||
layout_one_line(ui, text, font, color, egui::epaint::text::TextDirection::LeftToRight)
|
||||
}
|
||||
|
||||
fn layout_one_line(
|
||||
ui: &egui::Ui,
|
||||
text: impl Into<String>,
|
||||
font: egui::FontId,
|
||||
color: egui::Color32,
|
||||
direction: egui::epaint::text::TextDirection,
|
||||
) -> std::sync::Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::simple_singleline(text.into(), font, color);
|
||||
job.direction = direction;
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// Multi-line text with right-to-left script (a comment in Persian, Arabic, Hebrew), broken
|
||||
/// into lines that `fits` accepts (it measures a line in logical order) and each put in display
|
||||
/// order by the Unicode bidirectional algorithm (UAX #9).
|
||||
/// into logical-order lines that `fits` accepts (it measures a line in logical order).
|
||||
///
|
||||
/// Every paragraph takes its direction from its first strong character, as `dir="auto"` does,
|
||||
/// so a Persian comment reads right to left with its Latin words and numbers left to right
|
||||
/// inside it. Lines break between words, in reading order, and a word wider than a line
|
||||
/// between characters. Use it for painting; copying and accessibility keep the logical text.
|
||||
/// The lines keep reading order and each line's paragraph direction ([`Line::rtl`]): egui 0.37's
|
||||
/// epaint already shapes every line with the Unicode bidirectional algorithm (UAX #9), so
|
||||
/// preordering here would reorder mixed Persian/Latin text a second time. Lines break between
|
||||
/// words, in reading order, and a word wider than a line between characters. Copying and
|
||||
/// accessibility use the same logical text.
|
||||
pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec<Line> {
|
||||
let info = BidiInfo::new(text, None);
|
||||
let mut lines = Vec::new();
|
||||
@@ -109,12 +143,7 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec<Line> {
|
||||
let content = para.range.start..para.range.start.saturating_add(whole.trim_end_matches(is_paragraph_separator).len());
|
||||
let rtl = para.level.is_rtl();
|
||||
for line in break_lines(text, content, &mut fits) {
|
||||
let mut out = String::new();
|
||||
// unicode-bidi indexes the first level of a line, so an empty one would panic.
|
||||
if !line.is_empty() {
|
||||
push_line(&mut out, text, &info, para, line);
|
||||
}
|
||||
lines.push(Line { text: out, rtl });
|
||||
lines.push(Line { text: text.get(line).unwrap_or_default().to_owned(), rtl });
|
||||
}
|
||||
}
|
||||
lines
|
||||
@@ -122,12 +151,18 @@ pub fn wrap(text: &str, mut fits: impl FnMut(&str) -> bool) -> Vec<Line> {
|
||||
|
||||
/// One line of `text` in display order, its direction from its first strong character (an
|
||||
/// author's name). Text without right-to-left characters comes back borrowed and unchanged.
|
||||
///
|
||||
/// Only the bidi unit tests use this now: interface labels keep logical text and let the egui
|
||||
/// fork reorder, so no production call site preorders a label.
|
||||
#[cfg(test)]
|
||||
pub fn visual_auto(text: &str) -> Cow<'_, str> {
|
||||
if !has_rtl(text) {
|
||||
return Cow::Borrowed(text);
|
||||
}
|
||||
let lines: Vec<String> = wrap(text, |_| true).into_iter().map(|l| l.text).collect();
|
||||
Cow::Owned(lines.join("\n"))
|
||||
let base = BidiInfo::new(text, None).paragraphs.first().map_or(Level::ltr(), |para| para.level);
|
||||
let mut out = String::with_capacity(text.len());
|
||||
push_visual(&mut out, text, base);
|
||||
Cow::Owned(out)
|
||||
}
|
||||
|
||||
/// The byte ranges of the lines `range` of `text` breaks into: as many words as `fits` accepts
|
||||
@@ -209,7 +244,9 @@ fn is_paragraph_separator(c: char) -> bool {
|
||||
/// - 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.
|
||||
/// painting one line; a label egui wraps breaks at the wrong end. Kept for the tests that prove
|
||||
/// the old preordering is gone; production catalogs stay logical.
|
||||
#[cfg(test)]
|
||||
pub fn display_rtl(text: &str) -> String {
|
||||
if !text.chars().any(is_rtl_script) {
|
||||
return text.to_owned();
|
||||
@@ -225,13 +262,17 @@ pub fn display_rtl(text: &str) -> String {
|
||||
}
|
||||
|
||||
/// The most words a caption may have for its closing colon to stay on the right.
|
||||
#[cfg(test)]
|
||||
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.
|
||||
#[cfg(test)]
|
||||
const PLACEHOLDER_BASE: u32 = 0xE000;
|
||||
/// Placeholders protected per line; a line with more keeps the rest as plain text.
|
||||
#[cfg(test)]
|
||||
const MAX_PLACEHOLDERS: usize = 64;
|
||||
|
||||
#[cfg(test)]
|
||||
fn push_rtl_line(out: &mut String, line: &str) {
|
||||
let edge = |c: char| c.is_whitespace() || c == '·';
|
||||
let core = line.trim_start_matches(edge);
|
||||
@@ -266,6 +307,7 @@ fn push_rtl_line(out: &mut String, line: &str) {
|
||||
/// `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.
|
||||
#[cfg(test)]
|
||||
fn mask_placeholders(text: &str) -> (String, Vec<&str>) {
|
||||
let mut masked = String::with_capacity(text.len());
|
||||
let mut names: Vec<&str> = Vec::new();
|
||||
@@ -286,6 +328,7 @@ fn mask_placeholders(text: &str) -> (String, Vec<&str>) {
|
||||
}
|
||||
|
||||
/// The byte range of the first placeholder or keyboard shortcut in `text`.
|
||||
#[cfg(test)]
|
||||
fn next_atom(text: &str) -> Option<(usize, usize)> {
|
||||
let placeholder = text.find('{').and_then(|open| {
|
||||
let len = text.get(open..)?.find('}')?;
|
||||
@@ -308,6 +351,7 @@ fn next_atom(text: &str) -> Option<(usize, usize)> {
|
||||
}
|
||||
|
||||
/// The modifier-key symbols of keyboard shortcuts: ⌘ ⇧ ⌥ ⌃.
|
||||
#[cfg(test)]
|
||||
fn is_modifier_key(c: char) -> bool {
|
||||
matches!(c, '\u{2318}' | '\u{21E7}' | '\u{2325}' | '\u{2303}')
|
||||
}
|
||||
@@ -320,15 +364,18 @@ fn is_rtl_script(c: char) -> bool {
|
||||
|
||||
/// The zero-width (non-)joiners inside a word (Persian `میروم`): egui shapes them with the
|
||||
/// letter before them.
|
||||
#[cfg(test)]
|
||||
fn is_joiner(c: char) -> bool {
|
||||
matches!(c, '\u{200C}' | '\u{200D}')
|
||||
}
|
||||
|
||||
/// Direction marks, embeddings, overrides and isolates: they steer the reordering and are not drawn.
|
||||
#[cfg(test)]
|
||||
fn is_bidi_control(c: char) -> bool {
|
||||
matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn mirrored(c: char) -> char {
|
||||
match c {
|
||||
'(' => ')',
|
||||
@@ -474,50 +521,57 @@ mod tests {
|
||||
Line { text: text.to_owned(), rtl: false }
|
||||
}
|
||||
|
||||
/// Check that a Persian paragraph, including words joined by a zero-width non-joiner, reaches epaint as
|
||||
/// one logical right-to-left run.
|
||||
#[test]
|
||||
fn persian_paragraph_reads_right_to_left() {
|
||||
// "I go to school": the words come out in reverse for egui's left-to-right placement.
|
||||
assert_eq!(wrap_at("من به مدرسه میروم", 80), [rtl("میروم مدرسه به من")]);
|
||||
fn persian_paragraph_keeps_logical_order_for_epaint() {
|
||||
// The line stays in reading order; epaint shapes and reorders it (see `on_screen`).
|
||||
assert_eq!(wrap_at("من به مدرسه میروم", 80), [rtl("من به مدرسه میروم")]);
|
||||
// The zero-width non-joiner keeps a word whole, so the shaper gets it as one run.
|
||||
assert_eq!(wrap_at("کتابها", 80), [rtl("کتابها")]);
|
||||
}
|
||||
|
||||
/// Check that Arabic text wraps into lines in logical reading order rather than visual order.
|
||||
#[test]
|
||||
fn arabic_lines_break_in_reading_order() {
|
||||
// The first words go on the first line, each line reordered on its own.
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("اثنين واحد"), rtl("أربعة ثلاثة")]);
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("أربعة ثلاثة اثنين واحد")]);
|
||||
// The first words go on the first line, in logical order for epaint to reorder.
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 11), [rtl("واحد اثنين"), rtl("ثلاثة أربعة")]);
|
||||
assert_eq!(wrap_at("واحد اثنين ثلاثة أربعة", 80), [rtl("واحد اثنين ثلاثة أربعة")]);
|
||||
}
|
||||
|
||||
/// Check that each paragraph takes its base direction from its first strong character, not from leading
|
||||
/// numbers or neutral text.
|
||||
#[test]
|
||||
fn base_direction_comes_from_the_first_strong_character() {
|
||||
// Latin first: a left-to-right paragraph with an Arabic phrase inside it.
|
||||
assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See اثنين واحد here")]);
|
||||
// Persian first: right to left, the Latin words and the number left to right inside it.
|
||||
assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("است 2 نسخه PDF فایل")]);
|
||||
assert_eq!(wrap_at("سلام Hello World", 80), [rtl("Hello World سلام")]);
|
||||
assert_eq!(wrap_at("See واحد اثنين here", 80), [ltr("See واحد اثنين here")]);
|
||||
// Persian first: right to left, with the Latin words and number in reading order.
|
||||
assert_eq!(wrap_at("فایل PDF نسخه 2 است", 80), [rtl("فایل PDF نسخه 2 است")]);
|
||||
assert_eq!(wrap_at("سلام Hello World", 80), [rtl("سلام Hello World")]);
|
||||
// Leading neutrals and numbers don't decide it.
|
||||
assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("اثنين واحد .12")]);
|
||||
assert_eq!(wrap_at("12. واحد اثنين", 80), [rtl("12. واحد اثنين")]);
|
||||
// Each paragraph finds its own direction.
|
||||
assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("اثنين واحد"), ltr("OK then"), ltr(""), rtl("עולם שלום")]);
|
||||
assert_eq!(wrap_at("واحد اثنين\nOK then\n\nשלום עולם", 80), [rtl("واحد اثنين"), ltr("OK then"), ltr(""), rtl("שלום עולם")]);
|
||||
}
|
||||
|
||||
/// Check that right-to-left punctuation and brackets stay in logical order for epaint to mirror when
|
||||
/// displaying.
|
||||
#[test]
|
||||
fn rtl_punctuation_and_brackets() {
|
||||
// The sentence's full stop ends up on the left, brackets flip.
|
||||
assert_eq!(wrap_at("سلام دنیا.", 80), [rtl(".دنیا سلام")]);
|
||||
assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("ثلاثة (اثنين) واحد")]);
|
||||
assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("است؟ آماده آیا")]);
|
||||
// Logical text: epaint mirrors punctuation and brackets for display.
|
||||
assert_eq!(wrap_at("سلام دنیا.", 80), [rtl("سلام دنیا.")]);
|
||||
assert_eq!(wrap_at("واحد (اثنين) ثلاثة", 80), [rtl("واحد (اثنين) ثلاثة")]);
|
||||
assert_eq!(wrap_at("آیا آماده است؟", 80), [rtl("آیا آماده است؟")]);
|
||||
}
|
||||
|
||||
/// Check that Persian and Arabic-Indic digits stay in logical order inside a right-to-left paragraph.
|
||||
#[test]
|
||||
fn persian_digits_go_to_the_shaper_reversed() {
|
||||
// Persian and Arabic-Indic digits are Arabic script, which egui's shaper lays out right to
|
||||
// left; they read left to right, so they are handed over reversed.
|
||||
assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("شمسی ۲۰۴۱ سال")]);
|
||||
assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("٢١ صفحه")]);
|
||||
// Glued to a word, number and word swap places within one shaped run.
|
||||
assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۲۱")]);
|
||||
// Persian and Arabic-Indic digits stay in logical order; epaint's shaper preserves their
|
||||
// left-to-right reading inside the right-to-left paragraph.
|
||||
assert_eq!(wrap_at("سال ۱۴۰۲ شمسی", 80), [rtl("سال ۱۴۰۲ شمسی")]);
|
||||
assert_eq!(wrap_at("صفحه ١٢", 80), [rtl("صفحه ١٢")]);
|
||||
assert_eq!(wrap_at("ب۱۲", 80), [rtl("ب۱۲")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -546,8 +600,30 @@ mod tests {
|
||||
let output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
for line in wrap(text, |_| true) {
|
||||
let galley = ui.painter().layout_no_wrap(line.text, egui::FontId::proportional(13.0), egui::Color32::WHITE);
|
||||
let glyphs = galley.rows.iter().flat_map(|row| row.glyphs.iter());
|
||||
shown.push(glyphs.filter(|g| g.advance_width > 0.0).map(|g| g.chr).collect());
|
||||
let atlas = ui.fonts_mut(|fonts| fonts.image());
|
||||
let mut visual = String::new();
|
||||
for row in &galley.rows {
|
||||
// 0.37 keeps glyphs in logical order for selection. Read their actual
|
||||
// positions to test visual order instead of assuming vector order.
|
||||
let mut glyphs: Vec<_> = row.glyphs.iter().filter(|g| g.advance_width > 0.0).collect();
|
||||
glyphs.sort_by(|a, b| a.pos.x.total_cmp(&b.pos.x));
|
||||
visual.extend(glyphs.into_iter().map(|g| match g.chr {
|
||||
// `chr` remains logical even when the shaper mirrors the painted
|
||||
// bracket. Read the bitmap's middle-row ink to check what is visible.
|
||||
'(' | ')' => {
|
||||
let [x0, y0] = g.uv_rect.min.map(usize::from);
|
||||
let [x1, y1] = g.uv_rect.max.map(usize::from);
|
||||
let y = usize::midpoint(y0, y1);
|
||||
let ink: Vec<f32> = (x0..x1).map(|x| f32::from(atlas[(x, y)].a())).collect();
|
||||
let total: f32 = ink.iter().sum();
|
||||
assert!(total > 0.0 && !ink.is_empty(), "a bracket must have visible ink");
|
||||
let center = ink.iter().enumerate().map(|(i, alpha)| i as f32 * alpha).sum::<f32>() / total;
|
||||
if center < (ink.len() - 1) as f32 / 2.0 { '(' } else { ')' }
|
||||
}
|
||||
_ => g.chr,
|
||||
}));
|
||||
}
|
||||
shown.push(visual);
|
||||
}
|
||||
});
|
||||
output.drop_without_applying_deltas();
|
||||
@@ -573,6 +649,64 @@ mod tests {
|
||||
assert_eq!(on_screen("واحد (اثنين)."), [format!(".({}) {}", rev("اثنين"), rev("واحد"))]);
|
||||
}
|
||||
|
||||
/// A file name keeps its extension at the visual end even when the first strong word is
|
||||
/// Arabic: with an explicit `LeftToRight` base, `.pdf` is right of every Arabic glyph.
|
||||
#[test]
|
||||
fn file_names_are_laid_out_left_to_right() {
|
||||
if pdfcraft_fonts::ui_arabic_fonts().is_empty() {
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions());
|
||||
let mut positions: Vec<(char, f32)> = Vec::new();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let galley = layout_ltr(ui, "واحد اثنين.pdf", egui::FontId::proportional(13.0), egui::Color32::WHITE);
|
||||
positions = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect();
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max);
|
||||
let leftmost_extension = positions.iter().filter(|(c, _)| matches!(*c, '.' | 'p' | 'd' | 'f')).map(|(_, x)| *x).fold(f32::MAX, f32::min);
|
||||
assert!(rightmost_arabic < leftmost_extension, "the extension must stay to the right: {positions:?}");
|
||||
let last = positions.iter().filter(|(c, _)| !c.is_whitespace()).max_by(|a, b| a.1.total_cmp(&b.1));
|
||||
assert_eq!(last.map(|(c, _)| *c), Some('f'), "the painted line ends in the extension");
|
||||
}
|
||||
|
||||
/// Arabic/Kurdish labels get an explicit right-to-left paragraph, even when their first
|
||||
/// strong character is Latin. With English the same text stays left to right.
|
||||
#[test]
|
||||
fn arabic_ui_labels_use_an_explicit_paragraph_direction() {
|
||||
if pdfcraft_fonts::ui_arabic_fonts().is_empty() {
|
||||
return;
|
||||
}
|
||||
let previous = crate::i18n::current();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_fonts(crate::theme::font_definitions());
|
||||
let read = |lang: &str| {
|
||||
crate::i18n::set_current(crate::i18n::Lang::from_code(lang).expect("registered language"));
|
||||
let mut got = None;
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let galley = layout_ui(ui, "PDF واحد", egui::FontId::proportional(13.0), egui::Color32::WHITE);
|
||||
let positions: Vec<_> = galley.rows.iter().flat_map(|row| row.glyphs.iter().map(|g| (g.chr, g.pos.x))).collect();
|
||||
got = Some((galley.job.direction, positions));
|
||||
})
|
||||
.drop_without_applying_deltas();
|
||||
got.expect("one layout")
|
||||
};
|
||||
let (direction, positions) = read("ar");
|
||||
crate::i18n::set_current(previous);
|
||||
assert_eq!(direction, egui::epaint::text::TextDirection::RightToLeft);
|
||||
let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P");
|
||||
let rightmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MIN, f32::max);
|
||||
assert!(p > rightmost_arabic, "in an RTL paragraph the first Latin run is at the right: {positions:?}");
|
||||
|
||||
let (direction, positions) = read("en");
|
||||
crate::i18n::set_current(previous);
|
||||
assert_eq!(direction, egui::epaint::text::TextDirection::Auto);
|
||||
let p = positions.iter().find(|(c, _)| *c == 'P').map(|(_, x)| *x).expect("a Latin P");
|
||||
let leftmost_arabic = positions.iter().filter(|(c, _)| is_rtl_script(*c)).map(|(_, x)| *x).fold(f32::MAX, f32::min);
|
||||
assert!(p < leftmost_arabic, "with English the Latin run stays at the left: {positions:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_line_with_its_own_direction() {
|
||||
assert!(matches!(visual_auto("Ada Lovelace"), Cow::Borrowed("Ada Lovelace")));
|
||||
|
||||
@@ -283,7 +283,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool
|
||||
egui::Grid::new("bulk-field-properties").num_columns(3).spacing([16.0, 10.0]).show(ui, |ui| match d.tab {
|
||||
Tab::General => {
|
||||
row(ui, "Tooltip:", &mut d.tooltip, |ui, v| {
|
||||
ui.add(egui::TextEdit::singleline(v).desired_width(280.0));
|
||||
ui.add(egui::TextEdit::singleline(v).desired_width(280.0)).accessible_name(tl!("Tooltip"));
|
||||
});
|
||||
bool_row(ui, "Read Only", &mut d.read_only);
|
||||
bool_row(ui, "Required", &mut d.required);
|
||||
|
||||
@@ -566,7 +566,7 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let is_selected = selected.as_deref() == Some(f.name.as_str());
|
||||
resp.widget_info(|| {
|
||||
let label = if row.value.is_empty() { format!("{}. {}", k + 1, f.name) } else { format!("{}. {}: {}", k + 1, f.name, row.value) };
|
||||
egui::WidgetInfo::selected(egui::WidgetType::Button, true, is_selected, label)
|
||||
egui::WidgetInfo::selected(egui::Role::Button, true, is_selected, label)
|
||||
});
|
||||
if reveal.as_deref() == Some(f.name.as_str()) {
|
||||
resp.scroll_to_me(Some(egui::Align::Center));
|
||||
@@ -631,8 +631,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
[(up, "chevron-up", true, tl!("Earlier in calculation order")), (down, "chevron-down", false, tl!("Later in calculation order"))]
|
||||
{
|
||||
let to = if earlier { k.checked_sub(1) } else { Some(k + 1).filter(|&to| to < count) };
|
||||
let b = ui.interact(r, ui.id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() });
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, to.is_some(), format!("{tip}: {}", f.name)));
|
||||
let b =
|
||||
ui.interact(r, ui.scope_id().with(("calc-order", k, earlier)), if to.is_some() { Sense::click() } else { Sense::hover() });
|
||||
b.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, to.is_some(), format!("{tip}: {}", f.name)));
|
||||
if visible {
|
||||
let bpress = theme::Press::track(ui, &b);
|
||||
bpress.wash(ui, r, 4, false);
|
||||
|
||||
@@ -141,7 +141,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, view:
|
||||
/// way, choose its look (original, colour, greyscale, black and white) and clean-up level,
|
||||
/// Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`).
|
||||
pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Tokens, allowed: bool) {
|
||||
egui::Area::new(egui::Id::new("camera-bar"))
|
||||
egui::Area::new(egui::Id::unique("camera-bar"))
|
||||
.order(egui::Order::Middle)
|
||||
.pivot(Align2::RIGHT_TOP)
|
||||
.fixed_pos(area.right_top() + vec2(-18.0, 12.0))
|
||||
@@ -183,7 +183,7 @@ pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Token
|
||||
let icon = |ui: &mut egui::Ui, name: &str, tip: &str| {
|
||||
let r = ui.add_enabled(allowed, egui::Button::image(crate::icons::image(name, 18.0, t.text)).frame(false));
|
||||
// Named for screen readers (and tests), not only on hover.
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, allowed, tip));
|
||||
r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, allowed, tip));
|
||||
r.on_hover_text(tip).clicked()
|
||||
};
|
||||
if icon(ui, "rotate-ccw", tl!("Rotate counterclockwise")) {
|
||||
|
||||
+671
-77
File diff suppressed because it is too large.
Load diff
+186
-297
@@ -1,6 +1,7 @@
|
||||
//! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail.
|
||||
|
||||
use egui::{Align, Align2, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
|
||||
use craft_ui::panes::{Edge, Rail, RailItem, RailRequest};
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
|
||||
|
||||
use crate::canvas::{DocView, Fit, PageLayout};
|
||||
use crate::theme::{self, ThemePreference, Tokens};
|
||||
@@ -26,6 +27,8 @@ const TAB_STRIP: f32 = 38.0;
|
||||
const TAB_HEIGHT: f32 = 30.0;
|
||||
const STRIP_BUTTON: f32 = 28.0;
|
||||
|
||||
/// Draw the window's tab strip: home and open controls, shrinkable and scrollable document tabs with drag
|
||||
/// reordering, and platform window controls.
|
||||
pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// macOS: the strip is the 28 pt title bar, so the traffic lights are centred in it; tabs and
|
||||
@@ -38,7 +41,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
.frame(egui::Frame::NONE.fill(t.titlebar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 }))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
let drag = ui.interact(full, ui.id().with("titledrag"), Sense::click_and_drag());
|
||||
let drag = ui.interact(full, ui.scope_id().with("titledrag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
window_controls::desktop_grab(ui.ctx(), egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
@@ -92,7 +95,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
let cap = tab_cap(&natural, tabs_width, gap, TAB_MIN_WIDTH);
|
||||
// Where the tabs were scrolled to last frame, and how far they can go.
|
||||
let scroll_id = ui.id().with("tab_scroll");
|
||||
let scroll_id = ui.scope_id().with("tab_scroll");
|
||||
let (offset, max_offset) = ui.data(|d| d.get_temp::<(f32, f32)>(scroll_id)).unwrap_or((0.0, 0.0));
|
||||
let mut scroll_to = None;
|
||||
if overflow && arrow(ui, "chevron-left", offset > 0.5, tl!("Scroll tabs left")) {
|
||||
@@ -102,7 +105,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
// the strip jumping to it.
|
||||
let state = (app.active_ids().map(|(_, id)| id), app.views.len(), app.combine_showing());
|
||||
let changed = ui.data_mut(|data| {
|
||||
let id = ui.id().with("active_tab");
|
||||
let id = ui.scope_id().with("active_tab");
|
||||
let changed = data.get_temp::<(Option<pdfcraft_engine::DocId>, usize, bool)>(id) != Some(state);
|
||||
data.insert_temp(id, state);
|
||||
changed
|
||||
@@ -131,7 +134,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let file = (name != doc.name).then_some(doc.name.as_str());
|
||||
let response = tab(
|
||||
ui,
|
||||
ui.id().with(("document-tab", app.views[i].id)),
|
||||
ui.scope_id().with(("document-tab", app.views[i].id)),
|
||||
&t,
|
||||
"file-text",
|
||||
&name,
|
||||
@@ -213,7 +216,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let mut close = None;
|
||||
let response = tab(
|
||||
ui,
|
||||
ui.id().with("combine-tab"),
|
||||
ui.scope_id().with("combine-tab"),
|
||||
&t,
|
||||
"files",
|
||||
tl!("Combine files"),
|
||||
@@ -239,7 +242,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
let max = (out.content_size.x - out.inner_rect.width()).max(0.0);
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.offset.x, max)));
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id, (out.state.clamped_offset().x, max)));
|
||||
// A tab dragged to either edge of a strip that scrolls brings the hidden tabs in.
|
||||
if let Some(x) = drag_x
|
||||
&& max > 0.0
|
||||
@@ -252,7 +255,7 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
0.0
|
||||
};
|
||||
if step != 0.0 {
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.offset.x + step).clamp(0.0, max)));
|
||||
ui.data_mut(|d| d.insert_temp(scroll_id.with("to"), (out.state.clamped_offset().x + step).clamp(0.0, max)));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
@@ -308,25 +311,6 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Both theme entry points use the same choices and command path.
|
||||
fn theme_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
for (preference, command, label) in [
|
||||
(ThemePreference::System, "view.theme.system", "Use system setting"),
|
||||
(ThemePreference::Light, "view.theme.light", "Light gray"),
|
||||
(ThemePreference::Dark, "view.theme.dark", "Dark gray"),
|
||||
] {
|
||||
if ui.add(widgets::radio(app.theme_preference == preference, tl!(label))).clicked() {
|
||||
app.execute(command);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Appearance settings…")).clicked() {
|
||||
app.dialog = Some(Dialog::Preferences);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// The right-click menu on the title bar's theme button.
|
||||
fn appearance_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if ui.button(tl!("Appearance settings…")).clicked() {
|
||||
@@ -383,7 +367,7 @@ fn tab_name(name: &str) -> String {
|
||||
|
||||
/// The width a tab takes when nothing has to shrink.
|
||||
fn tab_natural_width(ui: &egui::Ui, t: &Tokens, name: &str) -> f32 {
|
||||
let label = crate::bidi::visual(&tab_name(name)).into_owned();
|
||||
let label = tab_name(name);
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(label, theme::regular(13.0), t.text).size().x) + TAB_CHROME
|
||||
}
|
||||
|
||||
@@ -411,6 +395,8 @@ fn tab_cap(natural: &[f32], budget: f32, gap: f32, min: f32) -> Option<f32> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Draw one tab with its icon, elided label, close button and unsaved dot; records a close request and
|
||||
/// returns the tab response for drag handling.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn tab(
|
||||
ui: &mut egui::Ui,
|
||||
@@ -432,13 +418,13 @@ fn tab(
|
||||
// A shrunk tab shows as much of its name as fits (the tooltip has all of it).
|
||||
if let Some(cap) = cap {
|
||||
let room = (cap - TAB_CHROME).max(0.0);
|
||||
label = ui.fonts_mut(|f| {
|
||||
crate::panels::ellipsized_prefix(&label, |s| f.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), t.text).size().x <= room)
|
||||
});
|
||||
label = crate::panels::ellipsized_prefix(&label, |s| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), t.text).size().x <= room);
|
||||
}
|
||||
// Painted text only: the accessibility name below keeps the logical order.
|
||||
let label = crate::bidi::visual(&label).into_owned();
|
||||
let text_w = ui.fonts_mut(|f| f.layout_no_wrap(label.clone(), font.clone(), t.text).size().x);
|
||||
// Logical text, laid out explicitly left to right: the fork shapes and reorders it once,
|
||||
// and an Arabic file name keeps its extension at the visual end. AccessKit names stay logical.
|
||||
let label_color = if active { t.text } else { t.text_muted };
|
||||
let label_galley = crate::bidi::layout_ltr(ui, label.clone(), font.clone(), label_color);
|
||||
let text_w = label_galley.size().x;
|
||||
let width = cap.map_or(text_w + TAB_CHROME, |cap| (text_w + TAB_CHROME).min(cap));
|
||||
// Full strip height so the active tab sits on the mode bar instead of floating in the title bar.
|
||||
let height = ui.available_height().max(height);
|
||||
@@ -462,7 +448,7 @@ fn tab(
|
||||
15.0,
|
||||
if active { t.accent } else { t.text_muted },
|
||||
);
|
||||
ui.painter().text(rect.min + vec2(28.0, rect.height() / 2.0), Align2::LEFT_CENTER, label, font, if active { t.text } else { t.text_muted });
|
||||
ui.painter().galley(rect.min + vec2(28.0, (rect.height() - label_galley.size().y) / 2.0), label_galley.clone(), label_color);
|
||||
});
|
||||
let x_rect = Rect::from_center_size(rect.right_center() - vec2(16.0, 0.0), vec2(20.0, 20.0));
|
||||
let x = ui.interact(x_rect, id.with("close"), Sense::click());
|
||||
@@ -478,14 +464,10 @@ fn tab(
|
||||
*close = Some(index);
|
||||
}
|
||||
let shown = match file {
|
||||
Some(file) => std::borrow::Cow::Owned(format!("{}\n{}", crate::bidi::visual(name), crate::bidi::visual(file))),
|
||||
None => crate::bidi::visual(name),
|
||||
Some(file) => format!("{name}\n{file}"),
|
||||
None => name.to_string(),
|
||||
};
|
||||
theme::hand(resp).on_hover_text(if dirty {
|
||||
crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", shown.as_ref())])
|
||||
} else {
|
||||
shown.into_owned()
|
||||
})
|
||||
theme::hand(resp).on_hover_text(if dirty { crate::i18n::fmt(tl!("{name} — unsaved changes"), &[("name", &shown)]) } else { shown })
|
||||
}
|
||||
|
||||
pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
@@ -496,10 +478,13 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
main_menu(app, ui);
|
||||
ui.add_space(6.0);
|
||||
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
|
||||
ui.add_space(10.0);
|
||||
// The menus, unless the native macOS menu bar has them.
|
||||
if app.native_menu.is_none() {
|
||||
app.menu_bar_ui(ui);
|
||||
ui.add_space(6.0);
|
||||
ui.painter().vline(ui.cursor().left(), ui.max_rect().y_range().shrink(12.0), Stroke::new(1.0, t.divider));
|
||||
ui.add_space(10.0);
|
||||
}
|
||||
// Acrobat's mode bar has no Read tab: reading is what the viewer does with no tool
|
||||
// in use. Read mode (the panes hidden) stays a View command and a default workspace.
|
||||
for (mode, label) in [(Mode::AllTools, "All tools"), (Mode::Edit, "Edit"), (Mode::Convert, "Convert"), (Mode::Sign, "E-Sign")] {
|
||||
@@ -539,103 +524,8 @@ pub fn mode_bar(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Keep menus usable when their contents are taller than the window.
|
||||
pub(crate) fn scroll_menu(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
||||
let height = (ui.ctx().content_rect().height() - 24.0).max(40.0);
|
||||
egui::ScrollArea::vertical().max_height(height).auto_shrink([true, true]).show(ui, add);
|
||||
}
|
||||
|
||||
fn main_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = widgets::ghost_button(ui, "panel-left", tl!("Menu"));
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
ui.set_min_width(230.0);
|
||||
ui.menu_button(tl!("File"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "File")));
|
||||
ui.menu_button(tl!("Edit"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Edit")));
|
||||
ui.menu_button(tl!("Pages"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Pages")));
|
||||
ui.menu_button(tl!("View"), |ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
if let Some(i) = app.active {
|
||||
let v = &mut app.views[i];
|
||||
let ctx = ui.ctx().clone();
|
||||
let key = |s| crate::commands::shortcut_label(&ctx, s);
|
||||
use crate::commands::{ACTUAL_SIZE, FIT_WIDTH, NEXT_VIEW, PAGE_LEVEL, PREV_VIEW, ROTATE_CCW, ROTATE_CW, ZOOM_IN, ZOOM_OUT};
|
||||
ui.label(egui::RichText::new(tl!("Zoom")).color(t.text_faint).small());
|
||||
if widgets::menu_item(ui, tl!("Actual size"), &key(ACTUAL_SIZE)).clicked() {
|
||||
v.set_zoom(1.0);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom to page level"), &key(PAGE_LEVEL)).clicked() {
|
||||
v.fit = Fit::Page;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit to width"), &key(FIT_WIDTH)).clicked() {
|
||||
v.fit = Fit::Width;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit to height"), "").clicked() {
|
||||
v.fit = Fit::Height;
|
||||
v.goto = Some((v.current, 0.0));
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Fit visible"), &crate::commands::command_shortcut_label(&ctx, "view.fit_visible")).clicked() {
|
||||
ui.close();
|
||||
app.execute("view.fit_visible");
|
||||
return;
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom in"), &key(ZOOM_IN)).clicked() {
|
||||
v.zoom_step(true);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Zoom out"), &key(ZOOM_OUT)).clicked() {
|
||||
v.zoom_step(false);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rotate view clockwise"), &key(ROTATE_CW)).clicked() {
|
||||
v.rotate_view(true);
|
||||
}
|
||||
if widgets::menu_item(ui, tl!("Rotate view counterclockwise"), &key(ROTATE_CCW)).clicked() {
|
||||
v.rotate_view(false);
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Page navigation")).color(t.text_faint).small());
|
||||
if ui.add_enabled(!v.back.is_empty(), egui::Button::new(tl!("Previous view")).shortcut_text(key(PREV_VIEW))).clicked() {
|
||||
v.view_history(false);
|
||||
}
|
||||
if ui.add_enabled(!v.forward.is_empty(), egui::Button::new(tl!("Next view")).shortcut_text(key(NEXT_VIEW))).clicked() {
|
||||
v.view_history(true);
|
||||
}
|
||||
ui.separator();
|
||||
if let Some(id) = page_display_menu(ui, &app.views[i]) {
|
||||
app.execute(id);
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
crate::commands::registry_menu(app, ui, "View");
|
||||
ui.menu_button(tl!("Display theme"), |ui| theme_menu(app, ui));
|
||||
ui.menu_button(tl!("Side panels"), |ui| {
|
||||
scroll_menu(ui, |ui| {
|
||||
for (p, label) in [
|
||||
(RightPanel::Comments, tl!("Comments")),
|
||||
(RightPanel::Bookmarks, tl!("Bookmarks")),
|
||||
(RightPanel::Pages, tl!("Pages")),
|
||||
(RightPanel::Fields, tl!("Fields")),
|
||||
(RightPanel::Layers, tl!("Layers")),
|
||||
(RightPanel::Attachments, tl!("Attachments")),
|
||||
(RightPanel::Signatures, tl!("Signatures")),
|
||||
(RightPanel::Accessibility, tl!("Accessibility Checker")),
|
||||
(RightPanel::Tags, tl!("Tags")),
|
||||
(RightPanel::Search, tl!("Search")),
|
||||
(RightPanel::Compare, tl!("Compare")),
|
||||
] {
|
||||
if ui.add(widgets::radio(app.right == Some(p), tl!(label))).clicked() {
|
||||
app.choose_right_panel(Some(p));
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
})
|
||||
});
|
||||
ui.menu_button(tl!("Help"), |ui| scroll_menu(ui, |ui| crate::commands::registry_menu(app, ui, "Help")));
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw the right tool rail with page navigation, history, zoom, rotation and page-display controls for the
|
||||
/// active view.
|
||||
pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some((index, id)) = app.active_ids() else { return };
|
||||
@@ -651,166 +541,165 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
let page_count = doc.info.pages.len();
|
||||
let labels: Vec<String> = doc.info.pages.iter().map(|p| p.label.clone()).collect();
|
||||
egui::Panel::right("rail")
|
||||
.resizable(false)
|
||||
.exact_size(48.0)
|
||||
let item = |panel: RightPanel, icon: &str, label: &str, marker: Option<&str>| {
|
||||
let item = RailItem::new(panel, icons::source(icon), tl!(label)).marker(marker.is_some());
|
||||
match marker {
|
||||
Some(marker) => item.marker_label(tl!(marker)),
|
||||
None => item,
|
||||
}
|
||||
};
|
||||
let has = |has: bool, label: &'static str| has.then_some(label);
|
||||
let mut items = vec![
|
||||
item(RightPanel::Comments, "message-square-text", "Comments", has(has_comments, "Has comments")),
|
||||
item(RightPanel::Bookmarks, "bookmark", "Bookmarks", has(has_outline, "Has bookmarks")),
|
||||
item(RightPanel::Pages, "files", "Page thumbnails", None),
|
||||
item(RightPanel::Fields, "text-cursor-input", "Form fields", has(has_fields, "Has form fields")),
|
||||
item(RightPanel::Layers, "layers", "Layers", has(has_layers, "Has layers")),
|
||||
item(RightPanel::Attachments, "paperclip", "Attachments", has(has_files, "Has attachments")),
|
||||
item(RightPanel::Signatures, "signature", "Signatures", has(has_signatures, "Signed")),
|
||||
];
|
||||
if has_check {
|
||||
items.push(item(RightPanel::Accessibility, "accessibility", "Accessibility Checker", None));
|
||||
}
|
||||
items.push(item(RightPanel::Tags, "tag", "Tags", None));
|
||||
let view = &mut app.views[index];
|
||||
let mut page_display = None;
|
||||
let rail = Rail::new(egui::Id::unique("rail"))
|
||||
.edge(Edge::End)
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
|
||||
.show(ui, |ui| {
|
||||
// Page navigation cluster at the bottom (as in Acrobat's rail). It is a panel of its own so
|
||||
// that in a short window the buttons above scroll instead of running into it (#3282).
|
||||
let view = &mut app.views[index];
|
||||
let mut page_display = None;
|
||||
egui::Panel::bottom("rail-nav").frame(egui::Frame::NONE).show_separator_line(false).resizable(false).show(ui, |ui| {
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
|
||||
if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
|
||||
view.zoom_step(false);
|
||||
.show(ui, &items, app.right.as_ref(), |ui| {
|
||||
// Page navigation cluster at the bottom, a panel of its own so that in a short
|
||||
// window the buttons above scroll instead of running into it (#3282).
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
|
||||
if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
|
||||
view.zoom_step(false);
|
||||
}
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
|
||||
if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
|
||||
view.zoom_step(true);
|
||||
}
|
||||
// Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
|
||||
let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
|
||||
let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
|
||||
page_display = egui::Popup::menu(&resp)
|
||||
.show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
rail_view_menu(ui, view)
|
||||
})
|
||||
.and_then(|r| r.inner);
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
|
||||
if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
|
||||
view.rotate_view(true);
|
||||
}
|
||||
// Not `columns-2` while fitting the page: that is the two-page view's icon.
|
||||
let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
|
||||
if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
|
||||
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
|
||||
view.goto = Some((view.current, 0.0));
|
||||
}
|
||||
let id = egui::Id::unique("zoom-input");
|
||||
let draft = id.with("draft");
|
||||
let mut typed = ui.data(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
|
||||
let edit = egui::TextEdit::singleline(&mut typed)
|
||||
.id(id)
|
||||
.desired_width(40.0)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.font(theme::medium(11.0))
|
||||
.text_color(t.text_muted)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(vec2(2.0, 2.0));
|
||||
let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
|
||||
ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
|
||||
if r.gained_focus() {
|
||||
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(draft));
|
||||
if r.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
&& let Some(zoom) = DocView::parse_zoom_percent(&typed)
|
||||
{
|
||||
view.set_zoom(zoom);
|
||||
}
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom in ({key})"), crate::commands::ZOOM_IN);
|
||||
if icons::button(ui, "zoom-in", 32.0, false, &tip).clicked() {
|
||||
view.zoom_step(true);
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
|
||||
view.step_page(true);
|
||||
}
|
||||
if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
|
||||
view.step_page(false);
|
||||
}
|
||||
// History is distinct from stepping to the adjacent page, and belongs to this tab.
|
||||
for (forward, icon, label, shortcut, enabled) in [
|
||||
(true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
|
||||
(false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
|
||||
] {
|
||||
let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
|
||||
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
|
||||
view.view_history(forward);
|
||||
}
|
||||
// Between rotate and zoom, as in Acrobat. Its command runs once `view` is free.
|
||||
let tip = crate::i18n::fmt(tl!("Page display: {layout}"), &[("layout", tl!(view.layout.label()))]);
|
||||
let resp = icons::button(ui, view.layout.icon(), 32.0, false, &tip);
|
||||
page_display = egui::Popup::menu(&resp)
|
||||
.show(|ui| {
|
||||
ui.set_min_width(220.0);
|
||||
rail_view_menu(ui, view)
|
||||
})
|
||||
.and_then(|r| r.inner);
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Rotate view clockwise ({key})"), crate::commands::ROTATE_CW);
|
||||
if icons::button(ui, "rotate-cw", 32.0, false, &tip).clicked() {
|
||||
view.rotate_view(true);
|
||||
}
|
||||
let font = theme::regular(11.0);
|
||||
let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str));
|
||||
if widgets::text_width(ui, &position, &font) > ui.available_width() {
|
||||
position = page_count.to_string();
|
||||
}
|
||||
// Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end.
|
||||
let shown = if view.current < page_count { view.current + 1 } else { page_count };
|
||||
let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]);
|
||||
ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of);
|
||||
let page_input_id = view.page_input_id();
|
||||
// Consume replacement selection once, including when the editor has lost focus.
|
||||
let reselect = std::mem::take(&mut view.page_input_reselect);
|
||||
// Navigation updates the idle display, never the text being edited.
|
||||
if !ui.memory(|m| m.has_focus(page_input_id)) {
|
||||
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
|
||||
} else if reselect {
|
||||
// The replacement changed the label while egui retained this editor's cursor.
|
||||
let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
|
||||
let end = egui::text::CCursor::new(view.page_input.chars().count());
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
|
||||
state.store(ui.ctx(), page_input_id);
|
||||
}
|
||||
let edit = egui::TextEdit::singleline(&mut view.page_input)
|
||||
.id(page_input_id)
|
||||
.desired_width(34.0)
|
||||
.align(egui::Align2::CENTER_TOP)
|
||||
.font(theme::medium(12.0))
|
||||
.margin(vec2(2.0, 4.0));
|
||||
let r = egui::Frame::NONE
|
||||
.fill(t.field)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(5))
|
||||
.show(ui, |ui| ui.add(edit))
|
||||
.inner
|
||||
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"))
|
||||
.accessible_name(tl!("Current page"));
|
||||
if r.lost_focus() {
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
// A page label first (logical page numbers, as Acrobat), then a number.
|
||||
let typed = view.page_input.clone();
|
||||
view.go_to_typed(&typed, &labels);
|
||||
}
|
||||
// Not `columns-2` while fitting the page: that is the two-page view's icon.
|
||||
let fit_icon = if view.fit == Fit::Width { "maximize" } else { "maximize-2" };
|
||||
if icons::button(ui, fit_icon, 32.0, false, tl!("Toggle fit page / fit width")).clicked() {
|
||||
view.fit = if view.fit == Fit::Width { Fit::Page } else { Fit::Width };
|
||||
view.goto = Some((view.current, 0.0));
|
||||
}
|
||||
let id = egui::Id::new("zoom-input");
|
||||
let draft = id.with("draft");
|
||||
let mut typed = ui.data(|d| d.get_temp::<String>(draft)).unwrap_or_else(|| format!("{:.0}%", view.zoom * 100.0));
|
||||
let edit = egui::TextEdit::singleline(&mut typed)
|
||||
.id(id)
|
||||
.desired_width(40.0)
|
||||
.horizontal_align(Align::Center)
|
||||
.font(theme::medium(11.0))
|
||||
.text_color(t.text_muted)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(vec2(2.0, 2.0));
|
||||
let r = ui.add(edit).on_hover_text(tl!("Zoom — type a percentage and press Enter"));
|
||||
ui.ctx().accesskit_node_builder(id, |node| node.set_label(tl!("Zoom")));
|
||||
if r.gained_focus() {
|
||||
let mut state = egui::TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
|
||||
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(typed.chars().count()));
|
||||
state.cursor.set_char_range(Some(all));
|
||||
state.store(ui.ctx(), id);
|
||||
}
|
||||
if r.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(draft, typed.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(draft));
|
||||
if r.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter))
|
||||
&& let Some(zoom) = DocView::parse_zoom_percent(&typed)
|
||||
{
|
||||
view.set_zoom(zoom);
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if icons::button(ui, "chevron-down", 30.0, false, tl!("Next page")).clicked() {
|
||||
view.step_page(true);
|
||||
}
|
||||
if icons::button(ui, "chevron-up", 30.0, false, tl!("Previous page")).clicked() {
|
||||
view.step_page(false);
|
||||
}
|
||||
// History is distinct from stepping to the adjacent page, and belongs to this tab.
|
||||
for (forward, icon, label, shortcut, enabled) in [
|
||||
(true, "chevron-right", tl!("Next view"), crate::commands::NEXT_VIEW, !view.forward.is_empty()),
|
||||
(false, "chevron-left", tl!("Previous view"), crate::commands::PREV_VIEW, !view.back.is_empty()),
|
||||
] {
|
||||
let tip = format!("{label} ({})", crate::commands::shortcut_label(ui.ctx(), shortcut));
|
||||
if ui.add_enabled_ui(enabled, |ui| icons::button(ui, icon, 30.0, false, &tip)).inner.clicked() {
|
||||
view.view_history(forward);
|
||||
}
|
||||
}
|
||||
let font = theme::regular(11.0);
|
||||
let mut position = page_position(view.current, page_count, labels.get(view.current).map_or("", String::as_str));
|
||||
if widgets::text_width(ui, &position, &font) > ui.available_width() {
|
||||
position = page_count.to_string();
|
||||
}
|
||||
// Out of range (an empty document), like `page_position`: "Page 0 of 0", never past the end.
|
||||
let shown = if view.current < page_count { view.current + 1 } else { page_count };
|
||||
let of = crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &shown.to_string()), ("n", &page_count.to_string())]);
|
||||
ui.label(egui::RichText::new(position).font(font).color(t.text_muted)).on_hover_text(of);
|
||||
let page_input_id = view.page_input_id();
|
||||
// Consume replacement selection once, including when the editor has lost focus.
|
||||
let reselect = std::mem::take(&mut view.page_input_reselect);
|
||||
// Navigation updates the idle display, never the text being edited.
|
||||
if !ui.memory(|m| m.has_focus(page_input_id)) {
|
||||
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
|
||||
} else if reselect {
|
||||
// The replacement changed the label while egui retained this editor's cursor.
|
||||
let mut state = egui::TextEdit::load_state(ui.ctx(), page_input_id).unwrap_or_default();
|
||||
let end = egui::text::CCursor::new(view.page_input.chars().count());
|
||||
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), end)));
|
||||
state.store(ui.ctx(), page_input_id);
|
||||
}
|
||||
let edit = egui::TextEdit::singleline(&mut view.page_input)
|
||||
.id(page_input_id)
|
||||
.desired_width(34.0)
|
||||
.horizontal_align(Align::Center)
|
||||
.font(theme::medium(12.0))
|
||||
.margin(vec2(2.0, 4.0));
|
||||
let r = egui::Frame::NONE
|
||||
.fill(t.field)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(5))
|
||||
.show(ui, |ui| ui.add(edit))
|
||||
.inner
|
||||
.on_hover_text(tl!("Current page — type a page number or label (such as iv) and press Enter"));
|
||||
if r.lost_focus() {
|
||||
if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
// A page label first (logical page numbers, as Acrobat), then a number.
|
||||
let typed = view.page_input.clone();
|
||||
view.go_to_typed(&typed, &labels);
|
||||
}
|
||||
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::ScrollArea::vertical().id_salt("rail-panels").show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
let mut rail_button = |ui: &mut egui::Ui, panel: RightPanel, icon: &str, tip: &str, has: bool| {
|
||||
let selected = app.right == Some(panel) && (app.mode == Mode::Read || app.docking.exposed(crate::docking::Panel::Inspector));
|
||||
let r = icons::button(ui, icon, 34.0, selected, tl!(tip));
|
||||
if has && !selected {
|
||||
let c = r.rect.right_top() + vec2(-8.0, 8.0);
|
||||
ui.painter().circle_filled(c, 3.0, t.accent);
|
||||
}
|
||||
if r.clicked() {
|
||||
app.choose_right_panel(if selected { None } else { Some(panel) });
|
||||
}
|
||||
};
|
||||
rail_button(ui, RightPanel::Comments, "message-square-text", "Comments", has_comments);
|
||||
rail_button(ui, RightPanel::Bookmarks, "bookmark", "Bookmarks", has_outline);
|
||||
rail_button(ui, RightPanel::Pages, "files", "Page thumbnails", false);
|
||||
rail_button(ui, RightPanel::Fields, "text-cursor-input", "Form fields", has_fields);
|
||||
rail_button(ui, RightPanel::Layers, "layers", "Layers", has_layers);
|
||||
rail_button(ui, RightPanel::Attachments, "paperclip", "Attachments", has_files);
|
||||
rail_button(ui, RightPanel::Signatures, "signature", "Signatures", has_signatures);
|
||||
if has_check {
|
||||
rail_button(ui, RightPanel::Accessibility, "accessibility", "Accessibility Checker", false);
|
||||
view.page_input = view.current_page_label(labels.get(view.current).map(String::as_str));
|
||||
}
|
||||
rail_button(ui, RightPanel::Tags, "tag", "Tags", false);
|
||||
});
|
||||
if let Some(id) = page_display {
|
||||
app.execute(id);
|
||||
}
|
||||
});
|
||||
match rail.request {
|
||||
Some(RailRequest::Open(panel)) => app.choose_right_panel(Some(panel)),
|
||||
Some(RailRequest::Close(_)) => app.choose_right_panel(None),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(id) = page_display {
|
||||
app.execute(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the rail shows under the page box: the page count, or, when the current page's label is
|
||||
@@ -820,7 +709,7 @@ fn page_position(current: usize, count: usize, label: &str) -> String {
|
||||
if label.is_empty() || label == number || current >= count { count.to_string() } else { format!("{number}/{count}") }
|
||||
}
|
||||
|
||||
/// View ▸ Page display and the rail's Page display menu: the layouts and the cover page.
|
||||
/// The rail's Page display menu: the layouts and the cover page (also View ▸ Page display).
|
||||
/// Returns the command a click asks for, for the caller to run.
|
||||
fn page_display_menu(ui: &mut egui::Ui, view: &DocView) -> Option<&'static str> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -614,7 +614,14 @@ pub(crate) fn grid(
|
||||
let over_grid = ui.rect_contains_pointer(viewport);
|
||||
let mut width = viewport.width();
|
||||
let mut focused_card = None;
|
||||
let output = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]).show_viewport(ui, |ui, clip| {
|
||||
// A zoom in the previous frame asks for a new scroll position. 0.37 keeps `State`'s offset
|
||||
// private, so the request is applied through the builder and consumed here.
|
||||
let pending_offset = ctx.data_mut(|d| d.remove_temp::<Vec2>(pending_offset_id()));
|
||||
let mut grid = egui::ScrollArea::vertical().id_salt("combine-grid").auto_shrink([false, false]);
|
||||
if let Some(offset) = pending_offset {
|
||||
grid = grid.vertical_scroll_offset(offset.y);
|
||||
}
|
||||
grid.show_viewport(ui, |ui, clip| {
|
||||
// Dragging near the top or bottom edge scrolls.
|
||||
if drag.is_some()
|
||||
&& let Some(p) = pointer
|
||||
@@ -792,12 +799,10 @@ pub(crate) fn grid(
|
||||
let cols = columns_in(width, cell);
|
||||
let content = TOP + places.len().div_ceil(cols) as f32 * cell.y;
|
||||
let y = (TOP + (i / cols) as f32 * cell.y - above).min(content - viewport.height()).max(0.0);
|
||||
// Set as the scroll position itself, before the next frame draws anything: no
|
||||
// Applied by the next frame's scroll area, before anything is drawn there: no
|
||||
// jump, and no scroll still animating (to a card revealed by the keys, say) can
|
||||
// carry it elsewhere.
|
||||
let mut state = egui::scroll_area::State::default();
|
||||
state.offset = vec2(0.0, y);
|
||||
state.store(&ctx, output.id);
|
||||
// carry it elsewhere. The request is consumed once.
|
||||
ctx.data_mut(|d| d.insert_temp(pending_offset_id(), vec2(0.0, y)));
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -806,6 +811,11 @@ pub(crate) fn grid(
|
||||
action
|
||||
}
|
||||
|
||||
/// The scroll position a zoom asked the next frame's grid to start at.
|
||||
fn pending_offset_id() -> egui::Id {
|
||||
egui::Id::unique("pdfcraft-combine-grid-offset")
|
||||
}
|
||||
|
||||
/// One card's place and what it shows.
|
||||
struct Card {
|
||||
/// The file, by its place in the list.
|
||||
@@ -826,8 +836,8 @@ struct Card {
|
||||
impl Card {
|
||||
fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id {
|
||||
match self.page {
|
||||
None => ui.id().with(("combine-card", f.id)),
|
||||
Some((nth, ..)) => ui.id().with(("combine-page", f.id, nth)),
|
||||
None => ui.scope_id().with(("combine-card", f.id)),
|
||||
Some((nth, ..)) => ui.scope_id().with(("combine-page", f.id, nth)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,7 +855,7 @@ impl Card {
|
||||
};
|
||||
// As the organize grid's pages: a check box that is on when selected; and, for screen
|
||||
// readers, selected as items in a list are.
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, self.selected, &label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, self.selected, &label));
|
||||
let pages = match self.page {
|
||||
// For screen readers: which page of how many.
|
||||
Some((_, p, _)) => crate::i18n::fmt(tl!("Page {p} of {n}"), &[("p", &p.saturating_add(1).to_string()), ("n", &f.pages.to_string())]),
|
||||
@@ -969,9 +979,11 @@ impl Card {
|
||||
}
|
||||
None => (theme::medium(12.5), t.text, name_y),
|
||||
};
|
||||
let fits = |s: &str| painter.layout_no_wrap(crate::bidi::visual(s).into_owned(), font.clone(), colour).size().x <= max_w;
|
||||
let shown = crate::bidi::visual(&fit_name(&f.name, fits)).into_owned();
|
||||
painter.text(pos2(c.center().x, y), Align2::CENTER_CENTER, shown, font, colour);
|
||||
let fits = |s: &str| crate::bidi::layout_ltr(ui, s.to_owned(), font.clone(), colour).size().x <= max_w;
|
||||
let shown = fit_name(&f.name, fits);
|
||||
// A file name is file/path chrome: always left to right, so an Arabic name ends in `.pdf`.
|
||||
let name_galley = crate::bidi::layout_ltr(ui, shown, font, colour);
|
||||
painter.galley(pos2(c.center().x - name_galley.size().x / 2.0, y - name_galley.size().y / 2.0), name_galley, colour);
|
||||
let note = if self.page.is_none() && !f.range.trim().is_empty() {
|
||||
match &self.check {
|
||||
Ok(k) => Some((crate::i18n::fmt(tl!("Pages: {k} of {n}"), &[("k", &k.to_string()), ("n", &f.pages.to_string())]), t.text_muted)),
|
||||
@@ -1143,10 +1155,10 @@ pub(crate) fn preview(app: &mut PdfCraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
// No larger than the GPU takes, or the render would be refused.
|
||||
let longest = ctx.input(|i| i.max_texture_side as f32).min(PREVIEW_SIDE);
|
||||
let thumbs = &mut app.combine_thumbs;
|
||||
let modal = egui::Modal::new(egui::Id::new("combine-preview")).show(ctx, |ui| {
|
||||
let modal = egui::Modal::new(egui::Id::unique("combine-preview")).show(ctx, |ui| {
|
||||
ui.set_width(width);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(crate::bidi::visual(&f.name)).font(theme::semibold(15.0)));
|
||||
ui.label(egui::RichText::new(f.name.clone()).font(theme::semibold(15.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if icons::button(ui, "x", 30.0, false, tl!("Close")).clicked() {
|
||||
close = true;
|
||||
|
||||
@@ -742,7 +742,7 @@ pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.close();
|
||||
}
|
||||
for (i, name) in &open_docs {
|
||||
if ui.button(crate::bidi::visual(name).as_ref()).clicked() {
|
||||
if ui.button(name.as_str()).clicked() {
|
||||
app.add_open_documents(&[*i]);
|
||||
ui.close();
|
||||
}
|
||||
@@ -921,7 +921,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
left_out.push((f.name.as_str(), f.problem.as_deref().unwrap_or_default()));
|
||||
}
|
||||
let mut choice: Option<bool> = None;
|
||||
let modal = egui::Modal::new(egui::Id::new("combine-skip")).show(ctx, |ui| {
|
||||
let modal = egui::Modal::new(egui::Id::unique("combine-skip")).show(ctx, |ui| {
|
||||
ui.set_width(460.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(icons::image("triangle-alert", 22.0, ERROR));
|
||||
@@ -932,7 +932,7 @@ fn skip_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(6.0);
|
||||
for (name, why) in left_out.iter().take(SKIP_PROMPT_NAMES) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(format!("• {}", crate::bidi::visual(name))).strong());
|
||||
ui.label(egui::RichText::new(format!("• {name}")).strong());
|
||||
ui.label(egui::RichText::new(*why).color(t.text_muted));
|
||||
});
|
||||
}
|
||||
@@ -978,7 +978,7 @@ fn unlock_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
};
|
||||
let batch = files.len() > 1;
|
||||
let (mut submit, mut cancel) = (false, false);
|
||||
let area = egui::Area::new(egui::Id::new("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| {
|
||||
let area = egui::Area::new(egui::Id::unique("combine-unlock")).order(egui::Order::Foreground).fixed_pos(prompt.at).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
|
||||
ui.set_width(340.0);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1189,7 +1189,7 @@ fn zoom_controls(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, edge: f32
|
||||
let percent =
|
||||
egui::Button::new(egui::RichText::new(format!("{:.0}%", zoom * 100.0)).font(theme::medium(12.0)).color(t.text_muted)).frame(false);
|
||||
let percent = ui.add_sized([44.0, 30.0], percent);
|
||||
percent.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Reset page size")));
|
||||
percent.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Reset page size")));
|
||||
if percent.on_hover_text(tl!("Reset page size")).clicked() {
|
||||
app.combine_tab.zoom_request = Some(1.0);
|
||||
}
|
||||
@@ -1347,7 +1347,7 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b
|
||||
None => "",
|
||||
};
|
||||
let a11y = if state.is_empty() { text.to_owned() } else { format!("{text}, {state}") };
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &a11y));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &a11y));
|
||||
let being_dragged = egui::DragAndDrop::payload::<HeadingDrag>(ui.ctx()).is_some_and(|p| p.0 == key);
|
||||
if being_dragged || response.is_pointer_button_down_on() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.pressed);
|
||||
@@ -1395,6 +1395,8 @@ fn heading(ui: &mut egui::Ui, t: &Tokens, key: SortKey, sort: Option<(SortKey, b
|
||||
event
|
||||
}
|
||||
|
||||
/// Draw the Combine files table body: reorder handles, editable page ranges with validation, file metadata
|
||||
/// and per-row warnings.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn table_body(
|
||||
app: &mut PdfCraftApp,
|
||||
@@ -1410,7 +1412,7 @@ fn table_body(
|
||||
let order = app.combine_columns.order;
|
||||
// The heading being dragged follows the pointer.
|
||||
if let (Some(dragged), Some(pos)) = (egui::DragAndDrop::payload::<HeadingDrag>(ui.ctx()), ui.ctx().pointer_latest_pos()) {
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("combine-heading-drag")));
|
||||
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("combine-heading-drag")));
|
||||
let galley = painter.layout_no_wrap(dragged.0.label().to_owned(), theme::medium(12.5), t.text);
|
||||
let r = Rect::from_min_size(pos + vec2(10.0, 8.0), galley.size() + vec2(16.0, 8.0));
|
||||
painter.rect(r, CornerRadius::same(4), t.card, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
@@ -1454,7 +1456,7 @@ fn table_body(
|
||||
row.set_selected(rows.selected.get(i).copied().unwrap_or(false));
|
||||
// Drag handle.
|
||||
row.col(|ui| {
|
||||
let id = ui.id().with(("combine-drag", f.id));
|
||||
let id = ui.scope_id().with(("combine-drag", f.id));
|
||||
ui.dnd_drag_source(id, i, |ui| {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(12.0, 18.0), Sense::hover());
|
||||
grip(ui, rect, if resp.hovered() || rows.dragging { t.text_muted } else { t.text_faint });
|
||||
@@ -1467,7 +1469,8 @@ fn table_body(
|
||||
row.col(|ui| match kind {
|
||||
SortKey::Name => {
|
||||
ui.add(icons::image("file-text", 16.0, t.icon));
|
||||
ui.add(egui::Label::new(crate::bidi::visual(&f.name).as_ref()).truncate().selectable(false));
|
||||
// A file name is file/path chrome: explicitly LTR, extension last.
|
||||
crate::widgets::label_ltr_truncated(ui, f.name.clone(), theme::regular(13.0), t.text);
|
||||
}
|
||||
// The range field and how many pages it takes.
|
||||
SortKey::Pages => {
|
||||
@@ -1581,7 +1584,7 @@ fn table_body(
|
||||
let r = response.rect;
|
||||
let y = if from > i { r.top() } else { r.bottom() };
|
||||
if from != i {
|
||||
response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("combine-drop-line"))).hline(
|
||||
response.ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("combine-drop-line"))).hline(
|
||||
r.x_range(),
|
||||
y,
|
||||
Stroke::new(2.0, t.accent),
|
||||
|
||||
@@ -10,7 +10,6 @@ use pdfcraft_engine::commands::{self, COMMANDS, CommandSpec, Shortcut};
|
||||
use crate::{
|
||||
Dialog, Mode, PdfCraftApp, PropsTab, RightPanel, SaveTarget,
|
||||
theme::{ThemeKind, ThemePreference},
|
||||
widgets,
|
||||
};
|
||||
|
||||
/// Keys the document view handles itself (`canvas::shortcuts`), for the View menu, tooltips and
|
||||
@@ -161,10 +160,12 @@ impl PdfCraftApp {
|
||||
let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default();
|
||||
match id {
|
||||
"ui.dock.reset" => {
|
||||
if let Err(error) = crate::docking::command(self, true, &serde_json::Value::Null) {
|
||||
if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) {
|
||||
self.notify(error);
|
||||
}
|
||||
}
|
||||
"view.side_panel" => self.toggle_side_panel(),
|
||||
"view.tools_pane" => self.left_open = !self.left_open,
|
||||
"file.open" => self.open_dialog(),
|
||||
"file.open_recent" => match self.recent.first().map(|r| r.path.clone()) {
|
||||
// The palette runs commands without a submenu: open the most recent file.
|
||||
@@ -726,11 +727,6 @@ impl PdfCraftApp {
|
||||
// Buttons also own keyboard focus for Tab/Enter activation, but they must not
|
||||
// suppress document shortcuts. Text editors retain their own undo/redo handling.
|
||||
let typing = ctx.text_edit_focused();
|
||||
// A form field's editor is open on the page: its text isn't in the document until
|
||||
// committed. (Not egui's keyboard focus: an Escape in this frame has already cleared that,
|
||||
// while the field has yet to see the Escape and discard its draft.)
|
||||
let active = self.active_ids();
|
||||
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
|
||||
let mut specs: Vec<&CommandSpec> = COMMANDS.iter().filter(|c| c.shortcut.is_some()).collect();
|
||||
specs.sort_by_key(|c| std::cmp::Reverse(c.shortcut.map(|s| s.modifier_count()).unwrap_or(0)));
|
||||
for spec in specs {
|
||||
@@ -763,23 +759,29 @@ impl PdfCraftApp {
|
||||
m |= Modifiers::CTRL;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(m, key))) {
|
||||
if form_typing {
|
||||
// A form field has the keyboard: let it take this frame's typing (and
|
||||
// Escape) first, so ⌘S saves what's on screen (#166). Runs next frame, for
|
||||
// this document only.
|
||||
self.deferred_commands.push((spec.id, active.map(|(_, id)| id)));
|
||||
ctx.request_repaint();
|
||||
} else if spec.id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
|
||||
// macOS ⌘W closes the last window when no document tabs remain (#869).
|
||||
// Route through the normal viewport-close path (and its quit guard),
|
||||
// not the disabled document-only `file.close` command.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else {
|
||||
self.execute(spec.id);
|
||||
}
|
||||
self.dispatch_command(ctx, spec.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run a registry command from its shortcut or a menu, which share this path.
|
||||
pub(crate) fn dispatch_command(&mut self, ctx: &egui::Context, id: &'static str) {
|
||||
let active = self.active_ids();
|
||||
let form_typing = active.and_then(|(i, _)| self.views.get(i)).is_some_and(|v| v.forms.focus.is_some());
|
||||
if form_typing {
|
||||
// A form field has the keyboard: let it take this frame's typing (and Escape) first,
|
||||
// so ⌘S saves what's on screen (#166). Runs next frame, for this document only.
|
||||
self.deferred_commands.push((id, active.map(|(_, id)| id)));
|
||||
ctx.request_repaint();
|
||||
} else if id == "file.close" && active.is_none() && crate::commands::mac_shortcuts(ctx) && !cfg!(target_arch = "wasm32") {
|
||||
// macOS ⌘W closes the last window when no document tabs remain (#869). Route through
|
||||
// the normal viewport-close path (and its quit guard), not the disabled document-only
|
||||
// `file.close` command.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
} else {
|
||||
self.execute(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab after `current` of `count` (before it when `forward` is false), wrapping around. From
|
||||
@@ -800,57 +802,6 @@ fn tab_after(current: Option<usize>, count: usize, forward: bool) -> Option<usiz
|
||||
})
|
||||
}
|
||||
|
||||
/// Render a top-level menu's registered commands (with live labels, shortcuts and enablement).
|
||||
pub(crate) fn registry_menu(app: &mut PdfCraftApp, ui: &mut egui::Ui, menu: &str) {
|
||||
for spec in commands::menu(menu) {
|
||||
let label = commands::current_label(spec, &app.session, app.active_ids().map(|(_, id)| id));
|
||||
let label = crate::i18n::menu_label(spec.id, &label);
|
||||
// Open Recent is a submenu of the live recent list, not one action: disabled while the
|
||||
// list is empty, otherwise each entry opens its file (or focuses the tab showing it), and
|
||||
// Clear Recent Files at the foot empties the list (#430).
|
||||
if spec.id == "file.open_recent" {
|
||||
if app.recent.is_empty() {
|
||||
ui.add_enabled(false, egui::Button::new(label));
|
||||
continue;
|
||||
}
|
||||
let mut open: Option<String> = None;
|
||||
let mut clear = false;
|
||||
ui.menu_button(label, |ui| {
|
||||
crate::chrome::scroll_menu(ui, |ui| {
|
||||
for r in &app.recent {
|
||||
if ui.button(&r.name).on_hover_text(&r.path).clicked() {
|
||||
open = Some(r.path.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button(tl!("Clear Recent Files")).clicked() {
|
||||
clear = true;
|
||||
ui.close();
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(p) = open {
|
||||
app.open_recent(&p);
|
||||
ui.close();
|
||||
}
|
||||
if clear {
|
||||
app.execute("file.clear_recent");
|
||||
ui.close();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let shortcut = spec.shortcut.or((spec.id == "edit.copy").then_some(COPY)).map(|s| shortcut_label(ui.ctx(), s)).unwrap_or_default();
|
||||
let enabled = app.command_enabled(spec);
|
||||
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(shortcut));
|
||||
if resp.clicked() {
|
||||
app.execute(spec.id);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let _ = widgets::menu_item;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1621,12 +1621,12 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
anchor = anchor.clamp(viewport.min, viewport.max);
|
||||
}
|
||||
let pos = pos2(anchor.x + 12.0, anchor.y);
|
||||
let area = egui::Area::new(egui::Id::new(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport);
|
||||
let area = egui::Area::new(egui::Id::unique(("comment-composer", view.id.0))).order(egui::Order::Foreground).constrain_to(viewport);
|
||||
area.fixed_pos(pos).show(ctx, |ui| {
|
||||
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::same(12)).corner_radius(CornerRadius::same(8)).show(ui, |ui| {
|
||||
ui.set_width(260.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(crate::bidi::visual_auto(&prefs.author)).font(theme::semibold(13.0)));
|
||||
ui.label(egui::RichText::new(prefs.author.clone()).font(theme::semibold(13.0)));
|
||||
ui.label(egui::RichText::new(tl!(title)).font(theme::regular(11.5)).color(t.text_faint));
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
@@ -1636,8 +1636,10 @@ pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
ComposerKind::Replace => tl!("Replacement text"),
|
||||
_ => tl!("Add a comment"),
|
||||
};
|
||||
let edit =
|
||||
ui.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text"));
|
||||
let edit = ui
|
||||
.add(egui::TextEdit::multiline(&mut c.text).hint_text(hint).desired_rows(3).desired_width(f32::INFINITY).id_salt("composer-text"))
|
||||
// 0.37 kittest rejects an unnamed text input; the hint is its logical name.
|
||||
.accessible_name(hint);
|
||||
if c.focus {
|
||||
edit.request_focus();
|
||||
c.focus = false;
|
||||
@@ -2056,8 +2058,9 @@ pub enum CanvasAction {
|
||||
/// How many custom colours the "Recent colours" row keeps.
|
||||
const RECENT_CUSTOM: usize = 5;
|
||||
|
||||
/// Return the stable egui id for the recent custom-colour palette.
|
||||
fn recent_custom_id() -> egui::Id {
|
||||
egui::Id::new("pdfcraft-recent-custom-colours")
|
||||
egui::Id::unique("pdfcraft-recent-custom-colours")
|
||||
}
|
||||
|
||||
/// Custom colours picked with "Other colour…" this session, newest first (#1193).
|
||||
@@ -2091,7 +2094,7 @@ fn rgb_of(c: Color32) -> Rgb {
|
||||
/// One round swatch; `on` rings it as the current colour.
|
||||
fn swatch(ui: &mut egui::Ui, c: Rgb, on: bool, tooltip: &str) -> egui::Response {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tooltip));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tooltip));
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(c));
|
||||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(40)));
|
||||
if on || resp.hovered() {
|
||||
@@ -2113,7 +2116,7 @@ fn other_colour_swatch(ui: &mut egui::Ui, open: bool) -> egui::Response {
|
||||
ui.painter().circle_stroke(c, 11.0, Stroke::new(1.5, SELECT_BLUE));
|
||||
}
|
||||
let label = tl!("Other colour…");
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, open, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, open, label));
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
@@ -2202,7 +2205,7 @@ pub(crate) fn quick_bar_controls(ui: &mut egui::Ui, tool: CommentTool, prefs: &m
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 9.0, color32(style.color));
|
||||
ui.painter().circle_stroke(r.center(), 9.0, Stroke::new(1.0, Color32::from_black_alpha(50)));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour")));
|
||||
let resp = resp.on_hover_text(tl!("Colour"));
|
||||
egui::Popup::menu(&resp).align(egui::RectAlign::RIGHT_START).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if let Some(c) = swatch_grid(ui, Some(style.color)) {
|
||||
@@ -2266,7 +2269,7 @@ fn fill_button(ui: &mut egui::Ui, fill: Option<Rgb>) -> egui::Response {
|
||||
Some(_) => tl!("Fill colour"),
|
||||
None => tl!("Fill colour: none"),
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
resp.on_hover_text(label)
|
||||
}
|
||||
|
||||
@@ -2587,7 +2590,7 @@ mod tests {
|
||||
h.run();
|
||||
h.get_by_label("Page").click_secondary();
|
||||
h.run();
|
||||
h.get_by_label("Colour ⏵").click();
|
||||
h.get_by_label("Colour").click();
|
||||
h.run();
|
||||
h.get_by_label("Other colour…").click();
|
||||
h.run();
|
||||
|
||||
@@ -124,7 +124,9 @@ pub(crate) fn show(
|
||||
let mut edit = None;
|
||||
// Search (the header's magnifier toggles it).
|
||||
if let Some(q) = view.comments.search.as_mut() {
|
||||
let r = ui.add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search"));
|
||||
let r = ui
|
||||
.add(crate::widgets::line(q).hint_text(tl!("Search comments")).desired_width(f32::INFINITY).id_salt("comment-search"))
|
||||
.accessible_name(tl!("Search comments"));
|
||||
if view.comments.search_focus {
|
||||
r.request_focus();
|
||||
view.comments.search_focus = false;
|
||||
@@ -133,9 +135,14 @@ pub(crate) fn show(
|
||||
}
|
||||
// "Add a comment": a sticky note on the current page, near its top-right corner.
|
||||
if allowed {
|
||||
let r = ui.add(
|
||||
crate::widgets::line(&mut view.comments.add_box).hint_text(tl!("Add a comment")).desired_width(f32::INFINITY).id_salt("comment-add-box"),
|
||||
);
|
||||
let r = ui
|
||||
.add(
|
||||
crate::widgets::line(&mut view.comments.add_box)
|
||||
.hint_text(tl!("Add a comment"))
|
||||
.desired_width(f32::INFINITY)
|
||||
.id_salt("comment-add-box"),
|
||||
)
|
||||
.accessible_name(tl!("Add a comment"));
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !view.comments.add_box.trim().is_empty() {
|
||||
let page = view.current.min(info.pages.len().saturating_sub(1));
|
||||
if let Some(p) = info.pages.get(page) {
|
||||
@@ -238,9 +245,7 @@ pub(crate) fn show(
|
||||
if g.is_some() && g != group {
|
||||
ui.add_space(6.0);
|
||||
let title = g.clone().unwrap_or_default();
|
||||
// Catalog labels are already in display order; an author's name is put in it here.
|
||||
let shown = if sort == SortBy::Author && a.author.is_some() { crate::bidi::visual_auto(&title) } else { tl!(&title).into() };
|
||||
ui.label(egui::RichText::new(shown).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
ui.label(egui::RichText::new(tl!(&title)).font(theme::semibold(12.5)).color(t.text_muted));
|
||||
group = g;
|
||||
}
|
||||
} else if a.page != page {
|
||||
@@ -287,6 +292,8 @@ pub(crate) fn show(
|
||||
edit
|
||||
}
|
||||
|
||||
/// Draw one comment thread card, including author, body or inline editor, replies, status and per-comment
|
||||
/// actions; returns an edit to apply.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn card(
|
||||
ui: &mut egui::Ui,
|
||||
@@ -322,7 +329,8 @@ fn card(
|
||||
let mut above = egui::Rect::NOTHING;
|
||||
let head = ui.horizontal(|ui| {
|
||||
above = avatar(ui, t, Some(subtype_icon(&a.subtype)), None, color);
|
||||
let author = a.author.as_deref().map_or_else(|| tl!("Unknown author").into(), crate::bidi::visual_auto);
|
||||
// The catalogue and author names stay logical: egui lays out RTL text itself.
|
||||
let author = a.author.as_deref().map_or_else(|| tl!("Unknown author"), |name| name);
|
||||
ui.label(egui::RichText::new(author).font(theme::semibold(12.5)).color(t.text));
|
||||
if let Some(m) = &a.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
@@ -402,7 +410,7 @@ fn card(
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
ui.horizontal(|ui| {
|
||||
let author = r.author.as_deref().map_or_else(|| tl!("Reply").into(), crate::bidi::visual_auto);
|
||||
let author = r.author.as_deref().map_or_else(|| tl!("Reply"), |name| name);
|
||||
ui.label(egui::RichText::new(author).font(theme::semibold(12.0)));
|
||||
if let Some(m) = &r.modified {
|
||||
ui.add(egui::Label::new(egui::RichText::new(m).font(theme::regular(11.0)).color(t.text_faint)).truncate());
|
||||
@@ -461,7 +469,7 @@ fn card(
|
||||
// clickable everywhere.
|
||||
let hit = if selected { frame.inner } else { rect };
|
||||
let click = ui
|
||||
.interact(hit, ui.id().with(("card", key)), Sense::click())
|
||||
.interact(hit, ui.scope_id().with(("card", key)), Sense::click())
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text(crate::i18n::fmt(tl!("{kind} — click to show it on the page"), &[("kind", tl!(kind_label(a)))]));
|
||||
if click.clicked() {
|
||||
@@ -472,7 +480,7 @@ fn card(
|
||||
if click.double_clicked() && allowed {
|
||||
view.comments.open_editor(a.page, a.index, a.contents.clone().unwrap_or_default());
|
||||
}
|
||||
let menu_id = ui.id().with(("card-menu", key));
|
||||
let menu_id = ui.scope_id().with(("card-menu", key));
|
||||
if open_menu {
|
||||
egui::Popup::open_id(ui.ctx(), menu_id);
|
||||
}
|
||||
@@ -636,8 +644,7 @@ pub(crate) fn header_controls(ui: &mut egui::Ui, info: &DocInfo, view: &mut DocV
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Author")).small());
|
||||
for a in &authors {
|
||||
let name = crate::bidi::visual_auto(a);
|
||||
toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { &name });
|
||||
toggle(ui, &mut cv.hidden_authors, a, if a.is_empty() { tl!("Unknown author") } else { a.as_str() });
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new(tl!("Status")).small());
|
||||
|
||||
@@ -107,6 +107,11 @@ pub struct ContentView {
|
||||
}
|
||||
|
||||
impl ContentView {
|
||||
/// Forget an in-progress pointer grab for content editing.
|
||||
pub(crate) fn cancel_pointer_gesture(&mut self) {
|
||||
self.grab = None;
|
||||
}
|
||||
|
||||
pub(crate) fn finish_draft(&mut self, added: &[Added]) -> Option<Edit> {
|
||||
self.draft.take().and_then(|d| finish(self, d, added))
|
||||
}
|
||||
@@ -134,6 +139,10 @@ pub(crate) struct MoveBackground {
|
||||
key: Option<(u64, usize, usize)>,
|
||||
renderer: Option<pdfcraft_render::RenderPool>,
|
||||
texture: Option<egui::TextureHandle>,
|
||||
/// The item alone on a transparent page: the pixels that follow the pointer. The page's own
|
||||
/// raster has whatever the item covers in its box too, which must stay where it is (#4464).
|
||||
item_renderer: Option<pdfcraft_render::RenderPool>,
|
||||
item: Option<egui::TextureHandle>,
|
||||
tag: Option<u64>,
|
||||
/// The item's display-space box when the gesture started.
|
||||
from: Option<[f64; 4]>,
|
||||
@@ -146,7 +155,12 @@ pub(crate) struct MoveBackground {
|
||||
|
||||
impl MoveBackground {
|
||||
pub fn pending(&self) -> bool {
|
||||
self.renderer.is_some() && self.texture.is_none()
|
||||
self.renderer.is_some() && self.item_renderer.is_some() && (self.texture.is_none() || self.item.is_none())
|
||||
}
|
||||
|
||||
/// Both rasters are in: the page without the item, and the item alone.
|
||||
fn ready(&self) -> bool {
|
||||
self.texture.is_some() && self.item.is_some()
|
||||
}
|
||||
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, doc: &pdfcraft_engine::Document, cv: &ContentView, scale: f32) {
|
||||
@@ -161,6 +175,7 @@ impl MoveBackground {
|
||||
*self = Self { key, ..Default::default() };
|
||||
if let Some((_, page, index)) = key {
|
||||
self.renderer = doc.added_content_preview(page, index).ok().map(|p| p.renderer());
|
||||
self.item_renderer = doc.added_content_alone(page, index).ok().map(|p| p.renderer());
|
||||
self.from = on_page(doc.added(), page).into_iter().find(|(i, _)| *i == index).map(|(_, a)| a.content.rect());
|
||||
self.picture = picture(page, index);
|
||||
}
|
||||
@@ -168,39 +183,60 @@ impl MoveBackground {
|
||||
if self.settling.is_some() {
|
||||
return;
|
||||
}
|
||||
let (Some((_, page, _)), Some(renderer)) = (self.key, self.renderer.as_ref()) else { return };
|
||||
let (Some((_, page, _)), Some(renderer), Some(item_renderer)) = (self.key, self.renderer.as_ref(), self.item_renderer.as_ref()) else {
|
||||
return;
|
||||
};
|
||||
let Some(p) = doc.info.pages.get(page) else { return };
|
||||
let max_side = ctx.input(|i| i.max_texture_side) as f32;
|
||||
let scale = scale.min(crate::canvas::BASE_SIDE.min(max_side) / p.width.max(p.height).max(1.0));
|
||||
let tag = (scale * 1000.0) as u64;
|
||||
if self.tag != Some(tag) {
|
||||
renderer.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
|
||||
for r in [renderer, item_renderer] {
|
||||
r.set_queue(vec![pdfcraft_render::RenderRequest { page, scale, tag, ..Default::default() }]);
|
||||
}
|
||||
self.tag = Some(tag);
|
||||
}
|
||||
if let Some(out) = renderer.try_recv() {
|
||||
if self.receive(ctx, tag) && self.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes in the rasters rendered at `tag`. `false` when either failed: the preview is then
|
||||
/// dropped whole, including rasters in from an earlier scale.
|
||||
fn receive(&mut self, ctx: &egui::Context, tag: u64) -> bool {
|
||||
let (Some(renderer), Some(item_renderer)) = (self.renderer.as_ref(), self.item_renderer.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let mut failed = false;
|
||||
for (r, slot, name) in
|
||||
[(renderer, &mut self.texture, "added-content-move-background"), (item_renderer, &mut self.item, "added-content-move-item")]
|
||||
{
|
||||
let Some(out) = r.try_recv() else { continue };
|
||||
if out.error.is_some() {
|
||||
// Without a background the item stays put while dragged, as before.
|
||||
self.renderer = None;
|
||||
return;
|
||||
}
|
||||
if out.request.tag == tag {
|
||||
self.texture = Some(ctx.load_texture(
|
||||
"added-content-move-background",
|
||||
failed = true;
|
||||
} else if out.request.tag == tag {
|
||||
*slot = Some(ctx.load_texture(
|
||||
name,
|
||||
crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba),
|
||||
egui::TextureOptions::LINEAR,
|
||||
));
|
||||
}
|
||||
}
|
||||
if self.pending() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
if failed {
|
||||
// Without both rasters the item stays put while dragged, as before.
|
||||
self.renderer = None;
|
||||
self.item_renderer = None;
|
||||
self.texture = None;
|
||||
self.item = None;
|
||||
}
|
||||
!failed
|
||||
}
|
||||
|
||||
/// The move was applied: keep the preview, now at the new generation, until the page is
|
||||
/// rendered again. Any other edit ends it.
|
||||
pub fn committed(&mut self, edit: &Edit, generation: u64) {
|
||||
match edit {
|
||||
Edit::UpdateContent { page, index, content } if self.texture.is_some() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
|
||||
Edit::UpdateContent { page, index, content } if self.ready() && self.key.is_some_and(|(_, p, i)| (p, i) == (*page, *index)) => {
|
||||
self.key = Some((generation, *page, *index));
|
||||
self.settling = Some(content.rect());
|
||||
}
|
||||
@@ -214,21 +250,11 @@ impl MoveBackground {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page without the moving item, then the item's pixels taken from `page_tex` (the page
|
||||
/// as rendered, item included where it was) at the dragged (to the pages' `pointer`) or
|
||||
/// applied position.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint(
|
||||
&self,
|
||||
painter: &egui::Painter,
|
||||
xf: &PageXform,
|
||||
page: usize,
|
||||
info: &DocInfo,
|
||||
cv: &ContentView,
|
||||
page_tex: Option<egui::TextureId>,
|
||||
pointer: Option<Pos2>,
|
||||
) {
|
||||
let (Some((_, kp, _)), Some(background), Some(page_tex), Some(from)) = (self.key, &self.texture, page_tex, self.from) else { return };
|
||||
/// The page without the moving item, then the item's own pixels (from its raster alone, so
|
||||
/// nothing it covered comes along) at the dragged (to the pages' `pointer`) or applied
|
||||
/// position.
|
||||
pub fn paint(&self, painter: &egui::Painter, xf: &PageXform, page: usize, info: &DocInfo, cv: &ContentView, pointer: Option<Pos2>) {
|
||||
let (Some((_, kp, _)), Some(background), Some(item), Some(from)) = (self.key, &self.texture, &self.item, self.from) else { return };
|
||||
if kp != page {
|
||||
return;
|
||||
}
|
||||
@@ -247,7 +273,7 @@ impl MoveBackground {
|
||||
xf.paint_image(&painter, background.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
// A pixel around the box, so its edges come along.
|
||||
let (r, to) = (r.expand(1.0), to.expand(1.0));
|
||||
let mut mesh = egui::Mesh::with_texture(page_tex);
|
||||
let mut mesh = egui::Mesh::with_texture(item.id());
|
||||
for (a, b) in [
|
||||
(r.left_top(), to.left_top()),
|
||||
(r.right_top(), to.right_top()),
|
||||
@@ -581,7 +607,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
// Shown at once, like the paragraph editor: a fade-in shows the page's copy through it. Over
|
||||
// the page, so only where the document shows: text scrolled under the mode bar or a side
|
||||
// panel must leave them their pointer (#2755).
|
||||
egui::Area::new(egui::Id::new(("added-text", view.id.0)))
|
||||
egui::Area::new(egui::Id::unique(("added-text", view.id.0)))
|
||||
.fade_in(false)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(at)
|
||||
@@ -617,7 +643,10 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
.font(font.clone())
|
||||
.desired_width(width)
|
||||
.desired_rows(1)
|
||||
.horizontal_align(halign)
|
||||
.align(egui::Align2::new(halign, egui::Align::TOP))
|
||||
// Keep the page editor's original inset; the theme's larger button padding
|
||||
// otherwise shifts narrow right-aligned boxes away from their text edge.
|
||||
.margin(egui::Margin::symmetric(4, 2))
|
||||
.background_color(if background_ready { Color32::TRANSPARENT } else { Color32::WHITE })
|
||||
.text_color(Color32::from_rgb(cr, cg, cb))
|
||||
.hint_text(tl!("Type text"))
|
||||
@@ -625,7 +654,7 @@ pub(crate) fn editor(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, ad
|
||||
if letter_spacing != 0.0 {
|
||||
edit = edit.layouter(&mut spaced);
|
||||
}
|
||||
let resp = ui.add(edit);
|
||||
let resp = ui.add(edit).accessible_name(tl!("Added text"));
|
||||
if t.focus {
|
||||
resp.request_focus();
|
||||
t.focus = false;
|
||||
@@ -693,7 +722,7 @@ fn finish(cv: &mut ContentView, d: TextDraft, added: &[Added]) -> Option<Edit> {
|
||||
|
||||
/// The added-text box's text field.
|
||||
pub(crate) fn input_id() -> egui::Id {
|
||||
egui::Id::new("added-text-input")
|
||||
egui::Id::unique("added-text-input")
|
||||
}
|
||||
|
||||
/// How to use the tools, under the Add content list.
|
||||
@@ -972,19 +1001,28 @@ pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &pdfcraft_engine::
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Crop (% trimmed from each side)")).small().color(t.text_muted));
|
||||
let mut crop = img.crop.map(|v| (v * 100.0).round());
|
||||
let most = (1.0 - pdfcraft_engine::AddedImage::MIN_CROP_KEEP) * 100.0;
|
||||
// Unrounded: a side the user doesn't touch keeps its stored value, also one read from a file.
|
||||
let shown = img.crop.map(|v| v * 100.0);
|
||||
let mut crop = shown;
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
|
||||
ui.label(label);
|
||||
// Applied when the drag ends, so a drag is one undo step.
|
||||
let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%"));
|
||||
// Up to what the opposite side leaves. Applied when the drag ends: one undo step.
|
||||
let room = (most - crop.get((k + 2) % 4).copied().unwrap_or(0.0)).max(0.0);
|
||||
let Some(side) = crop.get_mut(k) else { continue };
|
||||
let r = ui.add(egui::DragValue::new(side).range(0.0..=room).clamp_existing_to_range(false).speed(0.5).suffix("%"));
|
||||
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let mut i = img.clone();
|
||||
i.crop = crop.map(|v| v / 100.0);
|
||||
for (stored, (now, was)) in i.crop.iter_mut().zip(crop.iter().zip(&shown)) {
|
||||
if now != was {
|
||||
*stored = now / 100.0;
|
||||
}
|
||||
}
|
||||
if i.crop != img.crop {
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i)));
|
||||
}
|
||||
@@ -1052,6 +1090,35 @@ mod tests {
|
||||
assert!(i.flip_v && !i.flip_h, "Flip vertical mirrors top and bottom");
|
||||
}
|
||||
|
||||
/// #4464: both rasters were in at one scale and a render at a new scale failed. The rasters
|
||||
/// from the old scale must go too, or the item keeps following the pointer although the
|
||||
/// preview gave up.
|
||||
#[test]
|
||||
fn failed_rerender_drops_the_rasters_from_the_earlier_scale() {
|
||||
let ctx = egui::Context::default();
|
||||
let texture = |name| Some(ctx.load_texture(name, egui::ColorImage::filled([1, 1], Color32::WHITE), egui::TextureOptions::LINEAR));
|
||||
// Bytes no parser accepts: every render answers with an error.
|
||||
let pool = || {
|
||||
let pool = pdfcraft_render::RenderPool::new_inline(std::sync::Arc::new(b"not a pdf".to_vec()), Default::default());
|
||||
pool.set_queue(vec![pdfcraft_render::RenderRequest { page: 0, scale: 2.0, tag: 2000, ..Default::default() }]);
|
||||
pool
|
||||
};
|
||||
let mut mb = MoveBackground {
|
||||
key: Some((0, 0, 0)),
|
||||
renderer: Some(pool()),
|
||||
texture: texture("background"),
|
||||
item_renderer: Some(pool()),
|
||||
item: texture("item"),
|
||||
tag: Some(2000),
|
||||
from: Some([0.0, 0.0, 10.0, 10.0]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(mb.ready(), "both rasters from the earlier scale are in");
|
||||
assert!(!mb.receive(&ctx, 2000), "the new scale's renders fail");
|
||||
assert!(mb.texture.is_none() && mb.item.is_none(), "no raster is left to draw the moving item from");
|
||||
assert!(!mb.ready() && !mb.pending());
|
||||
}
|
||||
|
||||
/// #4245: the page counts the spacing after a line's last character too, so a line that fits
|
||||
/// only without it wraps in the saved text and must wrap in the editor.
|
||||
#[test]
|
||||
|
||||
+1891
-123
File diff suppressed because it is too large.
Load diff
@@ -232,7 +232,7 @@ impl Default for View {
|
||||
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
|
||||
pub fn contributors_ui(ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("credits_view");
|
||||
let id = egui::Id::unique("credits_view");
|
||||
let mut v = ui.data_mut(|d| d.get_temp::<View>(id)).unwrap_or_default();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(crate::i18n::t("Show"));
|
||||
|
||||
@@ -21,6 +21,7 @@ pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
unlock(app, ctx);
|
||||
}
|
||||
|
||||
/// Draw the active modal dialog and apply the action the user chose.
|
||||
fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
password(app, ctx);
|
||||
save_prompt(app, ctx);
|
||||
@@ -154,6 +155,22 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
ui.end_row();
|
||||
};
|
||||
// A file name or path is read left to right even in an RTL UI: the extension
|
||||
// stays at its visual end, as in the tab strip.
|
||||
let path_row = |ui: &mut egui::Ui, k: &str, v: String| {
|
||||
ui.label(egui::RichText::new(tl!(k)).color(t.text_muted));
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_width(420.0);
|
||||
// Align path text with editable fields while keeping its LTR text order.
|
||||
ui.add_space(inset);
|
||||
if v.is_empty() {
|
||||
ui.label(egui::RichText::new("—").color(t.text_faint));
|
||||
} else {
|
||||
crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text);
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
};
|
||||
egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
if tab == PropsTab::SpaceAudit {
|
||||
ui.add_space(4.0);
|
||||
@@ -162,7 +179,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab {
|
||||
PropsTab::Description => {
|
||||
row(ui, "File", crate::bidi::visual(&doc.name).into_owned());
|
||||
path_row(ui, "File", doc.name.clone());
|
||||
match app.props_draft.as_mut() {
|
||||
Some((_, draft)) if editable_info => {
|
||||
for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) {
|
||||
@@ -192,7 +209,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let Some((_, v)) = app.view_draft.as_mut() else { return };
|
||||
ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5)));
|
||||
ui.end_row();
|
||||
ui.label(tl!("Navigation tab"));
|
||||
let label = ui.label(tl!("Navigation tab"));
|
||||
ui.add_enabled_ui(editable, |ui| {
|
||||
egui::ComboBox::from_id_salt("iv-nav")
|
||||
.selected_text(tl!(format!("{:?}", v.navigation)
|
||||
@@ -209,10 +226,12 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
] {
|
||||
ui.selectable_value(&mut v.navigation, n, l);
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Page layout"));
|
||||
let label = ui.label(tl!("Page layout"));
|
||||
ui.add_enabled_ui(editable, |ui| {
|
||||
let names = [
|
||||
(L::Default, tl!("Default")),
|
||||
@@ -224,14 +243,18 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
(L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")),
|
||||
];
|
||||
let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n);
|
||||
egui::ComboBox::from_id_salt("iv-layout").selected_text(shown).show_ui(ui, |ui| {
|
||||
for (l, n) in names {
|
||||
ui.selectable_value(&mut v.layout, l, n);
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("iv-layout")
|
||||
.selected_text(shown)
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, n) in names {
|
||||
ui.selectable_value(&mut v.layout, l, n);
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Magnification"));
|
||||
let label = ui.label(tl!("Magnification"));
|
||||
ui.add_enabled_ui(editable, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
let names = [
|
||||
@@ -246,21 +269,25 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
M::Percent(p) => format!("{p:.0}%"),
|
||||
m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()),
|
||||
};
|
||||
egui::ComboBox::from_id_salt("iv-mag").selected_text(shown).show_ui(ui, |ui| {
|
||||
for (m, n) in names {
|
||||
ui.selectable_value(&mut v.magnification, m, n);
|
||||
}
|
||||
for p in [50.0, 75.0, 125.0, 150.0, 200.0] {
|
||||
ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%"));
|
||||
}
|
||||
});
|
||||
egui::ComboBox::from_id_salt("iv-mag")
|
||||
.selected_text(shown)
|
||||
.show_ui(ui, |ui| {
|
||||
for (m, n) in names {
|
||||
ui.selectable_value(&mut v.magnification, m, n);
|
||||
}
|
||||
for p in [50.0, 75.0, 125.0, 150.0, 200.0] {
|
||||
ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%"));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
});
|
||||
ui.end_row();
|
||||
ui.label(tl!("Open to page"));
|
||||
let label = ui.label(tl!("Open to page"));
|
||||
ui.add_enabled_ui(editable, |ui| {
|
||||
let mut p = v.page + 1;
|
||||
if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).changed() {
|
||||
if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() {
|
||||
v.page = p - 1;
|
||||
}
|
||||
ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())]));
|
||||
@@ -455,7 +482,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
PropsTab::Advanced => {
|
||||
row(ui, "PDF version", i.pdf_version.clone());
|
||||
row(ui, "Location", crate::bidi::visual(doc.path.as_deref().unwrap_or_default()).into_owned());
|
||||
path_row(ui, "Location", doc.path.clone().unwrap_or_default());
|
||||
row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size)));
|
||||
let p = &i.pages[0];
|
||||
row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height)));
|
||||
@@ -606,10 +633,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
d.to = d.to.clamp(1, count);
|
||||
d.from = d.from.clamp(1, d.to);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Original: replace pages"));
|
||||
let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count));
|
||||
ui.label(tl!("to"));
|
||||
let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count));
|
||||
let from_label = ui.label(tl!("Original: replace pages"));
|
||||
let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id);
|
||||
let to_label = ui.label(tl!("to"));
|
||||
let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id);
|
||||
if from.changed() || to.changed() {
|
||||
d.range_edited = true;
|
||||
}
|
||||
@@ -619,8 +646,8 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let max_start = d.src_pages.saturating_sub(n) + 1;
|
||||
d.src_from = d.src_from.clamp(1, max_start.max(1));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)]));
|
||||
ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1)));
|
||||
let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)]));
|
||||
ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id);
|
||||
ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())]));
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted));
|
||||
});
|
||||
@@ -1286,7 +1313,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
Dialog::About => {
|
||||
// Tabs About · Contributors · Models (craftrules standards/contributors.md).
|
||||
let tab_id = egui::Id::new("about_tab");
|
||||
let tab_id = egui::Id::unique("about_tab");
|
||||
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() {
|
||||
@@ -1882,7 +1909,16 @@ fn remote_link_prompt(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
ui.add_space(6.0);
|
||||
ui.label(tl!("This document is trying to open a local file:"));
|
||||
egui::ScrollArea::vertical().max_height(160.0).show(ui, |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(pending.path.to_string_lossy()).monospace()).wrap().selectable(true));
|
||||
// A local path is file/path chrome: left to right, so its final component stays last.
|
||||
let mut job = egui::text::LayoutJob::simple(
|
||||
pending.path.to_string_lossy().into_owned(),
|
||||
egui::FontId::monospace(13.0),
|
||||
crate::theme::Tokens::get(ui.ctx()).text,
|
||||
ui.available_width().max(0.0),
|
||||
);
|
||||
job.direction = egui::epaint::text::TextDirection::LeftToRight;
|
||||
let galley = ui.painter().layout_job(job);
|
||||
ui.add(egui::Label::new(egui::WidgetText::Galley(galley)).selectable(true));
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.label(tl!("Only allow this if you trust the source document."));
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
//! Application panel identities and persistence around the shared docking renderer.
|
||||
//! Document contents remain owned by their existing view implementations.
|
||||
|
||||
use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone};
|
||||
use craft_ui::layout::{SplitAxis, SplitSize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Panel {
|
||||
Canvas,
|
||||
Tools,
|
||||
Inspector,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
pub const ALL: &[Self] = &[Self::Canvas, Self::Tools, Self::Inspector];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "canvas",
|
||||
Self::Tools => "tools",
|
||||
Self::Inspector => "inspector",
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Canvas => "Document",
|
||||
Self::Tools => "Tools",
|
||||
Self::Inspector => "Inspector",
|
||||
}
|
||||
}
|
||||
fn default_side(self) -> Zone {
|
||||
match self {
|
||||
Self::Canvas => Zone::Right,
|
||||
Self::Tools => Zone::Left,
|
||||
Self::Inspector => Zone::Right,
|
||||
}
|
||||
}
|
||||
fn parse(id: &str) -> Result<Self, String> {
|
||||
Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}"))
|
||||
}
|
||||
}
|
||||
|
||||
fn permissions(panel: &Panel) -> Permissions {
|
||||
if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() }
|
||||
}
|
||||
|
||||
fn tabs(panel: Panel) -> Node<Panel> {
|
||||
Node::Tabs { panels: vec![panel], active: 0 }
|
||||
}
|
||||
|
||||
fn split(axis: SplitAxis, size: SplitSize, first: Node<Panel>, second: Node<Panel>) -> Node<Panel> {
|
||||
Node::Split { axis, size, first: Box::new(first), second: Box::new(second) }
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Workspace {
|
||||
pub layout: Layout<Panel>,
|
||||
/// Closed panels retain their most recent group, side, size or floating rectangle.
|
||||
pub hidden: Vec<(Panel, Location<Panel>)>,
|
||||
}
|
||||
|
||||
impl Default for Workspace {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(272.0),
|
||||
tabs(Panel::Tools),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedSecond(330.0), tabs(Panel::Canvas), tabs(Panel::Inspector)),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// Side of the Tools panel, including its saved position while closed.
|
||||
pub fn tools_on_right(&self) -> bool {
|
||||
let location = self
|
||||
.layout
|
||||
.location(&Panel::Tools)
|
||||
.ok()
|
||||
.or_else(|| self.hidden.iter().find(|(panel, _)| *panel == Panel::Tools).map(|(_, location)| location.clone()));
|
||||
location.is_some_and(|location| matches!(location.placement, Placement::Split(Zone::Right)))
|
||||
}
|
||||
|
||||
/// Place Tools at the outer left or right edge while preserving its open state.
|
||||
pub fn set_tools_on_right(&mut self, right: bool) -> Result<(), String> {
|
||||
let was_open = self.layout.contains(&Panel::Tools);
|
||||
self.set_visible(Panel::Tools, true)?;
|
||||
let anchor = if right && self.layout.contains(&Panel::Inspector) { Panel::Inspector } else { Panel::Canvas };
|
||||
self.apply(Action::Move { panel: Panel::Tools, anchor, placement: Placement::Split(if right { Zone::Right } else { Zone::Left }) })?;
|
||||
if !was_open {
|
||||
self.set_visible(Panel::Tools, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
self.layout.validate().map_err(|error| error.to_string())?;
|
||||
if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() {
|
||||
return Err("The panel layout must contain its document view and bounded hidden panels".into());
|
||||
}
|
||||
// Saved settings cannot turn the protected document view into a floating or closable tab.
|
||||
if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) {
|
||||
return Err("The document view cannot float".into());
|
||||
}
|
||||
let mut nodes: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = nodes.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
nodes.push(first);
|
||||
nodes.push(second);
|
||||
}
|
||||
Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => {
|
||||
return Err("The document view cannot share a tab group".into());
|
||||
}
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => {
|
||||
return Err("The document view cannot be collapsed".into());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (panel, location) in &self.hidden {
|
||||
if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) {
|
||||
return Err("A hidden panel has an invalid or duplicate identity".into());
|
||||
}
|
||||
if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) {
|
||||
return Err("A hidden panel cannot restore over the document view".into());
|
||||
}
|
||||
let mut probe = self.layout.clone();
|
||||
if let Err(error) = probe.restore(*panel, location)
|
||||
&& error != craft_ui::docking::DockError::MissingTarget
|
||||
{
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn apply(&mut self, action: Action<Panel>) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
if let Action::Close { panel } = &action {
|
||||
let location = next.layout.location(panel).map_err(|error| error.to_string())?;
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
next.hidden.push((*panel, location));
|
||||
} else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action {
|
||||
next.hidden.retain(|(id, _)| id != panel);
|
||||
}
|
||||
next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?;
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a panel's body is exposed by its selected tab or open stack entry.
|
||||
pub(crate) fn exposed(&self, panel: Panel) -> bool {
|
||||
if self.layout.floating.iter().any(|group| group.panels.get(group.active) == Some(&panel)) {
|
||||
return true;
|
||||
}
|
||||
let mut pending: Vec<_> = self.layout.root.iter().collect();
|
||||
while let Some(node) = pending.pop() {
|
||||
match node {
|
||||
Node::Split { first, second, .. } => {
|
||||
pending.push(first);
|
||||
pending.push(second);
|
||||
}
|
||||
Node::Tabs { panels, active } if panels.get(*active) == Some(&panel) => return true,
|
||||
Node::Stack { entries } if entries.iter().any(|entry| entry.panel == panel && entry.open) => return true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> {
|
||||
if self.layout.contains(&panel) == visible {
|
||||
return Ok(());
|
||||
}
|
||||
if !visible {
|
||||
return self.apply(Action::Close { panel });
|
||||
}
|
||||
self.validate()?;
|
||||
let mut next = self.clone();
|
||||
let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone());
|
||||
next.hidden.retain(|(id, _)| *id != panel);
|
||||
if let Some(saved) = &mut location
|
||||
&& saved.floating.is_some()
|
||||
&& saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor)))
|
||||
{
|
||||
// The other members of a floating group may have been closed too.
|
||||
saved.anchor = None;
|
||||
saved.placement = Placement::Tab { before: None };
|
||||
}
|
||||
let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok());
|
||||
if !restored {
|
||||
next.layout
|
||||
.apply_with_permissions(
|
||||
Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) },
|
||||
permissions,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
next.validate()?;
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command(&mut self, params: &Value) -> Result<(), String> {
|
||||
self.validate()?;
|
||||
if let Some(action) = params.get("action") {
|
||||
let action: Action<Panel> = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?;
|
||||
return self.apply(action);
|
||||
}
|
||||
let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?;
|
||||
if operation == "resizeSplit" {
|
||||
let path: Vec<bool> =
|
||||
serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?;
|
||||
let size: SplitSize =
|
||||
serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?;
|
||||
return self.apply(Action::ResizeSplit { path, size });
|
||||
}
|
||||
let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?;
|
||||
let action = match operation {
|
||||
"open" => {
|
||||
self.set_visible(panel, true)?;
|
||||
return self.apply(Action::Activate { panel });
|
||||
}
|
||||
"close" => Action::Close { panel },
|
||||
"activate" => Action::Activate { panel },
|
||||
"setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? },
|
||||
"resizeStack" => {
|
||||
let value = params.get("height").ok_or("height is required")?;
|
||||
let height = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
value
|
||||
.as_f64()
|
||||
.filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height))
|
||||
.ok_or("height must be finite and bounded")? as f32,
|
||||
)
|
||||
};
|
||||
Action::ResizeStack { panel, height }
|
||||
}
|
||||
"float" | "moveFloating" => {
|
||||
let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?;
|
||||
let mut values = [0.0f32; 4];
|
||||
for (output, value) in values.iter_mut().zip(rect) {
|
||||
*output = value
|
||||
.as_f64()
|
||||
.filter(|number| number.is_finite() && number.abs() <= 1_000_000.0)
|
||||
.ok_or("rect values must be finite and bounded")? as f32;
|
||||
}
|
||||
if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } }
|
||||
}
|
||||
"move" => {
|
||||
let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?;
|
||||
let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") {
|
||||
"center" => Placement::Split(Zone::Center),
|
||||
"left" => Placement::Split(Zone::Left),
|
||||
"right" => Placement::Split(Zone::Right),
|
||||
"top" => Placement::Split(Zone::Top),
|
||||
"bottom" => Placement::Split(Zone::Bottom),
|
||||
_ => return Err("zone must be center, left, right, top or bottom".into()),
|
||||
};
|
||||
let placement = if let Some(before) = params.get("before") {
|
||||
if !matches!(placement, Placement::Split(Zone::Center)) {
|
||||
return Err("before only applies to a center move".into());
|
||||
}
|
||||
Placement::Tab {
|
||||
before: if before.is_null() {
|
||||
None
|
||||
} else {
|
||||
Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?)
|
||||
},
|
||||
}
|
||||
} else {
|
||||
placement
|
||||
};
|
||||
Action::Move { panel, anchor, placement }
|
||||
}
|
||||
_ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()),
|
||||
};
|
||||
self.apply(action)
|
||||
}
|
||||
}
|
||||
|
||||
fn sync(workspace: &mut Workspace, app: &crate::PdfCraftApp) -> Result<(), String> {
|
||||
workspace.set_visible(Panel::Tools, app.left_open)?;
|
||||
workspace.set_visible(Panel::Inspector, app.right.is_some())
|
||||
}
|
||||
|
||||
fn drain_reveals(workspace: &mut Workspace, app: &mut crate::PdfCraftApp) {
|
||||
for panel in std::mem::take(&mut app.pending_panel_reveals) {
|
||||
if workspace.layout.contains(&panel) {
|
||||
let _ = workspace.apply(Action::Activate { panel });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn publish_visibility(app: &mut crate::PdfCraftApp, workspace: &Workspace) {
|
||||
app.left_open = workspace.layout.contains(&Panel::Tools);
|
||||
if !workspace.layout.contains(&Panel::Inspector) {
|
||||
if app.right.is_some() {
|
||||
app.choose_right_panel(None);
|
||||
}
|
||||
} else if app.right.is_none() {
|
||||
app.choose_right_panel(Some(crate::RightPanel::Comments));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn command(app: &mut crate::PdfCraftApp, reset: bool, params: &Value) -> Result<Value, String> {
|
||||
let mut workspace = app.docking.clone();
|
||||
if reset {
|
||||
workspace = Workspace::default();
|
||||
workspace.set_visible(Panel::Inspector, app.right.is_some())?;
|
||||
} else {
|
||||
sync(&mut workspace, app)?;
|
||||
workspace.command(params)?;
|
||||
}
|
||||
publish_visibility(app, &workspace);
|
||||
app.docking = workspace;
|
||||
Ok(json!({"docking": app.docking}))
|
||||
}
|
||||
|
||||
// Split paths change when a temporarily unavailable pane is pruned. Translate renderer
|
||||
// paths back to the saved tree before applying geometry changes.
|
||||
fn saved_split_path(saved: &Layout<Panel>, rendered: &Layout<Panel>, path: &[bool]) -> Option<Vec<bool>> {
|
||||
let mut target = rendered.root.as_ref()?;
|
||||
for second in path {
|
||||
let Node::Split { first, second: other, .. } = target else { return None };
|
||||
target = if *second { other } else { first };
|
||||
}
|
||||
let mut pending = vec![(saved.root.as_ref()?, Vec::new())];
|
||||
while let Some((node, original_path)) = pending.pop() {
|
||||
if let Node::Split { first, second, .. } = node {
|
||||
let mut projection = Layout { root: Some(node.clone()), floating: Vec::new() };
|
||||
if projection.contains(&Panel::Inspector) {
|
||||
let _ = projection.apply(Action::Close { panel: Panel::Inspector });
|
||||
}
|
||||
let has_available_panel = |child: &Node<Panel>| {
|
||||
Layout { root: Some(child.clone()), floating: Vec::new() }.panels().into_iter().any(|panel| *panel != Panel::Inspector)
|
||||
};
|
||||
// An ancestor whose Inspector-only side disappeared projects to its surviving
|
||||
// child. Its divider is absent, so only the child's split may own this resize.
|
||||
if has_available_panel(first) && has_available_panel(second) && projection.root.as_ref() == Some(target) {
|
||||
return Some(original_path);
|
||||
}
|
||||
let mut second_path = original_path.clone();
|
||||
second_path.push(true);
|
||||
pending.push((second, second_path));
|
||||
let mut first_path = original_path;
|
||||
first_path.push(false);
|
||||
pending.push((first, first_path));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn show(app: &mut crate::PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = crate::theme::Tokens::get(ui.ctx());
|
||||
let mut workspace = std::mem::take(&mut app.docking);
|
||||
if let Err(error) = workspace.validate().and_then(|()| sync(&mut workspace, app)) {
|
||||
app.notify_fmt("Panel layout could not be restored: {error}", &[("error", &error)]);
|
||||
workspace = Workspace::default();
|
||||
let _ = sync(&mut workspace, app);
|
||||
}
|
||||
let mut style = DockStyle::from_ui(ui);
|
||||
style.min_pane = 96.0;
|
||||
style.float_label = crate::i18n::t("Float panel").into();
|
||||
style.close_label = crate::i18n::t("Close panel").into();
|
||||
style.move_label = crate::i18n::t("Move to group").into();
|
||||
style.panels_label = crate::i18n::t("Panels").into();
|
||||
style.resize_label = crate::i18n::t("Resize panels").into();
|
||||
style.resize_window_label = crate::i18n::t("Resize panel window").into();
|
||||
style.background = t.panel;
|
||||
style.tab_background = t.chrome;
|
||||
style.active_background = t.selected;
|
||||
style.text = t.text;
|
||||
style.inactive_text = t.text_muted;
|
||||
style.border = egui::Stroke::new(1.0, t.divider);
|
||||
style.accent = t.accent;
|
||||
style.font = crate::theme::medium(12.0);
|
||||
drain_reveals(&mut workspace, app);
|
||||
let mut rendered = workspace.layout.clone();
|
||||
if app.active.is_none() && rendered.contains(&Panel::Inspector) {
|
||||
// Availability is a render projection: the saved group, selection and width survive Home.
|
||||
let _ = rendered.apply(Action::Close { panel: Panel::Inspector });
|
||||
}
|
||||
// Tools docked on its own is drawn bare, without a tab strip, like Acrobat's tool pane. Grouped
|
||||
// with another panel, or floating, it keeps its tab so it can be dragged away again; moving it
|
||||
// to the other side stays a preference and a command.
|
||||
let bare_tools = rendered.location(&Panel::Tools).is_ok_and(|l| l.floating.is_none() && matches!(l.placement, Placement::Split(_)));
|
||||
let render_permissions = move |panel: &Panel| if bare_tools && *panel == Panel::Tools { Permissions::PROTECTED } else { permissions(panel) };
|
||||
let output = DockArea::new(egui::Id::new("pdfcraft-docking")).show_with_limits(
|
||||
ui,
|
||||
&rendered,
|
||||
&style,
|
||||
|panel| crate::i18n::t(panel.label()).into(),
|
||||
render_permissions,
|
||||
panel_limits,
|
||||
|ui, panel| match panel {
|
||||
Panel::Canvas => {
|
||||
ui.painter().rect_filled(ui.max_rect(), 0.0, t.pasteboard);
|
||||
match app.active {
|
||||
None if app.combine_showing() => crate::combine_ui::page(app, ui),
|
||||
None => crate::home::show(app, ui),
|
||||
Some(index) => match crate::compare_ui::side_by_side(app, index) {
|
||||
Some((old, new)) => crate::compare_ui::compare_area(app, old, new, ui),
|
||||
None => crate::canvas::document_area(app, index, ui),
|
||||
},
|
||||
}
|
||||
}
|
||||
Panel::Tools => crate::panels::left_panel(app, ui),
|
||||
Panel::Inspector => {
|
||||
ui.data_mut(|data| data.insert_temp(egui::Id::new("pdfcraft-inspector-body"), ui.max_rect()));
|
||||
crate::panels::right_panel(app, ui);
|
||||
}
|
||||
},
|
||||
);
|
||||
let _ = sync(&mut workspace, app);
|
||||
drain_reveals(&mut workspace, app);
|
||||
publish_visibility(app, &workspace);
|
||||
app.docking = workspace;
|
||||
for mut action in output.actions {
|
||||
if app.active.is_none()
|
||||
&& let Action::ResizeSplit { path, .. } = &mut action
|
||||
{
|
||||
let Some(saved_path) = saved_split_path(&app.docking.layout, &rendered, path) else { continue };
|
||||
*path = saved_path;
|
||||
}
|
||||
if let Err(error) =
|
||||
serde_json::to_value(action).map_err(|error| error.to_string()).and_then(|action| command(app, false, &json!({"action": action})))
|
||||
{
|
||||
app.notify_fmt("Panel move was not applied: {error}", &[("error", &error)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_limits(panel: &Panel) -> PanelLimits {
|
||||
let (min, max_width) = match panel {
|
||||
Panel::Canvas => (egui::vec2(160.0, 160.0), 1_000_000.0),
|
||||
Panel::Tools => (egui::vec2(240.0, 120.0), 520.0),
|
||||
Panel::Inspector => (egui::vec2(260.0, 120.0), 520.0),
|
||||
};
|
||||
PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tools_side_moves_to_both_edges_and_survives_closed_restore() {
|
||||
let mut workspace = Workspace::default();
|
||||
assert!(!workspace.tools_on_right());
|
||||
workspace.set_tools_on_right(true).unwrap();
|
||||
assert!(workspace.tools_on_right());
|
||||
workspace.set_visible(Panel::Tools, false).unwrap();
|
||||
assert!(workspace.tools_on_right());
|
||||
workspace.set_tools_on_right(false).unwrap();
|
||||
assert!(!workspace.tools_on_right());
|
||||
assert!(!workspace.layout.contains(&Panel::Tools));
|
||||
workspace.set_visible(Panel::Tools, true).unwrap();
|
||||
assert!(!workspace.tools_on_right());
|
||||
workspace.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposed_respects_collapsed_stack_and_inactive_floating_tabs() {
|
||||
let mut workspace = Workspace {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedFirst(272.0),
|
||||
Node::Stack {
|
||||
entries: vec![
|
||||
craft_ui::docking::StackEntry { panel: Panel::Tools, open: true, height: None },
|
||||
craft_ui::docking::StackEntry { panel: Panel::Inspector, open: false, height: None },
|
||||
],
|
||||
},
|
||||
tabs(Panel::Canvas),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
};
|
||||
workspace.validate().unwrap();
|
||||
assert!(workspace.exposed(Panel::Tools));
|
||||
assert!(!workspace.exposed(Panel::Inspector));
|
||||
workspace.apply(Action::Activate { panel: Panel::Inspector }).unwrap();
|
||||
assert!(workspace.exposed(Panel::Inspector));
|
||||
workspace.apply(Action::Float { panel: Panel::Tools, rect: [10.0, 20.0, 300.0, 400.0] }).unwrap();
|
||||
workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Center) }).unwrap();
|
||||
assert!(!workspace.exposed(Panel::Tools));
|
||||
assert!(workspace.exposed(Panel::Inspector));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_inspector_split_resize_targets_saved_tools_split() {
|
||||
let mut workspace = Workspace::default();
|
||||
// Inspector at the outer edge makes the visible Tools split move to the root.
|
||||
workspace.apply(Action::Move { panel: Panel::Inspector, anchor: Panel::Tools, placement: Placement::Split(Zone::Left) }).unwrap();
|
||||
let mut rendered = workspace.layout.clone();
|
||||
rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
|
||||
let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
|
||||
workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
|
||||
let mut resized = workspace.layout.clone();
|
||||
resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
|
||||
assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_outer_inspector_does_not_capture_its_childs_resize() {
|
||||
let mut workspace = Workspace {
|
||||
layout: Layout {
|
||||
root: Some(split(
|
||||
SplitAxis::Horizontal,
|
||||
SplitSize::FixedSecond(330.0),
|
||||
split(SplitAxis::Horizontal, SplitSize::FixedFirst(272.0), tabs(Panel::Tools), tabs(Panel::Canvas)),
|
||||
tabs(Panel::Inspector),
|
||||
)),
|
||||
floating: Vec::new(),
|
||||
},
|
||||
hidden: Vec::new(),
|
||||
};
|
||||
workspace.validate().unwrap();
|
||||
let mut rendered = workspace.layout.clone();
|
||||
rendered.apply(Action::Close { panel: Panel::Inspector }).unwrap();
|
||||
let path = saved_split_path(&workspace.layout, &rendered, &[]).unwrap();
|
||||
assert_eq!(path, vec![false]);
|
||||
workspace.apply(Action::ResizeSplit { path, size: SplitSize::FixedFirst(290.0) }).unwrap();
|
||||
assert!(matches!(workspace.layout.root, Some(Node::Split { size: SplitSize::FixedSecond(330.0), .. })));
|
||||
let mut resized = workspace.layout.clone();
|
||||
resized.apply(Action::Close { panel: Panel::Inspector }).unwrap();
|
||||
assert!(matches!(resized.root, Some(Node::Split { size: SplitSize::FixedFirst(290.0), .. })));
|
||||
}
|
||||
}
|
||||
@@ -266,7 +266,7 @@ pub struct LineEditor {
|
||||
|
||||
/// The paragraph editor's text box.
|
||||
pub(crate) fn input_id() -> egui::Id {
|
||||
egui::Id::new("edit-text-line-input")
|
||||
egui::Id::unique("edit-text-line-input")
|
||||
}
|
||||
|
||||
/// The page text box `id` keeps its own Undo history, across every paragraph or box it has shown
|
||||
@@ -525,6 +525,19 @@ pub struct ImageSelection {
|
||||
settled: Option<(Rect, Rect)>,
|
||||
}
|
||||
|
||||
impl ImageSelection {
|
||||
/// Cancel an in-progress text, rotation or crop drag and restore the crop it was previewing.
|
||||
pub(crate) fn cancel_pointer_gesture(&mut self) {
|
||||
self.drag = None;
|
||||
self.turning = false;
|
||||
if let Some(crop) = &mut self.crop
|
||||
&& let Some((_, _, before)) = crop.drag.take()
|
||||
{
|
||||
crop.keep = before;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cropping a page image: the handles trim instead of resizing, the part that will go is
|
||||
/// dimmed, Enter or Done applies and Esc or Cancel leaves it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -1671,7 +1684,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
// Over the page, so only where the document shows: a paragraph scrolled under the mode bar or
|
||||
// a side panel must leave them their pointer (#2755). It stays at its paragraph, cut off as
|
||||
// the page is, rather than pushed back into the window.
|
||||
egui::Area::new(egui::Id::new("edit-text-line"))
|
||||
egui::Area::new(egui::Id::unique("edit-text-line"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fade_in(false)
|
||||
.fixed_pos(Pos2::new(ed.rect.left(), ed.rect.top()))
|
||||
@@ -1744,6 +1757,8 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
.desired_width(width)
|
||||
.desired_rows(rows)
|
||||
.show(ui);
|
||||
// The editor paints over the page with no visible label; name it for assistive tech.
|
||||
output.response.response.clone().accessible_name(tl!("Text editing line"));
|
||||
if ed.has_changes() {
|
||||
if background_ready {
|
||||
reveal = true;
|
||||
|
||||
@@ -159,7 +159,7 @@ pub(crate) fn show(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
let mut choice = None;
|
||||
let mut laid = None;
|
||||
egui::Window::new(tl!("Text Field Table"))
|
||||
.id(egui::Id::new("field_table_window"))
|
||||
.id(egui::Id::unique("field_table_window"))
|
||||
.open(&mut open)
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
@@ -255,15 +255,20 @@ fn body(ui: &mut egui::Ui, d: &mut FieldTableDraft, info: &PageInfo, form: &[For
|
||||
for (c, column) in d.table.columns.iter_mut().enumerate() {
|
||||
let width = layout.cells.as_ref().ok().and_then(|cells| cells.get(c)).map(|cell| cell.rect[2] - cell.rect[0]);
|
||||
let share = shares.get(c).copied().unwrap_or_default();
|
||||
let mut column_label_response = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(column_label(c)).strong());
|
||||
let label = ui.label(RichText::new(column_label(c)).strong());
|
||||
column_label_response = Some(label.id);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
let size = width.map_or_else(|| "—".to_owned(), |w| format!("{w:.1} pt · {:.0}%", share * 100.0));
|
||||
ui.label(RichText::new(size).color(t.text_muted).size(11.0));
|
||||
});
|
||||
});
|
||||
let full = ui.available_width();
|
||||
ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c)));
|
||||
let pattern = ui.add(widgets::line(&mut column.pattern).desired_width(full).id_salt(("field table column", c)));
|
||||
if let Some(label) = column_label_response {
|
||||
pattern.labelled_by(label);
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
};
|
||||
@@ -375,7 +380,7 @@ fn column_dividers(ui: &mut egui::Ui, d: &mut FieldTableDraft, bar: Rect, ends:
|
||||
let mut active = None;
|
||||
for (i, &x) in ends.iter().enumerate().take(n.saturating_sub(1)) {
|
||||
let hit = Rect::from_min_max(pos2(x - reach, bar.top()), pos2(x + reach, bar.bottom()));
|
||||
let resp = ui.interact(hit, ui.id().with(("field table divider", i)), Sense::click_and_drag());
|
||||
let resp = ui.interact(hit, ui.scope_id().with(("field table divider", i)), Sense::click_and_drag());
|
||||
let share = d.shares().get(i).copied().unwrap_or_default();
|
||||
resp.widget_info(|| egui::WidgetInfo::slider(true, (share * 100.0).round(), column_label(i)));
|
||||
let mut delta = f64::from(resp.drag_delta().x) / f64::from(bar.width());
|
||||
@@ -469,7 +474,7 @@ mod tests {
|
||||
|ui| {
|
||||
let other = ui.button("Other control");
|
||||
if let Some(i) = focus {
|
||||
ui.memory_mut(|memory| memory.request_focus(ui.id().with(("field table divider", i))));
|
||||
ui.memory_mut(|memory| memory.request_focus(ui.scope_id().with(("field table divider", i))));
|
||||
} else {
|
||||
other.request_focus();
|
||||
}
|
||||
|
||||
@@ -555,7 +555,7 @@ pub(crate) fn signature_entries(ui: &mut egui::Ui, app: &mut crate::PdfCraftApp,
|
||||
ui.horizontal(|ui| {
|
||||
let (rect, response) = ui.allocate_exact_size(vec2((ui.available_width() - 68.0).max(140.0), 52.0), Sense::click());
|
||||
let use_label = label("Use {what}");
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, use_label.clone()));
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, use_label.clone()));
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, CornerRadius::same(4), Color32::WHITE);
|
||||
painter.rect_stroke(
|
||||
@@ -718,7 +718,7 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
// Over the page, so only where the document shows: a box scrolled under the mode bar or a
|
||||
// side panel must leave them their pointer (#2755). It stays where it was placed, cut off as
|
||||
// the page is, rather than pushed back into the window.
|
||||
egui::Area::new(egui::Id::new(("fill-text", view.id.0)))
|
||||
egui::Area::new(egui::Id::unique(("fill-text", view.id.0)))
|
||||
.fade_in(false)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(pos)
|
||||
@@ -732,15 +732,17 @@ pub(crate) fn type_box(ctx: &egui::Context, view: &mut DocView, info: &DocInfo,
|
||||
// The area's max rect is last frame's size, which caps the edit's width; widen it so the
|
||||
// box grows as text is typed.
|
||||
ui.set_min_width(width);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut t.text)
|
||||
.font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
|
||||
.desired_width(width)
|
||||
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
|
||||
.text_color(Color32::BLACK)
|
||||
.hint_text(tl!("Type text"))
|
||||
.id_salt("fill-text-edit"),
|
||||
);
|
||||
let r = ui
|
||||
.add(
|
||||
egui::TextEdit::singleline(&mut t.text)
|
||||
.font(egui::FontId::proportional((t.size as f32 * zoom).max(8.0)))
|
||||
.desired_width(width)
|
||||
.background_color(Color32::from_rgba_unmultiplied(255, 255, 255, 230))
|
||||
.text_color(Color32::BLACK)
|
||||
.hint_text(tl!("Type text"))
|
||||
.id_salt("fill-text-edit"),
|
||||
)
|
||||
.accessible_name(tl!("Text to place"));
|
||||
if t.focus {
|
||||
r.request_focus();
|
||||
t.focus = false;
|
||||
@@ -911,10 +913,10 @@ fn scan_tab(
|
||||
}
|
||||
let Some(scan) = d.scan.as_mut() else {
|
||||
painter.text(area.center(), egui::Align2::CENTER_CENTER, tl!("No photo chosen"), egui::FontId::proportional(13.0), t.text_muted);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo")));
|
||||
return browse;
|
||||
};
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, tl!("Photo")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, tl!("Photo")));
|
||||
// The photo, fitted into the area; `to_screen`/`to_photo` map between its pixels and points.
|
||||
let [pw, ph] = scan.photo.size();
|
||||
let (pw, ph) = (pw.max(1) as f32, ph.max(1) as f32);
|
||||
|
||||
@@ -64,7 +64,7 @@ impl Fling {
|
||||
/// (animations off, single-page paging, a zoom, a dialog).
|
||||
pub(crate) fn read(&mut self, events: &[Event], now: f64, glide: bool) {
|
||||
for event in events {
|
||||
let Event::MouseWheel { unit, delta, phase, modifiers } = event else { continue };
|
||||
let Event::MouseWheel { unit, delta, phase, modifiers, .. } = event else { continue };
|
||||
self.stop();
|
||||
// Zooming, a mouse wheel and sideways Shift scrolling never glide.
|
||||
if *unit != MouseWheelUnit::Point || modifiers.command || modifiers.ctrl || modifiers.shift {
|
||||
@@ -160,7 +160,13 @@ mod tests {
|
||||
use egui::{Modifiers, vec2};
|
||||
|
||||
fn wheel(dy: f32, phase: TouchPhase) -> Event {
|
||||
Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, dy), phase, modifiers: Modifiers::NONE }
|
||||
Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, dy),
|
||||
phase,
|
||||
modifiers: Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// A swipe of `moves` passes of `dy` each, 60 per second, lifted the pass after the last.
|
||||
@@ -213,6 +219,7 @@ mod tests {
|
||||
assert!(!fling.gliding(), "the finger rested before it lifted");
|
||||
}
|
||||
|
||||
/// Check that new finger or wheel input stops an in-progress glide.
|
||||
#[test]
|
||||
fn new_input_stops_a_glide() {
|
||||
let mut fling = Fling::new(true);
|
||||
@@ -222,11 +229,18 @@ mod tests {
|
||||
|
||||
let mut fling = Fling::new(true);
|
||||
let now = swipe(&mut fling, -25.0, 6);
|
||||
let notch = Event::MouseWheel { unit: MouseWheelUnit::Line, delta: vec2(0.0, -1.0), phase: TouchPhase::Move, modifiers: Modifiers::NONE };
|
||||
let notch = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Line,
|
||||
delta: vec2(0.0, -1.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Modifiers::NONE,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
fling.read(&[notch], now + 0.1, true);
|
||||
assert!(!fling.gliding(), "a wheel notch");
|
||||
}
|
||||
|
||||
/// Check that disabled flinging, a false glide flag, and a command-modified zoom gesture prevent glide.
|
||||
#[test]
|
||||
fn no_glide_where_it_is_off_or_not_wanted() {
|
||||
let mut off = Fling::new(false);
|
||||
@@ -242,7 +256,13 @@ mod tests {
|
||||
assert!(!fling.gliding(), "animations off, paging or a dialog");
|
||||
|
||||
let mut zoom = Fling::new(true);
|
||||
let ctrl = |phase| Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -25.0), phase, modifiers: Modifiers::COMMAND };
|
||||
let ctrl = |phase| Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -25.0),
|
||||
phase,
|
||||
modifiers: Modifiers::COMMAND,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
};
|
||||
zoom.read(&[ctrl(TouchPhase::Start)], 0.0, true);
|
||||
zoom.read(&[ctrl(TouchPhase::Move)], dt, true);
|
||||
zoom.read(&[ctrl(TouchPhase::Move)], 2.0 * dt, true);
|
||||
|
||||
@@ -314,20 +314,21 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal(|ui| {
|
||||
let (icon, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
icons::paint(ui, icon, "folder", 20.0, t.icon);
|
||||
ui.label(egui::RichText::new(crate::bidi::visual(&name)).font(theme::semibold(14.0))).on_hover_text(folder);
|
||||
// Folder names and paths are file/path chrome: explicitly left to right, so an
|
||||
// Arabic location keeps its `.pdf`/extension at the visual end.
|
||||
widgets::label_ltr(ui, name.clone(), theme::semibold(14.0), t.text).on_hover_text(folder);
|
||||
ui.add_space(4.0);
|
||||
// Leave room for Unpin: a long location is cut short, never the button.
|
||||
let room = (ui.available_width() - 110.0).max(0.0);
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_width(room);
|
||||
let place = egui::RichText::new(crate::bidi::visual(&location(folder))).font(theme::regular(12.0)).color(t.text_faint);
|
||||
ui.add(egui::Label::new(place).truncate()).on_hover_text(folder);
|
||||
widgets::label_ltr(ui, location(folder), theme::regular(12.0), t.text_faint).on_hover_text(folder);
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "x", tl!("Unpin"));
|
||||
// Every folder has an Unpin button: say which one it is.
|
||||
let label = crate::i18n::fmt(tl!("Unpin {folder}"), &[("folder", &name)]);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if resp.clicked() {
|
||||
unpin = Some(folder.clone());
|
||||
}
|
||||
@@ -353,7 +354,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let shown = if expanded { listing.files.len() } else { listing.files.len().min(SHOWN) };
|
||||
for f in listing.files.iter().take(shown) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 38.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &f.name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &f.name));
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(8), t.hover);
|
||||
}
|
||||
@@ -369,7 +370,7 @@ pub(crate) fn section(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let detail = if when.is_empty() { size } else { format!("{when} · {size}") };
|
||||
let detail = ui.painter().text(rect.right_center() - vec2(12.0, 0.0), Align2::RIGHT_CENTER, detail, theme::regular(12.0), t.text_muted);
|
||||
let width = detail.left() - 16.0 - (rect.left() + 62.0);
|
||||
widgets::row_text(ui, rect.min + vec2(62.0, 19.0), crate::bidi::visual(&f.name), theme::medium(13.0), t.text, width);
|
||||
widgets::row_text(ui, rect.min + vec2(62.0, 19.0), f.name.as_str(), theme::medium(13.0), t.text, width);
|
||||
if resp.on_hover_text(&f.path).clicked() {
|
||||
open = Some(f.path.clone());
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ fn flyout(ui: &egui::Ui, t: &Tokens, resp: egui::Response) -> egui::Response {
|
||||
/// labels.
|
||||
fn labelled_icon(ui: &mut egui::Ui, t: &Tokens, icon: &str, label: &str) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(LABEL_ICON, 26.0), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), label));
|
||||
let tint = if ui.is_enabled() { t.icon } else { t.text_faint };
|
||||
icons::paint(ui, Rect::from_center_size(rect.center(), vec2(18.0, 18.0)), icon, 17.0, tint);
|
||||
resp.on_hover_text(label)
|
||||
@@ -317,7 +317,7 @@ fn number_with_presets(ui: &mut egui::Ui, n: Number<'_>, presets: &[f64], width:
|
||||
/// The text colour as a square swatch that opens the palette.
|
||||
fn colour_button(ui: &mut egui::Ui, t: &Tokens, color: [f64; 3]) -> egui::Response {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Colour")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Colour")));
|
||||
let [r, g, b] = color.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as u8);
|
||||
let fill = rect.shrink(4.0);
|
||||
ui.painter().rect_filled(fill, CornerRadius::same(2), Color32::from_rgb(r, g, b));
|
||||
|
||||
@@ -322,7 +322,7 @@ pub(crate) fn commit(view: &mut DocView, form: &[FormField]) {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f: &FormField, w: &FormWidget, rect: Rect, zoom: f32, widget: usize) {
|
||||
let mut enter = false;
|
||||
egui::Area::new(egui::Id::new(("form-editor", view.id.0)))
|
||||
egui::Area::new(egui::Id::unique(("form-editor", view.id.0)))
|
||||
.fade_in(false)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(rect.min)
|
||||
@@ -340,8 +340,9 @@ fn combo_editor(ctx: &egui::Context, view: &mut DocView, form: &[FormField], f:
|
||||
.background_color(FIELD_FILL)
|
||||
.text_color(Color32::BLACK)
|
||||
.margin(egui::Margin::symmetric(3, 1))
|
||||
.id(egui::Id::new(("form-field", view.id.0, &f.name, widget)));
|
||||
let r = ui.add_sized(rect.size(), te);
|
||||
.id(egui::Id::unique(("form-field", view.id.0, &f.name, widget)));
|
||||
// The form field's own logical name; not a visual/preordered label.
|
||||
let r = ui.add_sized(rect.size(), te).accessible_name(f.name.clone());
|
||||
if std::mem::take(&mut fx.request_focus) {
|
||||
r.request_focus();
|
||||
// Entered with Tab: typing replaces the value.
|
||||
@@ -381,7 +382,7 @@ fn calendar(ctx: &egui::Context, view: &mut DocView, field: egui::Rect, fmt: &st
|
||||
fx.calendar_text = Some(fx.text.clone());
|
||||
}
|
||||
let mut picked = None;
|
||||
let area = egui::Area::new(egui::Id::new(("date-picker", view.id.0)))
|
||||
let area = egui::Area::new(egui::Id::unique(("date-picker", view.id.0)))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(field.left_bottom() + vec2(0.0, 2.0))
|
||||
.show(ctx, |ui| {
|
||||
@@ -672,7 +673,7 @@ pub(crate) fn overlay(
|
||||
// Over the page, so only where the document shows: a field scrolled under the mode bar
|
||||
// or a side panel must leave them their pointer (#2755). It stays on its field, cut off
|
||||
// as the page is, rather than pushed back into the window.
|
||||
egui::Area::new(egui::Id::new(("form-editor", view.id.0)))
|
||||
egui::Area::new(egui::Id::unique(("form-editor", view.id.0)))
|
||||
.fade_in(false)
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(rect.min)
|
||||
@@ -697,7 +698,7 @@ pub(crate) fn overlay(
|
||||
.text_color(Color32::BLACK)
|
||||
.margin(egui::Margin::symmetric(3, 1))
|
||||
.password(f.has(field_flags::PASSWORD))
|
||||
.id(egui::Id::new(("form-field", view.id.0, &f.name, focus.widget)));
|
||||
.id(egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget)));
|
||||
if let Some(max) = f.max_len {
|
||||
te = te.char_limit(max);
|
||||
}
|
||||
@@ -705,11 +706,14 @@ pub(crate) fn overlay(
|
||||
te = te.desired_rows(((rect.height() / (font * 1.3)).floor() as usize).max(1));
|
||||
}
|
||||
let mut laid_out = None;
|
||||
let r = ui.add_sized(rect.size(), |ui: &mut egui::Ui| {
|
||||
let out = te.show(ui);
|
||||
laid_out = Some((out.galley, out.galley_pos));
|
||||
out.response.response
|
||||
});
|
||||
let r = ui
|
||||
.add_sized(rect.size(), |ui: &mut egui::Ui| {
|
||||
let out = te.show(ui);
|
||||
laid_out = Some((out.galley, out.galley_pos));
|
||||
out.response.response
|
||||
})
|
||||
// The form field's own logical name; not a visual/preordered label.
|
||||
.accessible_name(f.name.clone());
|
||||
if let (Some(tb), Some(before)) = (text_box, before)
|
||||
&& fx.text != before
|
||||
{
|
||||
@@ -784,7 +788,7 @@ pub(crate) fn overlay(
|
||||
if editable_combo(f) {
|
||||
combo_editor(ctx, view, form, f, w, rect, zoom, focus.widget);
|
||||
}
|
||||
let resp = egui::Area::new(egui::Id::new(("form-choices", view.id.0)))
|
||||
let resp = egui::Area::new(egui::Id::unique(("form-choices", view.id.0)))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(rect.left_bottom() + vec2(0.0, 2.0))
|
||||
.show(ctx, |ui| {
|
||||
@@ -843,10 +847,10 @@ pub(crate) fn overlay(
|
||||
FormFieldKind::CheckBox | FormFieldKind::Radio => {
|
||||
// Nothing is drawn over the box, but a widget there holds egui's keyboard focus, so
|
||||
// Space, Tab and the arrows come here instead of turning or scrolling the page.
|
||||
let id = egui::Id::new(("form-field", view.id.0, &f.name, focus.widget));
|
||||
let id = egui::Id::unique(("form-field", view.id.0, &f.name, focus.widget));
|
||||
let mut has_focus = false;
|
||||
// Not interactable, so clicks still reach the page under it.
|
||||
let area = egui::Area::new(egui::Id::new(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false);
|
||||
let area = egui::Area::new(egui::Id::unique(("form-toggle", view.id.0))).order(egui::Order::Foreground).interactable(false);
|
||||
area.fixed_pos(rect.min).show(ctx, |ui| {
|
||||
let r = ui.interact(rect, id, egui::Sense::focusable_noninteractive());
|
||||
if let Some(fx) = view.forms.focus.as_mut()
|
||||
|
||||
Loaded 100 of 233 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user