mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 10:04:03 +00:00
Merge remote-tracking branch 'upstream/main' into fix/xfa-enter-exit-on-focus
# Conflicts: # crates/engine/src/lib.rs
This commit is contained in:
@@ -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 }}
|
||||
|
||||
+7
-6
@@ -298,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)
|
||||
@@ -343,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 |
|
||||
|
||||
+22
-10
@@ -2439,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"
|
||||
@@ -2451,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)"
|
||||
@@ -2463,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)"
|
||||
@@ -3372,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
+451
-308
File diff suppressed because it is too large
Load Diff
+33
-9
@@ -13,13 +13,17 @@ 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.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
@@ -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.
|
||||
|
||||
+7
-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,7 @@ 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.
|
||||
|
||||
@@ -675,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]
|
||||
|
||||
@@ -6889,6 +6889,70 @@ 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 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).
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! The bookmark list.
|
||||
|
||||
/// A bookmark as the bookmark list shows it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BookmarkRow {
|
||||
/// Child indices from the top level, 0-based, as the edit functions take them.
|
||||
pub path: Vec<usize>,
|
||||
pub title: String,
|
||||
/// Shown expanded (a positive `/Count`).
|
||||
pub open: bool,
|
||||
/// The 0-based page it goes to.
|
||||
pub page: Option<usize>,
|
||||
/// Where on that page it goes ([`pdfcraft_render::DestView::Top`] when it doesn't say).
|
||||
pub view: pdfcraft_render::DestView,
|
||||
/// The title's colour (`/C`), `None` for the default black, and its style (`/F`).
|
||||
pub color: Option<[f32; 3]>,
|
||||
pub italic: bool,
|
||||
pub bold: bool,
|
||||
}
|
||||
|
||||
/// One page of the bookmark list, in outline order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct BookmarkPage {
|
||||
pub rows: Vec<BookmarkRow>,
|
||||
/// Where the next page starts, or `None` at the end.
|
||||
pub next: Option<usize>,
|
||||
/// Set only when the document's structure could not be read for editing: its listing then
|
||||
/// comes from the viewer's copy of the outline, which is shorter than the file's.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The rows of a viewer outline, in outline order, with their paths.
|
||||
pub(crate) fn outline_rows(items: &[pdfcraft_render::OutlineItem]) -> Vec<BookmarkRow> {
|
||||
let mut rows = Vec::new();
|
||||
// One entry per open level: its items, and how many of them have been listed.
|
||||
let mut levels: Vec<(&[pdfcraft_render::OutlineItem], usize)> = vec![(items, 0)];
|
||||
while let Some(&(list, next)) = levels.last() {
|
||||
let Some(item) = list.get(next) else {
|
||||
levels.pop();
|
||||
continue;
|
||||
};
|
||||
if let Some(top) = levels.last_mut() {
|
||||
top.1 += 1;
|
||||
}
|
||||
let path: Vec<usize> = levels.iter().map(|(_, listed)| listed.saturating_sub(1)).collect();
|
||||
rows.push(BookmarkRow {
|
||||
path,
|
||||
title: item.title.clone(),
|
||||
open: item.open,
|
||||
page: item.page,
|
||||
view: item.view,
|
||||
color: item.color,
|
||||
italic: item.italic,
|
||||
bold: item.bold,
|
||||
});
|
||||
if !item.children.is_empty() {
|
||||
levels.push((&item.children, 0));
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The comment list and its summary.
|
||||
|
||||
/// The comment list, read from the object graph (as `inspect` lists comments).
|
||||
pub(crate) fn comment_list(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_render::Annotation> {
|
||||
let mut list: Vec<pdfcraft_render::Annotation> = pdfcraft_annot::summaries(doc)
|
||||
.into_iter()
|
||||
.map(|s| pdfcraft_render::Annotation {
|
||||
page: s.page,
|
||||
subtype: s.subtype,
|
||||
author: s.author,
|
||||
contents: s.contents,
|
||||
modified: s.modified.map(|m| pdfcraft_render::pretty_date(&m)),
|
||||
name: s.name,
|
||||
in_reply_to: s.in_reply_to,
|
||||
parent: s.parent,
|
||||
grouped: s.grouped,
|
||||
rect: s.rect,
|
||||
color: s.color,
|
||||
index: s.index,
|
||||
state: s.state,
|
||||
quads: s.quads,
|
||||
locked: s.locked,
|
||||
intent: s.intent,
|
||||
fill_sign: s.fill_sign,
|
||||
no_rotate: s.no_rotate,
|
||||
points: s.points,
|
||||
})
|
||||
.collect();
|
||||
pdfcraft_render::link_replies(&mut list);
|
||||
list
|
||||
}
|
||||
|
||||
/// Order of Summarize Comments.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum SummarySort {
|
||||
#[default]
|
||||
Page,
|
||||
Author,
|
||||
Date,
|
||||
Type,
|
||||
}
|
||||
|
||||
impl SummarySort {
|
||||
pub const ALL: [SummarySort; 4] = [SummarySort::Page, SummarySort::Author, SummarySort::Date, SummarySort::Type];
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
SummarySort::Page => "Page",
|
||||
SummarySort::Author => "Author",
|
||||
SummarySort::Date => "Date",
|
||||
SummarySort::Type => "Type",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Option<SummarySort> {
|
||||
Self::ALL.into_iter().find(|k| k.name().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Acrobat's name for a comment type, as comment lists show it.
|
||||
pub fn comment_type_name(subtype: &str) -> &str {
|
||||
match subtype {
|
||||
"Text" => "Sticky Note",
|
||||
"FreeText" => "Text Box",
|
||||
"Highlight" => "Highlight",
|
||||
"Underline" => "Underline",
|
||||
"StrikeOut" => "Strikethrough",
|
||||
"Squiggly" => "Squiggly",
|
||||
"Square" => "Rectangle",
|
||||
"Circle" => "Oval",
|
||||
"Line" => "Line",
|
||||
"Ink" => "Pencil",
|
||||
"Polygon" => "Polygon",
|
||||
"PolyLine" => "Polygonal Line",
|
||||
"Stamp" => "Stamp",
|
||||
"Caret" => "Inserted Text",
|
||||
"FileAttachment" => "File Attachment",
|
||||
"Sound" => "Sound",
|
||||
"Redact" => "Redaction",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
|
||||
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
Some("FreeTextCallout") => "Callout",
|
||||
Some("PolygonCloud") => "Cloud",
|
||||
Some("FreeTextTypeWriter") => "Typewriter",
|
||||
_ => comment_type_name(&a.subtype),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of a comment summary: one block per comment (replies indented under it), with a
|
||||
/// "Page N" heading when sorted by page. Checkmarks and status replies are left out, as are
|
||||
/// pop-ups; the number is the comment's position on its page.
|
||||
pub fn comment_summary(name: &str, all: &[pdfcraft_render::Annotation], sort: SummarySort) -> String {
|
||||
use std::fmt::Write;
|
||||
let top: Vec<&pdfcraft_render::Annotation> = all.iter().filter(|a| a.is_comment()).collect();
|
||||
// Each reply under the annotation its `/IRT` points at, in document order, gathered once.
|
||||
type Children<'a> = std::collections::HashMap<(usize, usize), Vec<&'a pdfcraft_render::Annotation>>;
|
||||
let mut children = Children::new();
|
||||
for r in all.iter().filter(|r| r.state.is_none()) {
|
||||
if let Some(parent) = r.parent {
|
||||
children.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
// Write a comment's replies, indenting each level. A reply to a reply keeps its `/IRT` on the
|
||||
// direct parent, so following one level would leave the deeper replies out of the summary.
|
||||
fn write_replies(out: &mut String, children: &Children<'_>, parent: &pdfcraft_render::Annotation, depth: usize) {
|
||||
use std::fmt::Write;
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for r in children.get(&(parent.page, parent.index)).into_iter().flatten() {
|
||||
let indent = " ".repeat(depth + 1);
|
||||
let _ =
|
||||
writeln!(out, "{indent}Author: {} Subject: Reply Date: {}", r.author.as_deref().unwrap_or(""), r.modified.as_deref().unwrap_or(""));
|
||||
for line in r.contents.as_deref().unwrap_or("").lines() {
|
||||
let _ = writeln!(out, "{indent}{line}");
|
||||
}
|
||||
write_replies(out, children, r, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut numbered: Vec<(usize, &pdfcraft_render::Annotation)> = Vec::new();
|
||||
let mut last = usize::MAX;
|
||||
let mut n = 0;
|
||||
for a in &top {
|
||||
if a.page != last {
|
||||
last = a.page;
|
||||
n = 0;
|
||||
}
|
||||
n += 1;
|
||||
numbered.push((n, a));
|
||||
}
|
||||
match sort {
|
||||
SummarySort::Page => {}
|
||||
SummarySort::Author => numbered.sort_by(|x, y| x.1.author.cmp(&y.1.author)),
|
||||
SummarySort::Date => numbered.sort_by(|x, y| x.1.modified.cmp(&y.1.modified)),
|
||||
SummarySort::Type => numbered.sort_by(|x, y| comment_kind(x.1).cmp(comment_kind(y.1))),
|
||||
}
|
||||
let mut out = format!("Summary of Comments on {name}\n\n");
|
||||
if numbered.is_empty() {
|
||||
out.push_str("This document has no comments.\n");
|
||||
return out;
|
||||
}
|
||||
let mut heading = usize::MAX;
|
||||
for (n, a) in numbered {
|
||||
if sort == SummarySort::Page && a.page != heading {
|
||||
heading = a.page;
|
||||
let _ = writeln!(out, "Page: {}", a.page + 1);
|
||||
}
|
||||
let _ = write!(out, "Number: {n} Author: {} Subject: {}", a.author.as_deref().unwrap_or(""), comment_kind(a));
|
||||
if sort != SummarySort::Page {
|
||||
let _ = write!(out, " Page: {}", a.page + 1);
|
||||
}
|
||||
let _ = writeln!(out, " Date: {}", a.modified.as_deref().unwrap_or(""));
|
||||
if let Some(c) = a.contents.as_deref().filter(|c| !c.is_empty()) {
|
||||
let _ = writeln!(out, "{c}");
|
||||
}
|
||||
write_replies(&mut out, &children, a, 0);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,717 @@
|
||||
//! An open document: its working file, view data and edit state.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_incremental};
|
||||
use pdfcraft_render::{DocInfo, RenderConfig, RenderPool};
|
||||
|
||||
use crate::history::Editor;
|
||||
use crate::{
|
||||
CalculationOrder, CheckStyle, DocId, Edit, EditError, EditableObject, FormFieldKind, Hidden, InitialView, Label, MarkKind, SecuritySummary,
|
||||
SignatureInfo, TabOrder, TrustStore, XfaLayout, a11y, form_appearance, guard, js, measure, optimize, renderer_password, unrestricted,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::{record_link_scan, record_marker_scan};
|
||||
|
||||
pub struct Document {
|
||||
pub id: DocId,
|
||||
pub name: String,
|
||||
pub path: Option<String>,
|
||||
/// The working file: what Save writes.
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
/// What is displayed (the page view, `page_render`, page images): `bytes`, plus appearances
|
||||
/// drawn for display only for comments that have none ([`display_bytes`]). Never saved.
|
||||
pub display: Arc<Vec<u8>>,
|
||||
pub info: DocInfo,
|
||||
pub renderer: RenderPool,
|
||||
/// The password the document was opened with (needed to read attachments, etc.).
|
||||
pub password: Option<String>,
|
||||
/// `true` when there are edits that have not been saved.
|
||||
pub dirty: bool,
|
||||
/// Bumped on every change to the working file (edit, undo, redo, save); autosave compares it.
|
||||
pub(crate) generation: u64,
|
||||
/// The generation last handed out by `autosave_snapshots`.
|
||||
pub(crate) snapshot_generation: u64,
|
||||
/// Why the document cannot be edited (e.g. encryption), if so.
|
||||
pub read_only_reason: Option<String>,
|
||||
/// Interactive form fields of the current state (empty without a form).
|
||||
pub form: Arc<Vec<pdfcraft_forms::Field>>,
|
||||
pub(crate) marker_scan: OnceLock<pdfcraft_edit::MarkerScan>,
|
||||
pub(crate) links: OnceLock<Vec<pdfcraft_annot::links::LinkItem>>,
|
||||
/// Signature fields, validated against the session's trust store (Signatures panel).
|
||||
pub signatures: Arc<Vec<SignatureInfo>>,
|
||||
pub(crate) trust: Arc<TrustStore>,
|
||||
pub(crate) sig_cache: Arc<pdfcraft_sign::DigestCache>,
|
||||
pub(crate) editor: Option<Editor>,
|
||||
pub(crate) config: RenderConfig,
|
||||
/// What field scripts printed or asked for (see [`Session::take_js_output`]).
|
||||
pub(crate) js_output: js::JsOutput,
|
||||
/// Dynamic XFA forms: what laying the template out produced (pages and fields are
|
||||
/// PdfCraft's; Adobe's viewers draw the form from the XFA packets themselves).
|
||||
pub xfa: Option<XfaLayout>,
|
||||
/// The parsed template of a laid-out XFA form, for its scripts.
|
||||
pub(crate) xfa_template: Option<Arc<pdfcraft_xfa::model::Template>>,
|
||||
/// A script of the XFA form ran away, so its scripts are off (see [`Document::xfa_scripts_off`]).
|
||||
pub(crate) xfa_scripts_off: bool,
|
||||
/// SOM paths of the XFA fields whose exit scripts ran with a value change ([`xfa::on_changes`])
|
||||
/// since they last gained focus: their exit on losing focus has run already
|
||||
/// ([`Session::field_focus`]).
|
||||
pub(crate) xfa_exited: std::collections::HashSet<String>,
|
||||
/// A content-only refresh keeps `info` from before the edit; an edit may have added or
|
||||
/// embedded fonts, so the font list is read again on demand ([`Session::refresh_fonts`]).
|
||||
pub(crate) fonts_stale: bool,
|
||||
/// XFA forms: what was approximated, rewritten or could not be written to the XFA data
|
||||
/// (also in `info.warnings`, kept there when the document is re-read).
|
||||
pub xfa_warnings: Vec<String>,
|
||||
/// What the last successful [`Session::apply`](crate::Session::apply) wrote that its fonts can't draw: kept as typed,
|
||||
/// drawn with `?` in its place (see [`TextNotice`]). The interface shows it; tools return it.
|
||||
pub edit_notices: Vec<TextNotice>,
|
||||
}
|
||||
|
||||
/// Text an edit stored as typed but drew with `?` in place of `ch`, because the font it is drawn
|
||||
/// with (a form field's, or the standard fonts for comments) can't draw that character. Acrobat
|
||||
/// keeps such text too, drawing it with fonts PdfCraft can't embed yet.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct TextNotice {
|
||||
/// The form field's full name, or `None` for a comment.
|
||||
pub field: Option<String>,
|
||||
/// The page (0-based) of the comment or of the field's widget.
|
||||
pub page: usize,
|
||||
pub ch: char,
|
||||
/// Other PDF viewers redraw it from the stored text (a form field, with `/NeedAppearances`
|
||||
/// set); otherwise the `?` shows in every viewer.
|
||||
pub redrawn_elsewhere: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TextNotice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let ch = format!("\"{}\" (U+{:04X})", self.ch.escape_debug(), u32::from(self.ch));
|
||||
let page = self.page + 1;
|
||||
match (&self.field, self.redrawn_elsewhere) {
|
||||
(Some(name), true) => write!(
|
||||
f,
|
||||
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, PdfCraft shows ? in its place, and other PDF viewers redraw the field from the value"
|
||||
),
|
||||
(Some(name), false) => write!(
|
||||
f,
|
||||
"form field {name:?} (page {page}) holds {ch}, which its font can't draw: the value is saved, but this PDF/A document can't ask other viewers to redraw it, so ? shows in its place everywhere"
|
||||
),
|
||||
(None, _) => write!(
|
||||
f,
|
||||
"a comment on page {page} has {ch}, which the standard fonts can't draw: its text is saved, but its appearance shows ? in its place in every viewer"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `next` drew with `?` that `prev` didn't (a new or redrawn appearance carrying
|
||||
/// `pdfcraft_fonts::UNDRAWN_MARK`), one notice per field or comment, in page order.
|
||||
pub(crate) fn undrawn_notices(prev: &pdfcraft_cos::Document, next: &pdfcraft_cos::Document) -> Vec<TextNotice> {
|
||||
let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
|
||||
let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
|
||||
let mut names: Option<std::collections::HashMap<pdfcraft_cos::ObjRef, String>> = None;
|
||||
let mut out: Vec<TextNotice> = Vec::new();
|
||||
for (page, p) in pages.iter().enumerate() {
|
||||
let annots = next.get(*p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| next.resolve(&a));
|
||||
for r in annots.as_deref().and_then(pdfcraft_cos::Object::as_array).into_iter().flatten().filter_map(pdfcraft_cos::Object::as_ref) {
|
||||
let now = next.get(r);
|
||||
let Some(d) = now.as_dict() else { continue };
|
||||
let Some(ch) = pdfcraft_fonts::undrawn_mark(d) else { continue };
|
||||
// Only what this edit drew: a new annotation, or a new appearance.
|
||||
let was = prev.get(r);
|
||||
if was.as_dict().is_some_and(|w| w.get(b"AP") == d.get(b"AP") && pdfcraft_fonts::undrawn_mark(w) == Some(ch)) {
|
||||
continue;
|
||||
}
|
||||
let field = (d.name(b"Subtype") == Some(b"Widget")).then(|| {
|
||||
let names = names.get_or_insert_with(|| {
|
||||
pdfcraft_forms::fields(next)
|
||||
.into_iter()
|
||||
.flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.name.clone())).collect::<Vec<_>>())
|
||||
.collect()
|
||||
});
|
||||
names.get(&r).cloned().unwrap_or_default()
|
||||
});
|
||||
if field.is_some() && out.iter().any(|n| n.field == field) {
|
||||
continue;
|
||||
}
|
||||
out.push(TextNotice { redrawn_elsewhere: redrawn_elsewhere && field.is_some(), field, page, ch });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// How the document opens (Document Properties ▸ Initial View).
|
||||
/// Accessibility ▸ Check for accessibility: the full check (`None` if the document can't be
|
||||
/// read for editing).
|
||||
pub fn accessibility_check(&self, options: &a11y::Options) -> Option<a11y::Report> {
|
||||
self.editor.as_ref().map(|e| a11y::check(&e.cos, options))
|
||||
}
|
||||
|
||||
/// A script of this XFA form ran past its time limit and was abandoned, so the form's
|
||||
/// scripts no longer run in this document (reopening it runs them again). The script's
|
||||
/// error is in the JavaScript output; this is the state, for a viewer to show.
|
||||
pub fn xfa_scripts_off(&self) -> bool {
|
||||
self.xfa_scripts_off
|
||||
}
|
||||
|
||||
/// PDF Optimizer ▸ Audit space usage.
|
||||
pub fn audit_space(&self) -> Vec<optimize::SpaceUse> {
|
||||
self.editor.as_ref().map(|e| optimize::audit_space(&e.cos, self.bytes.len() as u64)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Edit a PDF: the images `page` draws (0-based).
|
||||
pub fn page_images(&self, page: usize) -> Vec<pdfcraft_edit::PageImage> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::page_images(&e.cos, page).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mixed editable objects on one page. Source inventories are zero-based and added items
|
||||
/// appear once. Rectangles are user-space points, independent of the viewer's zoom.
|
||||
pub fn editable_objects(&self, page: usize) -> Result<Vec<EditableObject>, String> {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
pdfcraft_edit::editable_objects(&editor.cos, page).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Displayed top-left-origin motion to a user-space vector. Use the linear map only,
|
||||
/// so even a large crop-box origin cannot erase a small drag by cancellation.
|
||||
pub fn object_move_offset(&self, page: usize, delta: [f64; 2]) -> Result<[f64; 2], String> {
|
||||
let p = self.info.pages.get(page).ok_or("the page no longer exists")?;
|
||||
let [x0, y0, x1, y1] = p.crop.map(f64::from);
|
||||
let (a, b) = (delta[0] / f64::from(p.width.max(1e-3)), delta[1] / f64::from(p.height.max(1e-3)));
|
||||
let (u, v) = match p.rotation {
|
||||
90 => (b, -a),
|
||||
180 => (-a, -b),
|
||||
270 => (-b, a),
|
||||
_ => (a, b),
|
||||
};
|
||||
let offset = [u * (x1 - x0), -v * (y1 - y0)];
|
||||
if !offset.iter().all(|v| v.is_finite()) {
|
||||
return Err("the move must be finite and non-zero".into());
|
||||
}
|
||||
Ok(offset)
|
||||
}
|
||||
|
||||
/// Save image as: image `index` on `page` as a file (extension, bytes).
|
||||
pub fn page_image_file(&self, page: usize, index: usize) -> Result<(&'static str, Vec<u8>), String> {
|
||||
self.check_copying().map_err(|e| e.to_string())?;
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let img = self.page_images(page).into_iter().nth(index).ok_or_else(|| format!("page {} has no image {}", page + 1, index + 1))?;
|
||||
if img.is_form {
|
||||
return Err("grouped Form artwork is not a bitmap; Save Image As is only available for raster images".into());
|
||||
}
|
||||
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
|
||||
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
|
||||
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Saved measurement annotations, calculated from their geometry and PDF scales, plus
|
||||
/// the ones that couldn't be read (unsupported formats are skipped, not fatal).
|
||||
pub fn measurements(&self) -> Result<measure::Listing, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
Ok(measure::list(&e.cos))
|
||||
}
|
||||
pub fn measurement_scale(&self, page: usize, at: measure::Point) -> Result<measure::Scale, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at(&e.cos, page, at).map_err(|e| e.to_string())
|
||||
}
|
||||
/// [`Self::measurement_scale`], measuring true size in `unit` (`per_point` of them in a
|
||||
/// point) where the page sets no scale.
|
||||
pub fn measurement_scale_or(&self, page: usize, at: measure::Point, per_point: f64, unit: &str) -> Result<measure::Scale, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::scale_at_or(&e.cos, page, at, per_point, unit).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_to_user(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::view_to_user(&e.cos, page, point).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_to_view(&self, page: usize, point: measure::Point) -> Result<measure::Point, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::user_to_view(&e.cos, page, point).map_err(|e| e.to_string())
|
||||
}
|
||||
pub fn measurement_paths(&self, page: usize) -> Result<measure::snap::Geometry, String> {
|
||||
let e = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
measure::snap::geometry(&e.cos, page).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A counter that changes with every edit (for caches of derived data).
|
||||
pub fn edit_generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
|
||||
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The form's calculation order: its calculated fields in the order they calculate, what each
|
||||
/// formula reads, what is wrong with the order, and the order Fix order writes.
|
||||
pub fn calculation_order(&self) -> CalculationOrder {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add alternate text: the figures, in document order.
|
||||
pub fn figures(&self) -> Vec<a11y::Figure> {
|
||||
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reachable indirect structure tags, in document order.
|
||||
pub fn tags(&self) -> Vec<a11y::TagInfo> {
|
||||
self.editor.as_ref().map(|e| a11y::tags(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Marked-content items directly owned by one reachable indirect structure tag.
|
||||
pub fn tag_content(&self, object: pdfcraft_cos::ObjRef) -> Result<Vec<a11y::TagContentItem>, a11y::TagError> {
|
||||
self.editor.as_ref().map(|e| a11y::tag_content(&e.cos, object)).unwrap_or_else(|| Ok(Vec::new()))
|
||||
}
|
||||
|
||||
/// The edit that fixes `rule`, for the rules with an automatic fix: the document language
|
||||
/// (`value` is the language), the title (`value` replaces it; else the current title or the
|
||||
/// file name) and the tab order.
|
||||
pub fn accessibility_fix(&self, rule: a11y::Rule, value: Option<&str>) -> Result<Edit, String> {
|
||||
let value = value.map(str::trim).filter(|v| !v.is_empty());
|
||||
match rule {
|
||||
a11y::Rule::PrimaryLanguage => {
|
||||
let mut v = self.initial_view();
|
||||
v.language = Some(value.ok_or("give the document language (for example en-US)")?.to_owned());
|
||||
Ok(Edit::SetInitialView(Box::new(v)))
|
||||
}
|
||||
a11y::Rule::Title => {
|
||||
let title = value
|
||||
.map(str::to_owned)
|
||||
.or_else(|| self.info_value("Title").filter(|t| !t.trim().is_empty()))
|
||||
.unwrap_or_else(|| self.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_owned());
|
||||
let mut v = self.initial_view();
|
||||
v.display_title = true;
|
||||
Ok(Edit::Batch {
|
||||
label: "Set document title".into(),
|
||||
edits: vec![Edit::SetInfo { key: "Title".into(), value: title }, Edit::SetInitialView(Box::new(v))],
|
||||
})
|
||||
}
|
||||
a11y::Rule::TabOrder => Ok(Edit::SetTabOrder { pages: (0..self.info.pages.len()).collect(), order: TabOrder::Structure }),
|
||||
other => Err(format!("\"{}\" has no automatic fix", other.name())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initial_view(&self) -> InitialView {
|
||||
self.editor.as_ref().map(|e| pdfcraft_organize::initial_view(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Signed: at least one signature field holds a signature.
|
||||
pub fn is_signed(&self) -> bool {
|
||||
self.signatures.iter().any(|s| s.signed)
|
||||
}
|
||||
|
||||
/// Whether comments are hidden (Comments ▸ Hide all comments).
|
||||
pub fn comments_hidden(&self) -> bool {
|
||||
self.config.hide_comments
|
||||
}
|
||||
|
||||
pub fn can_undo(&self) -> Option<&str> {
|
||||
self.undo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
pub fn can_redo(&self) -> Option<&str> {
|
||||
self.redo_label().map(Label::as_str)
|
||||
}
|
||||
|
||||
/// What Undo would undo, in parts, for an interface that says it in another language.
|
||||
pub fn undo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.undo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
/// What Redo would redo, in parts.
|
||||
pub fn redo_label(&self) -> Option<&Label> {
|
||||
self.editor.as_ref().and_then(|e| e.redo.last()).map(|(l, ..)| l)
|
||||
}
|
||||
|
||||
pub fn editable(&self) -> bool {
|
||||
self.editor.is_some()
|
||||
}
|
||||
|
||||
/// What the opening password allows; `None` when the document is not encrypted.
|
||||
pub fn permissions(&self) -> Option<pdfcraft_cos::Permissions> {
|
||||
self.editor.as_ref().and_then(|e| e.cos.permissions())
|
||||
}
|
||||
|
||||
/// Printing is allowed (Table 22, bit 3).
|
||||
pub fn allows_printing(&self) -> bool {
|
||||
self.permissions().is_none_or(|p| p.print())
|
||||
}
|
||||
|
||||
/// Copying or otherwise extracting text and images is allowed (Table 22, bit 5): copying
|
||||
/// selected text, snapshots, Save Image As, exporting. Assistive technology may read the
|
||||
/// content regardless (§7.6.4.2), so reading aloud and accessibility tools don't ask.
|
||||
pub fn allows_copying(&self) -> bool {
|
||||
self.permissions().is_none_or(|p| p.copy())
|
||||
}
|
||||
|
||||
/// [`Self::allows_copying`], as the error to report when it isn't.
|
||||
pub fn check_copying(&self) -> Result<(), EditError> {
|
||||
if self.allows_copying() { Ok(()) } else { Err(EditError::NotPermitted("copying text and images")) }
|
||||
}
|
||||
|
||||
/// Page changes (insert, delete, rotate, move, extract) are allowed.
|
||||
pub fn allows_assembly(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.assemble())
|
||||
}
|
||||
|
||||
/// Redaction marks waiting to be applied.
|
||||
pub fn redaction_marks(&self) -> usize {
|
||||
self.info.annotations.iter().filter(|a| a.subtype == "Redact").count()
|
||||
}
|
||||
|
||||
/// Page marks present (headers and footers, watermarks, backgrounds), for Update/Remove.
|
||||
pub fn marks(&self) -> &[MarkKind] {
|
||||
&self
|
||||
.marker_scan
|
||||
.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_marker_scan();
|
||||
pdfcraft_edit::scan_markers(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.marks
|
||||
}
|
||||
|
||||
/// Text and images added with Edit a PDF ▸ Add content (still editable).
|
||||
pub fn added(&self) -> &[pdfcraft_edit::Added] {
|
||||
&self
|
||||
.marker_scan
|
||||
.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_marker_scan();
|
||||
pdfcraft_edit::scan_markers(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.added
|
||||
}
|
||||
|
||||
/// Link annotations, for Edit a PDF ▸ Link.
|
||||
pub fn links(&self) -> &[pdfcraft_annot::links::LinkItem] {
|
||||
self.links.get_or_init(|| {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
#[cfg(test)]
|
||||
record_link_scan();
|
||||
pdfcraft_annot::links::list(&e.cos)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Changes to content and document information are allowed.
|
||||
pub fn allows_modification(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.modify())
|
||||
}
|
||||
|
||||
/// How widget `widget` of text field `name` lays its value out, for asking whether a typed
|
||||
/// value fits it ([`form_appearance::TextBox::fits`]): a field that doesn't scroll takes no
|
||||
/// more than fits. `None` without such a field or widget, or when the document can't be
|
||||
/// edited.
|
||||
pub fn field_text_box(&self, name: &str, widget: usize) -> Option<form_appearance::TextBox> {
|
||||
let cos = &self.editor.as_ref()?.cos;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
let w = f.widgets.get(widget)?;
|
||||
Some(form_appearance::text_box(cos, f, w))
|
||||
}
|
||||
|
||||
/// Filling in form fields is allowed (Table 22, bits 6 and 9).
|
||||
pub fn allows_form_filling(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.fill_forms())
|
||||
}
|
||||
|
||||
/// Adding and changing comments is allowed (Table 22, bit 6).
|
||||
pub fn allows_annotation(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| p.annotate())
|
||||
}
|
||||
|
||||
/// The document's security may be changed (Protect, Remove security): it is editable and,
|
||||
/// if encrypted, was opened with the owner password.
|
||||
pub fn allows_security_change(&self) -> bool {
|
||||
self.editable() && self.permissions().is_none_or(|p| unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A password restricts the document and it wasn't opened with the owner password: entering
|
||||
/// that password ([`Session::unlock`](crate::Session::unlock)) lifts the restrictions.
|
||||
pub fn can_unlock(&self) -> bool {
|
||||
self.editable() && self.permissions().is_some_and(|p| !unrestricted(&p))
|
||||
}
|
||||
|
||||
/// A summary of the document's security for Document Properties ▸ Security, including
|
||||
/// protection applied in this session (written by the next save).
|
||||
pub fn security_summary(&self) -> Option<SecuritySummary> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let h = editor.cos.output_handler()?;
|
||||
let d = h.dict();
|
||||
let stream = d.crypt_filters.iter().find(|(name, _)| *name == d.stm_f).map(|(_, m)| *m);
|
||||
let method = match (d.v, stream) {
|
||||
(1..=3, _) if d.length_bits <= 40 => "RC4, 40-bit",
|
||||
(1..=3, _) | (_, Some(pdfcraft_cos::CryptMethod::Rc4)) => "RC4, 128-bit",
|
||||
(_, Some(pdfcraft_cos::CryptMethod::Aes128)) => "AES, 128-bit",
|
||||
(_, Some(pdfcraft_cos::CryptMethod::Aes256)) => "AES, 256-bit",
|
||||
_ => "Attachments only",
|
||||
};
|
||||
let pending = editor.cos.encryption_changed();
|
||||
let permissions = if pending { pdfcraft_cos::Permissions { bits: h.permissions().bits, owner: false } } else { h.permissions() };
|
||||
Some(SecuritySummary { method: method.into(), owner: h.auth() == pdfcraft_cos::Auth::Owner && !pending, permissions, pending })
|
||||
}
|
||||
|
||||
/// Comment properties of the comment at `(page, index)` (Comment Properties dialog).
|
||||
pub fn comment_props(&self, page: usize, index: usize) -> Option<pdfcraft_annot::Props> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_annot::props(&e.cos, page, index))
|
||||
}
|
||||
|
||||
/// A form field's Appearance-tab look (borders, colours, font).
|
||||
pub fn field_look(&self, name: &str) -> Option<pdfcraft_forms::Look> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
Some(pdfcraft_forms::look(&e.cos, f))
|
||||
}
|
||||
|
||||
/// A check box's or radio button's mark (Field Properties ▸ Options), `None` for other fields.
|
||||
pub fn field_check_style(&self, name: &str) -> Option<CheckStyle> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
matches!(f.kind, FormFieldKind::CheckBox | FormFieldKind::Radio).then(|| pdfcraft_forms::check_style(&e.cos, f))
|
||||
}
|
||||
|
||||
/// Whether text field `name`'s value is cut off by its box at the field's font size (#4018):
|
||||
/// the page (0-based) of the first widget that cuts it, and how.
|
||||
pub fn field_overflow(&self, name: &str) -> Option<(Option<usize>, pdfcraft_forms::appearance::Overflow)> {
|
||||
let e = self.editor.as_ref()?;
|
||||
let f = self.form.iter().find(|f| f.name == name)?;
|
||||
f.widgets.iter().find_map(|w| pdfcraft_forms::appearance::value_overflow(&e.cos, f, w).map(|o| (w.page, o)))
|
||||
}
|
||||
|
||||
/// Remove Hidden Information: what each category would remove.
|
||||
pub fn hidden_info(&self) -> Vec<(Hidden, usize)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_redact::sanitize::scan(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Verification: faux redactions (opaque covers over extractable text) and redaction
|
||||
/// marks that were never applied. Fails closed: any read error is returned, never an
|
||||
/// empty list.
|
||||
pub fn audit_redactions(&self) -> Result<Vec<pdfcraft_audit::AuditFinding>, String> {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
pdfcraft_audit::audit_redactions(&editor.cos).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Every page's media, crop, bleed, trim and art boxes (user space), for Set Page Boxes.
|
||||
pub fn page_boxes(&self) -> Vec<[[f64; 4]; 5]> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::page_boxes(&e.cos).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Current value of a document-information entry (Title, Author, …).
|
||||
pub fn info_value(&self, key: &str) -> Option<String> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_organize::info(&e.cos, key))
|
||||
}
|
||||
|
||||
/// Whether the document information has an entry `key`, of any type, listed or not.
|
||||
pub fn has_info(&self, key: &str) -> bool {
|
||||
self.editor.as_ref().is_some_and(|e| pdfcraft_organize::has_info(&e.cos, key))
|
||||
}
|
||||
|
||||
/// The custom document properties as they are now (name, text), Document Properties ▸ Custom.
|
||||
pub fn custom_info(&self) -> Vec<(String, String)> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_organize::custom_info(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The name shown on the tab and window: the document title when the document asks for it
|
||||
/// (Initial View ▸ Show: Document Title) and has a real one, else the file name.
|
||||
pub fn display_name(&self) -> String {
|
||||
self.editor
|
||||
.as_ref()
|
||||
.filter(|e| pdfcraft_organize::displays_doc_title(&e.cos))
|
||||
.and_then(|e| pdfcraft_organize::info(&e.cos, "Title"))
|
||||
.map(|t| t.trim().to_owned())
|
||||
.filter(|t| !t.is_empty() && !is_placeholder_title(t))
|
||||
.unwrap_or_else(|| self.name.clone())
|
||||
}
|
||||
|
||||
/// Where each saved revision of the file ends (oldest first; `bytes[..end]` is that
|
||||
/// revision). Unsaved edits aren't a revision yet.
|
||||
pub fn revision_ends(&self) -> Vec<usize> {
|
||||
self.editor.as_ref().map(|e| e.cos.revision_ends()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The file as last opened or saved, without unsaved edits: what an incremental save appends
|
||||
/// to. This stays that file when undo has restored an older snapshot.
|
||||
pub fn saved_bytes(&self) -> Arc<Vec<u8>> {
|
||||
self.editor.as_ref().map_or_else(|| self.bytes.clone(), |e| e.saved.clone())
|
||||
}
|
||||
|
||||
/// Notes about damage repaired while opening (Document Properties ▸ Advanced, notices).
|
||||
pub fn repair_log(&self) -> Vec<String> {
|
||||
self.editor.as_ref().map(|e| e.cos.repair_log().to_vec()).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A title that names no document: what a web browser writes when it saves a blank page or a
|
||||
/// pop-up as PDF (`about:blank`), another browser-internal address, or a bare "Untitled". Such a
|
||||
/// title says less than the file name, so the file name is shown instead. An address is one
|
||||
/// word: "About: our company" is a real title.
|
||||
pub(crate) fn is_placeholder_title(title: &str) -> bool {
|
||||
let t = title.trim().to_lowercase();
|
||||
let address = !t.contains(char::is_whitespace) && ["about:", "blob:", "data:"].iter().any(|p| t.starts_with(p));
|
||||
address || t == "untitled"
|
||||
}
|
||||
|
||||
/// The working file plus appearances for comments that have none, and for form fields the file
|
||||
/// leaves to the viewer (#3470). Signature artwork is preserved; validation badges belong to the
|
||||
/// viewer and never enter these bytes, which image/text exports and Print as Image also consume.
|
||||
pub(crate) fn display_bytes(editor: Option<&mut Editor>, bytes: &Arc<Vec<u8>>) -> Arc<Vec<u8>> {
|
||||
let Some(e) = editor else { return bytes.clone() };
|
||||
let comments = pdfcraft_annot::with_missing_appearances_cached(&e.cos, &mut e.appearances);
|
||||
let fields = pdfcraft_forms::with_field_appearances_cached(comments.as_ref().unwrap_or(&e.cos), &mut e.field_appearances);
|
||||
let Some(display) = fields.or(comments) else {
|
||||
return bytes.clone();
|
||||
};
|
||||
match write_incremental(&display, &SaveOptions::default()) {
|
||||
Ok(written) => Arc::new(written),
|
||||
Err(err) => {
|
||||
e.cos.note_repair(format!("missing comment or field appearances could not be drawn ({err})"));
|
||||
bytes.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `comments` (the display copy with comment appearances, if it needed any; else the document
|
||||
/// itself) with its form fields drawn from their values where the file leaves that to the viewer.
|
||||
fn with_field_appearances(comments: Option<pdfcraft_cos::Document>, doc: &pdfcraft_cos::Document) -> Option<pdfcraft_cos::Document> {
|
||||
pdfcraft_forms::with_field_appearances(comments.as_ref().unwrap_or(doc)).or(comments)
|
||||
}
|
||||
|
||||
/// The reader shows the same pages in `a` and `b`: count, displayed size and rotation.
|
||||
pub(crate) fn same_pages(a: &[pdfcraft_render::PageInfo], b: &[pdfcraft_render::PageInfo]) -> bool {
|
||||
a.len() == b.len()
|
||||
&& a.iter().zip(b).all(|(x, y)| (x.width - y.width).abs() < 0.01 && (x.height - y.height).abs() < 0.01 && x.rotation == y.rotation)
|
||||
}
|
||||
|
||||
/// Each document-information field of `info` with its value in `cos` (`/Info`, else XMP), read
|
||||
/// the way the full inspection reads them: NULs and spaces trimmed, empty is none.
|
||||
pub(crate) fn info_fields<'a>(info: &'a mut DocInfo, cos: &pdfcraft_cos::Document) -> [(&'a mut Option<String>, Option<String>); 6] {
|
||||
let read = |key| pdfcraft_organize::info(cos, key).map(|v| v.trim_matches('\0').trim().to_string()).filter(|v| !v.is_empty());
|
||||
[
|
||||
(&mut info.title, read("Title")),
|
||||
(&mut info.author, read("Author")),
|
||||
(&mut info.subject, read("Subject")),
|
||||
(&mut info.keywords, read("Keywords")),
|
||||
(&mut info.creator, read("Creator")),
|
||||
(&mut info.producer, read("Producer")),
|
||||
]
|
||||
}
|
||||
|
||||
/// The copy PDFCraft displays when opening `bytes`: with appearances drawn, in memory only, for
|
||||
/// comments that have none and form fields the file leaves to the viewer. Render paths outside the app, such as the CLI, use this so their page
|
||||
/// images agree with the app and independent viewers. It never changes the source document, and an
|
||||
/// unparseable file, a write failure or a parser panic falls back to the original bytes.
|
||||
pub fn display_bytes_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Arc<Vec<u8>> {
|
||||
guard(|| {
|
||||
pdfcraft_cos::Document::open_with_password(bytes.clone(), password)
|
||||
.ok()
|
||||
.and_then(|cos| with_field_appearances(pdfcraft_annot::with_missing_appearances(&cos), &cos))
|
||||
.and_then(|display| write_incremental(&display, &SaveOptions::default()).ok())
|
||||
.map(Arc::new)
|
||||
})
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or(bytes)
|
||||
}
|
||||
|
||||
/// The password to give the renderer (`inspect`, `PageRenderer`) for `bytes` opened with
|
||||
/// `password`. The renderer authenticates only the user password of R2–R4 files, so for their
|
||||
/// owner password this is the user password that owner authentication recovers; otherwise it is
|
||||
/// `password` itself. Render paths outside a [`Session`](crate::Session), such as the CLI, use this so the owner
|
||||
/// password works there as it does in `doc_open`. A file that doesn't open with `password` keeps
|
||||
/// it, so the renderer reports the same error it always did.
|
||||
pub fn render_password_for(bytes: Arc<Vec<u8>>, password: Option<&str>) -> Option<String> {
|
||||
let password = password?;
|
||||
let recovered =
|
||||
guard(|| pdfcraft_cos::Document::open_with_password(bytes, Some(password)).ok().and_then(|d| recovered_render_password(&d))).ok().flatten();
|
||||
Some(recovered.unwrap_or_else(|| password.to_owned()))
|
||||
}
|
||||
|
||||
/// The user password owner authentication recovered from an R2–R4 `cos`, as the renderer takes it.
|
||||
pub(crate) fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option<String> {
|
||||
cos.security().and_then(|s| s.recovered_user_password()).map(|user| renderer_password(&user))
|
||||
}
|
||||
|
||||
/// Fails when the renderer still draws text under an applied redaction mark.
|
||||
///
|
||||
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
|
||||
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
|
||||
/// /Widths, whose advances the interpreter approximates; a CMap
|
||||
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
|
||||
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
|
||||
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
|
||||
/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its
|
||||
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
|
||||
/// the document as it was.
|
||||
pub(crate) fn shown_under_redactions(
|
||||
after: &pdfcraft_cos::Document,
|
||||
marks: &[pdfcraft_redact::Mark],
|
||||
overlays: &[pdfcraft_cos::ObjRef],
|
||||
pages: &[pdfcraft_render::PageInfo],
|
||||
password: Option<&str>,
|
||||
) -> Result<(), EditError> {
|
||||
use pdfcraft_cos::Object;
|
||||
use pdfcraft_redact::RedactError;
|
||||
let mut marked: Vec<usize> = marks.iter().map(|m| m.page).collect();
|
||||
marked.sort_unstable();
|
||||
marked.dedup();
|
||||
if marked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut shown = after.clone();
|
||||
let model = pdfcraft_model::pages(&shown);
|
||||
for &pi in &marked {
|
||||
let Some(page) = model.get(pi) else { continue };
|
||||
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
|
||||
let kept: Vec<Object> = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect();
|
||||
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
}
|
||||
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
let config = RenderConfig { password: password.map(Arc::from), ..Default::default() };
|
||||
let mut renderer = pdfcraft_render::PageRenderer::new(Arc::new(bytes), config);
|
||||
let mut residue = 0;
|
||||
for &pi in &marked {
|
||||
let request = pdfcraft_render::RenderRequest { page: pi, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
|
||||
// A page that cannot be read back cannot be confirmed clean.
|
||||
let (Some(info), Some(text)) = (pages.get(pi), renderer.render(request).text) else { return Err(RedactError::Unreadable(pi + 1).into()) };
|
||||
let rects: Vec<[f64; 4]> = marks
|
||||
.iter()
|
||||
.filter(|m| m.page == pi)
|
||||
.flat_map(|m| m.rects.iter())
|
||||
.map(|r| [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])])
|
||||
.collect();
|
||||
for glyph in text.glyphs.iter().filter(|g| !g.text.trim().is_empty()) {
|
||||
let [x0, y0, x1, y1] = info.view_rect_to_user(glyph.rect).map(f64::from);
|
||||
let area = (x1 - x0) * (y1 - y0);
|
||||
if area.is_nan() || area <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// Redaction itself takes any glyph a fifth under a mark; one that is half under it
|
||||
// and still drawn was not where redaction thought it was.
|
||||
let under = |r: &[f64; 4]| (x1.min(r[2]) - x0.max(r[0])).max(0.0) * (y1.min(r[3]) - y0.max(r[1])).max(0.0) >= 0.5 * area;
|
||||
if rects.iter().any(under) {
|
||||
residue += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if residue > 0 {
|
||||
return Err(RedactError::Residue(residue).into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
//! The errors editing and combining report.
|
||||
|
||||
use crate::measure;
|
||||
|
||||
/// Why a file can't be a source for Combine Files (or Insert / Replace pages).
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SourceProblem {
|
||||
#[error("it is password-protected")]
|
||||
Password,
|
||||
#[error("the password is wrong")]
|
||||
WrongPassword,
|
||||
#[error("its security settings don't allow copying pages")]
|
||||
NotPermitted,
|
||||
#[error("{0}")]
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum EditError {
|
||||
#[error("no such document")]
|
||||
NoDocument,
|
||||
#[error("this document can't be edited: {0}")]
|
||||
ReadOnly(String),
|
||||
#[error("the document's security settings don't allow {0}; open it with the owner password to make this change")]
|
||||
NotPermitted(&'static str),
|
||||
#[error("{0}")]
|
||||
Organize(#[from] pdfcraft_organize::OrganizeError),
|
||||
#[error("{0}")]
|
||||
Bookmark(#[from] pdfcraft_organize::OutlineError),
|
||||
#[error("{0}")]
|
||||
Comment(#[from] pdfcraft_annot::AnnotError),
|
||||
#[error(transparent)]
|
||||
Measure(#[from] measure::MeasureError),
|
||||
#[error("{0}")]
|
||||
Protection(String),
|
||||
#[error("the password is incorrect")]
|
||||
WrongPassword,
|
||||
#[error("{0}")]
|
||||
Form(#[from] pdfcraft_forms::FormError),
|
||||
#[error(transparent)]
|
||||
Redact(#[from] pdfcraft_redact::RedactError),
|
||||
#[error("{0}")]
|
||||
Print(String),
|
||||
#[error("{0}")]
|
||||
Accessibility(String),
|
||||
#[error(transparent)]
|
||||
Data(#[from] pdfcraft_xfdf::DataError),
|
||||
#[error("{0}")]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error("{0}")]
|
||||
Create(#[from] pdfcraft_create::CreateError),
|
||||
#[error("the edited document could not be written: {0}")]
|
||||
Write(String),
|
||||
#[error("the edited document could not be reopened: {0}")]
|
||||
Reopen(String),
|
||||
#[error("couldn't use {0}")]
|
||||
Source(String),
|
||||
#[error("nothing to undo")]
|
||||
NothingToUndo,
|
||||
#[error("nothing to redo")]
|
||||
NothingToRedo,
|
||||
#[error("{0}")]
|
||||
Sign(String),
|
||||
#[error("{0}")]
|
||||
Optimize(String),
|
||||
#[error("cancelled")]
|
||||
Cancelled,
|
||||
#[error("{0} isn't possible in a signed document: it would rewrite the file and invalidate the signatures")]
|
||||
SignedRewrite(String),
|
||||
#[error("this document is signed: rewriting it would invalidate its signatures (save it incrementally instead)")]
|
||||
Signed,
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl From<pdfcraft_sign::SignError> for EditError {
|
||||
fn from(e: pdfcraft_sign::SignError) -> Self {
|
||||
EditError::Sign(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Fill & Sign geometry: typed signatures, and boxes placed as a turned page displays them.
|
||||
|
||||
use crate::{Shape, script_outline, signature_image};
|
||||
|
||||
/// Fill & Sign: `text` in the script font as a typed signature, its left edge at `at` (user
|
||||
/// space, vertically centred) and `height` points tall. `None` for text with no outlines.
|
||||
///
|
||||
/// Left, centred and upright are as displayed on a page turned by `rotation` (its `/Rotate`):
|
||||
/// the box runs along the displayed axes, like an image signature's
|
||||
/// ([`SignatureImage::rect`](crate::SignatureImage::rect)), and the outlines are turned back so the name reads across.
|
||||
pub fn typed_signature_shape(at: [f64; 2], text: &str, height: f64, rotation: i64) -> Option<Shape> {
|
||||
let o = script_outline(text);
|
||||
let [left, bottom, right, top] = o.bounds();
|
||||
let span = (top - bottom).max(0.1);
|
||||
let width = right - left;
|
||||
if o.contours.is_empty() || o.width <= 0.0 || !(width > 0.0 && height > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let k = height / span;
|
||||
let rect = signature_image::upright_box(rotation, at, width * k, height)?;
|
||||
// Displayed right and up as user-space unit vectors: a point at (nx, ny) of the displayed box
|
||||
// is at this fraction of the user-space box. Exact for an unturned page.
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
let to_user = |nx: f64, ny: f64| [a * nx + c * ny + (-a).max(0.0) + (-c).max(0.0), b * nx + d * ny + (-b).max(0.0) + (-d).max(0.0)];
|
||||
let contours = o.contours.iter().map(|c| c.iter().map(|p| to_user((p[0] - left) / width, (p[1] - bottom) / span)).collect()).collect();
|
||||
Some(Shape::TypedSignature { rect, contours })
|
||||
}
|
||||
|
||||
/// `at` moved `right` and `up` points as displayed on a page turned by `rotation` (its `/Rotate`).
|
||||
pub fn displayed_offset(rotation: i64, at: [f64; 2], right: f64, up: f64) -> [f64; 2] {
|
||||
let [a, b, c, d, ..] = pdfcraft_model::view_matrix_for(rotation, [0.0; 4]);
|
||||
[at[0] + a * right + c * up, at[1] + b * right + d * up]
|
||||
}
|
||||
|
||||
/// The user-space box `width` x `height` points as displayed on a page turned by `rotation`,
|
||||
/// with its displayed top-left corner at `at`: where typed text and text boxes go. `None` for a
|
||||
/// non-finite point.
|
||||
pub fn text_box_at(rotation: i64, at: [f64; 2], width: f64, height: f64) -> Option<[f64; 4]> {
|
||||
signature_image::upright_box(rotation, displayed_offset(rotation, at, 0.0, -height / 2.0), width, height)
|
||||
}
|
||||
|
||||
/// `text` in the script font as the graphic of a visible digital signature, as Fill & Sign types
|
||||
/// it. `None` for text with no outlines.
|
||||
pub fn typed_signature_graphic(text: &str) -> Option<pdfcraft_sign::Graphic> {
|
||||
let o = script_outline(text);
|
||||
o.contours.iter().any(|c| c.len() > 2).then_some(pdfcraft_sign::Graphic::Outlines(o.contours))
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Undo/redo: the editing state of a document, its snapshots and their labels.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{AppearanceCache, Edit, EditError, Shape};
|
||||
|
||||
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
|
||||
pub(crate) const MAX_UNDO: usize = 100;
|
||||
|
||||
/// The passwords that go with one state of a document (protection can change them).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Keys {
|
||||
/// What the renderer and inspector open the working file with (the user password).
|
||||
pub(crate) render: Option<String>,
|
||||
/// What the editor re-opens a saved file with: the strongest password known (owner if any).
|
||||
pub(crate) reopen: Option<String>,
|
||||
}
|
||||
|
||||
/// What an edit can change, and so what the view data must be rebuilt from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Scope {
|
||||
/// Anything: re-inspect the whole document.
|
||||
Full,
|
||||
/// Document-information entries; page content and appearances are unchanged.
|
||||
Metadata,
|
||||
/// Only comments: re-read the comment list from the object graph.
|
||||
Comments,
|
||||
/// Only form field values (and their widget appearances).
|
||||
Form,
|
||||
/// Link annotations; the full inspection is refreshed, but page-content indexes stay valid.
|
||||
Links,
|
||||
/// Only page content streams, resources and added content/marks: the page tree,
|
||||
/// annotations, form fields, links, outline and fonts are unchanged, so the full
|
||||
/// re-inspection is skipped (seconds on very large files).
|
||||
Content,
|
||||
}
|
||||
|
||||
/// Edits during which field scripts run.
|
||||
pub(crate) fn uses_scripts(edit: &Edit) -> bool {
|
||||
match edit {
|
||||
Edit::SetFieldValue { .. } | Edit::ApplyScriptChanges { .. } | Edit::SetFieldScript { .. } | Edit::SetCalculationOrder { .. } => true,
|
||||
Edit::Batch { edits, .. } => edits.iter().any(uses_scripts),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
match edit {
|
||||
Edit::SetInfo { .. } => Scope::Metadata,
|
||||
// The Attachments list is read from the whole document.
|
||||
Edit::AddAttachment { .. } | Edit::DeleteAttachment { .. } => Scope::Full,
|
||||
// A file attachment also changes the Attachments list.
|
||||
Edit::AddAnnotation(a) if matches!(a.shape, Shape::Attachment { .. }) => Scope::Full,
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::AddCustomStamp { .. }
|
||||
| Edit::DeleteAnnotation { .. }
|
||||
| Edit::SetAnnotationContents { .. }
|
||||
| Edit::ReplyToAnnotation { .. }
|
||||
| Edit::SetAnnotationStatus { .. }
|
||||
| Edit::MarkAnnotation { .. }
|
||||
| Edit::LockAnnotation { .. }
|
||||
| Edit::ReplaceText { .. }
|
||||
| Edit::EraseInk { .. }
|
||||
| Edit::MoveAnnotation { .. }
|
||||
| Edit::ReshapeAnnotation { .. }
|
||||
| Edit::ResizeAnnotation { .. }
|
||||
| Edit::StyleAnnotation { .. }
|
||||
| Edit::FillAnnotation { .. }
|
||||
| Edit::FontSizeAnnotation { .. }
|
||||
| Edit::SetAnnotationInfo { .. } => Scope::Comments,
|
||||
Edit::AddLink { .. } | Edit::SetLink { .. } | Edit::DeleteLink { .. } | Edit::RemoveLinks { .. } | Edit::AddLinks { .. } => Scope::Links,
|
||||
Edit::SetFieldValue { .. } | Edit::ResetForm { .. } | Edit::SetFieldImage { .. } | Edit::ApplyScriptChanges { .. } => Scope::Form,
|
||||
// Page content, resources and added content/marks only: the page tree, annotations,
|
||||
// fields, links and document metadata are untouched (see `refresh_content`).
|
||||
Edit::MoveObjects { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::AddText { .. }
|
||||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::AddHeaderFooter { .. }
|
||||
| Edit::AddWatermark { .. }
|
||||
| Edit::AddBackground { .. }
|
||||
| Edit::RemoveMarks { .. } => Scope::Content,
|
||||
Edit::Batch { edits, .. } => {
|
||||
let mut scopes = edits.iter().map(scope_of);
|
||||
let first = scopes.next().unwrap_or(Scope::Full);
|
||||
if scopes.all(|s| s == first) { first } else { Scope::Full }
|
||||
}
|
||||
_ => Scope::Full,
|
||||
}
|
||||
}
|
||||
|
||||
/// One undo/redo step: its label, the document state, its passwords, and what it changed.
|
||||
pub(crate) type Snapshot = (Label, pdfcraft_cos::Document, Keys, Scope);
|
||||
|
||||
/// Editing state of a document (absent when the document cannot be edited yet, e.g. encrypted).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Editor {
|
||||
pub(crate) cos: pdfcraft_cos::Document,
|
||||
/// The file as last opened or saved. Incremental saves append to it, and Revert opens it.
|
||||
/// Undo may restore a snapshot whose own base is an earlier revision; this does not follow it.
|
||||
pub(crate) saved: Arc<Vec<u8>>,
|
||||
/// Passwords that open [`Self::saved`].
|
||||
pub(crate) saved_keys: Keys,
|
||||
/// [`Self::saved`] was never written where the document belongs: a new document (combine,
|
||||
/// extract, create) or one restored from recovery. It stays unsaved until a save, whatever
|
||||
/// undo brings back, or closing it would lose the whole document without asking.
|
||||
pub(crate) unsaved_file: bool,
|
||||
pub(crate) undo: Vec<Snapshot>,
|
||||
pub(crate) redo: Vec<Snapshot>,
|
||||
pub(crate) keys: Keys,
|
||||
/// Display appearances built for annotations without any, reused while no annotation edit
|
||||
/// runs (content edits leave every annotation dictionary alone). Cleared on any refresh
|
||||
/// that may change annotations; see [`AppearanceCache`].
|
||||
pub(crate) appearances: AppearanceCache,
|
||||
/// Display appearances drawn for form fields the file leaves to the viewer (#3470), reused
|
||||
/// for each widget while the objects it was drawn from are unchanged; see
|
||||
/// [`pdfcraft_forms::FieldAppearanceCache`].
|
||||
pub(crate) field_appearances: pdfcraft_forms::FieldAppearanceCache,
|
||||
/// The text blocks last computed for one page, reused while the document is unchanged.
|
||||
pub(crate) text_blocks: TextBlocksCache,
|
||||
}
|
||||
|
||||
/// One page's text blocks ([`pdfcraft_edit::text_blocks`]) and the document state they were read
|
||||
/// from. Opening a paragraph and applying its edit both need them, and grouping a long page is not
|
||||
/// cheap. The state ([`pdfcraft_cos::ReadState`]) changes with every edit and save and returns
|
||||
/// with undo, so a stale entry never matches, also after the document has been edited.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TextBlocksCache(std::sync::Mutex<Option<CachedTextBlocks>>);
|
||||
|
||||
/// The document state, the page (0-based) and its text blocks.
|
||||
type CachedTextBlocks = (pdfcraft_cos::ReadState, usize, Arc<Vec<pdfcraft_edit::TextBlock>>);
|
||||
|
||||
impl Clone for TextBlocksCache {
|
||||
fn clone(&self) -> Self {
|
||||
// A poisoned cache only loses the cached blocks.
|
||||
Self(std::sync::Mutex::new(self.0.lock().ok().and_then(|c| c.clone())))
|
||||
}
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
/// The text blocks on `page` (0-based), from the cache when the document hasn't changed.
|
||||
pub(crate) fn text_blocks(&self, page: usize) -> Result<Arc<Vec<pdfcraft_edit::TextBlock>>, EditError> {
|
||||
if let Ok(cache) = self.text_blocks.0.lock()
|
||||
&& let Some((state, p, blocks)) = cache.as_ref()
|
||||
&& *p == page
|
||||
&& state.matches(&self.cos)
|
||||
{
|
||||
return Ok(blocks.clone());
|
||||
}
|
||||
let blocks = Arc::new(pdfcraft_edit::text_blocks(&self.cos, page)?);
|
||||
if let Ok(mut cache) = self.text_blocks.0.lock() {
|
||||
*cache = Some((self.cos.read_state(), page, blocks.clone()));
|
||||
}
|
||||
Ok(blocks)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an edit is called in the Edit menu and in the history ("Undo Rotate pages"), in two parts:
|
||||
/// a whole English wording, and the one value the document itself supplies for the wording's hole
|
||||
/// (a file name, a field name). An interface in another language looks the wording up and says the
|
||||
/// label with [`said`](Self::said); [`as_str`](Self::as_str) is the English, said the same way, so
|
||||
/// the two cannot drift apart.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Label {
|
||||
/// The wording, with one named hole (`{name}`) where `value` goes. Listed in [`EDIT_LABELS`](crate::EDIT_LABELS).
|
||||
pub wording: &'static str,
|
||||
/// What the document supplies, when the wording has a hole for it.
|
||||
pub value: Option<String>,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
/// A wording with nothing to fill in.
|
||||
pub fn of(wording: &'static str) -> Self {
|
||||
Self { wording, value: None, text: wording.to_string() }
|
||||
}
|
||||
|
||||
/// A wording whose hole the document fills in.
|
||||
pub fn with(wording: &'static str, value: impl Into<String>) -> Self {
|
||||
let mut label = Self { wording, value: Some(value.into()), text: String::new() };
|
||||
label.text = label.said(wording);
|
||||
label
|
||||
}
|
||||
|
||||
/// The label read with this wording: the interface's translation of [`wording`](Self::wording),
|
||||
/// or the English wording itself. What the document supplies goes in as it stands and is not
|
||||
/// read again, so a name with braces in it stays whole.
|
||||
pub fn said(&self, wording: &str) -> String {
|
||||
let Some(value) = &self.value else { return wording.to_owned() };
|
||||
// `{` and `}` are ASCII, so these are char boundaries.
|
||||
match (wording.find('{'), wording.find('}')) {
|
||||
(Some(open), Some(close)) if open < close => {
|
||||
let mut said = String::with_capacity(wording.len() + value.len());
|
||||
said.push_str(&wording[..open]);
|
||||
said.push_str(value);
|
||||
said.push_str(&wording[close + 1..]);
|
||||
said
|
||||
}
|
||||
// A translation that lost its hole is said as it stands rather than losing the value.
|
||||
_ => wording.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The label as English reads it.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.text
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Label {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.text)
|
||||
}
|
||||
}
|
||||
|
||||
/// The passwords after `edit`, if it changes them.
|
||||
pub(crate) fn keys_after(edit: &Edit) -> Option<Keys> {
|
||||
match edit {
|
||||
Edit::Protect(p) => {
|
||||
Some(Keys { render: p.open_password.clone(), reopen: p.permissions_password.clone().or_else(|| p.open_password.clone()) })
|
||||
}
|
||||
Edit::RemoveProtection => Some(Keys::default()),
|
||||
Edit::Batch { edits, .. } => edits.iter().rev().find_map(keys_after),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Layer (optional content) visibility: what the view shows and what printing writes.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{Layer, LayerOp, RenderPool};
|
||||
|
||||
use crate::{Document, render_threads};
|
||||
|
||||
/// Write `layers`' visibility into the default optional-content configuration of `cos` (a copy
|
||||
/// made for printing): `/BaseState /ON`, `/ON` the visible layers, `/OFF` the hidden ones. Its
|
||||
/// other entries (`/Order`, `/RBGroups`, `/AS`…) are kept. A document without `/OCProperties`
|
||||
/// is left alone.
|
||||
pub(crate) fn set_default_layer_state(cos: &mut pdfcraft_cos::Document, layers: &[Layer]) {
|
||||
use pdfcraft_cos::{Dict, ObjRef, Object};
|
||||
let Some(root_ref) = cos.trailer().get(b"Root").and_then(Object::as_ref) else { return };
|
||||
let Some(mut root) = cos.get(root_ref).as_dict().cloned() else { return };
|
||||
let Some(properties) = root.get(b"OCProperties").cloned() else { return };
|
||||
let Some(mut properties) = cos.resolve(&properties).as_dict().cloned() else { return };
|
||||
let mut config = properties.get(b"D").and_then(|d| cos.resolve(d).as_dict().cloned()).unwrap_or_else(Dict::new);
|
||||
let refs =
|
||||
|visible: bool| Object::Array(layers.iter().filter(|l| l.visible == visible).map(|l| Object::Ref(ObjRef::new(l.id.0, l.id.1))).collect());
|
||||
config.set(b"BaseState".to_vec(), Object::name("ON"));
|
||||
config.set(b"ON".to_vec(), refs(true));
|
||||
config.set(b"OFF".to_vec(), refs(false));
|
||||
properties.set(b"D".to_vec(), Object::Dict(config));
|
||||
root.set(b"OCProperties".to_vec(), Object::Dict(properties));
|
||||
cos.set(root_ref, Object::Dict(root));
|
||||
}
|
||||
|
||||
/// Render `doc` with the layer visibility in its `info.layers`.
|
||||
pub(crate) fn use_layer_choices(doc: &mut Document) {
|
||||
let overrides: Vec<(i32, i32, bool)> = doc.info.layers.iter().map(|l| (l.id.0 as i32, l.id.1 as i32, l.visible)).collect();
|
||||
doc.config.layers = Arc::new(overrides);
|
||||
doc.renderer = RenderPool::new(doc.display.clone(), render_threads(), doc.config.clone());
|
||||
}
|
||||
|
||||
/// Apply a set-layer-visibility action to `layers`, one change at a time, so a toggle flips the
|
||||
/// state the changes before it left. Returns whether any layer ended up changed.
|
||||
pub(crate) fn apply_layer_state(layers: &mut [Layer], groups: &[Vec<(u32, u16)>], changes: &[(LayerOp, (u32, u16))], preserve_rb: bool) -> bool {
|
||||
let before: Vec<bool> = layers.iter().map(|l| l.visible).collect();
|
||||
let index: std::collections::HashMap<(u32, u16), usize> = layers.iter().enumerate().map(|(i, l)| (l.id, i)).collect();
|
||||
for &(op, ocg) in changes {
|
||||
let Some(layer) = index.get(&ocg).and_then(|&i| layers.get_mut(i)) else { continue };
|
||||
let on = match op {
|
||||
LayerOp::On => true,
|
||||
LayerOp::Off => false,
|
||||
LayerOp::Toggle => !layer.visible,
|
||||
};
|
||||
layer.visible = on;
|
||||
// Turning a layer off leaves the rest of its groups alone.
|
||||
if on && preserve_rb {
|
||||
for other in groups.iter().filter(|g| g.contains(&ocg)).flatten().filter(|&&o| o != ocg) {
|
||||
if let Some(l) = index.get(other).and_then(|&i| layers.get_mut(i)) {
|
||||
l.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
layers.iter().zip(before).any(|(l, was)| l.visible != was)
|
||||
}
|
||||
+50
-5540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,356 @@
|
||||
//! Opening a document: preparing it away from the session, then registering it.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_incremental};
|
||||
use pdfcraft_render::{DocInfo, OpenError, RenderConfig, RenderPool, inspect};
|
||||
|
||||
use crate::history::{Editor, Keys, TextBlocksCache};
|
||||
use crate::{
|
||||
AppearanceCache, DocId, Document, Session, TrustStore, XFA_SCRIPTS_OFF, XfaLayout, display_bytes, guard, info_fields, js, note_warnings,
|
||||
recovered_render_password, render_threads, same_pages, signatures_of, xfa, xfa_layout, xfa_values_from_datasets,
|
||||
};
|
||||
|
||||
/// Immutable session state needed while a document is prepared away from its owning thread.
|
||||
#[derive(Clone)]
|
||||
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.
|
||||
pub struct PreparedDocument {
|
||||
document: Document,
|
||||
}
|
||||
|
||||
impl OpenContext {
|
||||
/// Prepare a document without mutating the session that supplied this context.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let name = name.into();
|
||||
guard(|| Session::prepare_open_unguarded(self, name, path, bytes, password))
|
||||
.unwrap_or_else(|m| Err(OpenError::Invalid(format!("reading it failed unexpectedly ({m})"))))
|
||||
}
|
||||
}
|
||||
|
||||
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(), xfa_time_scale: self.xfa_time_scale.unwrap_or(1) }
|
||||
}
|
||||
|
||||
/// Prepare a document without adding it to this session.
|
||||
pub fn prepare_open(
|
||||
&self,
|
||||
name: impl Into<String>,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
self.open_context().prepare_open(name, path, bytes, password)
|
||||
}
|
||||
|
||||
/// Register a prepared document. This is the only open step that mutates the session.
|
||||
pub fn commit_open(&mut self, prepared: PreparedDocument) -> DocId {
|
||||
self.next_id += 1;
|
||||
let id = DocId(self.next_id);
|
||||
let mut document = prepared.document;
|
||||
document.id = id;
|
||||
self.docs.push(document);
|
||||
id
|
||||
}
|
||||
|
||||
/// Synchronous convenience API used by the CLI and automation.
|
||||
pub fn open(&mut self, name: impl Into<String>, path: Option<String>, bytes: Arc<Vec<u8>>, password: Option<&str>) -> Result<DocId, OpenError> {
|
||||
let prepared = self.prepare_open(name, path, bytes, password)?;
|
||||
Ok(self.commit_open(prepared))
|
||||
}
|
||||
|
||||
fn prepare_open_unguarded(
|
||||
context: &OpenContext,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
password: Option<&str>,
|
||||
) -> Result<PreparedDocument, OpenError> {
|
||||
let cos = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password));
|
||||
// The renderer authenticates on its own. It cannot use the owner password of R2–R4
|
||||
// files, so give it the user password that owner authentication recovers.
|
||||
let (info, render_password) = match inspect(bytes.clone(), password) {
|
||||
Ok(info) => (info, password.map(str::to_owned)),
|
||||
Err(OpenError::WrongPassword) => {
|
||||
let user = match &cos {
|
||||
Ok(Ok(d)) => recovered_render_password(d),
|
||||
_ => None,
|
||||
};
|
||||
let user = user.ok_or(OpenError::WrongPassword)?;
|
||||
(inspect(bytes.clone(), Some(&user))?, Some(user))
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
// A dynamic XFA form is a shell around an XML template; lay the template out into real
|
||||
// pages and fields so the rest of the engine works on it. The original bytes stay: the
|
||||
// laid-out form is one appended revision.
|
||||
let opts = SaveOptions { mod_date: context.now.map(pdfcraft_cos::pdf_date), ..SaveOptions::default() };
|
||||
// Write `work` as one more revision and reopen it: what the document then is.
|
||||
let rebase = |work: &pdfcraft_cos::Document| -> Result<(Arc<Vec<u8>>, DocInfo, pdfcraft_cos::Document), String> {
|
||||
let new_bytes = write_incremental(work, &opts).map(Arc::new).map_err(|e| e.to_string())?;
|
||||
let new_info = inspect(new_bytes.clone(), render_password.as_deref()).map_err(|e| e.to_string())?;
|
||||
let new_cos = pdfcraft_cos::Document::open_with_password(new_bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
Ok((new_bytes, new_info, new_cos))
|
||||
};
|
||||
let (bytes, info, cos, xfa) = match (info.xfa, cos) {
|
||||
(Some(pdfcraft_render::Xfa::Dynamic), Ok(Ok(cos))) if pdfcraft_xfa::existing_layout(&cos).is_none() => {
|
||||
let mut work = cos.clone();
|
||||
let laid_out = guard(|| xfa_layout(&mut work))
|
||||
.unwrap_or_else(|m| Err(format!("laying it out failed unexpectedly ({m})")))
|
||||
.and_then(|report| rebase(&work).map(|(b, i, c)| (b, i, c, report)));
|
||||
match laid_out {
|
||||
Ok((b, i, c, report)) => (b, i, Ok(Ok(c)), Some(report)),
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("This dynamic XFA form could not be laid out: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), None)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A static XFA form, or a dynamic one laid out earlier: its datasets may hold values
|
||||
// filled in by another viewer since; give the fields those values.
|
||||
(Some(_), Ok(Ok(cos))) => {
|
||||
let report = pdfcraft_xfa::existing_layout(&cos);
|
||||
let mut work = cos.clone();
|
||||
let synced =
|
||||
guard(|| xfa_values_from_datasets(&mut work)).unwrap_or_else(|m| Err(format!("reading its data failed unexpectedly ({m})")));
|
||||
match synced {
|
||||
Ok(names) if names.is_empty() => (bytes, info, Ok(Ok(cos)), report),
|
||||
Ok(names) => match rebase(&work) {
|
||||
Ok((b, mut i, c)) => {
|
||||
// The fields were rewritten from the XFA data: say so, and which.
|
||||
let shown: Vec<&str> = names.iter().take(5).map(String::as_str).collect();
|
||||
let more = if names.len() > shown.len() { format!(" and {} more", names.len() - shown.len()) } else { String::new() };
|
||||
i.warnings.push(format!(
|
||||
"{} form field value{} were taken from this form's XFA data (filled in by another viewer): {}{more}",
|
||||
names.len(),
|
||||
if names.len() == 1 { "" } else { "s" },
|
||||
shown.join(", ")
|
||||
));
|
||||
(b, i, Ok(Ok(c)), report)
|
||||
}
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let mut info = info;
|
||||
info.warnings.push(format!("The values in this form's XFA data could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)), report)
|
||||
}
|
||||
}
|
||||
}
|
||||
(_, cos) => (bytes, info, cos, None),
|
||||
};
|
||||
// A laid-out XFA form: its initialize and calculate scripts run now, as on opening in
|
||||
// Acrobat; what they change is one more revision, still nothing to save.
|
||||
let mut script_output = js::JsOutput::default();
|
||||
let mut ran_away = false;
|
||||
let xfa_template = match (&xfa, &cos) {
|
||||
(Some(_), Ok(Ok(c))) => xfa::template(c),
|
||||
_ => None,
|
||||
};
|
||||
let (bytes, info, cos) = match (&xfa, cos) {
|
||||
(Some(_), Ok(Ok(cos))) if !context.js_off => match xfa_template.clone() {
|
||||
Some(tpl) => {
|
||||
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,
|
||||
time_scale: context.xfa_time_scale,
|
||||
};
|
||||
let changes = xfa::on_open(&mut work, &tpl, &mut run);
|
||||
if run.ran_away {
|
||||
ran_away = true;
|
||||
}
|
||||
changes
|
||||
})
|
||||
.unwrap_or_else(|m| Err(format!("its scripts failed unexpectedly ({m})")));
|
||||
match ran {
|
||||
Ok(changes) if work.is_modified() => match rebase(&work) {
|
||||
Ok((b, mut i, c)) => {
|
||||
// The file as saved differs from what opening shows: say so.
|
||||
if !changes.is_empty() {
|
||||
i.warnings.push(format!(
|
||||
"This form's scripts changed it on opening ({}); saving keeps those changes",
|
||||
changes.describe()
|
||||
));
|
||||
}
|
||||
(b, i, Ok(Ok(c)))
|
||||
}
|
||||
Err(e) => {
|
||||
script_output.errors.push(format!("The form's scripts could not be applied: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)))
|
||||
}
|
||||
},
|
||||
Ok(_) => (bytes, info, Ok(Ok(cos))),
|
||||
Err(e) => {
|
||||
script_output.errors.push(format!("The form's scripts could not run: {e}"));
|
||||
(bytes, info, Ok(Ok(cos)))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => (bytes, info, Ok(Ok(cos))),
|
||||
},
|
||||
(_, cos) => (bytes, info, cos),
|
||||
};
|
||||
// The catalog leads to no page, but the reader found pages by scanning: gather the page
|
||||
// objects into a page tree as one more revision, or page edits address none of them
|
||||
// (#1656). Kept only when the reader then shows the same pages, and never on a signed
|
||||
// document, where writing the repair could rewrite the file under its signatures.
|
||||
let (bytes, info, cos) = match cos {
|
||||
Ok(Ok(cos)) if !info.pages.is_empty() && pdfcraft_organize::page_count(&cos).unwrap_or(0) == 0 => {
|
||||
let mut work = cos.clone();
|
||||
let signed = || pdfcraft_sign::pdf::list(&cos, &bytes, &context.trust).iter().any(|s| s.signed);
|
||||
let rebuilt = match pdfcraft_organize::recover_page_tree(&mut work) {
|
||||
Ok(n) if n > 0 && !signed() => rebase(&work).ok().filter(|(_, i, _)| same_pages(&i.pages, &info.pages)),
|
||||
_ => None,
|
||||
};
|
||||
match rebuilt {
|
||||
Some((b, i, mut c)) => {
|
||||
for line in cos.repair_log() {
|
||||
c.note_repair(line.clone());
|
||||
}
|
||||
c.note_repair(format!("the page tree is missing; its {} pages were found by scanning the file", i.pages.len()));
|
||||
(b, i, Ok(Ok(c)))
|
||||
}
|
||||
None => (bytes, info, Ok(Ok(cos))),
|
||||
}
|
||||
}
|
||||
cos => (bytes, info, cos),
|
||||
};
|
||||
// XFA notes survive the document being re-read after edits.
|
||||
let xfa_warnings: Vec<String> = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() };
|
||||
let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa);
|
||||
// A script that ran away on open turns the form's scripts off for this document.
|
||||
document.xfa_template = if ran_away { None } else { xfa_template };
|
||||
document.js_output.append(script_output);
|
||||
if ran_away {
|
||||
document.xfa_scripts_off = true;
|
||||
document.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
note_warnings(&mut document.xfa_warnings, &xfa_warnings);
|
||||
Ok(PreparedDocument { document })
|
||||
}
|
||||
|
||||
/// The last step of opening: build the document record and register it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_document(
|
||||
trust: &Arc<TrustStore>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
mut info: DocInfo,
|
||||
cos: Result<Result<pdfcraft_cos::Document, pdfcraft_cos::CosError>, Box<dyn std::any::Any + Send>>,
|
||||
render_password: Option<String>,
|
||||
password: Option<&str>,
|
||||
xfa: Option<XfaLayout>,
|
||||
) -> Document {
|
||||
let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() };
|
||||
let (mut editor, read_only_reason) = match cos {
|
||||
Ok(Ok(cos)) => {
|
||||
let keys = Keys { render: render_password.clone(), reopen: password.map(str::to_owned) };
|
||||
let saved = cos.bytes().clone();
|
||||
(
|
||||
Some(Editor {
|
||||
cos,
|
||||
saved,
|
||||
saved_keys: keys.clone(),
|
||||
unsaved_file: false,
|
||||
undo: Vec::new(),
|
||||
redo: Vec::new(),
|
||||
keys,
|
||||
appearances: AppearanceCache::default(),
|
||||
field_appearances: pdfcraft_forms::FieldAppearanceCache::default(),
|
||||
text_blocks: TextBlocksCache::default(),
|
||||
}),
|
||||
None,
|
||||
)
|
||||
}
|
||||
Ok(Err(e)) => (None, Some(e.to_string())),
|
||||
Err(_) => (None, Some("the document structure could not be read for editing".into())),
|
||||
};
|
||||
let sig_cache = Arc::new(pdfcraft_sign::DigestCache::default());
|
||||
let signatures = editor.as_ref().map(|e| signatures_of(&e.cos, &bytes, trust, &sig_cache)).unwrap_or_default();
|
||||
// The inspection reads only `/Info`; a file stating its title or author only in XMP still
|
||||
// shows them (#2373).
|
||||
if let Some(e) = &editor {
|
||||
for (field, value) in info_fields(&mut info, &e.cos) {
|
||||
if field.is_none() {
|
||||
*field = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
let display = display_bytes(editor.as_mut(), &bytes);
|
||||
let renderer = RenderPool::new(display.clone(), render_threads(), config.clone());
|
||||
let (form, adopted) = editor
|
||||
.as_ref()
|
||||
.map(|e| {
|
||||
let mut fields = pdfcraft_forms::fields_with_adopted(&e.cos);
|
||||
xfa::mark_script_buttons(&e.cos, &mut fields.items);
|
||||
(fields.items, fields.adopted_page_fields)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Fields the file lists only as page widgets are adopted leniently; the file says so.
|
||||
if adopted > 0
|
||||
&& let Some(e) = &mut editor
|
||||
{
|
||||
let line = if adopted == 1 {
|
||||
"the form's /Fields list omits 1 field; it was read from the page annotations".to_string()
|
||||
} else {
|
||||
format!("the form's /Fields list omits {adopted} fields; they were read from the page annotations")
|
||||
};
|
||||
e.cos.note_repair(line);
|
||||
}
|
||||
Document {
|
||||
id: DocId(0),
|
||||
name,
|
||||
path,
|
||||
bytes,
|
||||
display,
|
||||
info,
|
||||
renderer,
|
||||
password: render_password,
|
||||
dirty: false,
|
||||
generation: 0,
|
||||
snapshot_generation: 0,
|
||||
read_only_reason,
|
||||
form: Arc::new(form),
|
||||
marker_scan: OnceLock::new(),
|
||||
links: OnceLock::new(),
|
||||
signatures: Arc::new(signatures),
|
||||
trust: trust.clone(),
|
||||
sig_cache,
|
||||
editor,
|
||||
config,
|
||||
js_output: Default::default(),
|
||||
xfa,
|
||||
xfa_template: None,
|
||||
xfa_scripts_off: false,
|
||||
xfa_exited: Default::default(),
|
||||
fonts_stale: false,
|
||||
xfa_warnings: Vec::new(),
|
||||
edit_notices: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
//! Page filters: even or odd page numbers, and orientation as displayed.
|
||||
|
||||
/// Page filters of Acrobat's Rotate Pages and page selection: even/odd page numbers and
|
||||
/// orientation (as displayed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PageParity {
|
||||
#[default]
|
||||
Both,
|
||||
Even,
|
||||
Odd,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||
pub enum PageOrientation {
|
||||
#[default]
|
||||
Both,
|
||||
Landscape,
|
||||
Portrait,
|
||||
}
|
||||
|
||||
/// The pages of `pages` (0-based) that pass the filters.
|
||||
pub fn filter_pages(info: &pdfcraft_render::DocInfo, pages: &[usize], parity: PageParity, orientation: PageOrientation) -> Vec<usize> {
|
||||
pages
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|p| match parity {
|
||||
PageParity::Both => true,
|
||||
// Page numbers are 1-based: index 1 is page 2, an even page.
|
||||
PageParity::Even => p % 2 == 1,
|
||||
PageParity::Odd => p % 2 == 0,
|
||||
})
|
||||
.filter(|p| {
|
||||
let Some(pg) = info.pages.get(*p) else { return false };
|
||||
match orientation {
|
||||
PageOrientation::Both => true,
|
||||
PageOrientation::Landscape => pg.width > pg.height,
|
||||
PageOrientation::Portrait => pg.width <= pg.height,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Pictures placed on pages: added images, and the pictures of watermarks and backgrounds.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
|
||||
/// (0-based) of a PDF.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MarkFile {
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
/// Embed an image that is placed on a page, turned the way its EXIF Orientation says.
|
||||
pub(crate) fn embed_image(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<(pdfcraft_cos::ObjRef, (f64, f64)), pdfcraft_create::CreateError> {
|
||||
pdfcraft_create::image_xobject(doc, name, bytes)
|
||||
}
|
||||
|
||||
/// The largest box of `w`:`h` shape inside `rect`, centred in it: a stamp's picture keeps its
|
||||
/// shape in a box of any other shape. A box that is already that shape (to within rounding) is
|
||||
/// kept exactly, so callers that size it themselves get their box back. An empty or non-finite
|
||||
/// box, or a picture with no size, is returned as it is, for the annotation's own check to refuse.
|
||||
pub(crate) fn fit_picture(rect: [f64; 4], w: f64, h: f64) -> [f64; 4] {
|
||||
if !rect.iter().all(|v| v.is_finite()) {
|
||||
return rect;
|
||||
}
|
||||
let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])];
|
||||
let (bw, bh) = (rect[2] - rect[0], rect[3] - rect[1]);
|
||||
let usable = |v: f64| v.is_finite() && v > 0.0;
|
||||
if !(usable(bw) && usable(bh) && usable(w) && usable(h)) {
|
||||
return rect;
|
||||
}
|
||||
let k = (bw / w).min(bh / h);
|
||||
let (fw, fh) = (w * k, h * k);
|
||||
if (fw - bw).abs() <= bw * 1e-9 && (fh - bh).abs() <= bh * 1e-9 {
|
||||
return rect;
|
||||
}
|
||||
let (cx, cy) = (rect[0] + bw / 2.0, rect[1] + bh / 2.0);
|
||||
[cx - fw / 2.0, cy - fh / 2.0, cx + fw / 2.0, cy + fh / 2.0]
|
||||
}
|
||||
|
||||
/// Bring a mark's picture into `doc`: a PDF page as a form XObject, or an image.
|
||||
pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Result<pdfcraft_edit::MarkSource, EditError> {
|
||||
let head = &f.bytes[..f.bytes.len().min(1024)];
|
||||
if head.windows(5).any(|w| w == b"%PDF-") {
|
||||
let src = pdfcraft_cos::Document::open(f.bytes.clone()).map_err(|e| EditError::Source(format!("{}: {e}", f.name)))?;
|
||||
let (xobject, size) = pdfcraft_organize::page_as_form(doc, &src, f.page)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: false })
|
||||
} else {
|
||||
let (xobject, size) = embed_image(doc, &f.name, &f.bytes)?;
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Password protection to apply, and the security a document has.
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// Document Properties ▸ Security.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SecuritySummary {
|
||||
/// "AES, 256-bit" etc.
|
||||
pub method: String,
|
||||
/// Opened with the owner password (no restrictions apply).
|
||||
pub owner: bool,
|
||||
/// For pending protection: the restrictions as they will apply to others.
|
||||
pub permissions: pdfcraft_cos::Permissions,
|
||||
/// Set in this session; written by the next save.
|
||||
pub pending: bool,
|
||||
}
|
||||
|
||||
/// What printing a protected document allows (Acrobat: Printing allowed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Printing {
|
||||
None,
|
||||
/// "Low Resolution (150 dpi)".
|
||||
Low,
|
||||
High,
|
||||
}
|
||||
|
||||
/// What changes a protected document allows (Acrobat: Changes allowed).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Changes {
|
||||
None,
|
||||
/// "Inserting, deleting, and rotating pages".
|
||||
Pages,
|
||||
/// "Filling in form fields and signing existing signature fields".
|
||||
FillSign,
|
||||
/// "Commenting, filling in form fields, and signing existing signature fields".
|
||||
CommentFillSign,
|
||||
/// "Any except extracting pages".
|
||||
AnyExceptExtract,
|
||||
}
|
||||
|
||||
/// Password protection to apply (Protect Using Password and its Advanced options).
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct Protection {
|
||||
/// Required to open the document (the user password).
|
||||
pub open_password: Option<String>,
|
||||
/// Required to change security and lift the restrictions below (the owner password).
|
||||
pub permissions_password: Option<String>,
|
||||
pub printing: Printing,
|
||||
pub changes: Changes,
|
||||
/// "Enable copying of text, images, and other content".
|
||||
pub copy: bool,
|
||||
/// "Enable text access for screen reader devices".
|
||||
pub accessibility: bool,
|
||||
/// Compatibility level; AES-256 (Acrobat X and later) by default.
|
||||
pub algorithm: pdfcraft_cos::Algorithm,
|
||||
/// `false`: "Encrypt all document contents except metadata".
|
||||
pub encrypt_metadata: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Protection {
|
||||
// Never print passwords (edits end up in logs and error messages).
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Protection")
|
||||
.field("open_password", &self.open_password.as_ref().map(|_| "…"))
|
||||
.field("permissions_password", &self.permissions_password.as_ref().map(|_| "…"))
|
||||
.field("printing", &self.printing)
|
||||
.field("changes", &self.changes)
|
||||
.field("copy", &self.copy)
|
||||
.field("accessibility", &self.accessibility)
|
||||
.field("algorithm", &self.algorithm)
|
||||
.field("encrypt_metadata", &self.encrypt_metadata)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Protection {
|
||||
/// Acrobat's defaults: AES-256, printing and accessibility allowed, no changes, no copying.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open_password: None,
|
||||
permissions_password: None,
|
||||
printing: Printing::High,
|
||||
changes: Changes::None,
|
||||
copy: false,
|
||||
accessibility: true,
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
encrypt_metadata: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Protection {
|
||||
/// The `/P` permission bits (ISO 32000-2 Table 22), as Acrobat maps its choices.
|
||||
pub fn permission_bits(&self) -> i32 {
|
||||
let bit = |n: u32| 1i32 << (n - 1);
|
||||
if self.permissions_password.is_none() {
|
||||
return -1; // nothing restricted without a permissions password
|
||||
}
|
||||
let mut p = 0;
|
||||
match self.printing {
|
||||
Printing::None => {}
|
||||
Printing::Low => p |= bit(3),
|
||||
Printing::High => p |= bit(3) | bit(12),
|
||||
}
|
||||
p |= match self.changes {
|
||||
Changes::None => 0,
|
||||
Changes::Pages => bit(11),
|
||||
Changes::FillSign => bit(9),
|
||||
Changes::CommentFillSign => bit(6) | bit(9),
|
||||
Changes::AnyExceptExtract => bit(4) | bit(6) | bit(9),
|
||||
};
|
||||
if self.copy {
|
||||
p |= bit(5) | bit(10);
|
||||
}
|
||||
if self.accessibility {
|
||||
p |= bit(10);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
pub(crate) fn validate(&self) -> Result<(), EditError> {
|
||||
let bad = |m: &str| Err(EditError::Protection(m.to_string()));
|
||||
match (&self.open_password, &self.permissions_password) {
|
||||
(None, None) => return bad("enter a password to open the document, a permissions password, or both"),
|
||||
(Some(a), Some(b)) if a == b => return bad("the open password and the permissions password must be different"),
|
||||
_ => {}
|
||||
}
|
||||
if [&self.open_password, &self.permissions_password].into_iter().flatten().any(String::is_empty) {
|
||||
return bad("passwords can't be empty");
|
||||
}
|
||||
if self.algorithm != pdfcraft_cos::Algorithm::Aes256
|
||||
&& [&self.open_password, &self.permissions_password].into_iter().flatten().any(|p| p.chars().any(|c| !(' '..='~').contains(&c)))
|
||||
{
|
||||
return bad("this compatibility level supports only plain ASCII passwords; use AES-256 (Acrobat X and later)");
|
||||
}
|
||||
// Encryption keys on a password as `effective_password` gives it (AES-256: SASLprep,
|
||||
// which drops soft hyphens and folds compatibility forms; older levels: the first 32
|
||||
// bytes, padded). One that comes out as the empty password opens the file without asking,
|
||||
// and an open password that comes out as the permissions password lifts the restrictions.
|
||||
let effective = |p: &str| pdfcraft_cos::effective_password(self.algorithm, p);
|
||||
let (open, permissions) = (self.open_password.as_deref().map(effective), self.permissions_password.as_deref().map(effective));
|
||||
let empty = effective("");
|
||||
if open.iter().chain(&permissions).any(|p| *p == empty) {
|
||||
return bad("passwords can't be empty or made only of characters that passwords ignore, such as soft hyphens");
|
||||
}
|
||||
if open.is_some() && open == permissions {
|
||||
return bad("the open password and the permissions password must be different (PDF readers treat these two as the same password)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Opened as owner, or nothing is restricted (no permissions password was set): security may
|
||||
/// be changed, as in Acrobat.
|
||||
pub(crate) fn unrestricted(p: &pdfcraft_cos::Permissions) -> bool {
|
||||
const ALL: i32 = 0b1111_0011_1100; // bits 3–6 and 9–12
|
||||
p.owner || p.bits & ALL == ALL
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! What the engine re-exports from the other pdfcraft crates, so frontends need only this one.
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
pub use pdfcraft_organize::LabelStyle;
|
||||
pub use pdfcraft_organize::Standard;
|
||||
pub use pdfcraft_organize::check_custom_key;
|
||||
pub use pdfcraft_organize::view::{InitialView, Layout as InitialLayout, Magnification, Navigation};
|
||||
|
||||
pub use pdfcraft_cos::Algorithm;
|
||||
pub use pdfcraft_create::{CONVERTIBLE, CreateError, ImageResolution, SourceKind, decode_text, source_kind};
|
||||
pub use pdfcraft_edit::added::{
|
||||
CHAR_SPACING_LIMIT as ADDED_CHAR_SPACING_LIMIT, paste_place as added_paste_place, paste_rect as added_paste_rect, text_rect as added_text_rect,
|
||||
text_width as added_text_width,
|
||||
};
|
||||
pub use pdfcraft_edit::{
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
|
||||
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
|
||||
calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
pub use pdfcraft_edit::{BlockKey, BlockStyle, PageImage, RangeStyle, TextBlock, TextLine, block_overlapping, first_undrawable};
|
||||
pub use pdfcraft_measure as measure;
|
||||
pub use pdfcraft_xfa::Report as XfaLayout;
|
||||
|
||||
pub use pdfcraft_edit::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget};
|
||||
|
||||
pub use pdfcraft_fonts::{MAX_SIGNATURE_CHARS, ScriptOutline, script_missing, script_outline, undrawable_message};
|
||||
|
||||
/// Comment geometry helpers (text-box line breaking) for frontends.
|
||||
pub use pdfcraft_annot::appearance as annot_text;
|
||||
pub use pdfcraft_annot::links::{Highlight as LinkHighlight, LinkAction, LinkItem, LinkStyle};
|
||||
pub use pdfcraft_annot::{
|
||||
AppearanceCache, AttachIcon, FillMark, LineEnding, Markup, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, Props as CommentProps,
|
||||
ReviewState, Rgb, Shape, StampGroup, StampKind, Style, rect_quad,
|
||||
};
|
||||
pub use pdfcraft_forms::detect;
|
||||
pub use pdfcraft_optimize as optimize;
|
||||
pub use pdfcraft_preflight as pdfa;
|
||||
pub use pdfcraft_print as print;
|
||||
pub use pdfcraft_redact::codes::{CODE_SETS as REDACTION_CODE_SETS, CodeSet as RedactionCodeSet};
|
||||
pub use pdfcraft_redact::patterns::{
|
||||
MAX_WORDS as REDACT_MAX_WORDS, PATTERNS as REDACT_PATTERNS, Pattern as RedactPattern, find as find_pattern, word_list as redact_word_list,
|
||||
};
|
||||
pub use pdfcraft_redact::sanitize::{HIDDEN, Hidden};
|
||||
pub use pdfcraft_sign as sign;
|
||||
pub use pdfcraft_sign::{SignOptions, SignatureInfo, Status as SignatureStatus, TrustStore};
|
||||
pub use pdfcraft_xfdf::Format as DataFormat;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
//! Other files to combine with or copy pages from: opening them, and why one can't be used.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{EditError, SourceProblem};
|
||||
|
||||
/// A file to combine: its name (the bookmark title), bytes, and page range (`None`: all).
|
||||
pub type CombineSource = (String, Arc<Vec<u8>>, Option<String>);
|
||||
|
||||
/// What a combine produced: the new PDF bytes, and the fields renamed to keep colliding names
|
||||
/// separate (old fully-qualified name → new one), in order across the sources.
|
||||
#[derive(Debug)]
|
||||
pub struct Combined {
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
pub renamed_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Parse another PDF to copy pages from.
|
||||
pub(crate) fn open_source(name: &str, bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
open_source_with(name, bytes, None)
|
||||
}
|
||||
|
||||
/// [`open_source`], authenticating with `password` (user or owner) for an encrypted file.
|
||||
pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, EditError> {
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(pdfcraft_cos::CosError::WrongPassword)) => Err(SourceProblem::WrongPassword),
|
||||
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
||||
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `bytes` can be combined, opened with `password` (user or owner) if given, and if not
|
||||
/// why: what Combine would refuse it for, so the Combine files list can say so before Combine
|
||||
/// is pressed. The owner (permissions) password lifts the restriction on copying pages.
|
||||
pub fn combine_source_check(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<(), SourceProblem> {
|
||||
source_document(bytes, password).map(|_| ())
|
||||
}
|
||||
|
||||
/// The number of pages of `bytes`, opened with `password` (user or owner) if given, whatever
|
||||
/// its permissions allow; `None` when it can't be read.
|
||||
pub fn source_page_count(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Option<usize> {
|
||||
let doc = std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)).ok()?.ok()?;
|
||||
pdfcraft_organize::page_count(&doc).ok()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//! Test-only hooks: injected refresh panics and counts of the secondary page scans.
|
||||
|
||||
thread_local! {
|
||||
static SECONDARY_SCAN_COUNTS: std::cell::Cell<(usize, usize)> = const { std::cell::Cell::new((0, 0)) };
|
||||
static REFRESH_PANICS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
pub(crate) fn panic_on_refresh(count: usize) {
|
||||
REFRESH_PANICS.with(|n| n.set(count));
|
||||
}
|
||||
|
||||
pub(crate) fn check_refresh_panic() {
|
||||
REFRESH_PANICS.with(|n| {
|
||||
let rem = n.get();
|
||||
if rem > 0 {
|
||||
n.set(rem - 1);
|
||||
panic!("injected refresh panic");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn reset_secondary_scan_counts() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| counts.set((0, 0)));
|
||||
}
|
||||
|
||||
pub(crate) fn secondary_scan_counts() -> (usize, usize) {
|
||||
SECONDARY_SCAN_COUNTS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
pub(crate) fn record_marker_scan() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| {
|
||||
let (markers, links) = counts.get();
|
||||
counts.set((markers + 1, links));
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn record_link_scan() {
|
||||
SECONDARY_SCAN_COUNTS.with(|counts| {
|
||||
let (markers, links) = counts.get();
|
||||
counts.set((markers, links + 1));
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::document::is_placeholder_title;
|
||||
use super::edit::check_permission;
|
||||
use super::*;
|
||||
use pdfcraft_render::{Layer, LayerOp, OpenError, ReadingColors};
|
||||
|
||||
/// A minimal `n`-page document; page `i` shows "Page i+1".
|
||||
pub(crate) fn fixture(n: usize) -> Vec<u8> {
|
||||
@@ -8822,3 +8825,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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! A document's XFA form: laying it out, keeping its datasets and fields in step, and taking it out.
|
||||
|
||||
use crate::edit::EditCtx;
|
||||
use crate::{Document, EditError, FieldValue, XfaLayout, guard, xfa};
|
||||
|
||||
/// Lay a dynamic XFA form out (pages and fields) and give its widgets appearances.
|
||||
pub(crate) fn xfa_layout(doc: &mut pdfcraft_cos::Document) -> Result<XfaLayout, String> {
|
||||
let mut report = pdfcraft_xfa::render_into(doc).map_err(|e| e.to_string())?;
|
||||
xfa_redraw(doc)?;
|
||||
// The form's own values: one its field's font can't draw is drawn with `?` and reported.
|
||||
report.warnings.extend(undrawn_field_warnings(doc));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Give the fields of a laid-out XFA form their appearances. A signed signature box keeps the
|
||||
/// picture its signer's application drew.
|
||||
pub(crate) fn xfa_redraw(doc: &mut pdfcraft_cos::Document) -> Result<(), String> {
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if f.kind == pdfcraft_forms::FieldKind::Signature && doc.get(f.obj).as_dict().is_some_and(|d| d.contains(b"V")) {
|
||||
continue;
|
||||
}
|
||||
pdfcraft_forms::redraw_field(doc, &f.name).map_err(|e| format!("{}: {e}", f.name))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A warning for each field whose appearance PdfCraft drew with `?` for a character its font
|
||||
/// can't draw (laying out an XFA form's own values).
|
||||
pub(crate) fn undrawn_field_warnings(doc: &pdfcraft_cos::Document) -> Vec<String> {
|
||||
pdfcraft_forms::undrawn_fields(doc)
|
||||
.into_iter()
|
||||
.map(|(name, c)| {
|
||||
format!("form field {name:?} holds \"{}\" (U+{:04X}), which its font can't draw: it shows ? in its place", c.escape_debug(), u32::from(c))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The form's fields as the XFA data layer wants them.
|
||||
fn xfa_field_data(doc: &pdfcraft_cos::Document) -> Vec<pdfcraft_xfa::FieldDatum> {
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
pdfcraft_forms::fields(doc)
|
||||
.into_iter()
|
||||
.map(|f| {
|
||||
let data = match f.kind {
|
||||
K::Text | K::Combo | K::List => pdfcraft_xfa::FieldData::Text(f.value.join("\n")),
|
||||
K::CheckBox => pdfcraft_xfa::FieldData::Check(!f.value.is_empty()),
|
||||
K::Radio => pdfcraft_xfa::FieldData::Radio(f.value.first().cloned()),
|
||||
K::PushButton | K::Signature => pdfcraft_xfa::FieldData::None,
|
||||
};
|
||||
pdfcraft_xfa::FieldDatum { obj: f.obj, name: f.name, data }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keep the XFA datasets packet in step with the fields after an edit. Returns what could not
|
||||
/// be written.
|
||||
/// `datasets`: the stream this edit already wrote, replaced in place rather than added again
|
||||
/// (and set to the one written).
|
||||
/// `tpl`: the form's parsed template when the document has one cached (else it is parsed).
|
||||
pub(crate) fn xfa_sync_datasets(
|
||||
doc: &mut pdfcraft_cos::Document,
|
||||
tpl: Option<&pdfcraft_xfa::model::Template>,
|
||||
datasets: &mut Option<pdfcraft_cos::ObjRef>,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let data = xfa_field_data(doc);
|
||||
let r = pdfcraft_xfa::write_datasets_with(doc, tpl, &data, *datasets).map_err(|e| e.to_string())?;
|
||||
if r.stream.is_some() {
|
||||
*datasets = r.stream;
|
||||
}
|
||||
Ok(r.warnings)
|
||||
}
|
||||
|
||||
/// What a document whose form script ran away is told.
|
||||
pub(crate) const XFA_SCRIPTS_OFF: &str =
|
||||
"A script of this form ran too long and was abandoned; the form's scripts are off for this document (reopen it to run them again)";
|
||||
|
||||
/// Most XFA warnings kept per document.
|
||||
const MAX_XFA_WARNINGS: usize = 50;
|
||||
|
||||
/// Add `new` to `list` (no repeats, at most [`MAX_XFA_WARNINGS`]).
|
||||
pub(crate) fn note_warnings(list: &mut Vec<String>, new: &[String]) {
|
||||
for w in new {
|
||||
if !list.contains(w) && list.len() < MAX_XFA_WARNINGS {
|
||||
list.push(w.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A data file's text: UTF-8 or UTF-16 (by a byte order mark, or how `<` is written), or
|
||||
/// Latin-1 when its XML declaration says so. `None`: not text at all (FDF); `Err`: an
|
||||
/// encoding this can't read.
|
||||
pub(crate) fn decode_data_file(bytes: &[u8]) -> Option<Result<String, String>> {
|
||||
if bytes.starts_with(b"%FDF") {
|
||||
return None;
|
||||
}
|
||||
let declared = |text: &str| -> Option<String> {
|
||||
let head = text.get(..text.len().min(256))?;
|
||||
let decl = head.strip_prefix('\u{feff}').unwrap_or(head).trim_start().strip_prefix("<?xml")?;
|
||||
let decl = decl.split_once("?>")?.0;
|
||||
let enc = decl.split_once("encoding=")?.1;
|
||||
let quote = enc.chars().next()?;
|
||||
Some(enc.get(1..)?.split(quote).next()?.trim().to_ascii_lowercase())
|
||||
};
|
||||
if let Some((text, _)) = pdfcraft_xfa::packets::decode(bytes) {
|
||||
match declared(&text).as_deref() {
|
||||
None | Some("utf-8" | "utf8" | "utf-16" | "utf-16le" | "utf-16be" | "us-ascii" | "ascii") => return Some(Ok(text)),
|
||||
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") if !bytes.starts_with(&[0xEF, 0xBB, 0xBF]) => {
|
||||
// Each byte is the character it names (the 0x80–0x9F block of Windows-1252
|
||||
// differs, and is rare in form data).
|
||||
return Some(Ok(bytes.iter().map(|b| char::from(*b)).collect()));
|
||||
}
|
||||
Some(other) => return Some(Err(format!("the data file's encoding {other:?} is not supported (UTF-8, UTF-16 and ISO-8859-1 are)"))),
|
||||
}
|
||||
}
|
||||
// Not valid UTF-8/UTF-16: Latin-1 by declaration, else refused.
|
||||
let latin1: String = bytes.iter().map(|b| char::from(*b)).collect();
|
||||
match declared(&latin1).as_deref() {
|
||||
Some("iso-8859-1" | "latin1" | "latin-1" | "windows-1252" | "cp1252") => Some(Ok(latin1)),
|
||||
_ => Some(Err("the data file is not UTF-8, UTF-16 or ISO-8859-1 text".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the XFA out of `doc` and note what went with it: the packets, data no field showed,
|
||||
/// buttons that did nothing but run scripts, and fields whose value differs from the form's
|
||||
/// data (data PdfCraft could not read into them, now lost).
|
||||
pub(crate) fn strip_xfa(doc: &mut pdfcraft_cos::Document, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
let fields = pdfcraft_forms::fields(doc);
|
||||
let data = xfa_field_data(doc);
|
||||
let differ = pdfcraft_xfa::read_values(doc, &data)
|
||||
.into_iter()
|
||||
.filter(|(name, value)| {
|
||||
let Some(f) = fields.iter().find(|f| f.name == *name) else { return false };
|
||||
match value {
|
||||
pdfcraft_xfa::FieldData::Text(t) => f.value.join("\n") != *t && !t.trim().is_empty(),
|
||||
pdfcraft_xfa::FieldData::Check(on) => f.value.is_empty() == *on,
|
||||
pdfcraft_xfa::FieldData::Radio(sel) => f.value.first() != sel.as_ref(),
|
||||
pdfcraft_xfa::FieldData::None => false,
|
||||
}
|
||||
})
|
||||
.count();
|
||||
let r = pdfcraft_xfa::strip(doc).map_err(|e| EditError::Write(e.to_string()))?;
|
||||
cx.xfa_removed.extend(r.notes());
|
||||
if differ > 0 {
|
||||
cx.xfa_removed.push(format!(
|
||||
"{differ} field(s) held a value other than the form's XFA data (data PdfCraft could not read into them); the data's version is gone"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the document's XFA state in step with its file after an edit: a script may have laid
|
||||
/// the form out again (rows added, subforms shown), an edit may have taken the XFA out
|
||||
/// (flattened, or made static), or an undo put it back.
|
||||
pub(crate) fn sync_xfa_state(doc: &mut Document) {
|
||||
let Some(cos) = doc.editor.as_ref().map(|e| &e.cos) else { return };
|
||||
match pdfcraft_xfa::existing_layout(cos) {
|
||||
Some(report) => {
|
||||
if doc.xfa.is_none() {
|
||||
// Back after an undo: the XFA's own warnings apply again, the strip's don't.
|
||||
doc.xfa_warnings.retain(|w| !w.starts_with(XFA_REMOVED_PREFIX));
|
||||
if doc.xfa_template.is_none() && !doc.xfa_scripts_off {
|
||||
doc.xfa_template = xfa::template(cos);
|
||||
}
|
||||
}
|
||||
doc.xfa = Some(report);
|
||||
}
|
||||
None => {
|
||||
doc.xfa = None;
|
||||
doc.xfa_template = None;
|
||||
// Only what the strip said stays (the XFA's own warnings no longer apply).
|
||||
doc.xfa_warnings.retain(|w| w.starts_with(XFA_REMOVED_PREFIX));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the warnings of a strip start, so they can be told from the XFA's own.
|
||||
pub(crate) const XFA_REMOVED_PREFIX: &str = "XFA removed: ";
|
||||
|
||||
/// Is this an XFA data file (an XDP, a datasets document, a data element or a data document),
|
||||
/// rather than XFDF, Acrobat's `<fields>` XML or a table? By its root element.
|
||||
pub(crate) fn is_xfa_data_file(text: &str) -> bool {
|
||||
let text = text.get(..text.len().min(65_536)).unwrap_or(text);
|
||||
let mut rest = text.trim_start_matches('\u{feff}').trim_start();
|
||||
// Past the XML declaration, comments and processing instructions.
|
||||
let mut guard = 0;
|
||||
while guard < 100 {
|
||||
guard += 1;
|
||||
if let Some(r) = rest.strip_prefix("<?") {
|
||||
rest = r.split_once("?>").map_or("", |(_, r)| r).trim_start();
|
||||
} else if let Some(r) = rest.strip_prefix("<!--") {
|
||||
rest = r.split_once("-->").map_or("", |(_, r)| r).trim_start();
|
||||
} else if let Some(r) = rest.strip_prefix("<!") {
|
||||
rest = r.split_once('>').map_or("", |(_, r)| r).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(tag) = rest.strip_prefix('<') else { return false };
|
||||
let name = tag.split(|c: char| c.is_whitespace() || c == '>' || c == '/').next().unwrap_or("");
|
||||
let local = name.rsplit(':').next().unwrap_or(name);
|
||||
!local.is_empty() && !matches!(local, "xfdf" | "fields" | "html" | "body")
|
||||
}
|
||||
|
||||
/// The content type a picture is saved under, from its bytes: the kinds a laid-out form shows.
|
||||
pub(crate) fn image_content_type(bytes: &[u8]) -> Option<&'static str> {
|
||||
match pdfcraft_xfa::image::kind(bytes)? {
|
||||
pdfcraft_xfa::image::Kind::Jpeg => Some("image/jpeg"),
|
||||
pdfcraft_xfa::image::Kind::Png => Some("image/png"),
|
||||
pdfcraft_xfa::image::Kind::Gif => Some("image/gif"),
|
||||
pdfcraft_xfa::image::Kind::Bmp => Some("image/bmp"),
|
||||
pdfcraft_xfa::image::Kind::Tiff => Some("image/tiff"),
|
||||
}
|
||||
}
|
||||
|
||||
/// After the XFA data changed outside the fields (an import, a picture): a laid-out form is
|
||||
/// laid out again from the data (then, when `calculate`, its calculations run), and the
|
||||
/// fields take the data's values.
|
||||
pub(crate) 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, 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;
|
||||
cx.xfa_ran_away |= run.ran_away;
|
||||
if let Err(e) = r {
|
||||
cx.xfa_out.errors.push(e);
|
||||
}
|
||||
}
|
||||
let taken = xfa_values_from_datasets(doc).map_err(EditError::Write)?;
|
||||
if !taken.is_empty() {
|
||||
cx.xfa_out.console.push(format!("{} field value(s) taken from the imported data", taken.len()));
|
||||
}
|
||||
// Not laid out again (a static form, or scripts off): fields the data no longer holds are
|
||||
// emptied, so the data replaced is the data shown.
|
||||
if cx.xfa.is_none() && calculate {
|
||||
let data = xfa_field_data(doc);
|
||||
let present: std::collections::HashSet<String> = pdfcraft_xfa::read_values(doc, &data).into_iter().map(|(n, _)| n).collect();
|
||||
for f in pdfcraft_forms::fields(doc) {
|
||||
if present.contains(&f.name) || f.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
use pdfcraft_forms::FieldKind as K;
|
||||
let empty = match f.kind {
|
||||
K::Text => FieldValue::Text(String::new()),
|
||||
K::CheckBox => FieldValue::Check(false),
|
||||
K::Radio => FieldValue::Radio(None),
|
||||
K::Combo | K::List => FieldValue::Choice(Vec::new()),
|
||||
K::PushButton | K::Signature => continue,
|
||||
};
|
||||
if let Err(e) = pdfcraft_forms::set_value(doc, &f.name, &empty) {
|
||||
cx.xfa_out.errors.push(format!("{}: not emptied by the import: {e}", f.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Give the fields the values the XFA datasets hold (a form filled in another viewer). Returns
|
||||
/// the names of the fields that changed.
|
||||
pub(crate) 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);
|
||||
// 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) = 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
|
||||
// its saved value (an older viewer may have written it).
|
||||
let saved = |v: &str| f.options.iter().find(|(e, s)| e == v || s == v).map_or(v.to_string(), |(e, _)| e.clone());
|
||||
let picked: Vec<String> = if f.has(pdfcraft_forms::flags::MULTI_SELECT) {
|
||||
t.lines().map(str::trim).filter(|l| !l.is_empty()).map(saved).collect()
|
||||
} else {
|
||||
vec![saved(t.trim())]
|
||||
};
|
||||
(f.value != picked).then_some(FieldValue::Choice(picked))
|
||||
}
|
||||
pdfcraft_xfa::FieldData::Text(t) => (f.value.join("\n") != t).then_some(FieldValue::Text(t)),
|
||||
pdfcraft_xfa::FieldData::Check(on) => (f.value.is_empty() == on).then_some(FieldValue::Check(on)),
|
||||
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 {
|
||||
updates.push((name, v));
|
||||
}
|
||||
}
|
||||
// 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())
|
||||
}
|
||||
+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;
|
||||
@@ -60,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+12
-5
@@ -1553,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())
|
||||
}
|
||||
@@ -1572,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,
|
||||
|
||||
@@ -4360,6 +4360,82 @@ fn comb_fields_show_their_cells() {
|
||||
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 {
|
||||
|
||||
+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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
+666
-70
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
|
||||
@@ -157,10 +156,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.
|
||||
@@ -722,11 +723,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 {
|
||||
@@ -750,23 +746,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
|
||||
@@ -787,57 +789,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());
|
||||
|
||||
@@ -91,6 +91,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))
|
||||
}
|
||||
@@ -585,7 +590,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)
|
||||
@@ -619,7 +624,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"))
|
||||
@@ -627,7 +635,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;
|
||||
|
||||
+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), .. })));
|
||||
}
|
||||
}
|
||||
@@ -471,6 +471,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)]
|
||||
@@ -1615,7 +1628,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()))
|
||||
@@ -1677,7 +1690,7 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) -
|
||||
if snapshot.changes_with(text.as_str()) { galley } else { unseen(galley) }
|
||||
};
|
||||
let output = egui::TextEdit::multiline(&mut ed.text)
|
||||
.id(egui::Id::new("edit-text-line-input"))
|
||||
.id(egui::Id::unique("edit-text-line-input"))
|
||||
.font(font.clone())
|
||||
.margin(egui::Margin::ZERO)
|
||||
.layouter(&mut layouter)
|
||||
@@ -1686,6 +1699,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));
|
||||
|
||||
@@ -338,7 +338,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)
|
||||
@@ -356,8 +356,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.
|
||||
@@ -397,7 +398,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| {
|
||||
@@ -688,7 +689,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)
|
||||
@@ -713,7 +714,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);
|
||||
}
|
||||
@@ -721,11 +722,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
|
||||
{
|
||||
@@ -800,7 +804,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| {
|
||||
@@ -859,10 +863,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()
|
||||
|
||||
@@ -18,6 +18,7 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home page with the welcome text, community card and recent documents.
|
||||
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
@@ -72,7 +73,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
for id in RECOMMENDED {
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), 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);
|
||||
card_lift(ui, &t, rect, press);
|
||||
let fill = press.fill(t.card, t.hover, t.pressed);
|
||||
@@ -110,7 +111,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Open file")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file")));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
card_lift(ui, &t, rect, press);
|
||||
ui.painter().rect(
|
||||
@@ -142,7 +143,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("Clear Recent Files")));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
}
|
||||
@@ -155,7 +156,7 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let mut remove = None;
|
||||
for r in &app.recent {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &r.name));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
// The row, not its response: the pointer stays "in" the row over its remove button.
|
||||
let hovered = ui.rect_contains_pointer(rect);
|
||||
@@ -168,9 +169,9 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
if hovered {
|
||||
// Remove just this file from the list (#430); the file itself is untouched.
|
||||
let x_rect = Rect::from_center_size(rect.right_center() - vec2(26.0, 0.0), vec2(28.0, 28.0));
|
||||
let x = ui.interact(x_rect, ui.id().with(("remove-recent", &r.path)), Sense::click());
|
||||
let x = ui.interact(x_rect, ui.scope_id().with(("remove-recent", &r.path)), Sense::click());
|
||||
let label = crate::i18n::fmt(tl!("Remove {name} from Recent"), &[("name", &r.name)]);
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &label));
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &label));
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x_rect, CornerRadius::same(6), t.pressed);
|
||||
}
|
||||
@@ -197,8 +198,8 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
);
|
||||
// The name and path stop short of the page count; hovering shows the whole path.
|
||||
let width = detail.left() - 16.0 - (body.left() + 46.0);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 15.0), crate::bidi::visual(&r.name), theme::medium(13.5), t.text, width);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 32.0), crate::bidi::visual(&r.path), theme::regular(11.0), t.text_faint, width);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 15.0), r.name.as_str(), theme::medium(13.5), t.text, width);
|
||||
widgets::row_text(ui, body.min + vec2(46.0, 32.0), r.path.as_str(), theme::regular(11.0), t.text_faint, width);
|
||||
if theme::hand(resp).on_hover_text(&r.path).clicked() {
|
||||
open = Some(r.path.clone());
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept. Digits are Western (0-9).
|
||||
#
|
||||
# Write every entry in logical (typing) order. The interface draws text left to right, so
|
||||
# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl);
|
||||
# spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and
|
||||
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
|
||||
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
|
||||
Menu القائمة
|
||||
File ملف
|
||||
@@ -184,6 +184,15 @@
|
||||
Available متاح
|
||||
Ready جاهز
|
||||
Search tools and commands… البحث في الأدوات والأوامر…
|
||||
|
||||
# Accessible names for inputs that have no visible label.
|
||||
Added text النص المضاف
|
||||
Link URL عنوان URL للرابط
|
||||
Search tools and commands البحث في الأدوات والأوامر
|
||||
Text editing line سطر تحرير النص
|
||||
Text to place النص المراد وضعه
|
||||
Tooltip تلميح
|
||||
Words or phrases to redact الكلمات أو العبارات المراد حجبها
|
||||
No matching tools لا توجد أدوات مطابقة
|
||||
Tool أداة
|
||||
Command أمر
|
||||
@@ -2816,3 +2825,11 @@ certificate Version الإصدار
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today اليوم
|
||||
@plural {n} reply|{n} replies {n} رد|{n} رد|{n} ردّان|{n} ردود|{n} ردًا|{n} رد
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools أدوات الصفحات
|
||||
Insert pages from a file after the selection… إدراج صفحات من ملف بعد التحديد…
|
||||
Rotate the selected pages counterclockwise تدوير الصفحات المحددة عكس اتجاه عقارب الساعة
|
||||
Rotate the selected pages clockwise تدوير الصفحات المحددة باتجاه عقارب الساعة
|
||||
Extract the selected pages… استخراج الصفحات المحددة…
|
||||
Delete the selected pages حذف الصفحات المحددة
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -1832,6 +1832,15 @@
|
||||
Tool Инструмент
|
||||
Command Команда
|
||||
Search tools and commands… Търсене на инструменти и команди…
|
||||
|
||||
# Accessible names for inputs that have no visible label.
|
||||
Added text Добавен текст
|
||||
Link URL URL на връзката
|
||||
Search tools and commands Търсене на инструменти и команди
|
||||
Text editing line Ред за редактиране на текст
|
||||
Text to place Текст за поставяне
|
||||
Tooltip Подсказка
|
||||
Words or phrases to redact Думи или фрази за заличаване
|
||||
No matching tools Няма съответстващи инструменти
|
||||
All tools Всички инструменти
|
||||
Read Четене
|
||||
@@ -2503,3 +2512,11 @@ animation effects Off Изключени
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Днес
|
||||
@plural {n} reply|{n} replies {n} отговор|{n} отговора
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Инструменти за страници
|
||||
Insert pages from a file after the selection… Вмъкване на страници от файл след избраното…
|
||||
Rotate the selected pages counterclockwise Завъртане на избраните страници обратно на часовниковата стрелка
|
||||
Rotate the selected pages clockwise Завъртане на избраните страници по часовниковата стрелка
|
||||
Extract the selected pages… Извличане на избраните страници…
|
||||
Delete the selected pages Изтриване на избраните страници
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 8 and column 16.
|
@@ -178,6 +178,15 @@
|
||||
Available Disponible
|
||||
Ready Llest
|
||||
Search tools and commands… Cerca eines i ordres…
|
||||
|
||||
# Accessible names for inputs that have no visible label.
|
||||
Added text Text afegit
|
||||
Link URL URL de l'enllaç
|
||||
Search tools and commands Cerca eines i ordres
|
||||
Text editing line Línia d'edició de text
|
||||
Text to place Text que cal col·locar
|
||||
Tooltip Suggeriment
|
||||
Words or phrases to redact Paraules o frases per censurar
|
||||
No matching tools Cap eina coincident
|
||||
Tool Eina
|
||||
Command Ordre
|
||||
@@ -2801,3 +2810,11 @@ theme Appearance Aspecte
|
||||
Delete attachment Elimina l'adjunt
|
||||
Delete attachment {key} Elimina l'adjunt {key}
|
||||
@plural {n} reply|{n} replies {n} resposta|{n} respostes
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Eines de pàgina
|
||||
Insert pages from a file after the selection… Insereix pàgines d'un fitxer després de la selecció…
|
||||
Rotate the selected pages counterclockwise Gira les pàgines seleccionades en sentit antihorari
|
||||
Rotate the selected pages clockwise Gira les pàgines seleccionades en sentit horari
|
||||
Extract the selected pages… Extreu les pàgines seleccionades…
|
||||
Delete the selected pages Suprimeix les pàgines seleccionades
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 7 and column 3.
|
@@ -155,15 +155,6 @@ impl Catalog {
|
||||
(c, errors)
|
||||
}
|
||||
|
||||
/// Rewrite every translation (each plural form on its own), e.g. into display order for a
|
||||
/// right-to-left language. Keys are untouched.
|
||||
pub fn map_translations(&mut self, f: impl Fn(&str) -> String) {
|
||||
let tables = std::iter::once(&mut self.plain).chain(self.contextual.values_mut()).chain(std::iter::once(&mut self.ids));
|
||||
for translation in tables.flat_map(HashMap::values_mut).chain(self.plurals.values_mut().flatten()) {
|
||||
*translation = f(translation);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plain(&self, s: &str) -> Option<&str> {
|
||||
self.plain.get(s).map(String::as_str)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
# Product and technology names (PdfCraft, PDF, Acrobat JavaScript ...) stay in Latin letters;
|
||||
# the trailing "…" of dialog commands is kept. Digits are Western (0-9).
|
||||
#
|
||||
# Write every entry in logical (typing) order. The interface draws text left to right, so
|
||||
# i18n/mod.rs puts each entry into display order when it loads the catalog (bidi::display_rtl);
|
||||
# spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
# Write every entry in logical (typing) order. Catalogs stay logical: epaint shapes and
|
||||
# reorders each line with UAX #9, so preordering here would reverse right-to-left text twice.
|
||||
# Spaces and a colon at either end of an entry stay at that end, next to what the code appends.
|
||||
|
||||
Menu پێڕست
|
||||
File فایل
|
||||
@@ -184,6 +184,15 @@
|
||||
Available بەردەست
|
||||
Ready ئامادە
|
||||
Search tools and commands… گەڕان لە ئامرازەکان و فەرمانەکان…
|
||||
|
||||
# Accessible names for inputs that have no visible label.
|
||||
Added text دەقی زیادکراو
|
||||
Link URL ناونیشانی URLی بەستەر
|
||||
Search tools and commands گەڕان لە ئامرازەکان و فەرمانەکان
|
||||
Text editing line هێڵی دەستکاریکردنی دەق
|
||||
Text to place دەقی دانان
|
||||
Tooltip ڕێنمایی
|
||||
Words or phrases to redact وشە یان دەستەواژەکان بۆ شاردنەوە
|
||||
No matching tools هیچ ئامرازێکی گونجاو نییە
|
||||
Tool ئامراز
|
||||
Command فەرمان
|
||||
@@ -2732,3 +2741,11 @@ certificate Version وەشان
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today ئەمڕۆ
|
||||
@plural {n} reply|{n} replies {n} وەڵام|{n} وەڵام
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools ئامرازەکانی پەڕە
|
||||
Insert pages from a file after the selection… پەڕەکان لە فایلێکەوە لە دوای هەڵبژاردەکە دابنێ…
|
||||
Rotate the selected pages counterclockwise پەڕە هەڵبژێردراوەکان پێچەوانەی میلی کاتژمێر بسوڕێنە
|
||||
Rotate the selected pages clockwise پەڕە هەڵبژێردراوەکان بە ئاڕاستەی میلی کاتژمێر بسوڕێنە
|
||||
Extract the selected pages… پەڕە هەڵبژێردراوەکان دەربهێنە…
|
||||
Delete the selected pages پەڕە هەڵبژێردراوەکان بسڕەوە
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -305,6 +305,15 @@ key Space Mezerník
|
||||
Available Dostupné
|
||||
Ready Připraveno
|
||||
Search tools and commands… Hledat nástroje a příkazy…
|
||||
|
||||
# Accessible names for inputs that have no visible label.
|
||||
Added text Přidaný text
|
||||
Link URL Adresa URL odkazu
|
||||
Search tools and commands Hledat nástroje a příkazy
|
||||
Text editing line Řádek pro úpravu textu
|
||||
Text to place Text k umístění
|
||||
Tooltip Popisek
|
||||
Words or phrases to redact Slova nebo fráze k začernění
|
||||
No matching tools Žádné odpovídající nástroje
|
||||
Tool Nástroj
|
||||
Command Příkaz
|
||||
@@ -2847,3 +2856,11 @@ certificate Version Verze
|
||||
# Date picker: Today button (#4274) (AI-assisted, not separately native-reviewed)
|
||||
Today Dnes
|
||||
@plural {n} reply|{n} replies {n} odpověď|{n} odpovědi|{n} odpovědí
|
||||
|
||||
# Pages panel: the Page tools menu (#4415) (AI-assisted, not separately native-reviewed)
|
||||
Page tools Nástroje stránek
|
||||
Insert pages from a file after the selection… Vložit stránky ze souboru za výběr…
|
||||
Rotate the selected pages counterclockwise Otočit vybrané stránky proti směru hodinových ručiček
|
||||
Rotate the selected pages clockwise Otočit vybrané stránky po směru hodinových ručiček
|
||||
Extract the selected pages… Extrahovat vybrané stránky…
|
||||
Delete the selected pages Odstranit vybrané stránky
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user