mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
Merge remote-tracking branch 'origin/main' into seq6855
# Conflicts: # parity/acrobat-features.toml
This commit is contained in:
commit
b10c765e4c
44 files changed
+1152
-91
No files matched your search
+1
-1
@@ -275,7 +275,7 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
|
||||
| `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. |
|
||||
| `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/725a39fca940f77149ffbb9c58eef405a16997bc/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 |
|
||||
| `icu_segmenter_data` 2.3.0 | `data/segmenter_dictionary_extended_v1.rs.data` | Southeast Asian word dictionaries for line breaking (Thai, Lao, Khmer, Burmese) | The ICU4X Project Developers; word lists from the ICU project (Unicode, Inc.) | Unicode-3.0 | https://github.com/unicode-org/icu4x (via icu_segmenter_data) | Word boundaries for wrapping Thai text in Edit text and Add text (crates/edit/src/linebreak.rs). Non-visual data; the line segmenter loads only these dictionaries, not the Chinese and Japanese one |
|
||||
|
||||
|
||||
+1
-1
@@ -2235,7 +2235,7 @@ 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."
|
||||
usage = "Small special-icon subset embedded by egui default_fonts; retained in FontDefinitions::default(). Provenance: https://github.com/storytold/egui/blob/725a39fca940f77149ffbb9c58eef405a16997bc/crates/epaint_default_fonts/fonts/egui-icons.txt; complete MIT copyright and permission text in NOTICE."
|
||||
sha256 = "122806555c73df7cef55aa1d945289ae27fbc4dbbd31abbb4204ee0cda829cc8"
|
||||
|
||||
[[asset]]
|
||||
|
||||
Generated
+15
-14
@@ -1696,7 +1696,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"emath",
|
||||
@@ -1728,7 +1728,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "eframe"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1766,7 +1766,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"accesskit",
|
||||
"ahash",
|
||||
@@ -1787,7 +1787,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-wgpu"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -1806,7 +1806,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui-winit"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"accesskit_winit",
|
||||
"arboard",
|
||||
@@ -1833,7 +1833,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
@@ -1847,7 +1847,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"egui",
|
||||
@@ -1860,7 +1860,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "egui_kittest"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -1903,7 +1903,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"serde",
|
||||
@@ -1984,7 +1984,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytemuck",
|
||||
@@ -2011,7 +2011,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "epaint_default_fonts"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[package]]
|
||||
name = "equator"
|
||||
@@ -4565,6 +4565,7 @@ dependencies = [
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"rten",
|
||||
"rten-imageproc",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
@@ -8851,14 +8852,14 @@ dependencies = [
|
||||
[[patch.unused]]
|
||||
name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_system_font_provider"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=725a39fca940f77149ffbb9c58eef405a16997bc#725a39fca940f77149ffbb9c58eef405a16997bc"
|
||||
+15
-15
@@ -150,22 +150,22 @@ lopdf = { path = "vendor/lopdf" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
vello_cpu = { path = "vendor/vello_cpu" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# 725a39fca940f77149ffbb9c58eef405a16997bc, 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" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "725a39fca940f77149ffbb9c58eef405a16997bc" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
+23
@@ -1,5 +1,6 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
|
||||
@@ -39,6 +40,11 @@
|
||||
> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (M1: refresh object-generation reference identity against current upstream; estimates unchanged)
|
||||
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
|
||||
>
|
||||
>
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M2: scans keep their strokes at screen size and are smoothed when zoomed; M7 #2517: a narrowed then widened paragraph keeps the typed spaces the wrap consumed, after save and reopen; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged)
|
||||
@@ -246,6 +252,12 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-11-09 (M10, #5130):** Windows Printer default sends native two-sided bindings only when automatic duplex support is confirmed, preserving manual driver defaults (#5130/#5772); physical output remains unqualified. Both Windows submission paths preserve DLL guards and current spooler fixes. Review follow-up validates both duplex scopes through craft-print 9fe50c94 and corrects CI formatting. Estimates unchanged.
|
||||
|
||||
- **2026-10-31 (M6, PR #4166/#4088 integration):** Integrated main f137a289 (0.16.1), preserving upstream progress entries and automatic-field Print suppression with its console diagnostic. ShowPanel/SaveAs filtering, explicit button/console printing, field effects and document-error provenance remain intact, together with all three engine regressions and the headless automation regression. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
- **2026-10-29 (M6, PR #4166/#4088 integration):** Retained automatic-field Print suppression, ShowPanel/SaveAs filtering, explicit button/console printing and document-error provenance while integrating upstream 0.15. All three engine regressions and the headless regression remain in their current test modules, with matching parity evidence. Dependency manifests retain main's integrated craft-libs revision, which includes #366. Estimates unchanged.
|
||||
- **2026-10-27 (M10, annotation print precision):** Vector printing preserves small placement coefficients for existing form, comment and stamp appearances with large bounding boxes or matrices.
|
||||
Synthetic render and save/reopen regressions cover the change; estimates unchanged.
|
||||
|
||||
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
|
||||
|
||||
- **2026-11-08 (M5, comment exchange):** Comments keep their formatting and appearance through XFDF and FDF. XFDF export and import carry rich text, default style, border width, style and dash pattern, rotation, justification, fringe, line leaders and captions, the caret symbol and redaction overlay text as XFDF 3.0 (ISO 19444-1) names them, and a stamp's appearance as `<appearance>` (the DICT/STREAM/DATA encoding, bounded); copy and paste carries every comment's appearance. FDF export keeps a stamp's /AP with its forms and images, as XFDF does, and FDF import keeps any /AP the file carries (ISO 32000-2 §12.7.8.3.4). The intent goes out as both `intent` and `IT`. An unreadable XFDF appearance is left out, said in the import report and the repair log, and the comment is drawn by PdfCraft; an FDF past the import budget is refused whole. A redaction mark is always drawn by PdfCraft.
|
||||
@@ -345,6 +357,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
The regression checks two distinct appearances, inherited resources, print/reopen pixels and unchanged source data.
|
||||
Estimates unchanged.
|
||||
- **2026-10-21 (M3, window resize and move on Wayland):** With PdfCraft's own title bar on GNOME/Wayland, a second resize by a window edge, or a second move by the empty tab strip, works straight after the first; it waited for a click inside the window, because the compositor keeps the button's release from egui. A unit test covers the release given back; checked by hand in a test build. Estimates unchanged.
|
||||
- **2026-10-22 (M6, #4088):** Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot open the print dialog; ignored requests are reported in the JavaScript console. Explicit button and console printing remain available, including when they trigger automatic calculations. Engine and headless automation regressions cover the boundary. Estimates unchanged.
|
||||
|
||||
- **2026-10-24 (M5, move preview):** A comment being moved is drawn where it goes while it is dragged (a rectangle or oval showed only its blue frame): the comment is rendered alone once as the drag starts and laid over the page, which shows it where it was until the move is applied. Engine `Document::comment_layer`; synthetic engine and UI regressions. Estimates unchanged.
|
||||
- **2026-10-22 (M10, #3132):** Windows printing no longer swaps the letters of embedded TrueType subsets. A subset renumbers its glyphs (ReportLab's Arial space is glyph 11, the installed Arial's is 3), and the Windows print path drew those numbers with the installed font of the same name: `Revisión` printed as `Wj{nxn`, the bold `z` as `Ä`, on Brother queues and the Adobe PDF printer alike. The copy sent to the Windows spooler now names every embedded TrueType/OpenType program `PdfCraftPrint<n>`, in its `name` table and in the PDF; glyphs, encodings and `/ToUnicode` are untouched and a synthetic test renders the page identically before and after. Not yet checked on a physical Windows printer. Estimates unchanged.
|
||||
- **2026-10-24 (M5, border shape):** Text boxes and Fill & Sign's typed text take an oval or a circle for their border (Comment Properties ▸ Border shape, Fill & Sign's toolbar): the text box keeps its place and the shape goes around it, the text centred, a circle snug around the text (its box no wider than the text needs); stored as /RD and /PCBorderShape and drawn in the appearance. comment_add / comment_edit / fill_sign_add `border_shape`. One new label in every catalog. Synthetic annot, UI and automation regressions. Estimates unchanged.
|
||||
@@ -463,6 +477,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-15 (M14, gap 16, #1683):** Split view. View ▸ Split (⌘\\ / Ctrl+\\) and a tab's Split right / Split down show two panes, each with its own tab strip, scroll and zoom; a tab dropped on the right half of the page area splits right, and the divider drags. One document in both panes is one document with one Undo; closing one of its views keeps it open. The pane last clicked is the one menus, keys and panels act on. Nothing about the panes is kept across restarts. `view.split-window` planned → shipped. Multiple windows stay planned. Estimates unchanged.
|
||||
- **2026-10-15 (M12, XFA, #2574):** A `pageArea` with no `<occur>` repeats without limit instead of taking a subform's default of one use, so a later page area with `occur max="1"` is no longer drawn (with a copy of its fields) over every body page. A page area's `occur min` is honoured too: one the body never reached gets the pages it still needs after the body (the once-only closing page of a form, with its button, comes last). Acrobat Pro also puts that page last, and when printing keeps it but leaves it blank (`relevant="-print"` on the page area), which PdfCraft does not do yet; both re-observed on a synthetic fixture, see the 2026-10-19 line. Synthetic regression tests. Estimates unchanged.
|
||||
- **2026-10-19 (M12, XFA, #2574):** The Acrobat observations behind the line above were first made on a public real-world form, which the Privacy rule does not allow; they are replaced by ones made on a synthetic form (three `breakBefore pageArea` sections, then a page area with `occur min="1" max="1"`, with and without `relevant="-print"`). Acrobat Pro shows the closing page last in both and prints it in both, blank only with `relevant="-print"`: confirmed. The synthetic form also shows a difference not fixed yet: Acrobat opens it as 5 pages, with an empty first body page before the first section, where PdfCraft lays out 4. Two more synthetic forms narrow it down: with `breakBefore targetType="pageArea"` on the first section, with or without `startNew`, Acrobat opens an empty body page first; without a break on the first section it lays out 4, as PdfCraft does (so `startNew` is not the cause). Not fixed yet: `layout.rs` skips a break while the page is still empty. Estimates unchanged.
|
||||
- **2026-10-15 (M10, print a selected area, #2051):** The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image… (PNG). Print area… opens Print with Selected area (page N, size) under Pages to Print: the area prints alone, as a page of that size, with Fit, Actual size, Shrink or a custom scale, clipped to the area; Multiple, Booklet, Poster and Label stay for pages. `doc_print` takes `area`. New parity entry `print.selected-area` (shipped). Estimates unchanged.
|
||||
- **2026-10-15 (M7, find and replace in Edit text):** In Edit text mode the floating Find box gains a Replace with row (Edit ▸ Replace…, ⇧⌘H / Ctrl+Shift+H): Replace changes the highlighted match, Replace all every match as one undo step. Each match is rewritten inside its own paragraph through the paragraph rewrite, so fonts, sizes and colours stay and the paragraph rewraps; matches that span paragraphs, sit in OCR text, need another font or lie outside editable paragraphs are left alone and counted. `text_replace` drives the same plan. `edit.find-replace` planned → partial. Estimates unchanged.
|
||||
- **2026-10-15 (M8, #2447):** Removing links or comments (Remove Hidden Information, Sanitize) also drops the tagged-PDF structure tree's object references to them, so the full rewrite no longer keeps a removed link's URL or a comment's text. Regression seen to fail without the fix. Estimates unchanged.
|
||||
- **2026-10-15 (M6, CSV tab-containing headers):** CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Synthetic export/import and incremental/full save regressions cover header tabs, quotes and line breaks, plus ordinary TSV controls. Formula-cell policy is unchanged. Estimates unchanged.
|
||||
@@ -840,6 +855,13 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
|
||||
**2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients.
|
||||
Synthetic rendering and save/reopen regressions cover the change.
|
||||
Estimates unchanged.
|
||||
|
||||
**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.
|
||||
@@ -854,6 +876,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
|
||||
| 2026-10-28 | minor | M10: Recognize text widens each found line by fractions of its word height before reading it, so the first and last letters are no longer clipped at 300 dpi (word errors on synthetic 150–400 dpi pages 3.8 % → 0.3 %); word boxes keep their size. Estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-20 | minor | M9 #79 review, M2, M8: /ActualText that marks no glyph extracts where it paints, as far as it is visible; redaction strips /ActualText around paint it clips; named-layer recognition needs the layer form to draw n1 and n3. Estimates unchanged. |
|
||||
| 2026-10-25 | minor | M10: vector printing resolves annotation rectangle coordinates; synthetic regression evidence, estimates unchanged. |
|
||||
|
||||
@@ -49,7 +49,7 @@ fn assert_resolved(name: &str, version: &str, url: &str, rev: &str) {
|
||||
#[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");
|
||||
assert_resolved("egui-wgpu", "0.37.0", "https://github.com/storytold/egui.git", "725a39fca940f77149ffbb9c58eef405a16997bc");
|
||||
// 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"] {
|
||||
|
||||
@@ -191,3 +191,6 @@ real_name = "Helen Kwok"
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "vangel76"
|
||||
sync_github_name = false
|
||||
|
||||
[people.enriquemelzer-bit]
|
||||
sync_github_name = true
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::draw::draw_appearance;
|
||||
use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots};
|
||||
use super::lookup::{annot_dict, annot_ref, annots, change_annots, fill_sign_shape, page_ref};
|
||||
use super::modify::{free_text_da, n, refuse_undrawable_thai, rich_text, set_part_keys, shape_text_box};
|
||||
use super::types::{
|
||||
FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style,
|
||||
@@ -535,7 +535,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
let index = list.len();
|
||||
list.push(Object::Ref(r));
|
||||
add_popup(doc, r, popup, &mut list)?;
|
||||
set_annots(doc, page, list)?;
|
||||
change_annots(doc, page, list)?;
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,56 @@ thread_local! {
|
||||
pub(crate) static ANNOTS_ENTRIES_COPIED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Apple Markup's private copy of a page's markup: on the page and on each of its annotations.
|
||||
const APPLE_PAGE_MARKUP: [&[u8]; 2] = [b"AAPL:PPK", b"AAPL:PPKHash"];
|
||||
const APPLE_ANNOT_MARKUP: &[u8] = b"AAPL:AKExtras";
|
||||
|
||||
/// Drop Apple Markup's private copy of the page's markup once the annotation list changes. iOS
|
||||
/// draws Markup annotations from that copy, which no longer matches `/Annots`: iOS 26 showed a
|
||||
/// Markup signature in its own place and again over a signature added here. Without it, Apple
|
||||
/// draws every annotation from its appearance, as other viewers do. The drop is recorded in the
|
||||
/// document's repair log (iOS can no longer re-edit those strokes as live ink).
|
||||
fn drop_apple_markup(doc: &mut Document, page: ObjRef, list: &mut [Object]) -> Result<(), AnnotError> {
|
||||
let mut dropped = false;
|
||||
if doc.get(page).as_dict().is_some_and(|d| APPLE_PAGE_MARKUP.iter().any(|k| d.contains(k))) {
|
||||
doc.update_dict(page, |d| {
|
||||
for k in APPLE_PAGE_MARKUP {
|
||||
d.remove(k);
|
||||
}
|
||||
})?;
|
||||
dropped = true;
|
||||
}
|
||||
for entry in list {
|
||||
match entry {
|
||||
Object::Dict(d) if d.contains(APPLE_ANNOT_MARKUP) => {
|
||||
d.remove(APPLE_ANNOT_MARKUP);
|
||||
dropped = true;
|
||||
}
|
||||
Object::Ref(r) if doc.get(*r).as_dict().is_some_and(|d| d.contains(APPLE_ANNOT_MARKUP)) => {
|
||||
doc.update_dict(*r, |d| {
|
||||
d.remove(APPLE_ANNOT_MARKUP);
|
||||
})?;
|
||||
dropped = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
doc.note_repair(
|
||||
"dropped Apple Markup's private copy of a page's annotations after the page changed; iOS draws them from their appearance instead of as editable ink"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots` after an annotation was added, removed or answered: the point at
|
||||
/// which Apple Markup's private copy of the page goes stale (see [`drop_apple_markup`]).
|
||||
pub(crate) fn change_annots(doc: &mut Document, page: ObjRef, mut list: Vec<Object>) -> Result<(), AnnotError> {
|
||||
drop_apple_markup(doc, page, &mut list)?;
|
||||
Ok(set_annots(doc, page, list)?)
|
||||
}
|
||||
|
||||
/// Replace a page's `/Annots`, writing into the shared array object when it is indirect. An empty
|
||||
/// list removes a direct `/Annots`.
|
||||
pub fn set_annots(doc: &mut Document, page: ObjRef, list: Vec<Object>) -> Result<(), pdfcraft_cos::CosError> {
|
||||
|
||||
@@ -4,7 +4,7 @@ use super::create::{
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{is_comment_subtype, is_group_subordinate, line_endings_of, own_popup, popup_info, text_value};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, is_fill_sign, page_ref, set_annots};
|
||||
use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, change_annots, is_fill_sign, page_ref, set_annots};
|
||||
use super::text_style::{self, TextStyle};
|
||||
use super::types::{
|
||||
BorderShape, FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb,
|
||||
@@ -117,7 +117,10 @@ pub(crate) fn touch(d: &mut Dict, meta: &Meta) {
|
||||
/// go together from either ([`own_deletion`]).
|
||||
pub fn delete_annotation(doc: &mut Document, page: usize, index: usize) -> Result<(), AnnotError> {
|
||||
let index = deleted_index(doc, page, index)?;
|
||||
lift_annotation(doc, page, index)
|
||||
lift_annotation(doc, page, index)?;
|
||||
// Not in lift_annotation: a drag preview lifts on a throwaway copy and must not log a repair.
|
||||
let p = page_ref(doc, page)?;
|
||||
change_annots(doc, p, annots(doc, p))
|
||||
}
|
||||
|
||||
/// Whether [`delete_annotation`] deletes the annotation at `(page, index)`, or why not, without
|
||||
@@ -429,7 +432,7 @@ fn reply(
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
change_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
@@ -549,7 +552,7 @@ pub fn set_marked(doc: &mut Document, page: usize, index: usize, marked: bool, a
|
||||
let mut list = annots(doc, p);
|
||||
list.push(Object::Ref(r));
|
||||
let i = list.len() - 1;
|
||||
set_annots(doc, p, list)?;
|
||||
change_annots(doc, p, list)?;
|
||||
Ok(i)
|
||||
}
|
||||
|
||||
|
||||
@@ -5529,3 +5529,93 @@ fn summaries_list_only_usable_quads() {
|
||||
let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap();
|
||||
assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]);
|
||||
}
|
||||
|
||||
/// A page as iOS Markup saves a signature: its private copy on the page and on the stamp, which
|
||||
/// also carries a normal appearance. A plain note follows the stamp, at index 1.
|
||||
fn apple_markup_page() -> Document {
|
||||
document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 7 0 R] /AAPL:PPK 5 0 R /AAPL:PPKHash 5 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AP << /N 6 0 R >> /AAPL:AKExtras << /PPKType (draw) >> >>",
|
||||
b"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
b"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 22 >>
|
||||
stream
|
||||
0 0 1 RG 1 1 m 9 9 l S
|
||||
endstream",
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [400 400 420 420] /Contents (note) >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// Apple's copy is gone from the page and from every annotation left on it, and the drop is
|
||||
/// in the repair log (fidelity: never silent).
|
||||
fn assert_apple_copy_dropped(doc: &Document) {
|
||||
assert!(doc.repair_log().iter().any(|l| l.contains("Apple Markup")), "{:?}", doc.repair_log());
|
||||
let doc = reopen(doc);
|
||||
let page = doc.get(page_refs(&doc).unwrap()[0]);
|
||||
let page = page.as_dict().unwrap();
|
||||
assert!(!page.contains(b"AAPL:PPK") && !page.contains(b"AAPL:PPKHash"));
|
||||
let all = list(&doc, 0);
|
||||
assert_eq!(all[0].name(b"Subtype"), Some(&b"Stamp"[..]));
|
||||
assert!(all.iter().all(|d| !d.contains(b"AAPL:AKExtras")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_from_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
delete_annotation(&mut doc, 0, 1).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replying_on_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
add_reply(&mut doc, 0, 1, "Agreed", "Bob", &meta("r")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marking_on_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
set_marked(&mut doc, 0, 1, true, "Bob", &meta("m")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adding_to_an_apple_markup_page_drops_apples_stale_copy() {
|
||||
let mut doc = apple_markup_page();
|
||||
add_annotation(&mut doc, &new(0, Shape::Note { at: [300.0, 300.0], icon: NoteIcon::Comment }), &meta("n")).unwrap();
|
||||
assert_apple_copy_dropped(&doc);
|
||||
// The Markup stamp itself stays, with its flags and its appearance, byte for byte.
|
||||
let doc = reopen(&doc);
|
||||
let all = list(&doc, 0);
|
||||
assert_eq!(all[0].get(b"F").and_then(Object::as_int), Some(644));
|
||||
assert_eq!(ap_content(&doc, &all[0]), "0 0 1 RG 1 1 m 9 9 l S");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_edit_on_an_apple_markup_page_drops_nothing() {
|
||||
// An inline, locked Markup stamp: restyling it is refused, so Apple's copy must stay put
|
||||
// (promoting the inline dictionary to an object is not a change to the markup).
|
||||
let mut doc = document_of(&[
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>",
|
||||
b"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Stamp /Rect [100 100 200 150] /F 644 /AAPL:AKExtras << /PPKType (draw) >> >>] /AAPL:PPK 4 0 R /AAPL:PPKHash 4 0 R >>",
|
||||
b"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream",
|
||||
]);
|
||||
assert_eq!(
|
||||
set_style(&mut doc, 0, 0, StyleChange { color: Some([1.0, 0.0, 0.0]), opacity: None, width: None, endings: None }, &meta("")),
|
||||
Err(AnnotError::Invalid("the comment is locked".into()))
|
||||
);
|
||||
let page = doc.get(page_refs(&doc).unwrap()[0]);
|
||||
let page = page.as_dict().unwrap();
|
||||
assert!(page.contains(b"AAPL:PPK") && page.contains(b"AAPL:PPKHash"));
|
||||
assert!(list(&doc, 0)[0].contains(b"AAPL:AKExtras"));
|
||||
assert!(doc.repair_log().is_empty(), "{:?}", doc.repair_log());
|
||||
}
|
||||
@@ -21,7 +21,7 @@ fn to_view(r: [f64; 4], ph: f64) -> [f64; 4] {
|
||||
[r[0], ph - r[3], r[2], ph - r[1]]
|
||||
}
|
||||
|
||||
fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
|
||||
pub(crate) fn rect_arg(a: &Args, key: &str) -> Result<Option<[f64; 4]>> {
|
||||
a.get(key)
|
||||
.map(|v| {
|
||||
let r: Vec<f64> = v.as_array().map(|x| x.iter().filter_map(Value::as_f64).collect()).unwrap_or_default();
|
||||
|
||||
@@ -269,8 +269,18 @@ impl Automation {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new() };
|
||||
let sizes: Vec<(f64, f64)> = self.doc(a)?.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect();
|
||||
// Print a selected area: points from the top-left of the displayed page, as other tools
|
||||
// take rectangles; the print crate takes it y up.
|
||||
let area = match crate::content::rect_arg(a, "area")? {
|
||||
None => None,
|
||||
Some(r) => {
|
||||
let [page] = pages.as_slice() else { return Err(bad("area prints one page: give pages as a single page")) };
|
||||
let Some(&(_, h)) = sizes.get(*page) else { return Err(bad("area's page is not in the document")) };
|
||||
Some([r[0].min(r[2]), h - r[1].max(r[3]), r[0].max(r[2]), h - r[1].min(r[3])])
|
||||
}
|
||||
};
|
||||
let settings = print::Settings { pages, paper, orientation, layout, content, raster, crops: Vec::new(), area };
|
||||
let laid_out = print::layout(&sizes, &settings).map_err(|e| bad(e.to_string()))?;
|
||||
let sheets = laid_out.len();
|
||||
let bytes = self.session.print_pdf(id, &settings).map_err(failed)?;
|
||||
|
||||
@@ -38,6 +38,29 @@ fn search_document(a: &mut Automation, pages: usize) -> DocId {
|
||||
a.session.open("synthetic.pdf", None, Arc::new(numbered_pages(pages)), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_fill_cannot_print_but_js_run_can() {
|
||||
let mut a = Automation::new();
|
||||
let doc = search_document(&mut a, 1);
|
||||
json(&mut a, "form_add_field", json!({ "doc": doc.0, "page": 1, "type": "text", "rect": [20, 20, 180, 42], "name": "Qty" }));
|
||||
json(
|
||||
&mut a,
|
||||
"form_set_script",
|
||||
json!({ "doc": doc.0, "field": "Qty", "event": "validate", "script": "this.print(); console.println('validated');" }),
|
||||
);
|
||||
json(&mut a, "form_fill", json!({ "doc": doc.0, "values": { "Qty": "4" } }));
|
||||
let output = a.session.take_js_output(doc);
|
||||
assert!(output.requests.is_empty(), "{output:?}");
|
||||
assert!(output.console.contains(&"validated".to_string()), "{output:?}");
|
||||
assert!(output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{output:?}");
|
||||
assert!(output.errors.is_empty(), "{output:?}");
|
||||
let fields = json(&mut a, "form_fields", json!({ "doc": doc.0 }));
|
||||
let qty = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "Qty").unwrap();
|
||||
assert_eq!(qty["value"], "4");
|
||||
let explicit = json(&mut a, "js_run", json!({ "doc": doc.0, "script": "this.print();" }));
|
||||
assert_eq!(explicit["requests"], json!([{ "print": true }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_cache_stays_bounded_reextracts_and_follows_edits_and_close() {
|
||||
let mut a = Automation::new();
|
||||
|
||||
@@ -787,6 +787,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"collate": { "type": "boolean" },
|
||||
"duplex": { "type": "string", "enum": ["off", "long-edge", "short-edge", "printer"], "description": "Two-sided printing (default off); printer uses the printer's current default: Windows sends its duplex setting when supported, otherwise preserves the driver default, including manual two-sided preferences; CUPS leaves the setting unspecified." },
|
||||
"grayscale": { "type": "boolean" },
|
||||
"area": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Print a selected area: [x0, y0, x1, y1] in points from the top-left of the displayed page, of the one page in pages, sized by layout fit/actual/shrink/custom." },
|
||||
"as_image": { "type": "boolean", "description": "Print as image (Acrobat's Advanced Print Setup): every page rendered to a picture at dpi before it is placed on the paper, for printers that skip text, shading or transparency; the print is no longer searchable text, and with grayscale the pictures are grey, so the printer isn't asked for grayscale (a driver without that setting still prints them)." },
|
||||
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution for as_image (default 300)." },
|
||||
"options": { "type": "object", "additionalProperties": { "type": "string" }, "description": "Printer driver options from printer_options: key → choice value." },
|
||||
|
||||
@@ -12273,6 +12273,53 @@ fn a_root_less_write_output_is_atomic_too() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `doc_print` prints a selected area (Acrobat's "print selected area"): one page's rectangle,
|
||||
/// in points from the top-left, at actual size on its own sheet; several pages, an empty area or
|
||||
/// a layout that isn't a size choice are refused.
|
||||
#[test]
|
||||
fn doc_print_a_selected_area() {
|
||||
let dir = workdir("print-area");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"doc_print",
|
||||
json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "actual", "paper": "Letter", "path": "area.pdf" }),
|
||||
);
|
||||
assert_eq!((r["sheets"].as_u64(), r["pages"].as_u64()), (Some(1), Some(1)));
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).unwrap())).unwrap();
|
||||
let pages = pdfcraft_model::pages(&out);
|
||||
assert_eq!(pages.len(), 1);
|
||||
// The sheet is the paper (Letter, turned to suit the wide area); the page inside it is cut to the area: a clip to the
|
||||
// area's rectangle, then the source page drawn through it, and nothing else of the page.
|
||||
let media: Vec<f64> =
|
||||
out.resolve(pages[0].dict.get(b"MediaBox").unwrap()).as_array().unwrap().iter().map(|v| out.resolve(v).as_f64().unwrap()).collect();
|
||||
assert_eq!(media, [0.0, 0.0, 792.0, 612.0], "a landscape area takes a landscape Letter sheet");
|
||||
let contents = out.resolve(pages[0].dict.get(b"Contents").unwrap());
|
||||
let pdfcraft_cos::Object::Stream(stream) = &*contents else { panic!("sheet content") };
|
||||
let content = String::from_utf8(stream.decoded().unwrap()).unwrap();
|
||||
let tokens: Vec<&str> = content.split_whitespace().collect();
|
||||
let at = tokens.iter().position(|t| *t == "re").expect("the sheet clips to a rectangle");
|
||||
let rect: Vec<f64> = tokens[at - 4..at].iter().map(|t| t.parse().unwrap()).collect();
|
||||
assert_eq!(&tokens[at + 1..at + 3], ["W", "n"], "the rectangle is the clip: {content}");
|
||||
assert_eq!(rect, [36.0, 164.0, 164.0, 100.0], "the clip is the area (36..200 wide, 36..136 from the top, cut to the 200 x 300 page): {content}");
|
||||
assert_eq!(tokens.iter().filter(|t| **t == "re").count(), 1, "one clip, nothing drawn outside it: {content}");
|
||||
assert!(tokens.contains(&"Do"), "the page is drawn inside the clip: {content}");
|
||||
for (args, why) in [
|
||||
(json!({ "doc": doc, "pages": "1-2", "area": [36, 36, 236, 136], "path": "x.pdf" }), "one page"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 36.5, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 36, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [5000, 36, 5200, 136], "path": "x.pdf" }), "empty"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 236], "path": "x.pdf" }), "4 numbers"),
|
||||
(json!({ "doc": doc, "pages": "1", "area": [36, 36, 236, 136], "layout": "multiple", "per_sheet": 2, "path": "x.pdf" }), "Fit"),
|
||||
] {
|
||||
match a.call("doc_print", &args) {
|
||||
Err(ToolError::InvalidArgs(m)) => assert!(m.contains(why), "{why}: {m}"),
|
||||
other => panic!("{why}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Printer marks through the tools: added around the trim box (the page widened to make room),
|
||||
/// removable by kind.
|
||||
#[test]
|
||||
|
||||
@@ -82,6 +82,11 @@ family (`sign`, `sign_with_timestamp`, `timestamp_document`, `embed_ltv`).
|
||||
| `js` | The bridge to `pdfcraft-js`. `forms` never depends on `js` directly; the engine injects it, so a build without JavaScript is possible |
|
||||
| `xfa`, `ocr`, `compare`, `export` | Façades over the L3/L4 crates of the same names |
|
||||
|
||||
AcroForm Keystroke, Validate, Calculate and Format scripts run automatically. Their Print,
|
||||
ShowPanel and SaveAs requests are ignored with a JavaScript console diagnostic; field values,
|
||||
alerts and other requests are retained. Explicit button and console scripts can still request
|
||||
printing (#4088).
|
||||
|
||||
## Not done here
|
||||
|
||||
- **Rendering is still borrowed.** `pdfcraft-render` wraps `hayro` and inspects through `lopdf`
|
||||
|
||||
@@ -81,18 +81,21 @@ impl JsOutput {
|
||||
}
|
||||
|
||||
/// Adds what a field's own script produced: its alerts, console lines, errors and requests,
|
||||
/// except a request to show a side panel, open Save As, or open or save an attached file,
|
||||
/// which is noted on the console and dropped.
|
||||
/// except a request to print, show a side panel, open Save As, or open or save an attached
|
||||
/// file, which is noted on the console and dropped.
|
||||
fn absorb(&mut self, o: &Outcome) {
|
||||
self.alert(o.alerts.iter().cloned());
|
||||
self.say(o.console.iter().cloned());
|
||||
// A field's own scripts (keystroke, format, validate, calculate) run unasked: only a
|
||||
// button's may show or hide a side panel, open Save As, or open or save an attached file.
|
||||
// button's or the console's may print, show or hide a side panel, open Save As, or open
|
||||
// or save an attached file (#4088).
|
||||
// The warnings go through `say` like any other console line, so they count against the
|
||||
// line and byte caps instead of growing the console past them.
|
||||
let mut notes = Vec::new();
|
||||
for r in &o.requests {
|
||||
match r {
|
||||
Request::Print => notes
|
||||
.push("Print requested by an automatic field script was ignored; use a button or the JavaScript console to print".to_string()),
|
||||
Request::ShowPanel(_) => notes.push("A field script asked to show a side panel; only a button may show a side panel".to_string()),
|
||||
Request::SaveAs => notes.push("A field script asked to save the document as a new file; only a button may open Save As".to_string()),
|
||||
Request::OpenAttachment { name, .. } => {
|
||||
|
||||
@@ -2321,4 +2321,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The first and last letters of a line are read: ocrs' own 3-pixel margin clipped them at
|
||||
/// 300 dpi ("The" read as "he", "and" as "an", full stops lost, about 4 % of the words).
|
||||
/// Needs the OCR models (`cargo xtask models`).
|
||||
#[test]
|
||||
fn the_first_and_last_letters_of_a_line_are_read() {
|
||||
// The default engine is ocrs: Tesseract alone makes `available()` true but cannot run it.
|
||||
if Models::find().is_none() {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
}
|
||||
let paragraph = "The annual report summarizes revenue, operating costs and the outlook for the coming year. \
|
||||
Sales in the northern region grew by twelve percent, while the southern offices remained flat. \
|
||||
Management expects stable margins despite higher prices for energy and transport. ";
|
||||
let text = paragraph.repeat(6);
|
||||
let s = Session::new();
|
||||
let pdf = s.create_from_text("t", &text).unwrap();
|
||||
let mut viewer = Session::new();
|
||||
let id = viewer.open("text.pdf", None, pdf, None).unwrap();
|
||||
assert_eq!(viewer.get(id).unwrap().info.pages.len(), 1, "the text fits one page");
|
||||
let png = export::Exporter::new(viewer.get(id).unwrap()).png(0, 300.0).unwrap();
|
||||
let scan = s.create_from_images(&[("1.png".into(), png)]).unwrap();
|
||||
let mut reader = Session::new();
|
||||
let id = reader.open("scan.pdf", None, scan, None).unwrap();
|
||||
let read = reader.recognize_text(id, &[], OcrSettings::default()).unwrap()[0].text();
|
||||
let (got, want): (Vec<&str>, Vec<&str>) = (read.split_whitespace().collect(), text.split_whitespace().collect());
|
||||
// Word-level edit distance.
|
||||
let mut row: Vec<usize> = (0..=want.len()).collect();
|
||||
for g in &got {
|
||||
let mut prev = row[0];
|
||||
row[0] += 1;
|
||||
for (j, w) in want.iter().enumerate() {
|
||||
let next = (prev + usize::from(g != w)).min(row[j] + 1).min(row[j + 1] + 1);
|
||||
prev = row[j + 1];
|
||||
row[j + 1] = next;
|
||||
}
|
||||
}
|
||||
let errors = row[want.len()];
|
||||
assert!(errors * 100 <= want.len(), "{errors} word errors in {} words:\n{read}", want.len());
|
||||
}
|
||||
}
|
||||
@@ -6753,6 +6753,90 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_field_scripts_cannot_print() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
let cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let target = fields.iter().find(|f| f.name == "qty").unwrap();
|
||||
for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] {
|
||||
let mut runner = js::JsRunner::new(&cos, "order.pdf", false);
|
||||
let result = runner.run(
|
||||
event,
|
||||
"console.println('before'); this.print(); app.execMenuItem('SaveAs'); app.execMenuItem('ShowHideFileAttachment'); \
|
||||
this.pageNum = 0; this.print(); app.alert('ok'); event.value = '4';",
|
||||
target,
|
||||
"2",
|
||||
&fields,
|
||||
);
|
||||
assert!(result.rc, "{event:?}: {result:?}");
|
||||
assert_eq!(result.value, "4");
|
||||
assert_eq!(runner.output.requests, [js::Request::GoToPage(0)], "{event:?}: {:?}", runner.output);
|
||||
assert_eq!(runner.output.alerts, [js::Message::plain("ok")]);
|
||||
assert!(runner.output.console.contains(&"before".to_string()));
|
||||
assert!(runner.output.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{:?}", runner.output);
|
||||
assert_eq!(runner.output.console.iter().filter(|line| line.contains("only a button may")).count(), 2, "{:?}", runner.output);
|
||||
assert!(runner.output.errors.is_empty());
|
||||
|
||||
runner.output = js::JsOutput::default();
|
||||
runner.run(event, "this.print(); throw new Error('broken script');", target, "2", &fields);
|
||||
assert!(runner.output.requests.is_empty(), "{event:?}: {:?}", runner.output);
|
||||
assert!(runner.output.errors.iter().any(|e| e.contains("broken script")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automatic_print_suppression_preserves_document_errors() {
|
||||
use pdfcraft_forms::{FieldEvent, Scripts};
|
||||
let mut cos = pdfcraft_cos::Document::open(Arc::new(scripted_form())).unwrap();
|
||||
pdfcraft_forms::set_document_script(&mut cos, "failure", Some("throw new Error('document failure');")).unwrap();
|
||||
let fields = pdfcraft_forms::fields(&cos);
|
||||
let target = fields.iter().find(|f| f.name == "qty").unwrap();
|
||||
let mut runner = js::JsRunner::new(&cos, "order.pdf", false);
|
||||
for event in [FieldEvent::Keystroke, FieldEvent::Validate, FieldEvent::Calculate, FieldEvent::Format] {
|
||||
runner.run(event, "this.print(); throw new Error('field failure');", target, "2", &fields);
|
||||
}
|
||||
let out = runner.output;
|
||||
assert!(out.requests.is_empty(), "{out:?}");
|
||||
assert_eq!(out.console.iter().filter(|line| line.contains("Print") && line.contains("ignored")).count(), 4, "{out:?}");
|
||||
assert_eq!(out.doc_errors.len(), 1, "the document script's error is reported once: {out:?}");
|
||||
assert!(out.doc_errors[0].contains("document failure"), "{out:?}");
|
||||
assert_eq!(out.errors.iter().filter(|e| e.contains("document failure")).count(), 1, "{out:?}");
|
||||
assert_eq!(out.errors.iter().filter(|e| e.contains("field failure")).count(), 4, "each field script's error is retained: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_calculations_cannot_print_but_buttons_and_console_can() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("order.pdf", None, Arc::new(scripted_form()), None).unwrap();
|
||||
s.apply(
|
||||
id,
|
||||
Edit::SetFieldScript {
|
||||
name: "total".into(),
|
||||
event: "calculate".into(),
|
||||
script: Some("this.print(); event.value = times(getField('price').value, getField('qty').value);".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("4".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.requests.is_empty(), "{out:?}");
|
||||
assert!(out.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{out:?}");
|
||||
assert!(out.errors.is_empty(), "{out:?}");
|
||||
assert!(page_texts(&s, id)[0].contains("USD 10.00"));
|
||||
|
||||
for (target, qty, formatted) in [(Some("go"), 6, "USD 15.00"), (None, 8, "USD 20.00")] {
|
||||
let out = s.run_javascript(id, &format!("getField('qty').value = {qty}; this.print();"), target).unwrap();
|
||||
assert_eq!(out.requests, [js::Request::Print]);
|
||||
let automatic = s.take_js_output(id);
|
||||
assert!(automatic.requests.is_empty(), "{automatic:?}");
|
||||
assert!(automatic.console.iter().any(|line| line.contains("Print") && line.contains("ignored")), "{automatic:?}");
|
||||
assert!(automatic.errors.is_empty(), "{automatic:?}");
|
||||
assert!(page_texts(&s, id)[0].contains(formatted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_history_does_not_replay_field_script_side_effects() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
|
||||
@@ -14,6 +14,7 @@ workspace = true
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }
|
||||
rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }
|
||||
rten-imageproc = "0.26.0"
|
||||
serde_json.workspace = true
|
||||
sha2 = "0.11"
|
||||
thiserror.workspace = true
|
||||
|
||||
+80
-4
@@ -4,6 +4,8 @@
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rten_imageproc::{RectF, RotatedRect};
|
||||
|
||||
use super::{Line, MAX_PIXELS, MAX_SIDE, OcrError, OcrImage, RecognizeOptions, Recognizer, Word};
|
||||
|
||||
/// The validated byte length of a `width` × `height` RGBA raster, with the same caps
|
||||
@@ -85,6 +87,47 @@ impl Models {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far each found line is widened before its image is cut out for recognition, as fractions
|
||||
/// of the line's median word height: above and below every word, and before its first and after
|
||||
/// its last word. ocrs grows each word box by a fixed 3 pixels, a sliver of a letter on a page
|
||||
/// scanned at 300 dpi or more: the line images clipped the first and last letters ("The" read as
|
||||
/// "he", "and" as "an", closing full stops lost), and the more so the higher the resolution.
|
||||
/// Chosen on synthetic pages at 150 to 400 dpi, clean and noisy, and on MDPBench's pages. More
|
||||
/// padding above and below costs small, noisy text (the recogniser scales each line image to a
|
||||
/// fixed height, so the margin leaves less height for the letters): at 0.15 the 8 pt "Telefon"
|
||||
/// label of a noisy form scanned at 150 dpi read "Teleton".
|
||||
const LINE_PAD_ACROSS: f32 = 0.12;
|
||||
const LINE_PAD_ALONG: f32 = 0.1;
|
||||
|
||||
/// Widen `line` (its word boxes, in reading order) as [`LINE_PAD_ACROSS`] and
|
||||
/// [`LINE_PAD_ALONG`] say, after the lines were found, so finding them is unchanged. Returns the
|
||||
/// box the words covered before, which the recognised words are clipped to: the text layer sizes
|
||||
/// its text from them.
|
||||
fn pad_line(line: &mut [RotatedRect]) -> Option<RectF> {
|
||||
let before = rten_imageproc::bounding_rect(line.iter())?;
|
||||
// A box's height runs along its up axis and its width along the line, whichever way the
|
||||
// line lies on the page.
|
||||
let mut heights: Vec<f32> = line.iter().map(RotatedRect::height).filter(|h| h.is_finite() && *h > 0.0).collect();
|
||||
heights.sort_by(f32::total_cmp);
|
||||
let height = heights.get(heights.len() / 2).copied().unwrap_or(0.0);
|
||||
let last = line.len().saturating_sub(1);
|
||||
for (i, r) in line.iter_mut().enumerate() {
|
||||
let along = if i == 0 || i == last { 2.0 * LINE_PAD_ALONG * height } else { 0.0 };
|
||||
let across = 2.0 * LINE_PAD_ACROSS * height;
|
||||
r.resize(r.width() + along, r.height() + across);
|
||||
}
|
||||
Some(before)
|
||||
}
|
||||
|
||||
/// `rect` ([left, top, right, bottom]) clipped to `to`; unchanged if that leaves nothing: a word
|
||||
/// wholly in the padding is a letter the line found had cut off, and keeps its own box.
|
||||
fn clip(rect: [f32; 4], to: Option<RectF>) -> [f32; 4] {
|
||||
let Some(to) = to else { return rect };
|
||||
let [l, t, r, b] = rect;
|
||||
let clipped = [l.max(to.left()), t.max(to.top()), r.min(to.right()), b.min(to.bottom())];
|
||||
if clipped[0] < clipped[2] && clipped[1] < clipped[3] { clipped } else { rect }
|
||||
}
|
||||
|
||||
/// A loaded recogniser. Loading takes a moment; keep one and reuse it.
|
||||
pub struct Ocr {
|
||||
engine: ocrs::OcrEngine,
|
||||
@@ -140,18 +183,20 @@ impl Ocr {
|
||||
let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?;
|
||||
let input = self.engine.prepare_input(source).map_err(|e| err(&e))?;
|
||||
let found = self.engine.detect_words(&input).map_err(|e| err(&e))?;
|
||||
let lines = self.engine.find_text_lines(&input, &found);
|
||||
let mut lines = self.engine.find_text_lines(&input, &found);
|
||||
let before: Vec<Option<RectF>> = lines.iter_mut().map(|l| pad_line(l)).collect();
|
||||
let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?;
|
||||
use ocrs::TextItem;
|
||||
Ok(read
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|line| Line {
|
||||
.zip(before)
|
||||
.filter_map(|(line, before)| line.map(|l| (l, before)))
|
||||
.map(|(line, before)| Line {
|
||||
words: line
|
||||
.words()
|
||||
.map(|w| {
|
||||
let r = w.bounding_rect();
|
||||
Word::new(w.to_string(), [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], "ocrs")
|
||||
Word::new(w.to_string(), clip([r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], before), "ocrs")
|
||||
})
|
||||
.filter(|w| !w.text.trim().is_empty())
|
||||
.collect(),
|
||||
@@ -207,8 +252,39 @@ impl Recognizer for OcrsRecognizer {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use rten_imageproc::{PointF, Vec2};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A found line is widened by fractions of its word height: every word above and below, the
|
||||
/// first and the last word along the line; the box returned is the line's before.
|
||||
#[test]
|
||||
fn a_line_is_padded_by_its_word_height() {
|
||||
let word = |l: f32, r: f32| RotatedRect::from_rect(RectF::from_tlbr(100.0, l, 140.0, r));
|
||||
let mut line = vec![word(10.0, 90.0), word(100.0, 200.0), word(210.0, 260.0)];
|
||||
let before = pad_line(&mut line).unwrap();
|
||||
assert_eq!([before.left(), before.top(), before.right(), before.bottom()], [10.0, 100.0, 260.0, 140.0]);
|
||||
let (across, along) = (2.0 * 0.12 * 40.0, 2.0 * 0.1 * 40.0);
|
||||
assert!(line.iter().all(|r| (r.height() - (40.0 + across)).abs() < 1e-3));
|
||||
assert!((line[0].width() - (80.0 + along)).abs() < 1e-3 && (line[2].width() - (50.0 + along)).abs() < 1e-3);
|
||||
assert!((line[1].width() - 100.0).abs() < 1e-3, "a word inside the line keeps its width");
|
||||
// A box on its side (up axis horizontal) keeps its height across the line and its width
|
||||
// along it.
|
||||
let mut side = vec![RotatedRect::new(PointF::from_yx(0.0, 0.0), Vec2::from_yx(0.0, 1.0), 40.0, 80.0)];
|
||||
pad_line(&mut side).unwrap();
|
||||
assert!((side[0].height() - (80.0 + 2.0 * 0.12 * 80.0)).abs() < 1e-3 && (side[0].width() - (40.0 + 2.0 * 0.1 * 80.0)).abs() < 1e-3);
|
||||
assert!(pad_line(&mut []).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognised_words_are_clipped_to_the_line_found() {
|
||||
let line = Some(RectF::from_tlbr(100.0, 10.0, 140.0, 260.0));
|
||||
assert_eq!(clip([4.0, 94.0, 60.0, 146.0], line), [10.0, 100.0, 60.0, 140.0]);
|
||||
// A word wholly in the padding keeps its own box rather than none.
|
||||
assert_eq!(clip([262.0, 94.0, 270.0, 146.0], line), [262.0, 94.0, 270.0, 146.0]);
|
||||
assert_eq!(clip([1.0, 2.0, 3.0, 4.0], None), [1.0, 2.0, 3.0, 4.0]);
|
||||
}
|
||||
|
||||
/// Raw dimensions are untrusted (they come from callers, ultimately from documents): zero
|
||||
/// sides, sides or pixel totals over the caps, and products that would overflow the naive
|
||||
/// `width * height * 4` are errors, never panics, before anything is touched.
|
||||
|
||||
@@ -54,6 +54,8 @@ Each source page becomes a Form XObject (its content wrapped in q/Q, plus the pr
|
||||
annotations); sheets place them with a clip. The result is a fresh, unencrypted,
|
||||
garbage-collected file (callers check the print permission).
|
||||
Annotation appearances receive unused XObject names, preserving existing page resources even when they use the same naming prefix.
|
||||
Appearance placement keeps the full PDF number precision, including small coefficients needed by large appearance bounding boxes or matrices.
|
||||
Missing or unusable appearance streams still cannot be printed.
|
||||
|
||||
The GUI Print dialog remembers its last selected paper size across application sessions,
|
||||
including when Print is cancelled. The preference stores a canonical name such as `A4`;
|
||||
|
||||
@@ -3,7 +3,7 @@ use super::settings::{Content, Settings};
|
||||
use super::sheets::{Sheet, layout};
|
||||
use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
|
||||
// Model-derived transforms and clipping bounds need their full PDF decimal precision.
|
||||
// Transforms and clipping bounds need their full PDF decimal precision.
|
||||
fn geometry_number(value: f64) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Real(value), &mut bytes);
|
||||
@@ -236,7 +236,18 @@ pub(crate) fn page_form(
|
||||
}
|
||||
};
|
||||
let [a0, b0, c0, d0, e0, f0] = m.0;
|
||||
body.extend(format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(a0), n(b0), n(c0), n(d0), n(e0), n(f0)).bytes());
|
||||
body.extend(
|
||||
format!(
|
||||
"q {} {} {} {} {} {} cm /{name} Do Q\n",
|
||||
geometry_number(a0),
|
||||
geometry_number(b0),
|
||||
geometry_number(c0),
|
||||
geometry_number(d0),
|
||||
geometry_number(e0),
|
||||
geometry_number(f0)
|
||||
)
|
||||
.bytes(),
|
||||
);
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
}
|
||||
// With no appearance added the form keeps the page's own Resources, shared by reference,
|
||||
|
||||
@@ -164,6 +164,10 @@ pub struct Settings {
|
||||
/// resolution before it is placed on the paper. The engine does the rendering; `impose` is
|
||||
/// then given the image-only document.
|
||||
pub raster: Option<Raster>,
|
||||
/// Print a selected area (Acrobat's "print selected area"): only this rectangle of the one
|
||||
/// page in `pages`, in that page's display space (points, origin at the bottom-left of the
|
||||
/// page as shown, y up), sized like a page of its own by a [`Layout::Size`] choice.
|
||||
pub area: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
/// How pages are rendered when printing as image.
|
||||
@@ -205,6 +209,7 @@ impl Default for Settings {
|
||||
content: Content::default(),
|
||||
raster: None,
|
||||
crops: Vec::new(),
|
||||
area: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+48
-15
@@ -191,6 +191,52 @@ fn fit_scale((dw, dh): (f64, f64), (cw, ch): (f64, f64)) -> f64 {
|
||||
if dw <= 0.0 || dh <= 0.0 { 1.0 } else { (cw / dw).min(ch / dh) }
|
||||
}
|
||||
|
||||
/// A selected area: one page, within it, printed as if it were a page of that size.
|
||||
fn selected_area(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings) -> Result<Option<[f64; 4]>, PrintError> {
|
||||
let Some(a) = settings.area else { return Ok(None) };
|
||||
if !matches!(settings.layout, Layout::Size(_)) {
|
||||
return Err(PrintError::Invalid("an area prints with Fit, Actual size, Shrink or a custom scale".into()));
|
||||
}
|
||||
let refused = || PrintError::Invalid("the selected area is empty or not on one page".into());
|
||||
let [p] = pages else { return Err(refused()) };
|
||||
let d = sizes.get(*p).copied().ok_or_else(refused)?;
|
||||
// `min` and `max` pass over NaN, which would make an all-NaN area the whole page.
|
||||
if a.iter().any(|v| !v.is_finite()) {
|
||||
return Err(refused());
|
||||
}
|
||||
// Cut to the page; what is left must be a point wide.
|
||||
let [x0, y0, x1, y1] = [a[0].min(a[2]).max(0.0), a[1].min(a[3]).max(0.0), a[0].max(a[2]).min(d.0), a[1].max(a[3]).min(d.1)];
|
||||
if !(x1 - x0 >= 1.0 && y1 - y0 >= 1.0) {
|
||||
return Err(refused());
|
||||
}
|
||||
Ok(Some([x0, y0, x1, y1]))
|
||||
}
|
||||
|
||||
/// One sheet per page (or per selected area), scaled by `mode`.
|
||||
fn layout_size(sizes: &[(f64, f64)], pages: &[usize], settings: &Settings, mode: SizeMode, area: Option<[f64; 4]>) -> Result<Vec<Sheet>, PrintError> {
|
||||
let mut sheets = Vec::new();
|
||||
for &p in pages {
|
||||
let d = area.map_or(sizes[p], |a| (a[2] - a[0], a[3] - a[1]));
|
||||
let size = oriented(settings.paper, settings.orientation, d.0 > d.1);
|
||||
let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN));
|
||||
let s = match mode {
|
||||
SizeMode::Fit => fit,
|
||||
SizeMode::Actual => 1.0,
|
||||
SizeMode::Shrink => fit.min(1.0),
|
||||
SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0,
|
||||
SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())),
|
||||
};
|
||||
let sheet = [0.0, 0.0, size.0, size.1];
|
||||
// An area is placed as a page of its size would be, and clipped to it.
|
||||
let placed = match area {
|
||||
Some(a) => place_crop(p, a, sheet, s, 0),
|
||||
None => place_in(p, d, sheet, s, false),
|
||||
};
|
||||
sheets.push(Sheet { size, placed: vec![placed], ..Sheet::default() });
|
||||
}
|
||||
Ok(sheets)
|
||||
}
|
||||
|
||||
/// Lay out the sheets for `settings` given each source page's display size.
|
||||
pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, PrintError> {
|
||||
let pages: Vec<usize> = settings.pages.iter().copied().filter(|p| *p < sizes.len()).collect();
|
||||
@@ -201,23 +247,10 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result<Vec<Sheet>, P
|
||||
if !(paper.0 >= 72.0 && paper.1 >= 72.0 && paper.0.is_finite() && paper.1.is_finite()) {
|
||||
return Err(PrintError::Invalid("invalid paper size".into()));
|
||||
}
|
||||
let area = selected_area(sizes, &pages, settings)?;
|
||||
let mut sheets = Vec::new();
|
||||
match settings.layout {
|
||||
Layout::Size(mode) => {
|
||||
for &p in &pages {
|
||||
let d = sizes[p];
|
||||
let size = oriented(paper, settings.orientation, d.0 > d.1);
|
||||
let fit = fit_scale(d, (size.0 - 2.0 * MARGIN, size.1 - 2.0 * MARGIN));
|
||||
let s = match mode {
|
||||
SizeMode::Fit => fit,
|
||||
SizeMode::Actual => 1.0,
|
||||
SizeMode::Shrink => fit.min(1.0),
|
||||
SizeMode::Custom(pct) if pct.is_finite() && pct > 0.0 => pct / 100.0,
|
||||
SizeMode::Custom(_) => return Err(PrintError::Invalid("the scale must be more than 0%".into())),
|
||||
};
|
||||
sheets.push(Sheet { size, placed: vec![place_in(p, d, [0.0, 0.0, size.0, size.1], s, false)], ..Sheet::default() });
|
||||
}
|
||||
}
|
||||
Layout::Size(mode) => sheets = layout_size(sizes, &pages, settings, mode, area)?,
|
||||
Layout::Label { flip } => {
|
||||
sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip));
|
||||
}
|
||||
|
||||
@@ -1285,6 +1285,58 @@ fn imposed_pages_preserve_large_user_unit_matrices() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selected_area_prints_alone_fitted_or_at_actual_size() {
|
||||
// Page 1 is 200×300; the area is its 100×50 block whose corner is at (40, 200).
|
||||
let area = [40.0, 200.0, 140.0, 250.0];
|
||||
let fit = layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Fit)) }).unwrap();
|
||||
assert_eq!(fit.len(), 1);
|
||||
let pl = fit[0].placed[0];
|
||||
assert_eq!(pl.clip, area, "clipped to the area");
|
||||
// A landscape area on Auto takes a landscape sheet and fills its width inside the margin.
|
||||
assert_eq!(fit[0].size, (792.0, 612.0));
|
||||
let s = pl.matrix.0[0];
|
||||
assert!(close(s, (792.0 - 2.0 * MARGIN) / 100.0), "{s}");
|
||||
// The area's own corner lands where a page of its size would: centred on the sheet.
|
||||
let (x, y) = (pl.matrix.0[0] * area[0] + pl.matrix.0[4], pl.matrix.0[3] * area[1] + pl.matrix.0[5]);
|
||||
assert!(close(x, (792.0 - 100.0 * s) / 2.0) && close(y, (612.0 - 50.0 * s) / 2.0), "{x} {y}");
|
||||
let actual = layout(&[(200.0, 300.0)], &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap();
|
||||
assert_eq!(actual[0].placed[0].matrix.0[0], 1.0, "100%");
|
||||
// An area outside the page is cut to it; an empty one, several pages or another layout are refused.
|
||||
let cut = layout(&[(200.0, 300.0)], &Settings { area: Some([150.0, 250.0, 400.0, 900.0]), ..settings(vec![0], Layout::Size(SizeMode::Actual)) })
|
||||
.unwrap();
|
||||
assert_eq!(cut[0].placed[0].clip, [150.0, 250.0, 200.0, 300.0]);
|
||||
for (pages, area, layout_) in [
|
||||
(vec![0], [10.0, 10.0, 10.5, 40.0], Layout::Size(SizeMode::Fit)),
|
||||
(vec![0, 1], area, Layout::Size(SizeMode::Fit)),
|
||||
(vec![0], [10.0, 10.0, 10.0, 40.0], Layout::Size(SizeMode::Fit)),
|
||||
(vec![0], [250.0, 10.0, 400.0, 40.0], Layout::Size(SizeMode::Fit)),
|
||||
(vec![0], [f64::NAN, 0.0, 50.0, 50.0], Layout::Size(SizeMode::Fit)),
|
||||
(vec![0], [f64::NAN; 4], Layout::Size(SizeMode::Fit)),
|
||||
(vec![0], [0.0, 0.0, f64::INFINITY, 50.0], Layout::Size(SizeMode::Fit)),
|
||||
(
|
||||
vec![0],
|
||||
area,
|
||||
Layout::Multiple { cols: 2, rows: 1, per_sheet: 2, order: PageOrder::Horizontal, border: false, auto_rotate: true, custom: false },
|
||||
),
|
||||
(vec![0], area, Layout::Booklet { subset: BookletSubset::BothSides, binding: Binding::Left, creep: 0.0 }),
|
||||
(vec![0], area, Layout::Poster { scale: 200.0, overlap: 18.0, cut_marks: true }),
|
||||
(vec![0], area, Layout::Label { flip: false }),
|
||||
] {
|
||||
assert!(matches!(
|
||||
layout(&[(200.0, 300.0), (200.0, 300.0)], &Settings { area: Some(area), ..settings(pages, layout_) }),
|
||||
Err(PrintError::Invalid(_))
|
||||
));
|
||||
}
|
||||
// A page the document doesn't have is refused before the area is looked at.
|
||||
let missing = Settings { area: Some(area), ..settings(vec![5], Layout::Size(SizeMode::Fit)) };
|
||||
assert!(matches!(layout(&[(200.0, 300.0)], &missing), Err(PrintError::NoPages)));
|
||||
// The print-ready PDF has one sheet with only that area.
|
||||
let bytes = impose(&fixture(1), &Settings { area: Some(area), ..settings(vec![0], Layout::Size(SizeMode::Actual)) }).unwrap();
|
||||
let out = Document::open(Arc::new(bytes)).unwrap();
|
||||
assert_eq!(pdfcraft_model::pages(&out).len(), 1);
|
||||
}
|
||||
|
||||
/// `output_within` (#1818): the bounded wait every platform's `lpoptions` call goes through.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod bounded_commands {
|
||||
|
||||
@@ -535,6 +535,97 @@ fn missing_subtype_appearance_survives_vector_print() {
|
||||
assert_eq!(actual_bytes, expected_bytes);
|
||||
}
|
||||
|
||||
/// Appearance coordinates are independent of page units: a large BBox or Matrix can require
|
||||
/// placement coefficients far below four decimal places even on an ordinary 100-point page.
|
||||
#[test]
|
||||
fn annotation_placement_preserves_small_transform_coefficients() {
|
||||
let (control, _, _) = widget_fixture(Some(Object::name("Form")), None);
|
||||
let control = render(saved(&control));
|
||||
for scale in [1.0, 4096.0, 75000.0] {
|
||||
for scaled_matrix in [false, true] {
|
||||
let (mut doc, ap, widget) = widget_fixture(Some(Object::name("Form")), None);
|
||||
let mut form = doc.get(ap).as_dict().unwrap().clone();
|
||||
let coordinates = if scaled_matrix { 1.0 } else { scale };
|
||||
form.set(b"BBox".to_vec(), numbers(&[0.0, 0.0, 20.0 * coordinates, 10.0 * coordinates]));
|
||||
if scaled_matrix {
|
||||
form.set(b"Matrix".to_vec(), numbers(&[scale, 0.0, 0.0, scale, 7.0, -13.0]));
|
||||
}
|
||||
doc.set(
|
||||
ap,
|
||||
Object::Stream(Stream::flate(form, format!("/Ink cs 0 1 0 scn 0 0 {} {} re f", 20.0 * coordinates, 10.0 * coordinates).as_bytes())),
|
||||
);
|
||||
for (subtype, content) in [
|
||||
("Widget", Content::Document),
|
||||
("Widget", Content::FormFieldsOnly),
|
||||
("Square", Content::DocumentAndMarkups),
|
||||
("Stamp", Content::DocumentAndStamps),
|
||||
] {
|
||||
doc.update_dict(widget, |d| d.set(b"Subtype".to_vec(), Object::name(subtype))).unwrap();
|
||||
let before = saved(&doc);
|
||||
let source = render(before.clone());
|
||||
assert!(source.rgba == control.rgba, "equivalent source, scale={scale}, matrix={scaled_matrix}, {subtype}");
|
||||
let printed = impose(&doc, &print_settings(content)).unwrap();
|
||||
let reopened = Document::open(Arc::new(printed.clone())).unwrap();
|
||||
assert_eq!(pdfcraft_model::pages(&reopened).len(), 1);
|
||||
let printed = render(printed);
|
||||
assert_eq!(pixel(&printed, 30, 70), [0, 255, 0, 255], "scale={scale}, matrix={scaled_matrix}, {subtype}");
|
||||
if content != Content::FormFieldsOnly {
|
||||
assert!(printed.rgba == source.rgba, "scale={scale}, matrix={scaled_matrix}, {subtype}");
|
||||
}
|
||||
// Check the serialized placement too: sub-pixel translation rounding may be
|
||||
// invisible at 72 dpi while still moving the appearance's physical corners.
|
||||
let page = &pdfcraft_model::pages(&reopened)[0];
|
||||
let page_form = reopened.get(xobject(&reopened, &page.dict, b"P0"));
|
||||
let Object::Stream(page_form) = &*page_form else { panic!("page form") };
|
||||
let parsed = pdfcraft_content::parse(&page_form.decoded().unwrap());
|
||||
assert_eq!(parsed.skipped, 0);
|
||||
let matrices: Vec<_> = parsed.ops.iter().filter(|op| op.is("cm")).collect();
|
||||
assert_eq!(matrices.len(), 1);
|
||||
let placement = Matrix(matrices[0].nums::<6>().unwrap());
|
||||
let (x, y) = if scaled_matrix { (7.0, -13.0) } else { (0.0, 0.0) };
|
||||
for ((x, y), expected) in [(placement.apply(x, y), (10.0, 20.0)), (placement.apply(x + 20.0 * scale, y + 10.0 * scale), (50.0, 40.0))]
|
||||
{
|
||||
assert!((x - expected.0).abs() < 1e-10 && (y - expected.1).abs() < 1e-10, "corner ({x}, {y}) != {expected:?}");
|
||||
}
|
||||
assert_eq!(saved(&doc), before, "printing preserves the source");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_appearance_geometry_keeps_page_artwork() {
|
||||
let (mut control, _, _) = widget_fixture(Some(Object::name("Form")), None);
|
||||
let page = pdfcraft_model::pages(&control)[0].obj;
|
||||
control
|
||||
.update_dict(page, |d| {
|
||||
d.remove(b"Annots");
|
||||
})
|
||||
.unwrap();
|
||||
let control = render(saved(&control));
|
||||
for (bbox, matrix) in [
|
||||
(None, None),
|
||||
(Some(numbers(&[0.0, 0.0, 20.0])), None),
|
||||
(Some(numbers(&[0.0, 0.0, 0.0, 10.0])), None),
|
||||
(Some(numbers(&[0.0, 0.0, 20.0, 0.0])), None),
|
||||
(Some(numbers(&[0.0, 0.0, 20.0, 10.0])), Some([0.0; 6])),
|
||||
] {
|
||||
let (mut doc, ap, _) = widget_fixture(Some(Object::name("Form")), matrix);
|
||||
let mut form = doc.get(ap).as_dict().unwrap().clone();
|
||||
match bbox {
|
||||
Some(bbox) => form.set(b"BBox".to_vec(), bbox),
|
||||
None => {
|
||||
form.remove(b"BBox");
|
||||
}
|
||||
}
|
||||
doc.set(ap, Object::Stream(Stream::flate(form, b"/Ink cs 0 1 0 scn 0 0 20 10 re f")));
|
||||
let before = saved(&doc);
|
||||
let printed = render(impose(&doc, &print_settings(Content::Document)).unwrap());
|
||||
assert!(printed.rgba == control.rgba, "unusable appearance is omitted without losing page artwork");
|
||||
assert_eq!(saved(&doc), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn promotion_preserves_streams_and_explicit_subtypes() {
|
||||
for subtype in [None, Some(Object::name("Form")), Some(Object::name("Image")), Some(Object::Null), Some(Object::Int(7))] {
|
||||
|
||||
@@ -278,6 +278,7 @@ impl PdfCraftApp {
|
||||
object_clip_mark: Default::default(),
|
||||
grid_drop: None,
|
||||
last_snapshot: None,
|
||||
last_snapshot_of: None,
|
||||
last_copied_image: None,
|
||||
last_copied_file: None,
|
||||
system_clipboard: true,
|
||||
|
||||
@@ -742,6 +742,10 @@ pub struct DocView {
|
||||
/// Marquee Zoom / Snapshot: the rectangle being dragged (page, start), and a finished one.
|
||||
pub marquee: Option<(usize, Pos2)>,
|
||||
pub marquee_done: Option<crate::zoom_snap::Marquee>,
|
||||
/// Snapshot: the area just copied (page, view-space rect), kept with a small menu under it
|
||||
/// (Print area…, Save as image…), and what was chosen there.
|
||||
pub area_pick: Option<(usize, [f32; 4])>,
|
||||
pub area_choice: Option<crate::zoom_snap::AreaChoice>,
|
||||
/// Fill & Sign text being typed.
|
||||
pub fill_text: Option<crate::fill_sign::TypeBox>,
|
||||
/// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
|
||||
@@ -1165,6 +1169,8 @@ impl DocView {
|
||||
pages_focus_id: None,
|
||||
marquee: None,
|
||||
marquee_done: None,
|
||||
area_pick: None,
|
||||
area_choice: None,
|
||||
fill_text: None,
|
||||
fill_signature_page: None,
|
||||
lock_offer_dismissed: false,
|
||||
@@ -1193,6 +1199,10 @@ impl DocView {
|
||||
if self.select_anchor.is_some_and(|a| a > last) {
|
||||
self.select_anchor = None;
|
||||
}
|
||||
// A Snapshot area names a page by its index and a rectangle on it: after pages were
|
||||
// deleted or moved it would outline, print and save a different page.
|
||||
self.area_pick = None;
|
||||
self.area_choice = None;
|
||||
self.goto = None;
|
||||
self.goto_point = None;
|
||||
self.zoom_anchor = None;
|
||||
@@ -2904,6 +2914,21 @@ fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool
|
||||
if let Some(done) = app.views[index].marquee_done.take() {
|
||||
app.finish_marquee(index, done);
|
||||
}
|
||||
// The copied area and its menu belong to Snapshot: another tool puts them away. The tool is
|
||||
// the app's, not the tab's, so every tab's area goes, not only the one on screen.
|
||||
if tool != QuickTool::Snapshot {
|
||||
for v in &mut app.views {
|
||||
v.area_pick = None;
|
||||
}
|
||||
}
|
||||
if let Some(choice) = app.views[index].area_choice.take()
|
||||
&& let Some((page, area)) = app.views[index].area_pick
|
||||
{
|
||||
match choice {
|
||||
crate::zoom_snap::AreaChoice::Print => app.open_print_area(page, area),
|
||||
crate::zoom_snap::AreaChoice::Save => app.save_snapshot_image(index, page, area),
|
||||
}
|
||||
}
|
||||
// A signature rectangle was drawn, or an empty signature field clicked.
|
||||
let drawn = app.views[index].sign.drawn.take();
|
||||
if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) {
|
||||
|
||||
@@ -13,6 +13,15 @@ use crate::{PdfCraftApp, QuickTool};
|
||||
/// [x0, y0, x1, y1]); a plain click has an empty rectangle.
|
||||
pub type Marquee = (usize, Rect, [f32; 4]);
|
||||
|
||||
/// What was chosen in the menu under a copied area.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AreaChoice {
|
||||
/// Print only this area (Acrobat's "print selected area").
|
||||
Print,
|
||||
/// Save the copied picture as a PNG file.
|
||||
Save,
|
||||
}
|
||||
|
||||
/// Drag a rectangle on page `page` (Marquee Zoom, Snapshot), with a press nobody holds (`free`).
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, free: bool, view: &mut DocView) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
@@ -25,6 +34,30 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
|
||||
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
|
||||
{
|
||||
view.marquee = Some((page, o));
|
||||
view.area_pick = None;
|
||||
}
|
||||
// The area just copied stays outlined, with what can be done with it underneath.
|
||||
if let Some((p, area)) = view.area_pick
|
||||
&& p == page
|
||||
&& view.marquee.is_none()
|
||||
{
|
||||
let r = xf.view_rect(area);
|
||||
ui.painter().rect_stroke(r, CornerRadius::ZERO, Stroke::new(1.5, Color32::from_rgb(0x14, 0x73, 0xE6)), egui::StrokeKind::Middle);
|
||||
egui::Area::new(egui::Id::unique("snapshot-area-menu"))
|
||||
.order(egui::Order::Foreground)
|
||||
.fixed_pos(r.left_bottom() + egui::vec2(0.0, 6.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::popup(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button(tl!("Print area…")).clicked() {
|
||||
view.area_choice = Some(AreaChoice::Print);
|
||||
}
|
||||
if ui.button(tl!("Save as image…")).clicked() {
|
||||
view.area_choice = Some(AreaChoice::Save);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some((p, start)) = view.marquee
|
||||
&& p == page
|
||||
@@ -74,6 +107,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
match self.snapshot(index, page, view_rect) {
|
||||
Ok((w, h)) => {
|
||||
self.views[index].area_pick = Some((page, view_rect));
|
||||
self.notify_fmt("The selected area has been copied ({w} × {h} pixels)", &[("w", &w.to_string()), ("h", &h.to_string())])
|
||||
}
|
||||
Err(e) => self.notify_fmt("Couldn't take the snapshot: {e}", &[("e", &e.to_string())]),
|
||||
@@ -120,10 +154,83 @@ impl PdfCraftApp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Print ▸ Selected area: the Print dialog for only `view_rect` (page view points, y down) of
|
||||
/// `page`, sized like a page of its own (Fit to paper or Actual size).
|
||||
pub(crate) fn open_print_area(&mut self, page: usize, view_rect: [f32; 4]) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
let Some(height) = self.session.get(id).and_then(|d| d.info.pages.get(page)).map(|p| f64::from(p.height)) else { return };
|
||||
self.open_print();
|
||||
if self.dialog == Some(crate::Dialog::Print) {
|
||||
let [x0, y0, x1, y1] = view_rect.map(f64::from);
|
||||
let d = &mut self.print_draft;
|
||||
// The print crate takes the area in display space, y up.
|
||||
d.area = Some((page, [x0.min(x1), height - y0.max(y1), x0.max(x1), height - y0.min(y1)]));
|
||||
d.which = crate::print_ui::Which::Area;
|
||||
d.handling = crate::print_ui::Handling::Size;
|
||||
d.sheet = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save the area `view_rect` of `page` that view `index` copied as a PNG file (asked where;
|
||||
/// `save_override` in tests). The last snapshot is used when it was taken of that area;
|
||||
/// otherwise (another tab snapshotted since) the area is rendered again.
|
||||
pub(crate) fn save_snapshot_image(&mut self, index: usize, page: usize, view_rect: [f32; 4]) {
|
||||
let this_area = self.views.get(index).is_some_and(|v| self.last_snapshot_of == Some((v.id, page, view_rect)));
|
||||
let png = match self.last_snapshot.as_ref() {
|
||||
Some((w, h, pixels)) if this_area => pdfcraft_engine::export::encode_png(*w, *h, pixels),
|
||||
_ => self.render_area(index, page, view_rect).and_then(|(w, h, pixels)| pdfcraft_engine::export::encode_png(w, h, &pixels)),
|
||||
};
|
||||
let png = match png {
|
||||
Ok(png) => png,
|
||||
Err(e) => {
|
||||
self.notify_fmt("Couldn't save the image: {e}", &[("e", &e)]);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let stem = self
|
||||
.active_ids()
|
||||
.and_then(|(_, id)| self.session.get(id))
|
||||
.map_or_else(String::new, |d| d.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string());
|
||||
let name = format!("{stem} area.png");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let write = move |app: &mut Self, path: std::path::PathBuf| match crate::editing::write_atomically(&path.to_string_lossy(), &png) {
|
||||
Ok(()) => app.notify_fmt("Exported to {path}", &[("path", &path.display().to_string())]),
|
||||
Err(e) => app.notify_fmt("Couldn't write {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]),
|
||||
};
|
||||
match self.save_override.clone() {
|
||||
Some(p) => write(self, p.into()),
|
||||
None => {
|
||||
let dialog =
|
||||
rfd::AsyncFileDialog::new().set_title(tl!("Save as image").to_string()).add_filter("PNG", &["png"]).set_file_name(name);
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Err(e) = crate::editing::download(&name, &png) {
|
||||
self.notify_error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `view_rect` (page view points) of `page` and copy it to the clipboard (and keep it
|
||||
/// in `last_snapshot`), at the current zoom or [`SNAPSHOT_MIN_SCALE`] if that is finer.
|
||||
pub fn snapshot(&mut self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32), String> {
|
||||
let view = &self.views[index];
|
||||
let (w, h, rgba) = self.render_area(index, page, view_rect)?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_clipboard {
|
||||
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) };
|
||||
crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.last_snapshot = Some((w, h, rgba));
|
||||
self.last_snapshot_of = Some((self.views[index].id, page, view_rect));
|
||||
Ok((w, h))
|
||||
}
|
||||
|
||||
/// Render `view_rect` (page view points) of `page` in view `index` at its current zoom:
|
||||
/// (width, height, premultiplied RGBA).
|
||||
fn render_area(&self, index: usize, page: usize, view_rect: [f32; 4]) -> Result<(u32, u32, pdfcraft_render::Pixels), String> {
|
||||
let view = self.views.get(index).ok_or("no document")?;
|
||||
let doc = self.session.get(view.id).ok_or("no document")?;
|
||||
let ppp = self.ctx.as_ref().map_or(2.0, |c| c.pixels_per_point());
|
||||
let scale = snapshot_scale(view.render_scale(ppp), view_rect[2] - view_rect[0], view_rect[3] - view_rect[1]);
|
||||
@@ -142,14 +249,7 @@ impl PdfCraftApp {
|
||||
if let Some(e) = out.error {
|
||||
return Err(e);
|
||||
}
|
||||
let (w, h) = (out.width, out.height);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_clipboard {
|
||||
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
|
||||
crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.last_snapshot = Some((w, h, out.rgba));
|
||||
Ok((w, h))
|
||||
Ok((out.width, out.height, out.rgba))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ pub use save::PendingLock;
|
||||
pub use save::PendingOverwrite;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) use save::download;
|
||||
#[doc(hidden)]
|
||||
pub use save::download_mime_type;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use save::in_document_folder;
|
||||
pub use save::{SaveTarget, write_atomically};
|
||||
|
||||
@@ -429,6 +429,31 @@ fn short_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// The MIME type a download of `name` is offered as, from its extension.
|
||||
#[doc(hidden)]
|
||||
pub fn download_mime_type(name: &str) -> &'static str {
|
||||
let ext = std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"pdf" => "application/pdf",
|
||||
"png" => "image/png",
|
||||
"jpg" | "jpeg" => "image/jpeg",
|
||||
"tif" | "tiff" => "image/tiff",
|
||||
"svg" => "image/svg+xml",
|
||||
"csv" => "text/csv",
|
||||
"txt" => "text/plain",
|
||||
"html" | "htm" => "text/html",
|
||||
"xml" => "application/xml",
|
||||
"json" => "application/json",
|
||||
"xfdf" => "application/vnd.adobe.xfdf",
|
||||
"fdf" => "application/vnd.fdf",
|
||||
"rtf" => "application/rtf",
|
||||
"docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
"xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Offer bytes as a browser download.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
@@ -437,7 +462,7 @@ pub(crate) fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let array = js_sys::Uint8Array::from(bytes);
|
||||
let parts = js_sys::Array::of1(&array);
|
||||
let opts = web_sys::BlobPropertyBag::new();
|
||||
opts.set_type("application/pdf");
|
||||
opts.set_type(download_mime_type(name));
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
|
||||
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
|
||||
let document = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
|
||||
@@ -72,6 +72,8 @@ mod widgets;
|
||||
use pdfcraft_engine::{DocId, Session};
|
||||
|
||||
pub use canvas::{DocView, RasterMemory};
|
||||
#[doc(hidden)]
|
||||
pub use editing::download_mime_type;
|
||||
|
||||
/// Smallest window the shell is laid out for, in egui points (logical pixels, so the OS scale
|
||||
/// factor is already applied: 480 points are 600 px at 125 % and 720 px at 150 %). 480 is half of a
|
||||
@@ -704,6 +706,8 @@ pub struct PdfCraftApp {
|
||||
/// The last snapshot (width, height, RGBA); `system_clipboard` also puts it on the
|
||||
/// system clipboard (tests turn that off).
|
||||
pub last_snapshot: Option<(u32, u32, pdfcraft_render::Pixels)>,
|
||||
/// What `last_snapshot` was taken of: (document, page, rectangle in page view points).
|
||||
last_snapshot_of: Option<(DocId, usize, [f32; 4])>,
|
||||
/// The last image copied with Copy Image (width, height, straight RGBA), also put on the
|
||||
/// system clipboard when `system_clipboard` is on.
|
||||
pub last_copied_image: Option<(u32, u32, Vec<u8>)>,
|
||||
|
||||
@@ -227,6 +227,7 @@ impl PdfCraftApp {
|
||||
sheet: 0,
|
||||
selected,
|
||||
which,
|
||||
area: None,
|
||||
custom_new: None,
|
||||
..keep
|
||||
};
|
||||
|
||||
@@ -251,7 +251,7 @@ pub(crate) fn body(
|
||||
}
|
||||
});
|
||||
// Label stock gets the label layout (cut, turned upright, fitted).
|
||||
if d.paper != before && d.paper == print::LABEL_PAPER {
|
||||
if d.paper != before && d.paper == print::LABEL_PAPER && d.which != Which::Area {
|
||||
d.paper_before_label = Some(before);
|
||||
d.handling = Handling::Label;
|
||||
d.sheet = 0;
|
||||
@@ -314,6 +314,17 @@ pub(crate) fn body(
|
||||
d.which = Which::Range;
|
||||
}
|
||||
});
|
||||
// Offered only when an area was chosen with Snapshot (its menu's Print area…).
|
||||
if let Some((page, a)) = d.area {
|
||||
let label = crate::i18n::fmt(
|
||||
tl!("Selected area (page {page}, {size})"),
|
||||
&[("page", &(page + 1).to_string()), ("size", &d.units.size(a[2] - a[0], a[3] - a[1]))],
|
||||
);
|
||||
if widgets::radio_value(ui, &mut d.which, Which::Area, label).clicked() {
|
||||
// Leaving Label this way gives back the paper it replaced, as its tabs do.
|
||||
d.set_handling(Handling::Size);
|
||||
}
|
||||
}
|
||||
// Offered only when pages were picked before the dialog opened.
|
||||
if !d.selected.is_empty() {
|
||||
let label = format!("{} ({})", tl!("Selected pages"), d.selected.len());
|
||||
@@ -341,7 +352,9 @@ pub(crate) fn body(
|
||||
(Handling::Booklet, tl!("Booklet")),
|
||||
(Handling::Label, tl!("Label")),
|
||||
] {
|
||||
if widgets::mode_tab(ui, label, d.handling == h).clicked() {
|
||||
// A selected area prints only with Size.
|
||||
let offered = d.which != Which::Area || h == Handling::Size;
|
||||
if ui.add_enabled_ui(offered, |ui| widgets::mode_tab(ui, label, d.handling == h)).inner.clicked() {
|
||||
d.set_handling(h);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ pub enum Which {
|
||||
Range,
|
||||
/// The pages picked in the Pages panel or the organize grid when the dialog opened.
|
||||
Selected,
|
||||
/// Only the area chosen with Snapshot (`PrintDraft::area`).
|
||||
Area,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -46,6 +48,8 @@ pub struct PrintDraft {
|
||||
pub range: String,
|
||||
/// The pages `Which::Selected` prints (0-based, in page order); empty when none were picked.
|
||||
pub selected: Vec<usize>,
|
||||
/// The area Snapshot's Print area… asked for: (page, rectangle in display space, points, y up).
|
||||
pub area: Option<(usize, [f64; 4])>,
|
||||
pub subset: Subset,
|
||||
pub reverse: bool,
|
||||
pub handling: Handling,
|
||||
@@ -124,6 +128,7 @@ impl Default for PrintDraft {
|
||||
which: Which::All,
|
||||
range: String::new(),
|
||||
selected: Vec::new(),
|
||||
area: None,
|
||||
subset: Subset::All,
|
||||
reverse: false,
|
||||
handling: Handling::Size,
|
||||
@@ -227,11 +232,34 @@ impl PrintDraft {
|
||||
|
||||
/// The engine settings for this draft (page count and labels from the document).
|
||||
pub fn settings(&self, count: usize, labels: &[String]) -> Result<print::Settings, String> {
|
||||
let size = Layout::Size(match self.size {
|
||||
SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale),
|
||||
m => m,
|
||||
});
|
||||
let paper = self.paper_size();
|
||||
let raster = self.as_image.then_some(print::Raster { dpi: self.dpi, grayscale: self.grayscale });
|
||||
// A selected area prints alone, its one page sized like a page of its own: Size is its
|
||||
// only handling (the dialog offers no other while it is chosen).
|
||||
if self.which == Which::Area {
|
||||
let Some((page, area)) = self.area.filter(|(p, _)| *p < count) else {
|
||||
return Err("The selected area is no longer on a page of this document.".into());
|
||||
};
|
||||
return Ok(print::Settings {
|
||||
pages: vec![page],
|
||||
paper,
|
||||
orientation: self.orientation,
|
||||
layout: size,
|
||||
content: self.content,
|
||||
raster,
|
||||
crops: Vec::new(),
|
||||
area: Some(area),
|
||||
});
|
||||
}
|
||||
if self.cut_and_stack() && matches!(self.duplex, Some(spool::Duplex::LongEdge | spool::Duplex::ShortEdge)) {
|
||||
return Err("Cut and stack needs Two-sided: Off. Print single-sided sheets.".into());
|
||||
}
|
||||
let range = match self.which {
|
||||
Which::All | Which::Selected | Which::Current => None,
|
||||
Which::All | Which::Selected | Which::Current | Which::Area => None,
|
||||
Which::Range => Some(self.range.clone()),
|
||||
};
|
||||
// Selected and Current are positions, not a typed range: a range would read numbers as
|
||||
@@ -239,16 +267,13 @@ impl PrintDraft {
|
||||
let pages = match self.which {
|
||||
Which::Selected => print::select_listed(count, &self.selected, self.subset, self.reverse),
|
||||
Which::Current => print::select_listed(count, &[self.current_page], self.subset, self.reverse),
|
||||
Which::All | Which::Range => print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse),
|
||||
Which::All | Which::Range | Which::Area => print::select_pages(count, range.as_deref(), labels, self.subset, self.reverse),
|
||||
}
|
||||
// What the person got wrong about what to print is said in their language: the engine hands
|
||||
// the wording over whole and the page numbers apart from it.
|
||||
.map_err(|e| e.said(&crate::i18n::words))?;
|
||||
let layout = match self.handling {
|
||||
Handling::Size => Layout::Size(match self.size {
|
||||
SizeMode::Custom(_) => SizeMode::Custom(self.custom_scale),
|
||||
m => m,
|
||||
}),
|
||||
Handling::Size => size,
|
||||
Handling::Multiple => {
|
||||
let grid = if self.custom_grid {
|
||||
Layout::grid(self.cols, self.rows).ok_or_else(|| tl!("Columns and rows must each be 1 to 99.").to_string())?
|
||||
@@ -268,12 +293,13 @@ impl PrintDraft {
|
||||
};
|
||||
Ok(print::Settings {
|
||||
pages,
|
||||
paper: self.paper_size(),
|
||||
paper,
|
||||
orientation: self.orientation,
|
||||
layout,
|
||||
content: self.content,
|
||||
raster: self.raster(),
|
||||
crops: self.crops.clone(),
|
||||
area: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
//! The type a web download is offered as follows the file's extension (it was `application/pdf`
|
||||
//! for every file).
|
||||
|
||||
use pdfcraft_ui_egui::download_mime_type;
|
||||
|
||||
#[test]
|
||||
fn png_downloads_as_image_png() {
|
||||
assert_eq!(download_mime_type("area.png"), "image/png");
|
||||
assert_eq!(download_mime_type("Area.PNG"), "image/png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_files_get_their_own_types() {
|
||||
assert_eq!(download_mime_type("report.pdf"), "application/pdf");
|
||||
assert_eq!(download_mime_type("measurements.csv"), "text/csv");
|
||||
assert_eq!(download_mime_type("fields.xfdf"), "application/vnd.adobe.xfdf");
|
||||
assert_eq!(download_mime_type("photo.jpeg"), "image/jpeg");
|
||||
assert_eq!(download_mime_type("page.tif"), "image/tiff");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_extensions_are_plain_bytes() {
|
||||
assert_eq!(download_mime_type("data.bin"), "application/octet-stream");
|
||||
assert_eq!(download_mime_type("no extension"), "application/octet-stream");
|
||||
}
|
||||
@@ -39,6 +39,7 @@ mod create;
|
||||
mod date_format;
|
||||
mod dialog_chrome;
|
||||
mod document_links;
|
||||
mod download_mime;
|
||||
mod editing;
|
||||
mod editor_taps;
|
||||
mod export;
|
||||
@@ -82,6 +83,7 @@ mod scroll_prefetch;
|
||||
mod search_position;
|
||||
mod side_panels;
|
||||
mod signing;
|
||||
mod snapshot_tabs;
|
||||
mod split_view;
|
||||
mod split_view_edits;
|
||||
mod split_view_find;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Snapshot with several tabs: each tab keeps its own area and its menu (Print area…, Save as
|
||||
//! image…), so what the menu does is about that tab's area; leaving Snapshot puts away the area in
|
||||
//! every tab, not only the one on screen.
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool};
|
||||
|
||||
/// Two tabs, a.pdf (0) and b.pdf (1), with Snapshot chosen and the system clipboard left alone.
|
||||
fn harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("a.pdf", None, crate::support::fixtures::pdf_pages(2)).unwrap();
|
||||
app.open_bytes("b.pdf", None, crate::support::fixtures::pdf_pages(2)).unwrap();
|
||||
app.system_clipboard = false;
|
||||
app
|
||||
});
|
||||
crate::support::settle(&mut h);
|
||||
assert!(h.state_mut().execute("edit.snapshot"));
|
||||
h.run_steps(2);
|
||||
h
|
||||
}
|
||||
|
||||
/// Take a snapshot of `area` on page 1 in tab `index`, as the Snapshot tool does at the end of a drag.
|
||||
fn snapshot_in(h: &mut Harness<'static, PdfCraftApp>, index: usize, area: [f32; 4]) -> (u32, u32, Vec<u8>) {
|
||||
h.state_mut().active = Some(index);
|
||||
h.run_steps(2);
|
||||
h.state_mut().snapshot(index, 0, area).expect("a snapshot");
|
||||
h.state_mut().views[index].area_pick = Some((0, area));
|
||||
h.run_steps(2);
|
||||
let (w, hgt, px) = h.state().last_snapshot.clone().expect("kept");
|
||||
(w, hgt, px.to_vec())
|
||||
}
|
||||
|
||||
/// Snapshot in tab A, then in tab B, back to A and Save as image…: the PNG is A's area, not the
|
||||
/// snapshot B took last.
|
||||
#[test]
|
||||
fn save_as_image_writes_this_tabs_area() {
|
||||
let mut h = harness();
|
||||
// Areas of different shapes, so their pictures can't be mistaken for each other.
|
||||
let (wa, ha, pa) = snapshot_in(&mut h, 0, [10.0, 130.0, 150.0, 170.0]);
|
||||
let (wb, hb, _) = snapshot_in(&mut h, 1, [20.0, 20.0, 60.0, 140.0]);
|
||||
assert_ne!((wa, ha), (wb, hb));
|
||||
// Back to A: its area is still outlined with its menu.
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].area_pick, Some((0, [10.0, 130.0, 150.0, 170.0])));
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-snapshot-tabs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("a area.png");
|
||||
h.state_mut().save_override = Some(file.to_string_lossy().into_owned());
|
||||
h.get_by_label("Save as image…").click();
|
||||
h.run_steps(3);
|
||||
let png = std::fs::read(&file).expect("written");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// The PNG header's width and height (IHDR), then the picture itself.
|
||||
let size = |png: &[u8]| (u32::from_be_bytes(png[16..20].try_into().unwrap()), u32::from_be_bytes(png[20..24].try_into().unwrap()));
|
||||
assert_eq!(size(&png), (wa, ha), "tab A's area, not tab B's ({wb} × {hb})");
|
||||
assert_eq!(png, pdfcraft_engine::export::encode_png(wa, ha, &pa).unwrap(), "tab A's pixels");
|
||||
}
|
||||
|
||||
/// Print area… opens Print on the area; the area stays outlined (with its menu) in the tab, as
|
||||
/// the parity note says, until another tool is chosen.
|
||||
#[test]
|
||||
fn the_area_stays_after_print_area() {
|
||||
let mut h = harness();
|
||||
let area = [10.0, 130.0, 150.0, 170.0];
|
||||
snapshot_in(&mut h, 0, area);
|
||||
h.get_by_label("Print area…").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Print));
|
||||
assert_eq!(h.state().views[0].area_pick, Some((0, area)));
|
||||
}
|
||||
|
||||
/// An area is picked in each of two tabs while Snapshot is on; choosing another tool puts both
|
||||
/// away, including the tab that is not on screen.
|
||||
#[test]
|
||||
fn leaving_snapshot_clears_every_tab() {
|
||||
let mut h = harness();
|
||||
assert_eq!(h.state().quick_tool, QuickTool::Snapshot);
|
||||
for index in 0..2 {
|
||||
h.state_mut().active = Some(index);
|
||||
h.run_steps(2);
|
||||
h.state_mut().views[index].area_pick = Some((0, [10.0, 130.0, 150.0, 170.0]));
|
||||
h.run_steps(2);
|
||||
// Still Snapshot: the area stays outlined.
|
||||
assert!(h.state().views[index].area_pick.is_some(), "tab {index} keeps its area under Snapshot");
|
||||
}
|
||||
// Tab B is on screen; choose another tool.
|
||||
h.state_mut().quick_tool = QuickTool::Select;
|
||||
h.run_steps(3);
|
||||
assert!(h.state().views[1].area_pick.is_none(), "the tab on screen");
|
||||
assert!(h.state().views[0].area_pick.is_none(), "the other tab");
|
||||
}
|
||||
|
||||
/// A change to the page list leaves no area behind: its page index would name another page.
|
||||
#[test]
|
||||
fn a_document_change_puts_the_area_away() {
|
||||
let mut h = harness();
|
||||
h.state_mut().views[0].area_pick = Some((0, [10.0, 130.0, 150.0, 170.0]));
|
||||
let info = h.state().session.get(h.state().views[0].id).unwrap().info.clone();
|
||||
h.state_mut().views[0].document_changed(&info);
|
||||
assert!(h.state().views[0].area_pick.is_none());
|
||||
}
|
||||
@@ -1076,6 +1076,59 @@ fn marquee_zoom_and_snapshot() {
|
||||
assert!(after > before * 2.0, "{before} → {after}");
|
||||
}
|
||||
|
||||
/// Print a selected area: the area Snapshot copied stays outlined with a small menu under it;
|
||||
/// Print area… opens Print on just that area (Fit to paper), and Save as image… writes a PNG.
|
||||
#[test]
|
||||
fn a_snapshot_area_prints_alone_or_saves_as_an_image() {
|
||||
use pdfcraft_ui_egui::PrintWhich;
|
||||
let mut h = harness();
|
||||
let i = h.state().active.unwrap();
|
||||
let r = h.state().views[i].page_screen_rect(0).expect("on screen");
|
||||
let at = |x: f32, y: f32| egui::pos2(r.left() + x / 200.0 * r.width(), r.top() + (300.0 - y) / 300.0 * r.height());
|
||||
h.state_mut().system_clipboard = false;
|
||||
assert!(h.state_mut().execute("edit.snapshot"));
|
||||
drag(&mut h, at(15.0, 175.0), at(115.0, 125.0));
|
||||
h.run_steps(2);
|
||||
let (page, area) = h.state().views[i].area_pick.expect("the copied area is kept");
|
||||
assert_eq!(page, 0);
|
||||
assert!(area[2] - area[0] > 90.0 && area[3] - area[1] > 40.0, "{area:?}");
|
||||
// Save as image… writes the copied picture as a PNG.
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-area-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
h.state_mut().save_override = Some(dir.join("area.png").to_string_lossy().into_owned());
|
||||
h.get_by_label("Save as image…").click();
|
||||
h.run_steps(3);
|
||||
assert!(std::fs::read(dir.join("area.png")).expect("written").starts_with(b"\x89PNG"));
|
||||
// Print area… opens Print on that area alone.
|
||||
h.get_by_label("Print area…").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::Print));
|
||||
assert_eq!(h.state().print_draft.which, PrintWhich::Area);
|
||||
h.get_by_label_contains("Selected area (page 1,");
|
||||
h.state_mut().print_draft.printer = None;
|
||||
h.state_mut().save_override = Some(dir.join("area.pdf").to_string_lossy().into_owned());
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Save as PDF").click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let out = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(dir.join("area.pdf")).expect("saved"))).unwrap();
|
||||
assert_eq!(pdfcraft_model::pages(&out).len(), 1, "one sheet for the area");
|
||||
// The next Print is for pages again.
|
||||
assert!(h.state_mut().execute("print.dialog"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().print_draft.which, PrintWhich::All);
|
||||
h.state_mut().dialog = None;
|
||||
h.run_steps(2);
|
||||
// Another snapshot keeps its area; another tool puts it away.
|
||||
drag(&mut h, at(15.0, 175.0), at(115.0, 125.0));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[i].area_pick.is_some());
|
||||
h.state_mut().set_option("quick", "select").unwrap();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().views[i].area_pick.is_none());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #5716: what a snapshot holds doesn't depend on how far the page is zoomed out.
|
||||
#[test]
|
||||
fn a_snapshot_keeps_its_resolution_when_zoomed_out() {
|
||||
|
||||
+14
-1
@@ -1,5 +1,10 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M6, PR #4166/#4088: integrate main f137a289 at 0.16.1; retain automatic-field Print suppression, explicit button/console printing and document-error provenance; 761 scoped tests and verification gates passed; 13-step CLI exercise and render inspection passed; existing warnings retained; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-29 | **Last updated:** 2026-10-29 | **Change:** minor (M6, PR #4166/#4088: preserve automatic-field Print suppression, explicit script printing and document-error provenance on upstream 0.15; retain the integrated craft-libs revision; estimates unchanged)
|
||||
>
|
||||
>
|
||||
> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: vector-print appearance transforms retain small coefficients; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-09 | **Last updated:** 2026-11-09 | **Change:** patch (M10 #5130: preserve manual duplex defaults; validate DocumentDuplex review fix and CI formatting; estimates unchanged)
|
||||
>
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged)
|
||||
@@ -50,6 +55,8 @@
|
||||
> **Last reviewed:** 2026-10-23 | **Last updated:** 2026-10-23 | **Change:** minor (M10: table spans retain text from covered slots in Office exports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: image export preserves soft-mask Decode opacity ranges; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M10: vector printing preserves existing page XObjects when adding annotation appearances; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M6, #4088: automatic AcroForm field scripts cannot request printing; explicit button and console printing are retained; estimates unchanged)
|
||||
>
|
||||
>
|
||||
> **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M2, PR #4172: CJK fallback in Combine thumbnails and figure previews; FangSong and subset-prefixed Founder family matching; estimates unchanged)
|
||||
>
|
||||
@@ -252,7 +259,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly). |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Tab and Shift-Tab wrapping to the same field or another widget of that field refresh the newly opened editor from the accepted value before Save. Synthetic UI regressions cover both directions, same-frame Save, validation refusal and undo/redo. Renaming a field renames it in the calculations that read it (field lists, notation, literal `getField` names), in one undo step, and says how many changed. Prepare a form ▸ Calculation order, a page of its own: each calculated field's formula and value, what is wrong (fields that calculate too early, loops, fields missing from `/CO`) and Fix order, Up/Down, Alt+↑/↓ and drag, and on the page each field's step and arrows to and from the field in focus (`form_calculation_order` headlessly).<br>Done since (#4088): automatic AcroForm field scripts ignore Print with a console diagnostic; explicit button and console printing remain available. Integrated main f137a289 (0.16.1) on 2026-10-31; ShowPanel/SaveAs filtering, all three engine regressions, the headless regression, field effects and document-error provenance are retained. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
@@ -309,6 +316,12 @@ M2 (renderer), M7 (content editing), M10 (OCR and Office), M11 (Preflight and st
|
||||
|
||||
## Revision history
|
||||
|
||||
**2026-10-31 · patch:** M6/PR #4166 integrated main f137a289 (0.16.1), preserving both roadmap histories. Automatic Keystroke, Validate, Calculate and Format scripts still suppress Print alongside ShowPanel/SaveAs; explicit button and console Print requests, field effects and document-error provenance are retained. The three engine regressions and headless automation regression remain in their current modules. Manifests, lockfile and CI match incoming main. Windows x64 / Rust 1.95 validation: 761 engine/automation tests passed, zero failed and four existing ignored; formatting, scoped all-target Clippy with -D warnings, layers/assets/parity and the engine/automation WebAssembly check passed. A fresh CLI build and the 13-step synthetic form exercise passed; the inspected render shows Qty=4 and Total=8, and explicit button/console Print requests are retained. Main's 100 headless parity notices, five renderer WebAssembly warnings and three optional egui patch notices remain. Estimates unchanged.
|
||||
|
||||
**2026-10-29 · minor:** M6/PR #4166 refreshed against upstream 0.15. Automatic field scripts still suppress Print with a console diagnostic alongside ShowPanel/SaveAs filtering, and explicit button/console scripts retain their requests and document-error provenance. The three engine regressions, headless regression and parity evidence remain in the current modules. Dependency manifests retain main's integrated craft-libs revision, including #366. Estimates unchanged.
|
||||
**2026-10-27 · minor:** M10: vector printing retains small appearance placement coefficients.
|
||||
Synthetic rendering and save/reopen regressions cover the change.
|
||||
Estimates unchanged.
|
||||
**2026-10-31 · minor:** M3/M4 #5330: direct quick-toolbar Crop pages action reuses command permissions and the existing crop tool. Estimates unchanged.
|
||||
|
||||
**2026-10-26 · patch:** M2/PR #4172: reconcile current main and preserve upstream Japanese serif/weight selection alongside the installed CJK viewer and preview fallback. Estimates unchanged.
|
||||
|
||||
@@ -1598,8 +1598,8 @@ milestone = "M3"
|
||||
status = "shipped"
|
||||
commands = ["edit.snapshot"]
|
||||
tools = ["page_render"]
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_keeps_its_resolution_when_zoomed_out", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_is_drawn_at_the_finer_of_the_zoom_and_the_minimum", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_stays_within_what_the_renderer_draws_at_once", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_of_a_large_page_reaches_its_far_corner"]
|
||||
notes = "copies the area to the clipboard as an image, at 300 ppi or the current zoom if that is finer (#5716), or as fine as the renderer draws in one tile (4096 pixels a side), drawn while the app waits. Missing: a choice of resolution, and a selection that stays active to copy later"
|
||||
evidence = ["crates/ui-egui/tests/it/viewer.rs::marquee_zoom_and_snapshot", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_keeps_its_resolution_when_zoomed_out", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_is_drawn_at_the_finer_of_the_zoom_and_the_minimum", "crates/ui-egui/src/canvas/zoom_snap.rs::a_snapshot_stays_within_what_the_renderer_draws_at_once", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_of_a_large_page_reaches_its_far_corner", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_area_prints_alone_or_saves_as_an_image"]
|
||||
notes = "copies the area to the clipboard as an image, at 300 ppi or the current zoom if that is finer (#5716), or as fine as the renderer draws in one tile (4096 pixels a side), drawn while the app waits; the area then stays outlined with Print area… and Save as image… (PNG) under it until another tool is chosen (print.selected-area). Missing: a choice of resolution, and a selection that stays active to copy later"
|
||||
|
||||
[[feature]]
|
||||
id = "view.copy-image"
|
||||
@@ -5411,8 +5411,8 @@ milestone = "M6"
|
||||
status = "shipped"
|
||||
commands = ["app.preferences"]
|
||||
tools = ["js_enabled"]
|
||||
evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/it/document_links.rs::a_script_cannot_open_other_kinds_of_address"]
|
||||
notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91)."
|
||||
evidence = ["crates/js/src/tests.rs::the_sandbox_has_no_host_access", "crates/js/src/tests.rs::errors_and_runaway_scripts_are_contained", "crates/ui-egui/tests/it/javascript.rs::document_scripts_and_preferences", "crates/ui-egui/tests/it/document_links.rs::a_script_asks_first_and_only_once", "crates/ui-egui/tests/it/document_links.rs::a_script_cannot_open_other_kinds_of_address", "crates/engine/src/tests.rs::automatic_field_scripts_cannot_print", "crates/engine/src/tests.rs::automatic_print_suppression_preserves_document_errors", "crates/engine/src/tests.rs::form_calculations_cannot_print_but_buttons_and_console_can", "crates/automation/src/tests.rs::form_fill_cannot_print_but_js_run_can"]
|
||||
notes = "Preferences \u25b8 JavaScript \u25b8 Enable Acrobat JavaScript (remembered); no host access; loop and recursion limits; app.launchURL asks before opening and only offers http, https and mailto addresses (#90, #91). Automatic AcroForm Keystroke, Validate, Calculate and Format scripts cannot request printing; an ignored Print request is reported in the JavaScript console. Explicit button and console printing remain available (#4088). Integration with main f137a289 (0.16.1), validated on 2026-10-31 on Windows x64 / Rust 1.95: 761 engine/automation tests passed, 0 failed, 4 existing ignored; formatting, scoped all-target Clippy, layers/assets/parity, scoped engine/automation WebAssembly and a fresh CLI build passed. The 13-step synthetic CLI exercise preserved Qty=4, Total=8 and explicit button/console Print requests through save/render; the rendered values were inspected. Existing main warnings remain (100 headless parity notices, 5 renderer WebAssembly warnings, 3 optional egui patch notices). Native printer transport remains untested."
|
||||
|
||||
[[feature]]
|
||||
id = "form.folder-level-js"
|
||||
@@ -8228,6 +8228,18 @@ tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::page_selection", "crates/print/src/tests.rs::a_number_in_parentheses_names_the_page_at_that_position", "crates/automation/tests/it/automation.rs::printing_through_tools"]
|
||||
notes = "all, current, ranges with labels and open ends; (n) names the nth page whatever its label (ISO 32000-2 §12.4.2: labels and page indices need not coincide), alone or as a range end; labels are looked up first, so a page labelled exactly \"(n)\" keeps that name. The same parser serves Combine files ranges. Organize Pages range selection still takes positions only, without labels or (n)."
|
||||
|
||||
[[feature]]
|
||||
id = "print.selected-area"
|
||||
title = "Print a selected area"
|
||||
area = "L"
|
||||
tier = "P2"
|
||||
milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["edit.snapshot", "print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::a_selected_area_prints_alone_fitted_or_at_actual_size", "crates/ui-egui/tests/it/viewer.rs::a_snapshot_area_prints_alone_or_saves_as_an_image", "crates/automation/tests/it/automation.rs::doc_print_a_selected_area"]
|
||||
notes = "Requested in #2051. The area Take a Snapshot copies stays outlined with a small menu under it, Print area… and Save as image…; Print then offers Selected area (page N, size), printed alone as a page of that size with Fit, Actual size, Shrink or a custom scale, clipped to the area. Multiple, Booklet, Poster and Label are for pages: the dialog offers only Size while the area is chosen and the print crate refuses the others. Print as image, the paper and the printer's options apply as usual. doc_print takes area [x0, y0, x1, y1] in points from the top-left of the displayed page, with one page in pages. Missing: an area spanning two pages, drawing the area from inside the Print dialog, and adjusting it after it is drawn."
|
||||
|
||||
[[feature]]
|
||||
id = "print.odd-even-reverse"
|
||||
title = "Odd/even pages and reverse order"
|
||||
@@ -8333,8 +8345,8 @@ milestone = "M10"
|
||||
status = "shipped"
|
||||
commands = ["print.dialog"]
|
||||
tools = ["doc_print"]
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"]
|
||||
notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources"
|
||||
evidence = ["crates/print/src/tests.rs::imposed_pdf_has_the_sheets_and_honours_comments_and_forms", "crates/print/src/tests/appearances.rs::annotation_appearances_do_not_replace_page_xobjects", "crates/print/src/tests/appearances.rs::printable_link_appearance_survives_vector_print", "crates/print/src/tests/appearances.rs::annotation_placement_preserves_small_transform_coefficients", "crates/print/src/tests/appearances.rs::indirect_annotation_rectangles_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_rectangles_are_skipped_when_printing", "crates/print/src/tests/appearances.rs::indirect_annotation_states_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_states_do_not_select_an_appearance", "crates/print/src/tests/appearances.rs::chained_annotation_values_keep_their_printed_appearances", "crates/print/src/tests/appearances.rs::invalid_annotation_reference_chains_are_skipped_when_printing"]
|
||||
notes = "Existing field and comment appearances retain direct or indirect rectangle coordinates and appearance-state names, including reference chains of up to 64 objects with cycle detection. State dictionaries require a usable AS name; missing/invalid/unresolved/cyclic/overly deep or unknown names select no appearance, while a single normal stream needs no AS. Print content modes and source objects are preserved. Malformed rectangles are skipped; missing appearances are not generated. Print and Hidden flags honoured; existing Link appearances print as document content, but missing Link appearances are not synthesized. Appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Print flag honoured; appearance resource names preserve existing page XObjects and distinct annotations, including inherited resources. Placement retains small transform coefficients for large appearance bounding boxes and matrices. Missing or unusable appearance streams remain outside this path."
|
||||
|
||||
[[feature]]
|
||||
id = "print.print-with-comment-summary"
|
||||
|
||||
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
The checked-in, resolved Cargo graph pins `winit` to storytold/winit
|
||||
`a517b3846ee8457d84ee4975110bfda6797c44dc` and the egui crates to storytold/egui
|
||||
`6d518fc799681571963842f95b55a6b68431b15a`. Neither `vendor/winit` nor
|
||||
`725a39fca940f77149ffbb9c58eef405a16997bc`. Neither `vendor/winit` nor
|
||||
`vendor/egui-wgpu` is a root `[patch.crates-io]` path entry. The retained copies
|
||||
record the app's earlier patches; their source-reading tests establish provenance,
|
||||
while the resolved forks and their behavioral tests establish runtime behavior.
|
||||
|
||||
Reference in new issue
Block a user