diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac276f13c9..9d168c7aa6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml
index fba20b1cc7..268da70ca1 100644
--- a/.github/workflows/freebsd.yml
+++ b/.github/workflows/freebsd.yml
@@ -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
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c6ee0e1c31..a5c2ba7f7f 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -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 }}
diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 40626dc6ba..c5be9d7bc1 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -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 |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 383272f3f7..8b2186d5b4 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -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"
diff --git a/Cargo.lock b/Cargo.lock
index 37f2487f8b..3c713ba5cc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,9 +4,9 @@ version = 4
[[package]]
name = "accesskit"
-version = "0.24.1"
+version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932"
dependencies = [
"enumn",
"serde",
@@ -15,57 +15,51 @@ dependencies = [
[[package]]
name = "accesskit_atspi_common"
-version = "0.18.1"
+version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.36.0",
+ "accesskit_consumer",
"atspi-common",
- "phf 0.13.1",
+ "phf 0.14.0",
"serde",
"zvariant",
]
[[package]]
name = "accesskit_consumer"
-version = "0.35.0"
+version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
]
[[package]]
-name = "accesskit_consumer"
-version = "0.36.0"
+name = "accesskit_ios"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937"
dependencies = [
"accesskit",
- "hashbrown 0.16.1",
-]
-
-[[package]]
-name = "accesskit_consumer"
-version = "0.38.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
-dependencies = [
- "accesskit",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
]
[[package]]
name = "accesskit_macos"
-version = "0.26.3"
+version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4"
dependencies = [
"accesskit",
- "accesskit_consumer 0.38.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
"objc2-foundation 0.2.2",
@@ -73,9 +67,9 @@ dependencies = [
[[package]]
name = "accesskit_unix"
-version = "0.21.1"
+version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d"
dependencies = [
"accesskit",
"accesskit_atspi_common",
@@ -91,13 +85,13 @@ dependencies = [
[[package]]
name = "accesskit_windows"
-version = "0.32.1"
+version = "0.35.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
- "hashbrown 0.16.1",
+ "accesskit_consumer",
+ "hashbrown 0.17.1",
"static_assertions",
"windows",
"windows-core",
@@ -105,11 +99,12 @@ dependencies = [
[[package]]
name = "accesskit_winit"
-version = "0.32.2"
+version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77"
dependencies = [
"accesskit",
+ "accesskit_ios",
"accesskit_macos",
"accesskit_unix",
"accesskit_windows",
@@ -220,21 +215,22 @@ checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "arboard"
-version = "3.6.1"
+version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
dependencies = [
"clipboard-win",
"image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -447,9 +443,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "aws-lc-rs"
-version = "1.18.1"
+version = "1.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13"
dependencies = [
"aws-lc-sys",
"untrusted 0.7.1",
@@ -458,9 +454,9 @@ dependencies = [
[[package]]
name = "aws-lc-sys"
-version = "0.45.0"
+version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc"
dependencies = [
"cc",
"cmake",
@@ -493,15 +489,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
-[[package]]
-name = "bit-set"
-version = "0.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
-dependencies = [
- "bit-vec 0.8.0",
-]
-
[[package]]
name = "bit-set"
version = "0.10.0"
@@ -512,10 +499,13 @@ dependencies = [
]
[[package]]
-name = "bit-vec"
-version = "0.8.0"
+name = "bit-set"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c"
+dependencies = [
+ "bit-vec 0.10.1",
+]
[[package]]
name = "bit-vec"
@@ -523,6 +513,16 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+[[package]]
+name = "bit-vec"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269"
+dependencies = [
+ "borsh",
+ "serde",
+]
+
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -580,7 +580,7 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -649,7 +649,7 @@ dependencies = [
"oneshot",
"pastey",
"portable-atomic",
- "rand 0.10.3",
+ "rand",
"regress",
"rustc-hash 2.1.3",
"ryu-js",
@@ -740,6 +740,30 @@ dependencies = [
"static_assertions",
]
+[[package]]
+name = "borsh"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
+dependencies = [
+ "borsh-derive",
+ "bytes",
+ "cfg_aliases",
+]
+
+[[package]]
+name = "borsh-derive"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
+dependencies = [
+ "once_cell",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
[[package]]
name = "bp256"
version = "0.14.0"
@@ -1147,11 +1171,35 @@ name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+[[package]]
+name = "craft-keymap"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+dependencies = [
+ "egui",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "craft-menu"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
+dependencies = [
+ "craft-keymap",
+ "craft-quit",
+ "egui",
+ "muda",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
[[package]]
name = "craft-print"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=666c358065261a2816b376fa3bb10538dbf53040#666c358065261a2816b376fa3bb10538dbf53040"
+source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68"
dependencies = [
+ "craft-winprint-gdi",
"miniz_oxide 0.8.9",
"roxmltree",
"tempfile",
@@ -1162,24 +1210,60 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=f58bc1a3df490992d633393d0898af0588ce882c#f58bc1a3df490992d633393d0898af0588ce882c"
+source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"dispatch2",
"egui",
- "muda",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
-source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
+source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb"
dependencies = [
"egui",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
"serde",
]
+[[package]]
+name = "craft-update"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
+dependencies = [
+ "chrono",
+ "dunce",
+ "egui",
+ "flate2",
+ "getrandom 0.3.4",
+ "log",
+ "plist",
+ "semver",
+ "serde",
+ "serde_json",
+ "sha2 0.10.9",
+ "tar",
+ "tempfile",
+ "ureq",
+ "verifysign",
+ "winreg",
+ "winsafe",
+ "zip",
+]
+
+[[package]]
+name = "craft-winprint-gdi"
+version = "0.1.0"
+source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1439,7 +1523,7 @@ dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
"libc",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -1535,9 +1619,8 @@ dependencies = [
[[package]]
name = "ecolor"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"emath",
@@ -1561,16 +1644,15 @@ checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"subtle",
"zeroize",
]
[[package]]
name = "eframe"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1585,7 +1667,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"parking_lot",
@@ -1594,6 +1676,7 @@ dependencies = [
"profiling",
"raw-window-handle",
"ron",
+ "send_wrapper",
"serde",
"static_assertions",
"wasm-bindgen",
@@ -1606,9 +1689,8 @@ dependencies = [
[[package]]
name = "egui"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit",
"ahash",
@@ -1628,7 +1710,8 @@ dependencies = [
[[package]]
name = "egui-wgpu"
-version = "0.36.2"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1646,32 +1729,35 @@ dependencies = [
[[package]]
name = "egui-winit"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"accesskit_winit",
"arboard",
+ "block2 0.6.2",
"bytemuck",
"egui",
+ "image",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2",
+ "parking_lot",
"profiling",
"raw-window-handle",
"serde",
"smithay-clipboard",
"web-time",
"webbrowser",
+ "windows-sys 0.61.2",
"winit",
]
[[package]]
name = "egui_extras"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
@@ -1684,9 +1770,8 @@ dependencies = [
[[package]]
name = "egui_glow"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"egui",
@@ -1698,9 +1783,8 @@ dependencies = [
[[package]]
name = "egui_kittest"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"eframe",
"egui",
@@ -1742,9 +1826,8 @@ dependencies = [
[[package]]
name = "emath"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"bytemuck",
"serde",
@@ -1772,22 +1855,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
-version = "2.7.3"
+version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
-version = "0.17.0"
+version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.6",
]
[[package]]
@@ -1824,9 +1907,8 @@ dependencies = [
[[package]]
name = "epaint"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"bytemuck",
@@ -1843,16 +1925,17 @@ dependencies = [
"serde",
"skrifa 0.44.0",
"smallvec",
+ "unicode-bidi",
"unicode-general-category",
+ "unicode-properties",
"unicode-segmentation",
- "vello_cpu 0.1.0",
+ "vello_cpu 0.2.0",
]
[[package]]
name = "epaint_default_fonts"
-version = "0.36.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[package]]
name = "equator"
@@ -1887,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -2057,7 +2140,7 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
]
@@ -2068,6 +2151,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
[[package]]
name = "foldhash"
version = "0.2.0"
@@ -2318,7 +2407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
@@ -2330,18 +2419,18 @@ dependencies = [
[[package]]
name = "glifo"
-version = "0.2.0"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be"
dependencies = [
"bytemuck",
- "foldhash",
+ "foldhash 0.2.0",
"hashbrown 0.17.1",
"log",
"peniko",
"skrifa 0.44.0",
"smallvec",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
]
[[package]]
@@ -2370,7 +2459,7 @@ dependencies = [
"glutin_glx_sys",
"glutin_wgl_sys",
"libloading",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -2505,6 +2594,15 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash 0.1.5",
+]
+
[[package]]
name = "hashbrown"
version = "0.16.1"
@@ -2513,7 +2611,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -2524,7 +2622,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.2.0",
]
[[package]]
@@ -3065,12 +3163,12 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kittest"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+checksum = "0bb81a721ba76aa095b7f45deb180504f786904e56ca4b8e141f66eb89a78b75"
dependencies = [
"accesskit",
- "accesskit_consumer 0.35.0",
+ "accesskit_consumer",
]
[[package]]
@@ -3193,10 +3291,10 @@ dependencies = [
"log",
"md-5",
"nom",
- "rand 0.10.3",
+ "rand",
"rangemap",
"rayon",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
"weezl 0.2.1",
@@ -3298,13 +3396,13 @@ dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"png 0.18.1",
"thiserror 2.0.21",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -3381,6 +3479,18 @@ dependencies = [
"jni-sys 0.3.1",
]
+[[package]]
+name = "nix"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3491,9 +3601,9 @@ dependencies = [
[[package]]
name = "objc2"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
@@ -3522,7 +3632,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3572,7 +3682,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
]
[[package]]
@@ -3583,7 +3693,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-io-surface",
]
@@ -3639,7 +3749,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3650,7 +3760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
]
@@ -3686,7 +3796,7 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
dependencies = [
"bitflags 2.13.2",
"block2 0.6.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-foundation 0.3.2",
]
@@ -3710,7 +3820,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -3755,7 +3865,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
dependencies = [
"bitflags 2.13.2",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
]
@@ -3846,6 +3956,16 @@ dependencies = [
"pin-project-lite",
]
+[[package]]
+name = "os_pipe"
+version = "1.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
+dependencies = [
+ "libc",
+ "windows-sys 0.59.0",
+]
+
[[package]]
name = "p256"
version = "0.14.0"
@@ -3856,7 +3976,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3870,7 +3990,7 @@ dependencies = [
"fiat-crypto",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3884,7 +4004,7 @@ dependencies = [
"elliptic-curve",
"primefield",
"primeorder",
- "sha2 0.11.0",
+ "sha2 0.11.1",
]
[[package]]
@@ -3937,7 +4057,7 @@ name = "pdfcraft"
version = "0.14.0"
dependencies = [
"chrono",
- "craft-quit",
+ "craft-update",
"eframe",
"egui",
"fmv-macos-events",
@@ -3946,10 +4066,8 @@ dependencies = [
"pdfcraft-ui-egui",
"pollster 1.0.1",
"raw-window-handle",
- "rustls-native-certs",
"serde_json",
"uds_windows",
- "ureq",
"wayland-client",
"wayland-protocols",
"windows",
@@ -3996,7 +4114,7 @@ dependencies = [
name = "pdfcraft-automation"
version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"flate2",
"image",
"pdfcraft-cos",
@@ -4010,7 +4128,7 @@ dependencies = [
"png 0.18.1",
"rustix 1.1.5",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"tempfile",
]
@@ -4018,7 +4136,7 @@ dependencies = [
name = "pdfcraft-cli"
version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"pdfcraft-audit",
"pdfcraft-automation",
"pdfcraft-cos",
@@ -4082,7 +4200,7 @@ dependencies = [
"aes",
"md-5",
"proptest",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"stringprep",
"thiserror 2.0.21",
]
@@ -4329,7 +4447,7 @@ version = "0.14.0"
dependencies = [
"aes",
"aws-lc-rs",
- "base64 0.22.1",
+ "base64 0.23.1",
"bp256",
"bp384",
"cbc",
@@ -4354,7 +4472,7 @@ dependencies = [
"rustls-cng",
"security-framework",
"sha1 0.11.0",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"sha3",
"thiserror 2.0.21",
]
@@ -4368,12 +4486,15 @@ name = "pdfcraft-ui-egui"
version = "0.14.0"
dependencies = [
"arboard",
- "base64 0.22.1",
+ "base64 0.23.1",
"block2 0.6.2",
"bytemuck",
"chrono",
+ "craft-keymap",
+ "craft-menu",
"craft-quit",
"craft-ui",
+ "craft-update",
"eframe",
"egui",
"egui_extras",
@@ -4383,7 +4504,7 @@ dependencies = [
"image",
"js-sys",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pdfcraft-cos",
@@ -4407,6 +4528,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
+ "web-time",
"winsafe",
"xattr",
]
@@ -4427,13 +4549,13 @@ dependencies = [
name = "pdfcraft-xfa"
version = "0.14.0"
dependencies = [
- "base64 0.22.1",
+ "base64 0.23.1",
"image",
"pdfcraft-cos",
"pdfcraft-fonts",
"png 0.18.1",
"roxmltree",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"thiserror 2.0.21",
]
@@ -4496,6 +4618,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
[[package]]
name = "phf"
version = "0.13.1"
@@ -4504,7 +4637,6 @@ checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_macros 0.13.1",
"phf_shared 0.13.1",
- "serde",
]
[[package]]
@@ -4515,6 +4647,7 @@ checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros 0.14.0",
"phf_shared 0.14.0",
+ "serde",
]
[[package]]
@@ -4668,9 +4801,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "plist"
-version = "1.10.2"
+version = "1.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
+checksum = "327a4cb2ad49dc48adfdc9da89e22c64d488b508c67a044d71ba729e4521a285"
dependencies = [
"base64 0.23.1",
"indexmap",
@@ -4770,15 +4903,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
[[package]]
name = "presser"
version = "0.3.1"
@@ -4862,16 +4986,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
[[package]]
name = "proptest"
-version = "1.11.0"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
+checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0"
dependencies = [
- "bit-set 0.8.0",
- "bit-vec 0.8.0",
+ "bit-set 0.11.1",
+ "bit-vec 0.10.1",
"bitflags 2.13.2",
+ "chacha20",
+ "core_detect",
"num-traits",
- "rand 0.9.5",
- "rand_chacha",
+ "rand",
"rand_xorshift",
"regex-syntax",
"rusty-fork",
@@ -4945,7 +5070,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
- "rand 0.10.3",
+ "rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -4969,7 +5094,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -4993,16 +5118,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-[[package]]
-name = "rand"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
-dependencies = [
- "rand_chacha",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand"
version = "0.10.3"
@@ -5014,31 +5129,12 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.5",
-]
-
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-[[package]]
-name = "rand_core"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
-dependencies = [
- "getrandom 0.3.4",
-]
-
[[package]]
name = "rand_core"
version = "0.10.1"
@@ -5056,11 +5152,11 @@ dependencies = [
[[package]]
name = "rand_xorshift"
-version = "0.4.0"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
+checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf"
dependencies = [
- "rand_core 0.9.5",
+ "rand_core 0.10.1",
]
[[package]]
@@ -5087,7 +5183,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
dependencies = [
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"objc2-quartz-core 0.3.2",
@@ -5280,7 +5376,7 @@ dependencies = [
"js-sys",
"libc",
"log",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
@@ -5506,7 +5602,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
- "windows-sys 0.52.0",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5519,7 +5615,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5551,9 +5647,9 @@ dependencies = [
[[package]]
name = "rustls-native-certs"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe",
"rustls-pki-types",
@@ -5589,7 +5685,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -5718,6 +5814,16 @@ name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+dependencies = [
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "serde"
@@ -5751,9 +5857,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -5850,9 +5956,9 @@ dependencies = [
[[package]]
name = "sha2"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -6008,7 +6114,7 @@ dependencies = [
"const-oid 0.9.6",
"der 0.7.10",
"hex",
- "rand 0.10.3",
+ "rand",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -6244,7 +6350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6463,10 +6569,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -6800,6 +6906,17 @@ dependencies = [
"once_cell",
]
+[[package]]
+name = "tree_magic_mini"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
+dependencies = [
+ "memchr",
+ "nom",
+ "petgraph",
+]
+
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -6815,6 +6932,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
+[[package]]
+name = "typed-path"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
+
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6835,7 +6958,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -6903,24 +7026,26 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
-version = "3.4.2"
+version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+checksum = "cde93ff05758a36a851a398a772b0b79a82fbe3c186a46f2696baaeaf84c966b"
dependencies = [
"base64 0.23.1",
+ "flate2",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
+ "rustls-platform-verifier",
"ureq-proto",
"utf8-zero",
]
[[package]]
name = "ureq-proto"
-version = "0.6.4"
+version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922"
dependencies = [
"base64 0.23.1",
"http",
@@ -7025,9 +7150,9 @@ dependencies = [
[[package]]
name = "vello_common"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd",
@@ -7053,14 +7178,24 @@ dependencies = [
[[package]]
name = "vello_cpu"
-version = "0.1.0"
+version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
dependencies = [
"bytemuck",
- "glifo 0.2.0",
+ "glifo 0.3.0",
"hashbrown 0.17.1",
- "vello_common 0.1.0",
+ "vello_common 0.2.0",
+]
+
+[[package]]
+name = "verifysign"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6acfcc42a8d44f340d001193d97ef56bf33210b51ad760ae8308c97d5241ca51"
+dependencies = [
+ "core-foundation 0.10.1",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7332,7 +7467,7 @@ dependencies = [
"jni",
"log",
"ndk-context",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"url",
@@ -7488,7 +7623,7 @@ dependencies = [
"naga",
"naga-types",
"ndk-sys",
- "objc2 0.6.4",
+ "objc2 0.6.5",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation 0.3.2",
@@ -7549,7 +7684,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
- "windows-sys 0.60.2",
+ "windows-sys 0.59.0",
]
[[package]]
@@ -7670,16 +7805,16 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
name = "windows-sys"
-version = "0.60.2"
+version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -7697,31 +7832,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -7739,99 +7857,52 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winit"
version = "0.30.13"
+source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc"
dependencies = [
"ahash",
"android-activity",
@@ -7909,10 +7980,20 @@ dependencies = [
]
[[package]]
-name = "winresource"
-version = "0.1.31"
+name = "winreg"
+version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087"
+checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97"
+dependencies = [
+ "cfg-if",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "winresource"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b"
dependencies = [
"toml",
"version_check",
@@ -7930,6 +8011,24 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+[[package]]
+name = "wl-clipboard-rs"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1"
+dependencies = [
+ "libc",
+ "log",
+ "os_pipe",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "tree_magic_mini",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+]
+
[[package]]
name = "wnaf"
version = "0.14.1"
@@ -8076,7 +8175,7 @@ dependencies = [
"png 0.18.1",
"serde",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
"toml",
]
@@ -8303,6 +8402,23 @@ dependencies = [
"syn 3.0.6",
]
+[[package]]
+name = "zip"
+version = "9.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2886b00003a72c4ccee3f42cfc34908d33b2508a74d827dc2a4244706475c704"
+dependencies = [
+ "crc32fast",
+ "flate2",
+ "getrandom 0.4.3",
+ "indexmap",
+ "memchr",
+ "nix",
+ "typed-path",
+ "windows-sys 0.61.2",
+ "zopfli",
+]
+
[[package]]
name = "zlib-rs"
version = "0.6.8"
@@ -8315,6 +8431,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
+
[[package]]
name = "zune-core"
version = "0.5.3"
@@ -8370,3 +8498,18 @@ dependencies = [
"syn 3.0.6",
"winnow",
]
+
+[[patch.unused]]
+name = "egui_demo_lib"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_inspection"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
+
+[[patch.unused]]
+name = "egui_system_font_provider"
+version = "0.37.0"
+source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
diff --git a/Cargo.toml b/Cargo.toml
index 78c7a78c6f..8eb38b29af 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/NOTICE b/NOTICE
index 8cacb0b585..63a8fe672c 100644
--- a/NOTICE
+++ b/NOTICE
@@ -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.
diff --git a/README.md b/README.md
index d39bec5aa9..f319ffeadb 100644
--- a/README.md
+++ b/README.md
@@ -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.
Made by the ArtCraft team and community.
-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.
diff --git a/ROADMAP.md b/ROADMAP.md
index 9fe97284ca..165fccc5e8 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -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 |
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index 7b5f9a82f3..3d9d87a812 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -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.
diff --git a/apps/pdfcraft-web/Cargo.toml b/apps/pdfcraft-web/Cargo.toml
index a64d729dcd..ad8243d594 100644
--- a/apps/pdfcraft-web/Cargo.toml
+++ b/apps/pdfcraft-web/Cargo.toml
@@ -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"
diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml
index 6943ad6e44..966af354d1 100644
--- a/apps/pdfcraft/Cargo.toml
+++ b/apps/pdfcraft/Cargo.toml
@@ -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 }
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 96725cd8d5..ee8ae152cc 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -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, exists: impl Fn(&str) -> bool) -> Vec 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::>()) {
+ 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 {
@@ -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);
diff --git a/apps/pdfcraft/src/updates.rs b/apps/pdfcraft/src/updates.rs
deleted file mode 100644
index 686c8cee84..0000000000
--- a/apps/pdfcraft/src/updates.rs
+++ /dev/null
@@ -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 {
- 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 {
- let found = rustls_native_certs::load_native_certs();
- let certs: Vec> = 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::().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 {
- 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("").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:?}");
- }
-}
diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs
index bc05099a51..b09ace5f40 100644
--- a/apps/pdfcraft/tests/it/main.rs
+++ b/apps/pdfcraft/tests/it/main.rs
@@ -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;
diff --git a/apps/pdfcraft/tests/it/vendor_graph.rs b/apps/pdfcraft/tests/it/vendor_graph.rs
new file mode 100644
index 0000000000..a6a4d4b292
--- /dev/null
+++ b/apps/pdfcraft/tests/it/vendor_graph.rs
@@ -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 {
+ 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");
+ }
+}
diff --git a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
index 6cedf57e33..b95058ba94 100644
--- a/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
index 8701fc20a1..877f9e2a22 100644
--- a/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
@@ -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 (`한`
diff --git a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
index 52e65622f6..89efbd3d81 100644
--- a/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
index 76bb32ccbd..f18dde0f2a 100644
--- a/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
index 30c9d4779c..f5f2aada2f 100644
--- a/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
index 5422af59b5..5c8e892f25 100644
--- a/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
+++ b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
index 96686f7ecf..8087219b21 100644
--- a/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
@@ -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);
diff --git a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
index 691659e5a6..6469354cdc 100644
--- a/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
@@ -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.
diff --git a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
index a1bdd11840..a8009591bf 100644
--- a/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
index aef55877d7..1df11d8a68 100644
--- a/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
+++ b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
@@ -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
diff --git a/apps/pdfcraft/tests/it/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
index fed0cf3e42..a9bde6a1ee 100644
--- a/apps/pdfcraft/tests/it/winit_x11_dpi.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
@@ -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("../..");
diff --git a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
index f244d1387a..61e5c8ebfa 100644
--- a/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
+++ b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
@@ -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:
diff --git a/crates/automation/Cargo.toml b/crates/automation/Cargo.toml
index bed579ab84..a6e43de1f0 100644
--- a/crates/automation/Cargo.toml
+++ b/crates/automation/Cargo.toml
@@ -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]
diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs
index 2fcff5f74e..df798e02f3 100644
--- a/crates/automation/tests/it/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -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> = xml
+ .split("")
+ .skip(1)
+ .map(|p| {
+ let p = p.split_once("").map_or(p, |(inside, _)| inside);
+ p.split("")
+ .skip(1)
+ .filter_map(|r| Some((r.split_once("")?.1.split_once("")?.0.to_string(), r.contains(""))))
+ .collect::>()
+ })
+ .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::(), "Before Chevron after.", "{xml}");
+ assert_eq!(sentence.iter().map(|(_, i)| *i).collect::>(), [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).
diff --git a/crates/engine/src/bookmarks.rs b/crates/engine/src/bookmarks.rs
new file mode 100644
index 0000000000..deaece22e4
--- /dev/null
+++ b/crates/engine/src/bookmarks.rs
@@ -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,
+ pub title: String,
+ /// Shown expanded (a positive `/Count`).
+ pub open: bool,
+ /// The 0-based page it goes to.
+ pub page: Option,
+ /// 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,
+ /// Where the next page starts, or `None` at the end.
+ pub next: Option,
+ /// 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 {
+ 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 = 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
+}
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
index 4ebfecafc7..1f25c23c98 100644
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -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"),
diff --git a/crates/engine/src/comments.rs b/crates/engine/src/comments.rs
new file mode 100644
index 0000000000..eee799ed2f
--- /dev/null
+++ b/crates/engine/src/comments.rs
@@ -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 {
+ let mut list: Vec = 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 {
+ 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
+}
diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs
index 1326ccb5e8..044e40e2e2 100644
--- a/crates/engine/src/compare.rs
+++ b/crates/engine/src/compare.rs
@@ -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,
diff --git a/crates/engine/src/document.rs b/crates/engine/src/document.rs
new file mode 100644
index 0000000000..068facdb29
--- /dev/null
+++ b/crates/engine/src/document.rs
@@ -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,
+ /// The working file: what Save writes.
+ pub bytes: Arc>,
+ /// 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>,
+ pub info: DocInfo,
+ pub renderer: RenderPool,
+ /// The password the document was opened with (needed to read attachments, etc.).
+ pub password: Option,
+ /// `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,
+ /// Interactive form fields of the current state (empty without a form).
+ pub form: Arc>,
+ pub(crate) marker_scan: OnceLock,
+ pub(crate) links: OnceLock>,
+ /// Signature fields, validated against the session's trust store (Signatures panel).
+ pub signatures: Arc>,
+ pub(crate) trust: Arc,
+ pub(crate) sig_cache: Arc,
+ pub(crate) editor: Option,
+ 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,
+ /// The parsed template of a laid-out XFA form, for its scripts.
+ pub(crate) xfa_template: Option>,
+ /// 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,
+ /// 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,
+ /// 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,
+}
+
+/// 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,
+ /// 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 {
+ let Ok(pages) = pdfcraft_annot::page_refs(next) else { return Vec::new() };
+ let redrawn_elsewhere = pdfcraft_forms::need_appearances(next);
+ let mut names: Option> = None;
+ let mut out: Vec = 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::>())
+ .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 {
+ 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 {
+ 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 {
+ 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, 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), 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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, 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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, 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, 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 {
+ 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 {
+ 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> {
+ 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 {
+ 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>) -> Arc> {
+ 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, doc: &pdfcraft_cos::Document) -> Option {
+ 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, Option); 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>, password: Option<&str>) -> Arc> {
+ 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>, password: Option<&str>) -> Option {
+ 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 {
+ 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 = 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