mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-29 20:47:32 -06:00
Merge origin/main into fix/radio-join-group-by-name
Resolves test-file conflicts where main and this branch both appended tests: main's file plus this branch's appended tests, unchanged. fmt, clippy -p pdfcraft-forms -p pdfcraft-automation, and both crates' tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
@@ -22,6 +22,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado
|
||||
- **Privacy.** When observing Acrobat, use synthetic fixtures only. Never capture the Home view, recent files or account info. Capture by window id (`plan/acrobat/tools/`). Never commit Acrobat outputs, corpus files or personal data.
|
||||
- **Layering.** Nothing below L7 depends on egui/winit/eframe/rfd (`plan/architecture.md` §3). `cos`/`filters`/`crypt`/`arlington` stay standalone.
|
||||
- **Page input.** Page modules read `PagePointer` (`crates/ui-egui/src/page_pointer.rs`), never egui's raw pointer state (`hover_pos`, `interact_pos`, `latest_pos`, `press_origin`, button reads); `page_pointer::tests::page_modules_read_the_pointer_only_through_page_pointer` enforces it.
|
||||
- **Page cursor.** Page modules claim the cursor for what is under the pointer (`view.cursor.claim(Layer::…, icon)`, `crates/ui-egui/src/canvas/page_cursor.rs`), never set it (`set_cursor_icon`, `cursor_icon =`, `on_hover_cursor`); `document_area` sets it once, from the claim on the highest layer the active tool reaches. `page_pointer::tests::page_modules_claim_the_cursor_and_never_set_it` enforces it.
|
||||
- **Fidelity.** The PDF object graph is the model. Preserve unknown data. Saves are incremental unless a full rewrite is required. Never silently drop or repair data without recording it.
|
||||
- **Never panic.** Every PDF, script, MCP call, settings file and keystroke is untrusted input, and none of it may crash the app or lose the user's work. This outranks feature work. Return `Result` (the crate's error enum) and propagate with `?`, or fall back leniently and record it. In non-test code:
|
||||
- No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!` unless it is provably infallible, with a comment saying why.
|
||||
|
||||
Generated
+45
-45
@@ -1178,17 +1178,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1211,7 +1211,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1221,7 +1221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.9.1",
|
||||
@@ -1247,7 +1247,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1258,7 +1258,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1279,7 +1279,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -4205,7 +4205,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4229,7 +4229,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4241,7 +4241,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4263,7 +4263,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4285,7 +4285,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4303,11 +4303,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4315,7 +4315,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4330,7 +4330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-syntax",
|
||||
@@ -4346,7 +4346,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4358,7 +4358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4373,7 +4373,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4414,14 +4414,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"proptest",
|
||||
@@ -4431,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4441,7 +4441,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4454,11 +4454,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4466,7 +4466,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4479,14 +4479,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4497,7 +4497,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4512,11 +4512,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4527,7 +4527,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4537,7 +4537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4548,7 +4548,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4560,7 +4560,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4576,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4595,7 +4595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4631,11 +4631,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4687,7 +4687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4699,7 +4699,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4713,7 +4713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+41
-41
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,51 +13,51 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.14.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.14.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.14.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.14.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.14.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.14.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.14.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.14.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.14.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.14.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.14.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.14.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.14.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.14.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.14.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.14.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.14.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.14.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.14.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.14.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.14.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.14.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.14.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.14.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.14.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.14.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.14.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.14.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.14.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.14.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.14.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.14.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.14.0", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.15.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.15.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.15.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.15.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.15.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.15.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.15.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.15.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.15.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.15.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.15.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.15.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.15.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.15.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.15.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.15.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.15.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.15.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.15.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.15.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.15.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.15.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.15.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.15.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.15.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.15.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.15.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.15.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.15.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.15.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.15.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.15.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.15.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -243,4 +243,40 @@ mod page_argument_tests {
|
||||
assert_eq!(std::fs::read(&source).unwrap(), bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_clips_glyphs_outside_the_crop_box() {
|
||||
let dir = PageDir::new();
|
||||
let path = dir.0.join("cropped.pdf");
|
||||
let body = "BT /F1 12 Tf 20 260 Td (Outside header) Tj 0 -110 Td (Visible body) Tj 0 -120 Td (Outside footer) Tj ET";
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /CropBox [0 100 200 200] /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
std::fs::write(&path, pdf).unwrap();
|
||||
|
||||
for args in [vec![], vec!["--page", "1"]] {
|
||||
let out = Command::new(BIN).arg("text").arg(&path).args(&args).output().unwrap();
|
||||
assert!(out.status.success(), "{args:?}: {out:?}");
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert_eq!(String::from_utf8(out.stdout).unwrap().trim(), "Visible body", "{args:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,14 +17,21 @@ fn request_json(r: &pdfcraft_engine::js::Request) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// A message a script showed, as `js_run` reports it: its text, title (null when it gave none),
|
||||
/// icon and buttons.
|
||||
fn alert_json(m: &pdfcraft_engine::js::Message) -> Value {
|
||||
json!({ "message": m.text, "title": m.title, "icon": m.icon.name(), "buttons": m.buttons.name() })
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// `js_run`: run `script` in the document, or as button `field`'s script, and report its
|
||||
/// messages, console lines, requests, error, the failing document scripts' errors and result.
|
||||
pub(crate) fn js_run(&mut self, a: &Args) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let o = self.session.run_javascript(id, a.str("script")?, a.opt_str("field")?).map_err(failed)?;
|
||||
let reqs: Vec<Value> = o.requests.iter().map(request_json).collect();
|
||||
Ok(
|
||||
json!({ "alerts": o.alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }),
|
||||
)
|
||||
let alerts: Vec<Value> = o.alerts.iter().map(alert_json).collect();
|
||||
Ok(json!({ "alerts": alerts, "console": o.console, "requests": reqs, "error": o.error, "document_errors": o.doc_errors, "result": o.result }))
|
||||
}
|
||||
|
||||
pub(crate) fn js_document_scripts(&self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::error::{Result, ToolError, failed};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary};
|
||||
use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary, text_style_levels};
|
||||
use super::paths::{read_input, write_atomic};
|
||||
use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools};
|
||||
|
||||
@@ -234,6 +234,34 @@ impl Automation {
|
||||
out["next"] = listing["next"].clone();
|
||||
out
|
||||
}
|
||||
"text_styles" => {
|
||||
// The samples are the document's text, as text_lines is.
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
let styles: Vec<Value> = doc
|
||||
.text_styles()
|
||||
.into_iter()
|
||||
.map(|u| {
|
||||
json!({
|
||||
"font": u.style.font,
|
||||
"size": u.style.size,
|
||||
"bold": u.style.bold,
|
||||
"italic": u.style.italic,
|
||||
"lines": u.lines,
|
||||
"pages": u.pages,
|
||||
"sample": u.sample,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
json!({ "styles": styles })
|
||||
}
|
||||
"bookmark_from_text_styles" => {
|
||||
let levels = text_style_levels(&a)?;
|
||||
let mut out = self.apply(&a, Edit::BookmarksFromTextStyles { levels })?;
|
||||
let listing = self.bookmark_listing(&a)?;
|
||||
out["bookmarks"] = listing["bookmarks"].clone();
|
||||
out["next"] = listing["next"].clone();
|
||||
out
|
||||
}
|
||||
"doc_protect" => self.doc_protect(&a)?,
|
||||
"page_replace" => {
|
||||
let pages = self.pages(&a, "pages")?;
|
||||
|
||||
@@ -600,17 +600,23 @@ impl Automation {
|
||||
};
|
||||
let mut out = self.apply(a, Edit::MoveInTabOrder { name: field.to_owned(), earlier })?;
|
||||
out["tab_order"] = self.tab_sequence(a)?;
|
||||
out["page_tabs"] = self.page_tabs(a)?;
|
||||
return Ok(out);
|
||||
}
|
||||
let order = match a.str("order")? {
|
||||
"row" | "rows" => pdfcraft_engine::TabOrder::Row,
|
||||
"column" | "columns" => pdfcraft_engine::TabOrder::Column,
|
||||
"structure" => pdfcraft_engine::TabOrder::Structure,
|
||||
"annotations" | "unspecified" => pdfcraft_engine::TabOrder::Annotations,
|
||||
o => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
|
||||
let order = match a.opt_str("order")? {
|
||||
None => None,
|
||||
Some("row" | "rows") => Some(pdfcraft_engine::TabOrder::Row),
|
||||
Some("column" | "columns") => Some(pdfcraft_engine::TabOrder::Column),
|
||||
Some("structure") => Some(pdfcraft_engine::TabOrder::Structure),
|
||||
Some("annotations" | "unspecified") => Some(pdfcraft_engine::TabOrder::Annotations),
|
||||
Some(o) => return Err(bad(format!("unknown order {o:?} (row, column, structure, annotations)"))),
|
||||
};
|
||||
let mut out = match order {
|
||||
Some(order) => self.apply(a, Edit::SetTabOrder { pages, order })?,
|
||||
None => json!({}),
|
||||
};
|
||||
let mut out = self.apply(a, Edit::SetTabOrder { pages, order })?;
|
||||
out["tab_order"] = self.tab_sequence(a)?;
|
||||
out["page_tabs"] = self.page_tabs(a)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -671,12 +677,35 @@ impl Automation {
|
||||
Ok(json!(seq.iter().map(|x| x.1).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
/// Each page's tab order (Page properties ▸ Tab order), in page order.
|
||||
fn page_tabs(&self, a: &Args) -> Result<Value> {
|
||||
let names: Vec<&str> = self
|
||||
.doc(a)?
|
||||
.tab_orders()
|
||||
.into_iter()
|
||||
.map(|o| match o {
|
||||
pdfcraft_engine::TabOrder::Row => "row",
|
||||
pdfcraft_engine::TabOrder::Column => "column",
|
||||
pdfcraft_engine::TabOrder::Structure => "structure",
|
||||
pdfcraft_engine::TabOrder::Annotations => "annotations",
|
||||
})
|
||||
.collect();
|
||||
Ok(json!(names))
|
||||
}
|
||||
|
||||
pub(crate) fn form_delete_field(&mut self, a: &Args) -> Result<Value> {
|
||||
let name = a.str("field")?.to_owned();
|
||||
if !self.doc(a)?.form.iter().any(|f| f.name == name) {
|
||||
return Err(failed(format!("there is no field named {name:?} (see form_fields)")));
|
||||
}
|
||||
self.apply(a, Edit::DeleteField { name })
|
||||
let Some(widget) = a.opt_int("widget")? else { return self.apply(a, Edit::DeleteField { name }) };
|
||||
let count = self.doc(a)?.form.iter().find(|f| f.name == name).map_or(0, |f| f.widgets.len());
|
||||
let widget = usize::try_from(widget)
|
||||
.ok()
|
||||
.and_then(|w| w.checked_sub(1))
|
||||
.filter(|w| *w < count)
|
||||
.ok_or_else(|| bad(format!("widget must be 1 to {count}: {name} has {count} widgets")))?;
|
||||
self.apply(a, Edit::DeleteWidget { name, widget })
|
||||
}
|
||||
|
||||
pub(crate) fn doc_export_data(&mut self, a: &Args) -> Result<Value> {
|
||||
|
||||
@@ -7,6 +7,36 @@ const MAX_BOOKMARK_POSITION: f64 = 1_000_000.0;
|
||||
|
||||
// ---- JSON helpers ----------------------------------------------------------------------------
|
||||
|
||||
/// The most bookmark levels `bookmark_from_text_styles` takes.
|
||||
const MAX_STYLE_LEVELS: usize = 9;
|
||||
|
||||
/// bookmark_from_text_styles' `levels`: one style per bookmark level, the first level 1, each
|
||||
/// `{ font, size, bold, italic }` as text_styles lists them.
|
||||
pub(crate) fn text_style_levels(a: &Args) -> Result<Vec<pdfcraft_engine::text_style_bookmarks::TextStyle>> {
|
||||
let levels =
|
||||
a.get("levels").and_then(Value::as_array).ok_or_else(|| ToolError::InvalidArgs("levels must list a style for each bookmark level".into()))?;
|
||||
if levels.is_empty() || levels.len() > MAX_STYLE_LEVELS {
|
||||
return Err(ToolError::InvalidArgs(format!("levels must list 1 to {MAX_STYLE_LEVELS} styles")));
|
||||
}
|
||||
levels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
let bad = |what: &str| ToolError::InvalidArgs(format!("levels[{i}]: {what}"));
|
||||
let font =
|
||||
v.get("font").and_then(Value::as_str).filter(|f| !f.is_empty()).ok_or_else(|| bad("font must be a font name from text_styles"))?;
|
||||
let size =
|
||||
v.get("size").and_then(Value::as_f64).filter(|s| s.is_finite() && *s > 0.0).ok_or_else(|| bad("size must be a positive number"))?;
|
||||
let flag = |key: &str| match v.get(key) {
|
||||
None | Some(Value::Null) => Ok(false),
|
||||
Some(Value::Bool(b)) => Ok(*b),
|
||||
Some(_) => Err(bad(&format!("{key} must be true or false"))),
|
||||
};
|
||||
Ok(pdfcraft_engine::text_style_bookmarks::TextStyle { font: font.to_string(), size, bold: flag("bold")?, italic: flag("italic")? })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_style_ranges(value: Option<&Value>) -> Result<Vec<pdfcraft_engine::RangeStyle>> {
|
||||
let Some(value) = value else { return Ok(Vec::new()) };
|
||||
let values = value.as_array().filter(|v| v.len() <= 1024).ok_or_else(|| Args::wrong("ranges", "an array of at most 1024 style objects"))?;
|
||||
|
||||
@@ -347,6 +347,39 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"Make bookmarks from the document's tagged headings (H, H1-H6), nested by level under a new first bookmark titled \"Untitled\". Fails if the document has no tagged headings. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
|
||||
)
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"text_styles",
|
||||
"List text styles",
|
||||
"The styles the document's text is set in, the largest first: font (without its subset tag), the size it is shown at in points, bold, italic, how many lines and pages use it, and the first line in it as a sample. For choosing the styles bookmark_from_text_styles makes bookmarks from (headings are usually the larger, rarer styles). Reads the first 5000 pages.",
|
||||
)
|
||||
.ro()
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t(
|
||||
"bookmark_from_text_styles",
|
||||
"New bookmarks from text styles",
|
||||
"Make bookmarks from the lines set in chosen text styles, for documents whose headings are not tagged: levels lists one style per bookmark level ({font, size, bold, italic} as text_styles gives them; sizes within a quarter point match), the first level 1, the next nested under the bookmark before it, and so on. A heading set on several lines is one bookmark, and each goes to the top of its heading. They go under a new first bookmark titled \"Untitled\". Fails if no text is set in the styles. Returns the first page of the bookmark tree (see bookmark_list). Undoable.",
|
||||
)
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"levels": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 9,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"font": { "type": "string", "minLength": 1 },
|
||||
"size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"bold": { "type": "boolean" },
|
||||
"italic": { "type": "boolean" }
|
||||
},
|
||||
"required": ["font", "size"]
|
||||
}
|
||||
}
|
||||
}),
|
||||
&["doc", "levels"],
|
||||
)),
|
||||
t("page_number", "Number pages", "Label a range of pages (e.g. i, ii, iii for front matter, or A-1, A-2 for an appendix). Later pages keep their labels. Undoable.").with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
@@ -464,7 +497,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Returns the resulting order of fields. Undoable.")
|
||||
t("form_tab_order", "Set the tab order", "Set the tab order of pages (default all): row (top to bottom, left to right; right to left in a document bound on the right), column, structure, or annotations (unspecified). Or order tabs manually: `field` with `move` earlier|later moves that field one place on its page. Without `order` or `field` it only reads. Returns the resulting order of fields (`tab_order`) and each page's tab order (`page_tabs`, as Page properties ▸ Tab Order shows it). Undoable.")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "order": { "type": "string", "enum": ["row", "column", "structure", "annotations"] }, "pages": pages("to set (default: all)"), "field": { "type": "string" }, "move": { "type": "string", "enum": ["earlier", "later"] } }),
|
||||
&["doc"],
|
||||
@@ -478,11 +511,11 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("doc_export_data", "Export comments or form data", "Write comments and/or form data to path; the extension picks the format: .xfdf or .fdf (comments and/or fields), .xml, .csv or .txt (form data). For an XFA form, .xml is its data alone (rooted at the form's root data node, as Acrobat exports it) and .xdp an XML Data Package holding the whole datasets. what: all (default), comments, fields.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "what": { "type": "string", "enum": ["all", "comments", "fields"] } }), &["doc", "path"])),
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
t("doc_import_data", "Import comments or form data", "Import comments and/or field values from an XFDF, FDF, XML, CSV or tab-delimited text file (detected from its content), or the comments of another PDF, each onto the page with the same number (comments on pages past this document's last page are left out). Comments with the same name are replaced; values go through the form's formats and validation. For an XFA form, an XDP, datasets or data XML file replaces the form's data: a laid-out form is laid out again from it (rows included) and its calculations run. Undoable.")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets. Undoable.")
|
||||
t("form_delete_field", "Delete a field", "Delete a form field and all its widgets, or with widget only that one: one radio button of a group (widget n is the nth of the radio group's options in form_fields), or one copy of a duplicated field. A field's last widget takes the field with it. Undoable.")
|
||||
.destructive()
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" } }), &["doc", "field"])),
|
||||
.with(schema(json!({ "doc": doc(), "field": { "type": "string" }, "widget": { "type": "integer", "minimum": 1, "description": "Delete only this widget (1-based)." } }), &["doc", "field"])),
|
||||
t(
|
||||
"redact_mark",
|
||||
"Mark for redaction",
|
||||
@@ -1135,7 +1168,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("form_merge_data", "Merge data files into spreadsheet", "Collect the field values of form data files (FDF, XFDF) or filled-in PDF forms into one CSV file at path: a column per field name, a row per file. Returns the row and column counts.")
|
||||
.cmd("form.merge_data")
|
||||
.with(schema(json!({ "paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, "path": { "type": "string" } }), &["paths", "path"])),
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts, console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
t("js_run", "Run JavaScript", "Run Acrobat JavaScript in the document, as the JavaScript console does (or as push button `field`'s Mouse Up script when field is given; on a laid-out XFA form, `field` runs that button's XFA click script instead, JavaScript or FormCalc, which can add and remove rows and show or hide subforms). The form object model is available: this/getField, event, app, util, console, display, color, and the document-level scripts (except the PDF container scripts when XFA layout is active; they remain available through js_document_scripts). Field changes and resetForm are applied as one undoable step; returns the script's alerts (each with its message, title, icon and buttons, in order; every question is still answered OK), console output, requests (print, page, url, submit), error, and the errors of document-level scripts that failed (the rest still ran).")
|
||||
.cmd("tools.js_console")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "script": { "type": "string" }, "field": { "type": "string", "description": "Run as this button's Mouse Up event." } }),
|
||||
|
||||
@@ -516,6 +516,78 @@ fn autotag_form_fields_writes_reviewable_widget_tags() {
|
||||
assert_eq!(result["status"], "passed");
|
||||
}
|
||||
|
||||
/// A two-page PDF with headings set apart only by their style: chapters in Helvetica-Bold 18,
|
||||
/// a section in Helvetica-Bold 14, text in Helvetica 11 (#1693).
|
||||
fn styled_headings_pdf() -> Vec<u8> {
|
||||
let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET \
|
||||
BT /FB 14 Tf 72 640 Td (Background) Tj ET";
|
||||
let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R >>".into(),
|
||||
format!("<< /Length {} >>\nstream\n{page1}\nendstream", page1.len()),
|
||||
format!("<< /Length {} >>\nstream\n{page2}\nendstream", page2.len()),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".into(),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
bytes.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// #1693: text_styles lists the styles, largest first; bookmark_from_text_styles makes the
|
||||
/// bookmarks from the ones given per level, returns the tree, undoes in one step and says why
|
||||
/// when it can't.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_through_tools() {
|
||||
let dir = workdir("text-style-bookmarks");
|
||||
std::fs::write(dir.join("styled.pdf"), styled_headings_pdf()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
|
||||
let styles = ok(&mut a, "text_styles", json!({ "doc": doc }))["styles"].clone();
|
||||
assert_eq!(
|
||||
styles,
|
||||
json!([
|
||||
{ "font": "Helvetica-Bold", "size": 18.0, "bold": true, "italic": false, "lines": 2, "pages": 2, "sample": "Introduction" },
|
||||
{ "font": "Helvetica-Bold", "size": 14.0, "bold": true, "italic": false, "lines": 1, "pages": 1, "sample": "Background" },
|
||||
{ "font": "Helvetica", "size": 11.0, "bold": false, "italic": false, "lines": 2, "pages": 2, "sample": "Some text" },
|
||||
])
|
||||
);
|
||||
// The styles as text_styles gave them, the chapters first.
|
||||
let levels = json!([styles[0].clone(), { "font": "Helvetica-Bold", "size": 14, "bold": true }]);
|
||||
let r = ok(&mut a, "bookmark_from_text_styles", json!({ "doc": doc, "levels": levels }));
|
||||
let top = &r["bookmarks"][0];
|
||||
assert_eq!(top["title"], "Untitled");
|
||||
let titles: Vec<(&str, u64)> =
|
||||
top["children"].as_array().unwrap().iter().map(|c| (c["title"].as_str().unwrap(), c["page"].as_u64().unwrap())).collect();
|
||||
assert_eq!(titles, [("Introduction", 1), ("Results", 2)]);
|
||||
assert_eq!(top["children"][0]["children"][0]["title"], "Background");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "New bookmarks from text styles");
|
||||
// What can't be done says why.
|
||||
let call = |a: &mut pdfcraft_automation::Automation, levels: Value| a.call("bookmark_from_text_styles", &json!({ "doc": doc, "levels": levels }));
|
||||
assert!(matches!(call(&mut a, json!([])), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(matches!(call(&mut a, json!([{ "font": "Helvetica-Bold" }])), Err(ToolError::InvalidArgs(m)) if m.contains("size")));
|
||||
assert!(
|
||||
matches!(call(&mut a, json!([{ "font": "Helvetica-Bold", "size": 18, "bold": "yes" }])), Err(ToolError::InvalidArgs(m)) if m.contains("bold"))
|
||||
);
|
||||
let unused = call(&mut a, json!([{ "font": "Times-Roman", "size": 30 }]));
|
||||
assert!(matches!(&unused, Err(ToolError::Failed(m)) if m.contains("no text is set in the chosen styles")), "{unused:?}");
|
||||
let too_many: Vec<Value> = (0..10).map(|_| styles[0].clone()).collect();
|
||||
assert!(matches!(call(&mut a, json!(too_many)), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
/// A one-page PDF whose form holds one text field named `name`.
|
||||
fn form_pdf(name: &str) -> Vec<u8> {
|
||||
let objs: Vec<String> = vec![
|
||||
@@ -4963,6 +5035,23 @@ fn the_calculation_order_is_read_fixed_and_set() {
|
||||
assert_eq!((value(&mut a, "total"), value(&mut a, "tax")), (json!("0"), json!("0")), "undone with the values it recalculated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_tab_orders_are_read_and_set_per_page_through_tools() {
|
||||
let dir = workdir("pagetabs");
|
||||
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, "form_tab_order", json!({ "doc": doc }));
|
||||
assert_eq!(r["page_tabs"], json!(["annotations", "annotations", "annotations"]));
|
||||
assert!(r["undo"].is_null(), "reading changes nothing: {r}");
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "column", "pages": [1, 3] }));
|
||||
assert_eq!(r["page_tabs"], json!(["column", "annotations", "column"]));
|
||||
assert_eq!((r["undo"].as_str(), r["dirty"].as_bool()), (Some("Set tab order"), Some(true)));
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "structure", "pages": [2] }));
|
||||
assert_eq!(r["page_tabs"], json!(["column", "structure", "column"]));
|
||||
let r = ok(&mut a, "form_tab_order", json!({ "doc": doc, "order": "unspecified", "pages": [1] }));
|
||||
assert_eq!(r["page_tabs"], json!(["annotations", "structure", "column"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_order_and_field_appearance_through_tools() {
|
||||
let dir = workdir("taborder");
|
||||
@@ -6536,6 +6625,8 @@ fn ocr_recognize_files_with_distinct_names_runs_as_before() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Field scripts set through the tools validate, calculate and format, and `js_run` reports console
|
||||
/// lines, every message with its details, and requests.
|
||||
#[test]
|
||||
fn javascript_through_tools() {
|
||||
let dir = workdir("js");
|
||||
@@ -6569,10 +6660,17 @@ fn javascript_through_tools() {
|
||||
let r = ok(
|
||||
&mut a,
|
||||
"js_run",
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok');" }),
|
||||
json!({ "doc": doc, "script": "console.println(getField('Total').value); getField('Qty').value = 3; this.pageNum = 1; app.alert('ok'); app.alert({ cMsg: 'Clear?', cTitle: 'Order', nIcon: 2, nType: 3 });" }),
|
||||
);
|
||||
assert_eq!(r["console"][0], "2500");
|
||||
assert_eq!(r["alerts"][0], "ok");
|
||||
// Every message, in order, with its title, icon and buttons.
|
||||
assert_eq!(
|
||||
r["alerts"],
|
||||
json!([
|
||||
{ "message": "ok", "title": null, "icon": "error", "buttons": "ok" },
|
||||
{ "message": "Clear?", "title": "Order", "icon": "question", "buttons": "yes_no_cancel" },
|
||||
])
|
||||
);
|
||||
assert_eq!(r["requests"][0]["page"], 2);
|
||||
let f = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let total = f["fields"].as_array().unwrap().iter().find(|x| x["name"] == "Total").unwrap().clone();
|
||||
@@ -7493,6 +7591,8 @@ fn repeated_xfa_print_buttons_work_through_tools_and_after_reopen() {
|
||||
check(&mut a, doc);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form's scripts run on open, on fills and for buttons through the tools, with undo,
|
||||
/// and `js_run` reports their messages.
|
||||
#[test]
|
||||
fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
let dir = workdir("xfa-scripts");
|
||||
@@ -7521,7 +7621,7 @@ fn xfa_scripts_run_for_buttons_and_field_changes_through_tools() {
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert!(field(&mut a, "amount_2").is_none());
|
||||
let r = ok(&mut a, "js_run", json!({ "doc": doc, "script": "", "field": "hello" }));
|
||||
assert_eq!(r["alerts"], json!(["Hello 4"]));
|
||||
assert_eq!(r["alerts"], json!([{ "message": "Hello 4", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7534,7 +7634,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
ok(&mut a, "js_set_document_script", json!({"doc":doc,"name":"viewerFallback","script":fallback}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"app.alert('Console message');"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Console message"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Console message", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"saved.pdf"}));
|
||||
ok(&mut a, "doc_close", json!({"doc":doc}));
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"saved.pdf"}))["doc"].as_u64().unwrap();
|
||||
@@ -7544,7 +7644,7 @@ fn active_xfa_skips_container_fallback_scripts_after_save_and_reopen() {
|
||||
assert_eq!(out["alerts"], json!([]));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":"","field":"hello"}));
|
||||
assert!(out["error"].is_null(), "{out}");
|
||||
assert_eq!(out["alerts"], json!(["Hello 2"]));
|
||||
assert_eq!(out["alerts"], json!([{ "message": "Hello 2", "title": null, "icon": "error", "buttons": "ok" }]));
|
||||
ok(&mut a, "xfa_to_static", json!({"doc":doc}));
|
||||
let out = ok(&mut a, "js_run", json!({"doc":doc,"script":""}));
|
||||
assert_eq!(out["alerts"].as_array().unwrap().len(), 1);
|
||||
@@ -7968,7 +8068,7 @@ fn hebrew_text_through_tools_saves_reopens_extracts_and_renders() {
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path": "a.pdf"}))["doc"].as_u64().unwrap();
|
||||
let text = "שם משפחה: שרון, טל׳ 052-1234567 (נייד)";
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 400, 40], "size": 11, "align": "right"});
|
||||
let args = json!({"doc": doc, "page": 1, "text": text, "rect": [10, 20, 190, 40], "size": 7, "align": "right"});
|
||||
if pdfcraft_fonts::document_hebrew_font(false).is_none() {
|
||||
assert!(a.call("page_add_text", &args).unwrap_err().to_string().contains("U+05E9"));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc": doc}))["count"], 0);
|
||||
@@ -9262,3 +9362,24 @@ fn page_render_draws_stamps_without_an_appearance() {
|
||||
let grey = custom.iter().filter(|p| p[0].abs_diff(p[1]) < 24 && p[1].abs_diff(p[2]) < 24).count();
|
||||
assert!(grey > 100 && grey == custom.len(), "an unknown name is a grey placeholder: {grey} of {}", custom.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_radio_button_through_tools_keeps_the_group() {
|
||||
let dir = workdir("delete-radio");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
for (x, export) in [(20, "S"), (40, "M"), (60, "L")] {
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "radio", "rect": [x, 100, x + 14, 114], "group": "size", "export": export });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
}
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "L" } }));
|
||||
ok(&mut a, "form_delete_field", json!({ "doc": doc, "field": "size", "widget": 2 }));
|
||||
let size = |a: &mut Automation| ok(a, "form_fields", json!({ "doc": doc }))["fields"][0].clone();
|
||||
assert_eq!((size(&mut a)["options"].clone(), size(&mut a)["value"].clone()), (json!(["S", "L"]), json!("L")), "only M is gone");
|
||||
for widget in [0, 3, -1] {
|
||||
let r = a.call("form_delete_field", &json!({ "doc": doc, "field": "size", "widget": widget }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "widget {widget}: {r:?}");
|
||||
}
|
||||
ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(size(&mut a)["options"], json!(["S", "M", "L"]), "one undo step brings it back");
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
use crate::{EditError, check, n, page_list, place_tagged};
|
||||
|
||||
const HIDDEN: i64 = 2;
|
||||
const NO_ROTATE: i64 = 16;
|
||||
const NO_VIEW: i64 = 32;
|
||||
|
||||
fn nums(doc: &Document, o: Option<&Object>) -> Option<Vec<f64>> {
|
||||
@@ -74,6 +75,28 @@ fn placement(doc: &Document, form: &Dict, rect: [f64; 4]) -> Option<[f64; 6]> {
|
||||
Some([sx, 0.0, 0.0, sy, rect[0] - x0 * sx, rect[1] - y0 * sy])
|
||||
}
|
||||
|
||||
/// `m` followed by a turn of `degrees` (the page's /Rotate) about the upper-left corner of
|
||||
/// `rect`: a NoRotate annotation stays upright on a rotated page (ISO 32000-2 §12.5.3), its
|
||||
/// upper-left corner where it was, which is how MuPDF and Poppler draw it.
|
||||
fn upright(m: [f64; 6], degrees: i64, rect: [f64; 4]) -> [f64; 6] {
|
||||
let (sin, cos) = match degrees.rem_euclid(360) {
|
||||
90 => (1.0, 0.0),
|
||||
180 => (0.0, -1.0),
|
||||
270 => (-1.0, 0.0),
|
||||
_ => return m,
|
||||
};
|
||||
let (px, py) = (rect[0], rect[3]);
|
||||
let t = [cos, sin, -sin, cos, px - px * cos + py * sin, py - px * sin - py * cos];
|
||||
[
|
||||
m[0] * t[0] + m[1] * t[2],
|
||||
m[0] * t[1] + m[1] * t[3],
|
||||
m[2] * t[0] + m[3] * t[2],
|
||||
m[2] * t[1] + m[3] * t[3],
|
||||
m[4] * t[0] + m[5] * t[2] + t[4],
|
||||
m[4] * t[1] + m[5] * t[3] + t[5],
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Which {
|
||||
/// Every comment and/or every form field.
|
||||
@@ -194,6 +217,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
let mut gone_inline: HashSet<usize> = HashSet::new();
|
||||
// Selected, but nothing was drawn for it: it stays, pop-up and reply handling included.
|
||||
let mut undrawn: HashSet<ObjRef> = HashSet::new();
|
||||
let rotation = page.dict.get(b"Rotate").map(|r| doc.resolve(r)).and_then(|r| r.as_int()).unwrap_or(0);
|
||||
// A viewer draws many comments from their dictionary even when the file stores no
|
||||
// appearance (notes, FreeText, ink, stamps…). Build the appearance it would show, so
|
||||
// flattening keeps a comment that still has ink on screen instead of deleting it.
|
||||
@@ -230,6 +254,7 @@ fn flatten_in(doc: &mut Document, pages: &[usize], which: Which) -> Result<usize
|
||||
if let (Some(ap), Some(rect)) = (appearance(doc, d), rect) {
|
||||
let form = doc.get(ap).as_dict().cloned().unwrap_or_default();
|
||||
if let Some(m) = placement(doc, &form, rect) {
|
||||
let m = if flags & NO_ROTATE != 0 { upright(m, rotation, rect) } else { m };
|
||||
let name = xobject_name(&existing_xo, &xobjects, &mut name_n);
|
||||
content.push_str(&format!("q {} {} {} {} {} {} cm /{name} Do Q\n", n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])));
|
||||
xobjects.set(name.into_bytes(), Object::Ref(ap));
|
||||
|
||||
@@ -507,6 +507,42 @@ fn flattening_draws_appearances_into_the_page_and_removes_the_comments() {
|
||||
assert!(marks_present(&doc).is_empty(), "flattened content is not a removable mark");
|
||||
}
|
||||
|
||||
/// #2368: a widget with /NoRotate on a rotated page is shown upright, turned with the page's
|
||||
/// /Rotate about its rectangle's upper-left corner (§12.5.3, as MuPDF and Poppler draw it).
|
||||
/// Flattening drew its appearance at the rectangle as it is, so the widget turned sideways and
|
||||
/// moved; a neighbour without the flag is unchanged, and so is everything on an unrotated page.
|
||||
#[test]
|
||||
fn flattening_keeps_a_no_rotate_widget_upright_on_a_rotated_page() {
|
||||
let page = stream("", " ");
|
||||
let form = "<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 15 >>\nstream\n0 0 100 50 re f\nendstream";
|
||||
for (rotate, plain, upright) in [
|
||||
(0, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 1 0 0 1 100 500 cm /PCFl1 Do Q"),
|
||||
(90, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 1 -1 0 150 550 cm /PCFl1 Do Q"),
|
||||
(180, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q -1 0 0 -1 100 600 cm /PCFl1 Do Q"),
|
||||
(270, "q 1 0 0 1 100 500 cm /PCFl0 Do Q", "q 0 -1 1 0 50 550 cm /PCFl1 Do Q"),
|
||||
] {
|
||||
let widget =
|
||||
|flags: i64| format!("<< /Type /Annot /Subtype /Widget /FT /Tx /T (f{flags}) /Rect [100 500 200 550] /F {flags} /AP << /N 7 0 R >> >>");
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [4 0 R 5 0 R] >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Rotate {rotate} /Resources << >> /Contents 6 0 R /Annots [4 0 R 5 0 R] >>"
|
||||
),
|
||||
&widget(4),
|
||||
&widget(4 | 16),
|
||||
&page,
|
||||
form,
|
||||
]);
|
||||
assert_eq!(flatten(&mut doc, &[0], false, true).unwrap(), 2);
|
||||
let doc = reopen(&doc);
|
||||
let s = streams(&doc, 0);
|
||||
let flat = s.last().unwrap();
|
||||
assert!(flat.contains(plain), "/Rotate {rotate}: {flat}");
|
||||
assert!(flat.contains(upright), "/Rotate {rotate}: {flat}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1317: a cloudy square this builder cannot draw stays on the page. A solid square, a hidden
|
||||
/// cloudy square and a cloudy square that already has an appearance keep their previous results.
|
||||
#[test]
|
||||
|
||||
@@ -378,6 +378,8 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.organize", "Organize pages", PAGES, None, Document, "layout-grid"),
|
||||
c("bookmark.add", "New bookmark", PAGES, Some(Shortcut::cmd("B")), Assembly, "bookmark-plus"),
|
||||
c("bookmark.from_structure", "New bookmarks from structure", PAGES, None, Assembly, "list"),
|
||||
// Bookmarks from the lines set in chosen text styles, for headings that aren't tagged (#1693).
|
||||
c("bookmark.from_text_styles", "New bookmarks from text styles…", PAGES, None, Assembly, "list"),
|
||||
c("page.rotate", "Rotate pages clockwise", PAGES, None, Assembly, "rotate-cw"),
|
||||
c("page.rotate_ccw", "Rotate pages counterclockwise", PAGES, None, Assembly, "rotate-ccw"),
|
||||
c("page.delete", "Delete pages", PAGES, None, Assembly, "trash-2"),
|
||||
@@ -392,6 +394,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("page.extract", "Extract pages…", PAGES, None, Assembly, "file-output"),
|
||||
c("page.split", "Split document…", PAGES, None, Assembly, "scissors"),
|
||||
c("page.number", "Number pages…", PAGES, None, Assembly, "hash"),
|
||||
c("page.properties", "Page properties…", PAGES, None, Modification, "info"),
|
||||
c("help.shortcuts", "Keyboard shortcuts", HELP, None, Nothing, "circle-help"),
|
||||
c("help.discord", "Join the ArtCraft Discord", HELP, None, Nothing, "messages-square"),
|
||||
c("help.app_page", "PdfCraft web page", HELP, None, Nothing, "globe"),
|
||||
|
||||
@@ -237,6 +237,16 @@ impl Document {
|
||||
self.generation
|
||||
}
|
||||
|
||||
/// The styles the document's text is set in, the largest first, for bookmarks from text
|
||||
/// styles (#1693).
|
||||
/// Empty when the document doesn't allow copying: the samples are its text.
|
||||
pub fn text_styles(&self) -> Vec<crate::text_style_bookmarks::TextStyleUse> {
|
||||
if !self.allows_copying() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.editor.as_ref().map(|e| crate::text_style_bookmarks::styles(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the lines of existing text on `page` (0-based).
|
||||
pub fn text_lines(&self, page: usize) -> Vec<pdfcraft_edit::TextLine> {
|
||||
self.editor.as_ref().and_then(|e| pdfcraft_edit::text_lines(&e.cos, page).ok()).unwrap_or_default()
|
||||
@@ -248,6 +258,11 @@ impl Document {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::calculation_order(&e.cos, &self.form)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Page properties ▸ Tab order: each page's tab order, in page order.
|
||||
pub fn tab_orders(&self) -> Vec<TabOrder> {
|
||||
self.editor.as_ref().map(|e| pdfcraft_forms::tab_orders(&e.cos)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Add alternate text: the figures, in document order.
|
||||
pub fn figures(&self) -> Vec<a11y::Figure> {
|
||||
self.editor.as_ref().map(|e| a11y::figures(&e.cos)).unwrap_or_default()
|
||||
|
||||
@@ -122,6 +122,11 @@ pub enum Edit {
|
||||
/// Bookmarks for the tagged headings (H, H1–H6), nested by level under a new first
|
||||
/// "Untitled" bookmark (New Bookmarks from Structure).
|
||||
BookmarksFromStructure,
|
||||
/// Bookmarks for the lines set in `levels`' styles (the first is level 1, the next nests
|
||||
/// under it, …), under a new first "Untitled" bookmark (#1693; see [`crate::text_style_bookmarks`]).
|
||||
BookmarksFromTextStyles {
|
||||
levels: Vec<crate::text_style_bookmarks::TextStyle>,
|
||||
},
|
||||
/// Label pages `from..=to` (0-based) as Acrobat's "Number pages" does; later pages keep their labels.
|
||||
NumberPages {
|
||||
from: usize,
|
||||
@@ -287,6 +292,12 @@ pub enum Edit {
|
||||
DeleteField {
|
||||
name: String,
|
||||
},
|
||||
/// Delete one widget of a field (`widget`, 0-based in the field's widgets): one radio button
|
||||
/// of a group, or one copy of a duplicated field. The last widget takes the field with it.
|
||||
DeleteWidget {
|
||||
name: String,
|
||||
widget: usize,
|
||||
},
|
||||
/// Page properties ▸ Tab order, for pages (0-based).
|
||||
SetTabOrder {
|
||||
pages: Vec<usize>,
|
||||
@@ -517,7 +528,8 @@ pub enum Edit {
|
||||
links: Vec<(usize, Vec<[f64; 4]>, String)>,
|
||||
style: LinkStyle,
|
||||
},
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text).
|
||||
/// Import comments and/or form data (XFDF, FDF, XML, CSV, tab-delimited text), or the
|
||||
/// comments of another PDF onto the pages with the same numbers.
|
||||
ImportData {
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
|
||||
@@ -39,6 +39,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Colour",
|
||||
"Style",
|
||||
"New bookmarks from structure",
|
||||
"New bookmarks from text styles",
|
||||
"Number pages",
|
||||
"Measure distance",
|
||||
"Measure perimeter",
|
||||
@@ -186,6 +187,7 @@ impl Edit {
|
||||
Edit::SetBookmarkColor { .. } => Label::of("Colour"),
|
||||
Edit::SetBookmarkStyle { .. } => Label::of("Style"),
|
||||
Edit::BookmarksFromStructure => Label::of("New bookmarks from structure"),
|
||||
Edit::BookmarksFromTextStyles { .. } => Label::of("New bookmarks from text styles"),
|
||||
Edit::NumberPages { .. } => Label::of("Number pages"),
|
||||
// What was measured is part of the wording too, for the same reason.
|
||||
Edit::AddMeasurement(m) => Label::of(match m.kind {
|
||||
@@ -224,7 +226,7 @@ impl Edit {
|
||||
Edit::ResetForm { .. } => Label::of("Clear form"),
|
||||
Edit::AddField { .. } => Label::of("Add field"),
|
||||
Edit::SetFieldProps { .. } => Label::of("Change field properties"),
|
||||
Edit::DeleteField { .. } => Label::of("Delete field"),
|
||||
Edit::DeleteField { .. } | Edit::DeleteWidget { .. } => Label::of("Delete field"),
|
||||
Edit::DuplicateField { .. } => Label::of("Duplicate field"),
|
||||
Edit::SetTabOrder { .. } | Edit::MoveInTabOrder { .. } => Label::of("Set tab order"),
|
||||
Edit::SetCalculationOrder { .. } => Label::of("Set calculation order"),
|
||||
|
||||
@@ -2,6 +2,8 @@ use super::{
|
||||
AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture,
|
||||
image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change,
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
|
||||
/// Whether the opening password allows an edit (§7.6.4.2, Table 22).
|
||||
pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> {
|
||||
@@ -32,6 +34,17 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
Err(EditError::NotPermitted("page changes"))
|
||||
}
|
||||
}
|
||||
// Creates bookmarks (assembly), and their titles are the document's text, copied out of it
|
||||
// (Table 22, bit 5), as an export of it would be.
|
||||
Edit::BookmarksFromTextStyles { .. } => {
|
||||
if !p.assemble() {
|
||||
Err(EditError::NotPermitted("page changes"))
|
||||
} else if !p.copy() {
|
||||
Err(EditError::NotPermitted("copying text and images"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Edit::AddMeasurement(_)
|
||||
| Edit::AddAnnotation(_)
|
||||
| Edit::AddCustomStamp { .. }
|
||||
@@ -106,6 +119,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::XfaToStatic
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetTabOrder { .. }
|
||||
| Edit::MoveInTabOrder { .. }
|
||||
@@ -280,10 +294,30 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::BookmarksFromStructure => {
|
||||
let entries: Vec<_> = pdfcraft_a11y::headings(doc)
|
||||
.into_iter()
|
||||
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj })
|
||||
.map(|h| pdfcraft_organize::OutlineEntry { level: h.level, title: h.title, page: h.page, element: h.obj, top: None })
|
||||
.collect();
|
||||
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
|
||||
}
|
||||
Edit::BookmarksFromTextStyles { levels } => {
|
||||
if levels.is_empty() {
|
||||
return Err(EditError::Invalid("choose a text style for at least one bookmark level".into()));
|
||||
}
|
||||
// Each line goes to the first level whose style it is in: a style given again, or one
|
||||
// within the size tolerance of an earlier level's, would leave its level empty.
|
||||
for (i, a) in levels.iter().enumerate() {
|
||||
if let Some(j) = levels.iter().skip(i.saturating_add(1)).position(|b| a.matches(b)) {
|
||||
let (first, second) = (i.saturating_add(1), i.saturating_add(j).saturating_add(2));
|
||||
return Err(EditError::Invalid(format!(
|
||||
"bookmark levels {first} and {second} have the same text style: choose a different one for each level"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let entries = crate::text_style_bookmarks::headings(doc, levels);
|
||||
if entries.is_empty() {
|
||||
return Err(EditError::Invalid("no text is set in the chosen styles".into()));
|
||||
}
|
||||
pdfcraft_organize::add_bookmark_tree(doc, "Untitled", &entries)?;
|
||||
}
|
||||
Edit::NumberPages { from, to, style, prefix, first } => pdfcraft_organize::number_pages(doc, *from, *to, *style, prefix, *first)?,
|
||||
Edit::AddMeasurement(m) => {
|
||||
measure::add(doc, m, &cx.meta())?;
|
||||
@@ -446,6 +480,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
}
|
||||
}
|
||||
Edit::DeleteField { name } => pdfcraft_forms::delete_field(doc, name)?,
|
||||
Edit::DeleteWidget { name, widget } => pdfcraft_forms::delete_widget(doc, name, *widget)?,
|
||||
Edit::SetTabOrder { pages, order } => pdfcraft_forms::set_tab_order(doc, pages, *order)?,
|
||||
Edit::MoveInTabOrder { name, earlier } => pdfcraft_forms::move_in_tab_order(doc, name, *earlier)?,
|
||||
// As after a script edit: the values the new order gives show at once.
|
||||
@@ -633,7 +668,11 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("no web addresses were found".into())));
|
||||
}
|
||||
}
|
||||
Edit::ImportData { bytes, .. } => {
|
||||
Edit::ImportData { name, bytes } if bytes.starts_with(b"%PDF-") => {
|
||||
let src = comment_source(bytes).map_err(|p| EditError::Source(format!("{name}: {p}")))?;
|
||||
import_pdf_comments(doc, &src)?;
|
||||
}
|
||||
Edit::ImportData { name, bytes } => {
|
||||
// An XFA form takes XFA data files (XDP, datasets, or a data document) as its data;
|
||||
// XFDF, FDF, Acrobat's XML and tables go the usual way.
|
||||
let text = (pdfcraft_xfa::read_packets(doc).ok().flatten().is_some()).then(|| decode_data_file(bytes)).flatten();
|
||||
@@ -647,9 +686,21 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
cx.xfa_out.console.push(format!("Imported XFA data: {} node(s)", r.nodes));
|
||||
xfa_after_data_change(doc, cx, true)?;
|
||||
}
|
||||
Some(Err(why)) => return Err(EditError::Invalid(why)),
|
||||
Some(Err(why)) if !bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => return Err(EditError::Invalid(why)),
|
||||
_ => {
|
||||
pdfcraft_xfdf::import(doc, bytes)?;
|
||||
// A PDF with bytes before its header, within the 1,024 the reader allows, is read
|
||||
// as a PDF when it is not a data file. A file the PDF reader cannot open keeps
|
||||
// the data importer's error.
|
||||
let mut data = doc.clone();
|
||||
match pdfcraft_xfdf::import(&mut data, bytes) {
|
||||
Ok(_) => *doc = data,
|
||||
Err(e) if bytes[..bytes.len().min(1024)].windows(5).any(|w| w == b"%PDF-") => match comment_source(bytes) {
|
||||
Ok(src) => import_pdf_comments(doc, &src)?,
|
||||
Err(SourceProblem::Unreadable(_)) => return Err(e.into()),
|
||||
Err(p) => return Err(EditError::Source(format!("{name}: {p}"))),
|
||||
},
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,15 @@
|
||||
//! off, scripts don't run and AF calls keep working natively.
|
||||
|
||||
use pdfcraft_forms::{Field, FieldChange, FieldEvent, FieldKind, FieldProps, ScriptResult, Scripts};
|
||||
pub use pdfcraft_js::{Limits, Outcome, Panel, Request};
|
||||
pub use pdfcraft_js::{Limits, Message, MessageButtons, MessageIcon, Outcome, Panel, Request};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
|
||||
/// What scripts produced for the viewer since it last looked.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct JsOutput {
|
||||
pub alerts: Vec<String>,
|
||||
/// The messages scripts showed, in order, with their titles, icons and buttons.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
pub requests: Vec<Request>,
|
||||
/// Script errors (as Acrobat's console shows them).
|
||||
@@ -195,6 +196,8 @@ fn info(cos: &pdfcraft_cos::Document) -> Vec<(String, String)> {
|
||||
}
|
||||
|
||||
impl Scripts for JsRunner {
|
||||
/// Run field `target`'s script for `event` over the form's fields, keeping what it showed and
|
||||
/// asked for; a refusal carries its last message.
|
||||
fn run(&mut self, event: FieldEvent, script: &str, target: &Field, value: &str, fields: &[Field]) -> ScriptResult {
|
||||
let states: Vec<_> = fields
|
||||
.iter()
|
||||
@@ -218,7 +221,7 @@ impl Scripts for JsRunner {
|
||||
rc: o.rc,
|
||||
value: o.value.clone(),
|
||||
changes: changes(&states, &o.changed),
|
||||
message: (!o.rc).then(|| o.alerts.last().cloned()).flatten(),
|
||||
message: (!o.rc).then(|| o.alerts.last().map(|m| m.text.clone())).flatten(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ mod stamp_preview;
|
||||
mod test_hooks;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod text_style_bookmarks;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
mod xfa_form;
|
||||
@@ -66,7 +67,7 @@ pub use links::{
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::StampPreview;
|
||||
pub use text_preview::TextEditPreview;
|
||||
pub use text_preview::{TextEditPreview, UnmarkedPages};
|
||||
|
||||
pub use pdfcraft_organize::{BoxSpec, FieldConflicts, PageBox, SplitBy, Transition, TransitionSpec, mime_for_name, split_ranges};
|
||||
|
||||
|
||||
@@ -25,6 +25,24 @@ pub(crate) fn open_source_with(name: &str, bytes: &Arc<Vec<u8>>, password: Optio
|
||||
source_document(bytes, password).map_err(|p| EditError::Source(format!("{name}: {p}")))
|
||||
}
|
||||
|
||||
/// Import the comments of `src` onto the pages of `doc` with the same numbers.
|
||||
pub(crate) fn import_pdf_comments(doc: &mut pdfcraft_cos::Document, src: &pdfcraft_cos::Document) -> Result<(), EditError> {
|
||||
if pdfcraft_organize::import_comments(doc, src)? == 0 {
|
||||
return Err(pdfcraft_xfdf::DataError::NothingImported.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse another PDF to import comments from, whatever its permissions allow.
|
||||
pub(crate) fn comment_source(bytes: &Arc<Vec<u8>>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open(bytes.clone())) {
|
||||
Ok(Ok(d)) => Ok(d),
|
||||
Ok(Err(pdfcraft_cos::CosError::NeedsPassword)) => Err(SourceProblem::Password),
|
||||
Ok(Err(e)) => Err(SourceProblem::Unreadable(e.to_string())),
|
||||
Err(_) => Err(SourceProblem::Unreadable("the file could not be read".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_document(bytes: &Arc<Vec<u8>>, password: Option<&str>) -> Result<pdfcraft_cos::Document, SourceProblem> {
|
||||
match std::panic::catch_unwind(|| pdfcraft_cos::Document::open_with_password(bytes.clone(), password)) {
|
||||
Ok(Ok(d)) if d.permissions().is_some_and(|p| !p.assemble()) => Err(SourceProblem::NotPermitted),
|
||||
|
||||
+364
-77
@@ -1849,6 +1849,153 @@ fn bookmarks_from_structure_nest_the_headings_and_undo() {
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// Three pages whose headings are set apart only by how they look (#1693): chapters in
|
||||
/// Helvetica-Bold 18, sections in Helvetica-Bold 14, text in Helvetica 11. Page 2's chapter title
|
||||
/// runs over two lines, its bold font is another subset of the same font, and one section is set
|
||||
/// with `1 Tf` and a text matrix 14 times larger. Page 3 has two sections on one row.
|
||||
fn styled_headings_fixture() -> Vec<u8> {
|
||||
let page1 = "BT /FB 18 Tf 72 700 Td (Chapter One) Tj ET BT /FR 11 Tf 72 670 Td (Body text) Tj ET \
|
||||
BT /FB 14 Tf 72 640 Td (Section A) Tj ET BT /FR 11 Tf 72 620 Td (More body) Tj ET";
|
||||
let page2 = "BT /FS 18 Tf 72 700 Td (Chapter Two) Tj ET BT /FS 18 Tf 72 680 Td (and its subtitle) Tj ET \
|
||||
BT /FB 1 Tf 14 0 0 14 72 640 Tm (Section B) Tj ET BT /FR 11 Tf 72 620 Td (Body) Tj ET \
|
||||
BT /FB 14 Tf 72 600 Td (Section C) Tj ET";
|
||||
// Two sections on one row, the right one drawn first: read from the left.
|
||||
let page3 = "BT /FB 14 Tf 300 500 Td (Right part) Tj ET BT /FB 14 Tf 72 500 Td (Left part) Tj ET";
|
||||
let fonts = "/Font << /FB 6 0 R /FR 7 0 R /FS 8 0 R >>";
|
||||
pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj(format!("<< /Type /Pages /Kids [3 0 R 4 0 R 10 0 R] /Count 3 /MediaBox [0 0 612 792] /Resources << {fonts} >> >>"))
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 5 0 R >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 9 0 R >>")
|
||||
.stream("", page1)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /ABCDEF+Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.stream("", page2)
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 11 0 R >>")
|
||||
.stream("", page3)
|
||||
.build()
|
||||
}
|
||||
|
||||
/// #1693: the document's text styles, largest first, and bookmarks made from the chosen ones:
|
||||
/// chapters at level 1, sections nested under the chapter before them. A two-line title is one
|
||||
/// bookmark; a subset of the same font and a size set by the text matrix count as the style.
|
||||
/// Each bookmark goes to its heading's top. One undo step; no style, or none in use, is refused.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_nest_the_headings_and_undo() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
|
||||
let styles = s.get(id).unwrap().text_styles();
|
||||
let listed: Vec<(String, f64, bool, usize, usize)> =
|
||||
styles.iter().map(|u| (u.style.font.clone(), u.style.size, u.style.bold, u.lines, u.pages)).collect();
|
||||
assert_eq!(
|
||||
listed,
|
||||
[
|
||||
("Helvetica-Bold".to_string(), 18.0, true, 3, 2),
|
||||
("Helvetica-Bold".to_string(), 14.0, true, 5, 3),
|
||||
("Helvetica".to_string(), 11.0, false, 3, 2),
|
||||
]
|
||||
);
|
||||
assert_eq!(styles[0].sample, "Chapter One");
|
||||
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter.clone(), section.clone()] }).unwrap();
|
||||
let outline = s.get(id).unwrap().info.outline.clone();
|
||||
assert_eq!(
|
||||
outline_titles(&outline),
|
||||
["Untitled→0[Chapter One→1[Section A→1],Chapter Two and its subtitle→2[Section B→2,Section C→2,Left part→3,Right part→3]]"]
|
||||
);
|
||||
let first = &outline[0].children[0];
|
||||
match first.view {
|
||||
pdfcraft_render::DestView::Xyz { left: None, top: Some(top), zoom: None } => {
|
||||
assert!((700.0..720.0).contains(&top), "the top of the heading's line: {top}")
|
||||
}
|
||||
ref v => panic!("{v:?}"),
|
||||
}
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("New bookmarks from text styles"));
|
||||
s.undo(id).unwrap();
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
// One level is enough; the sections are then left out.
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![chapter] }).unwrap();
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Chapter One→1,Chapter Two and its subtitle→2]"]);
|
||||
s.undo(id).unwrap();
|
||||
// Nothing chosen, or a style no text is in: refused, nothing changes.
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![] }).unwrap_err();
|
||||
assert!(err.to_string().contains("at least one bookmark level"), "{err}");
|
||||
let unused = pdfcraft_engine_style(§ion, 30.0);
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![unused] }).unwrap_err();
|
||||
assert!(err.to_string().contains("no text is set in the chosen styles"), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: a line goes to the first level whose style it is in, so the same style
|
||||
/// for two levels (or one within the size tolerance of the other) would leave the later level
|
||||
/// empty. Refused at the edit, for the dialog and automation alike; nothing changes.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_refuse_one_style_for_two_levels() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("styled.pdf", None, Arc::new(styled_headings_fixture()), None).unwrap();
|
||||
let styles = s.get(id).unwrap().text_styles();
|
||||
let (chapter, section) = (styles[0].style.clone(), styles[1].style.clone());
|
||||
let near = pdfcraft_engine_style(§ion, section.size + 0.1);
|
||||
for levels in [vec![chapter.clone(), chapter.clone()], vec![chapter.clone(), section.clone(), near]] {
|
||||
let n = levels.len();
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels }).unwrap_err();
|
||||
assert!(err.to_string().contains(&format!("levels {} and {n} have the same text style", n - 1)), "{err}");
|
||||
assert!(s.get(id).unwrap().info.outline.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document
|
||||
/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction.
|
||||
#[test]
|
||||
fn bookmarks_from_text_styles_need_copying_permission() {
|
||||
let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("r.pdf", None, bytes.clone(), None).unwrap();
|
||||
assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying());
|
||||
assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied");
|
||||
let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false };
|
||||
let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err();
|
||||
assert_eq!(err, EditError::NotPermitted("copying text and images"));
|
||||
let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
|
||||
assert!(!s.get(owner).unwrap().text_styles().is_empty());
|
||||
assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may");
|
||||
}
|
||||
|
||||
/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower,
|
||||
/// are two bookmarks, not one heading run on across the columns.
|
||||
#[test]
|
||||
fn headings_in_two_columns_are_two_bookmarks() {
|
||||
let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET";
|
||||
let bytes = pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>")
|
||||
.stream("", page)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
|
||||
.build();
|
||||
let mut s = Session::new();
|
||||
let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let style = s.get(id).unwrap().text_styles()[0].style.clone();
|
||||
s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap();
|
||||
assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]);
|
||||
}
|
||||
|
||||
/// `style` at another size.
|
||||
fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle {
|
||||
text_style_bookmarks::TextStyle { size, ..style.clone() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_pages_shows_in_the_viewer_and_undoes() {
|
||||
let (mut s, id) = session_with(5);
|
||||
@@ -3002,6 +3149,45 @@ fn headers_footers_watermarks_and_backgrounds_show_update_and_remove() {
|
||||
assert_eq!(s.get(id).unwrap().can_undo(), Some("Remove background"));
|
||||
}
|
||||
|
||||
/// Update previews its marks on the page without the ones it replaces: a yellow background
|
||||
/// updated to blue isn't previewed as blue over yellow, nor a new watermark over the old one.
|
||||
/// The other kinds of mark and the page's own content stay, every page is prepared at once (the
|
||||
/// dialog steps through them), a document without that kind has nothing to prepare, and
|
||||
/// preparing changes nothing in the document.
|
||||
#[test]
|
||||
fn update_previews_on_the_page_without_the_marks_it_replaces() {
|
||||
let (mut s, id) = session_with(2);
|
||||
let yellow = Background { color: [1.0, 1.0, 0.0], ..Background::default() };
|
||||
s.apply(id, Edit::AddBackground { pages: vec![0, 1], settings: yellow, replace: false, file: None }).unwrap();
|
||||
let red = Watermark { text: "SECRET".into(), color: [1.0, 0.0, 0.0], opacity: 1.0, ..Watermark::default() };
|
||||
s.apply(id, Edit::AddWatermark { pages: vec![0], settings: red, replace: false, file: None }).unwrap();
|
||||
let doc = s.get(id).unwrap();
|
||||
let (generation, dirty) = (doc.edit_generation(), doc.dirty);
|
||||
let px = |p: &pdfcraft_render::RenderedPage, x: u32, y: u32| -> [u8; 3] {
|
||||
p.rgba[(((300 - y) * p.width + x) * 4) as usize..][..3].try_into().unwrap()
|
||||
};
|
||||
let reds = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r > 200 && *g < 80 && *b < 80).count();
|
||||
let dark = |p: &pdfcraft_render::RenderedPage| p.rgba.as_chunks::<4>().0.iter().filter(|[r, g, b, _]| *r < 100 && *g < 100 && *b < 100).count();
|
||||
assert_eq!(pixel(&s, id, 0, 5, 5)[..3], [255, 255, 0], "the page has the yellow background");
|
||||
|
||||
let mut unmarked = doc.without_marks(MarkKind::Background).unwrap().expect("the pages have a background");
|
||||
let page = unmarked.render_page(0, 1.0).unwrap();
|
||||
assert_eq!(px(&page, 5, 5), [255, 255, 255], "without the background the page is white");
|
||||
assert!(reds(&page) > 50, "the watermark stays");
|
||||
assert!(dark(&page) > 10, "the page text stays");
|
||||
|
||||
let page = doc.without_marks(MarkKind::Watermark).unwrap().expect("page 1 has a watermark").render_page(0, 1.0).unwrap();
|
||||
assert_eq!(reds(&page), 0, "without the watermark");
|
||||
assert_eq!(px(&page, 5, 5), [255, 255, 0], "the background stays");
|
||||
|
||||
assert_eq!(px(&unmarked.render_page(1, 1.0).unwrap(), 5, 5), [255, 255, 255], "made once, for every page: page 2 is white too");
|
||||
assert_eq!(unmarked.parser_builds(), 1, "parsed once for both pages");
|
||||
assert!(doc.without_marks(MarkKind::HeaderFooter).unwrap().is_none(), "the document has no header");
|
||||
assert!(unmarked.render_page(9, 1.0).is_err(), "no page 10");
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!((doc.edit_generation(), doc.dirty, doc.can_undo()), (generation, dirty, Some("Add watermark")), "the document is unchanged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_indexes_match_after_open_and_save_when_requested() {
|
||||
let (mut source, source_id) = session_with(1);
|
||||
@@ -4808,6 +4994,8 @@ fn scripted_form_with(helpers: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// AcroForm field scripts validate, calculate and format through edits, and a button script's
|
||||
/// changes, messages and requests come back.
|
||||
#[test]
|
||||
fn form_javascript_validates_calculates_and_formats() {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
@@ -4826,7 +5014,7 @@ fn form_javascript_validates_calculates_and_formats() {
|
||||
// A button script changes fields in one undoable step, and asks for a page.
|
||||
let o =
|
||||
s.run_javascript(id, "getField('price').value = 3; getField('total').readonly = true; app.alert('Done'); this.print();", Some("go")).unwrap();
|
||||
assert_eq!(o.alerts, ["Done"]);
|
||||
assert_eq!(o.alerts, [crate::js::Message::plain("Done")]);
|
||||
assert_eq!(o.requests, [js::Request::Print]);
|
||||
assert_eq!(value(&s, "total"), ["12"], "recalculated after the change");
|
||||
assert!(s.get(id).unwrap().form.iter().find(|f| f.name == "total").unwrap().read_only());
|
||||
@@ -4890,17 +5078,17 @@ fn active_xfa_does_not_run_pdf_container_scripts_but_keeps_requested_alerts() {
|
||||
assert!(s.take_js_output(id).alerts.is_empty());
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
assert_eq!(s.get(id).unwrap().document_scripts(), [("viewerFallback".into(), fallback.into())]);
|
||||
let out = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Hello 2"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Hello 2")]);
|
||||
assert_eq!(out.requests, [js::Request::Print]);
|
||||
// The same context rule applies to format/validate/calculate through JsRunner.
|
||||
s.apply(id, Edit::SetFieldScript { name: "qty".into(), event: "validate".into(), script: Some("app.alert('Field message');".into()) }).unwrap();
|
||||
s.take_js_output(id);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("3".into()) }).unwrap();
|
||||
assert_eq!(s.take_js_output(id).alerts, ["Field message"]);
|
||||
assert_eq!(s.take_js_output(id).alerts, [js::Message::plain("Field message")]);
|
||||
// Saved XFA keeps its container scripts for inspection, but does not execute them.
|
||||
let saved = s.get(id).unwrap().bytes.clone();
|
||||
let reopened = s.open("saved.pdf", None, saved, None).unwrap();
|
||||
@@ -4925,7 +5113,7 @@ fn active_xfa_without_template_scripts_does_not_initialize_pdf_fallback_scripts(
|
||||
s.apply(id, Edit::SetDocumentScript { name: "fallback".into(), script: Some("throw new Error('PDF fallback ran');".into()) }).unwrap();
|
||||
let out = s.run_javascript(id, "app.alert('Console message');", None).unwrap();
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.alerts, ["Console message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("Console message")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4942,7 +5130,7 @@ fn inactive_xfa_and_ordinary_pdfs_keep_document_helpers_and_alerts() {
|
||||
let out = pdfcraft_js::run("twice(3)", &pdfcraft_js::Event::doc("Console"), &runner.doc, &[], &runner.doc_scripts, Default::default());
|
||||
assert!(out.error.is_none(), "{out:?}");
|
||||
assert_eq!(out.result.as_deref(), Some("6"));
|
||||
assert_eq!(out.alerts, ["PDF message"]);
|
||||
assert_eq!(out.alerts, [js::Message::plain("PDF message")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6250,6 +6438,8 @@ fn repeated_xfa_print_buttons_run_their_click_script() {
|
||||
}
|
||||
}
|
||||
|
||||
/// An XFA form's scripts initialize, calculate, validate, show subforms and add and remove rows,
|
||||
/// with undo and save/reopen.
|
||||
#[test]
|
||||
fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::scripted_template()));
|
||||
@@ -6274,7 +6464,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// A failing validate script shows its message; the value stays, as in Acrobat.
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "total"), vec!["2500".to_string()]);
|
||||
// A button adds a row: new fields, same page, one undo step.
|
||||
let before = names(&s).len();
|
||||
@@ -6289,7 +6479,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
// Message boxes reach the viewer.
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
// The check box shows the hidden subform; undo hides it again.
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
@@ -6304,7 +6494,7 @@ fn xfa_scripts_initialize_calculate_validate_toggle_and_add_rows() {
|
||||
// FormCalc scripts run too.
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec!["FormCalc".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("FormCalc")]);
|
||||
// Saved and reopened: the visible subform, the values and the rows are what they were.
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
let id2 = s.open("again.pdf", None, saved, None).unwrap();
|
||||
@@ -6525,6 +6715,7 @@ fn an_xfa_form_converts_to_a_static_form_that_keeps_its_fields() {
|
||||
assert_eq!(value(&s, id, "total"), vec!["15".to_string()]);
|
||||
}
|
||||
|
||||
/// A dynamic XFA form scripted in FormCalc runs as the JavaScript one does.
|
||||
#[test]
|
||||
fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::formcalc_template()));
|
||||
@@ -6543,7 +6734,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
assert_eq!(value(&s, "words"), vec!["Thirty-Five".to_string()]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "qty".into(), value: FieldValue::Text("500".into()) }).unwrap();
|
||||
let out = s.take_js_output(id);
|
||||
assert!(out.alerts.iter().any(|a| a == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert!(out.alerts.iter().any(|a| a.text == "Quantity must be 100 or less"), "{out:?}");
|
||||
assert_eq!(value(&s, "qty"), vec!["500".to_string()]);
|
||||
// Buttons: a row is added, summed and removed; the message box reaches the viewer.
|
||||
let before = names(&s).len();
|
||||
@@ -6554,7 +6745,7 @@ fn dynamic_xfa_forms_run_formcalc_scripts() {
|
||||
s.apply(id, Edit::SetFieldValue { name: "amount_2".into(), value: FieldValue::Text("32".into()) }).unwrap();
|
||||
assert_eq!(value(&s, "grand"), vec!["42".to_string()], "{:?}", s.take_js_output(id));
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.alerts, vec!["Hello 500".to_string()]);
|
||||
assert_eq!(o.alerts, vec![crate::js::Message::plain("Hello 500")]);
|
||||
s.apply(id, Edit::SetFieldValue { name: "more".into(), value: FieldValue::Check(true) }).unwrap();
|
||||
assert!(names(&s).iter().any(|n| n == "note"), "{:?} {:?}", names(&s), s.take_js_output(id));
|
||||
s.run_javascript(id, "", Some("removeRow")).unwrap();
|
||||
@@ -6689,6 +6880,8 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Hostile scripts run on open are merged, capped, kept from printing, saving and opening links,
|
||||
/// and noted.
|
||||
#[test]
|
||||
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
|
||||
@@ -6720,6 +6913,36 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
|
||||
assert!(out.console.iter().any(|c| c.contains("only print when a button is clicked")), "{:?}", out.console);
|
||||
}
|
||||
|
||||
/// An XFA button's message boxes reach the viewer with their titles, icons and buttons, from
|
||||
/// JavaScript (`xfa.host.messageBox`, `app.alert` in its own order) and FormCalc alike, every
|
||||
/// one of them and in order.
|
||||
#[test]
|
||||
fn xfa_message_boxes_keep_their_title_icon_and_buttons() {
|
||||
use crate::js::{Message, MessageButtons, MessageIcon};
|
||||
let tpl = pdfcraft_xfa::fixtures::scripted_template()
|
||||
.replace(
|
||||
r#"xfa.host.messageBox("Hello " + qty.rawValue);"#,
|
||||
r#"xfa.host.messageBox("Clear the rows?", "Order form", 2, 2); app.alert("Saved", 3, 0, "Order form");"#,
|
||||
)
|
||||
.replace(r#"$host.messageBox("FormCalc")"#, r#"$host.messageBox("Too many", "Limits", 1, 1)"#);
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&tpl));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("boxes.pdf", None, bytes, None).expect("opens");
|
||||
let o = s.run_javascript(id, "", Some("hello")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
let titled = |text: &str, title: &str, icon, buttons| Message { text: text.into(), title: Some(title.into()), icon, buttons };
|
||||
assert_eq!(
|
||||
o.alerts,
|
||||
vec![
|
||||
titled("Clear the rows?", "Order form", MessageIcon::Question, MessageButtons::YesNo),
|
||||
titled("Saved", "Order form", MessageIcon::Status, MessageButtons::Ok),
|
||||
]
|
||||
);
|
||||
let o = s.run_javascript(id, "", Some("legacy")).unwrap();
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.alerts, vec![titled("Too many", "Limits", MessageIcon::Warning, MessageButtons::OkCancel)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runaway_xfa_calculations_at_open_end_quickly_with_a_report() {
|
||||
// Nested loops in one frame stop at the loop limit; loops in nested calls (that limit is per
|
||||
@@ -8713,6 +8936,70 @@ fn legacy_border_dashes_and_the_caret_paragraph_symbol_are_drawn() {
|
||||
assert!(blue.count() > 20, "the pilcrow is drawn beside the caret");
|
||||
}
|
||||
|
||||
/// Import comments with a PDF chosen copies that PDF's comments in one undoable step, and refuses
|
||||
/// a PDF that has none.
|
||||
#[test]
|
||||
fn importing_a_pdf_copies_its_comments() {
|
||||
let mut s = Session::new();
|
||||
let id = s.open("doc.pdf", None, Arc::new(pdf_with_annots(&[])), None).unwrap();
|
||||
let review = pdf_with_annots(&[
|
||||
b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
|
||||
b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
|
||||
]);
|
||||
s.apply(id, Edit::ImportData { name: "review.pdf".into(), bytes: Arc::new(review.clone()) }).unwrap();
|
||||
let contents = |s: &Session| s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())], "the comment, not the link");
|
||||
assert_eq!(s.undo(id).unwrap().to_string(), "Import review.pdf");
|
||||
assert!(s.get(id).unwrap().info.annotations.is_empty());
|
||||
|
||||
// Bytes before the header, as the reader allows them.
|
||||
let prefixed = [b"\r\n \n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the review".to_string())]);
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let links = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>"]);
|
||||
let err = s.apply(id, Edit::ImportData { name: "links.pdf".into(), bytes: Arc::new(links.clone()) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), pdfcraft_xfdf::DataError::NothingImported.to_string());
|
||||
// A PDF that needs a password is refused with that reason, with or without bytes before its header.
|
||||
let mut locked = pdfcraft_cos::Document::open(Arc::new(review)).unwrap();
|
||||
locked
|
||||
.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "u",
|
||||
owner_password: "o",
|
||||
permissions: 0,
|
||||
encrypt_metadata: true,
|
||||
seed: [1; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let locked = pdfcraft_cos::write_full(&locked, &Default::default()).unwrap();
|
||||
for bytes in [locked.clone(), [b"\n".as_slice(), &locked].concat()] {
|
||||
let err = s.apply(id, Edit::ImportData { name: "locked.pdf".into(), bytes: Arc::new(bytes) }).unwrap_err();
|
||||
assert_eq!(err.to_string(), EditError::Source("locked.pdf: it is password-protected".into()).to_string());
|
||||
}
|
||||
|
||||
// An XFDF file that mentions the PDF header is still read as XFDF.
|
||||
let xfdf = r#"<?xml version="1.0"?><!-- exported from a file starting %PDF-1.7 --><xfdf xmlns="http://ns.adobe.com/xfdf/"><annots><text page="0" name="n1" rect="10,10,30,30"><contents>from the data file</contents></text></annots></xfdf>"#;
|
||||
s.apply(id, Edit::ImportData { name: "notes.xfdf".into(), bytes: Arc::new(xfdf.as_bytes().to_vec()) }).unwrap();
|
||||
assert_eq!(contents(&s), [Some("from the data file".to_string())]);
|
||||
}
|
||||
|
||||
/// An XFA form imports the comments of a PDF with binary bytes before its header, instead of
|
||||
/// refusing it as a data file in an unknown encoding.
|
||||
#[test]
|
||||
fn an_xfa_form_imports_comments_from_a_pdf_with_binary_bytes_before_its_header() {
|
||||
let data = "<form><page1><country>JP</country></page1></form>";
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::shell(&pdfcraft_xfa::fixtures::fields_template(data)));
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fields.pdf", None, bytes, None).expect("opens");
|
||||
let review = pdf_with_annots(&[b"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>"]);
|
||||
let prefixed = [b"\xC0\xC1\xF5\n".as_slice(), &review].concat();
|
||||
s.apply(id, Edit::ImportData { name: "prefixed.pdf".into(), bytes: Arc::new(prefixed) }).unwrap();
|
||||
let contents: Vec<_> = s.get(id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
|
||||
assert!(contents.contains(&Some("from the review".to_string())), "{contents:?}");
|
||||
}
|
||||
|
||||
/// A one-page 200 × 300 document whose page lists `annots` (objects 4, 5, …).
|
||||
fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
let list: String = (0..annots.len()).map(|i| format!("{} 0 R ", i + 4)).collect();
|
||||
@@ -8737,69 +9024,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its
|
||||
/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the
|
||||
/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked
|
||||
/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without
|
||||
/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was.
|
||||
#[test]
|
||||
fn need_appearances_buttons_are_displayed_from_their_captions() {
|
||||
let pdf = |need: bool| {
|
||||
let flag = if need { "/NeedAppearances true " } else { "" };
|
||||
format!(
|
||||
"%PDF-1.7\n\
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\
|
||||
4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\
|
||||
5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\
|
||||
6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\
|
||||
9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let render = |bytes: Arc<Vec<u8>>| {
|
||||
let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default());
|
||||
r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() })
|
||||
};
|
||||
// Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1.
|
||||
let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| {
|
||||
let (px, _) = page.rgba.as_chunks::<4>();
|
||||
(200 - y1..200 - y0)
|
||||
.flat_map(|y| (x0..x1).map(move |x| (x, y)))
|
||||
.filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128))
|
||||
.count()
|
||||
};
|
||||
// In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside
|
||||
// (where a check mark leaves the box empty), and the lower-left corner (outside a dot).
|
||||
let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34);
|
||||
let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34);
|
||||
let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9);
|
||||
let mut s = Session::new();
|
||||
let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false };
|
||||
let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap();
|
||||
let printed = render(Arc::new(pictures));
|
||||
for (page, how) in [(&shown, "shown"), (&printed, "printed")] {
|
||||
let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100)));
|
||||
assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}");
|
||||
assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty");
|
||||
assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty");
|
||||
assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border");
|
||||
let dot = ink(page, (188, 108, 212, 132));
|
||||
assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}");
|
||||
}
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances");
|
||||
|
||||
// Without /NeedAppearances the file's filled squares are shown.
|
||||
let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap();
|
||||
let shown = render(s.get(id).unwrap().display.clone());
|
||||
for x in [20, 100, 180] {
|
||||
assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone.
|
||||
//! The page beneath an inline text editor, with the source glyphs removed on a COS clone; and,
|
||||
//! the same way, the page beneath a dragged added item or the marks an Update replaces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -18,18 +19,75 @@ impl TextEditPreview {
|
||||
}
|
||||
|
||||
pub fn render_background(&self, scale: f32) -> Result<RenderedPage, String> {
|
||||
render_one(&self.bytes, &self.config, self.page, scale)
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's pages without its marks of one kind ([`Document::without_marks`]).
|
||||
pub struct UnmarkedPages {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
config: RenderConfig,
|
||||
/// Parsed on the first [`Self::render_page`], once (a copy that can't be parsed isn't tried
|
||||
/// again), and kept: parsing costs about as much as reading the document.
|
||||
parsed: Option<PageRenderer>,
|
||||
/// How many times it was parsed.
|
||||
#[cfg(test)]
|
||||
builds: u64,
|
||||
}
|
||||
|
||||
impl UnmarkedPages {
|
||||
pub fn renderer(&self) -> RenderPool {
|
||||
RenderPool::new(self.bytes.clone(), 1, self.config.clone())
|
||||
}
|
||||
|
||||
/// `page` at `scale`, on this thread.
|
||||
pub fn render_page(&mut self, page: usize, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let mut renderer = PageRenderer::new(self.bytes.clone(), self.config.clone());
|
||||
let out = renderer.render(RenderRequest { page: self.page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
if self.parsed.is_none() {
|
||||
self.parsed = Some(PageRenderer::new(self.bytes.clone(), self.config.clone()));
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.builds += 1;
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
let Some(renderer) = self.parsed.as_mut() else { return Err("the document could not be parsed".into()) };
|
||||
let out = guard(|| renderer.render(RenderRequest { page, scale, ..Default::default() }));
|
||||
let out = match out {
|
||||
Ok(out) => out,
|
||||
Err(e) => {
|
||||
// A panic may have left it half-way: parse afresh next time.
|
||||
self.parsed = None;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How many times [`Self::render_page`] has parsed the document.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn parser_builds(&self) -> u64 {
|
||||
self.builds
|
||||
}
|
||||
}
|
||||
|
||||
/// `page` of `bytes` at `scale`, on this thread.
|
||||
fn render_one(bytes: &Arc<Vec<u8>>, config: &RenderConfig, page: usize, scale: f32) -> Result<RenderedPage, String> {
|
||||
if !scale.is_finite() || scale <= 0.0 {
|
||||
return Err("preview scale must be finite and positive".into());
|
||||
}
|
||||
guard(|| {
|
||||
let mut renderer = PageRenderer::new(bytes.clone(), config.clone());
|
||||
let out = renderer.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = &out.error {
|
||||
return Err(e.clone());
|
||||
}
|
||||
Ok(out)
|
||||
})?
|
||||
}
|
||||
|
||||
impl Document {
|
||||
@@ -90,6 +148,33 @@ impl Document {
|
||||
})?
|
||||
}
|
||||
|
||||
/// The document without its marks of `kind`, on every page: what Update replaces them on, so
|
||||
/// its preview doesn't draw the new marks over the old ones (or tint the old background).
|
||||
/// Made once for all the pages a dialog steps through, as cloning and writing the document
|
||||
/// costs about a save. `None` when the document has none, and the thumbnails already are
|
||||
/// those pages. Read-only, like [`Self::text_edit_preview`].
|
||||
pub fn without_marks(&self, kind: crate::MarkKind) -> Result<Option<UnmarkedPages>, String> {
|
||||
if !self.marks().contains(&kind) {
|
||||
return Ok(None);
|
||||
}
|
||||
guard(|| {
|
||||
let editor = self.editor.as_ref().ok_or("the document can't be read")?;
|
||||
let mut cos = editor.cos.clone();
|
||||
let n = pdfcraft_model::pages(&cos).len();
|
||||
if pdfcraft_edit::remove_marks(&mut cos, &(0..n).collect::<Vec<_>>(), kind).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = Arc::new(pdfcraft_cos::write_incremental(&cos, &pdfcraft_cos::SaveOptions::default()).map_err(|e| e.to_string())?);
|
||||
Ok(Some(UnmarkedPages {
|
||||
bytes,
|
||||
config: self.config.clone(),
|
||||
parsed: None,
|
||||
#[cfg(test)]
|
||||
builds: 0,
|
||||
}))
|
||||
})?
|
||||
}
|
||||
|
||||
/// An added item alone, on a transparent page: what follows the pointer while it is dragged,
|
||||
/// so the page under its first place doesn't come along. Read-only, like
|
||||
/// [`Self::added_content_preview`].
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Bookmarks from text styles (#1693): a document's headings are often set apart only by how
|
||||
//! they look (chapter titles in one font at one size, sections in another), not tagged as
|
||||
//! headings, so New Bookmarks from Structure finds nothing. [`styles`] lists the styles the
|
||||
//! text is set in; [`headings`] finds the lines in the styles chosen for each bookmark level.
|
||||
//!
|
||||
//! A style is a font (without its subset tag: `ABCDEF+Garamond` and `GHIJKL+Garamond` are
|
||||
//! subsets of one font), the size the text is shown at, and whether it is bold and italic.
|
||||
//! Lines are taken in reading order down each page: top to bottom, then left to right.
|
||||
|
||||
use pdfcraft_cos::Document;
|
||||
use pdfcraft_organize::OutlineEntry;
|
||||
|
||||
/// How a line of text looks.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyle {
|
||||
/// The font's name, without a subset tag.
|
||||
pub font: String,
|
||||
/// The size the text is shown at, in points (rounded to a tenth).
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
}
|
||||
|
||||
impl TextStyle {
|
||||
/// Whether `other` is this style. Sizes within a quarter point match, as the same heading
|
||||
/// size is often written with rounding differences (12 and 11.96).
|
||||
pub(crate) fn matches(&self, other: &TextStyle) -> bool {
|
||||
self.font == other.font && self.bold == other.bold && self.italic == other.italic && (self.size - other.size).abs() <= SIZE_TOLERANCE
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart two sizes may be and still be one style.
|
||||
const SIZE_TOLERANCE: f64 = 0.25;
|
||||
|
||||
/// A style and how much text is set in it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TextStyleUse {
|
||||
pub style: TextStyle,
|
||||
/// Lines in this style.
|
||||
pub lines: usize,
|
||||
/// Pages they are on.
|
||||
pub pages: usize,
|
||||
/// The first line in it, cut to [`SAMPLE_CHARS`] characters.
|
||||
pub sample: String,
|
||||
}
|
||||
|
||||
/// The most pages read: a longer document is listed and searched by its first pages.
|
||||
pub const MAX_PAGES: usize = 5000;
|
||||
/// The most bookmarks made at once.
|
||||
pub const MAX_BOOKMARKS: usize = 10_000;
|
||||
/// The longest bookmark title kept, in characters (a heading run on into a paragraph).
|
||||
const MAX_TITLE_CHARS: usize = 300;
|
||||
/// The longest sample shown for a style.
|
||||
const SAMPLE_CHARS: usize = 60;
|
||||
/// The most styles listed, the largest first.
|
||||
const MAX_STYLES: usize = 200;
|
||||
|
||||
/// One line of text in reading order.
|
||||
struct Line {
|
||||
page: usize,
|
||||
text: String,
|
||||
style: TextStyle,
|
||||
/// Top, bottom, left and right of its box in user space.
|
||||
top: f64,
|
||||
bottom: f64,
|
||||
left: f64,
|
||||
right: f64,
|
||||
}
|
||||
|
||||
/// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2).
|
||||
fn without_subset_tag(name: &str) -> &str {
|
||||
match name.split_once('+') {
|
||||
Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) && !rest.is_empty() => rest,
|
||||
_ => name,
|
||||
}
|
||||
}
|
||||
|
||||
/// The text lines of `doc`'s first [`MAX_PAGES`] pages, each page's in reading order. Text that
|
||||
/// is only whitespace, or whose size can't be read, is left out.
|
||||
fn lines(doc: &Document) -> Vec<Line> {
|
||||
let pages = pdfcraft_organize::page_count(doc).unwrap_or(0).min(MAX_PAGES);
|
||||
let mut out = Vec::new();
|
||||
for page in 0..pages {
|
||||
let Ok(found) = pdfcraft_edit::text_lines(doc, page) else { continue };
|
||||
let mut on_page: Vec<Line> = found
|
||||
.into_iter()
|
||||
.filter_map(|l| {
|
||||
let text = l.text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// `size` is the size the line is shown at in user space (the `Tf` size times the
|
||||
// text rendering matrix), so `1 Tf` with a 14× text matrix is 14.
|
||||
let size = l.size;
|
||||
let [x0, y0, x1, y1] = l.rect;
|
||||
let ok = !text.is_empty() && size.is_finite() && size > 0.0 && [x0, y0, x1, y1].iter().all(|v| v.is_finite());
|
||||
ok.then(|| Line {
|
||||
page,
|
||||
text,
|
||||
style: TextStyle {
|
||||
font: without_subset_tag(&l.base_font).to_string(),
|
||||
size: (size * 10.0).round() / 10.0,
|
||||
bold: l.bold,
|
||||
italic: l.italic,
|
||||
},
|
||||
top: y0.max(y1),
|
||||
bottom: y0.min(y1),
|
||||
left: x0.min(x1),
|
||||
right: x0.max(x1),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Down the page; along each row from the left. Boxes of one row differ by a fraction
|
||||
// of a point, so rows are compared at a tenth of a point.
|
||||
on_page.sort_by(|a, b| (b.top * 10.0).round().total_cmp(&(a.top * 10.0).round()).then(a.left.total_cmp(&b.left)));
|
||||
out.extend(on_page);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The styles `doc`'s text is set in, the largest size first (then the most used): headings
|
||||
/// are usually larger than the text around them.
|
||||
pub fn styles(doc: &Document) -> Vec<TextStyleUse> {
|
||||
let mut uses: Vec<(TextStyleUse, Option<usize>)> = Vec::new();
|
||||
// The styles by font, weight, slant and size in quarter points, so a line is matched against
|
||||
// the few styles near its size rather than every style found (a page can hold thousands).
|
||||
// A size matches within a quarter point: the bucket's neighbours are looked at too.
|
||||
let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec<usize>> = std::collections::HashMap::new();
|
||||
let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64;
|
||||
for line in lines(doc) {
|
||||
let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b);
|
||||
let b = bucket(line.style.size);
|
||||
let found = [b.saturating_sub(1), b, b.saturating_add(1)]
|
||||
.into_iter()
|
||||
.filter_map(|b| index.get(&key(b)))
|
||||
.flatten()
|
||||
.copied()
|
||||
.find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style)));
|
||||
match found.and_then(|i| uses.get_mut(i)) {
|
||||
Some((u, last_page)) => {
|
||||
u.lines = u.lines.saturating_add(1);
|
||||
if *last_page != Some(line.page) {
|
||||
u.pages = u.pages.saturating_add(1);
|
||||
*last_page = Some(line.page);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let sample = line.text.chars().take(SAMPLE_CHARS).collect();
|
||||
index.entry(key(b)).or_default().push(uses.len());
|
||||
uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut uses: Vec<TextStyleUse> = uses.into_iter().map(|(u, _)| u).collect();
|
||||
uses.sort_by(|a, b| b.style.size.total_cmp(&a.style.size).then(b.lines.cmp(&a.lines)));
|
||||
uses.truncate(MAX_STYLES);
|
||||
uses
|
||||
}
|
||||
|
||||
/// The bookmarks for the lines in `levels`' styles: a line in `levels[0]`'s style is a level-1
|
||||
/// bookmark, `levels[1]`'s a level-2 one under the level-1 bookmark before it, and so on. A
|
||||
/// heading set on several lines (one after another in the same style, close below each other)
|
||||
/// is one bookmark. Each goes to the top of its heading.
|
||||
pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec<OutlineEntry> {
|
||||
let mut entries: Vec<OutlineEntry> = Vec::new();
|
||||
// The heading the previous line belonged to: its level, and the top, bottom, left and right
|
||||
// of its last line.
|
||||
let mut open: Option<(usize, f64, f64, f64, f64)> = None;
|
||||
for line in lines(doc) {
|
||||
let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else {
|
||||
open = None;
|
||||
continue;
|
||||
};
|
||||
// A heading goes on to the next row down, close below its last line and under it. A line
|
||||
// on the same row (two headings side by side), or in another column, is a heading of
|
||||
// its own.
|
||||
let continues = open.is_some_and(|(l, top, bottom, left, right)| {
|
||||
let size = line.style.size;
|
||||
let under = line.left < right && left < line.right;
|
||||
l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under
|
||||
}) && entries.last().is_some_and(|e| e.page == line.page);
|
||||
if continues {
|
||||
if let Some(e) = entries.last_mut()
|
||||
&& e.title.chars().count() < MAX_TITLE_CHARS
|
||||
{
|
||||
e.title.push(' ');
|
||||
e.title.push_str(&line.text);
|
||||
e.title = e.title.chars().take(MAX_TITLE_CHARS).collect();
|
||||
}
|
||||
} else {
|
||||
if entries.len() >= MAX_BOOKMARKS {
|
||||
break;
|
||||
}
|
||||
// The level counts from 1; the position in `levels` is at most its length, which a
|
||||
// caller keeps small (a few levels), so it fits a u8.
|
||||
let level = u8::try_from(level.saturating_add(1)).unwrap_or(u8::MAX);
|
||||
entries.push(OutlineEntry {
|
||||
level,
|
||||
title: line.text.chars().take(MAX_TITLE_CHARS).collect(),
|
||||
page: line.page,
|
||||
element: None,
|
||||
top: Some(line.top),
|
||||
});
|
||||
}
|
||||
open = Some((level, line.top, line.bottom, line.left, line.right));
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subset_tags_are_left_out_of_the_font_name() {
|
||||
assert_eq!(without_subset_tag("ABCDEF+Garamond-Bold"), "Garamond-Bold");
|
||||
assert_eq!(without_subset_tag("Garamond-Bold"), "Garamond-Bold");
|
||||
assert_eq!(without_subset_tag("ABCDE+Garamond"), "ABCDE+Garamond", "a tag is six letters");
|
||||
assert_eq!(without_subset_tag("abcdef+Garamond"), "abcdef+Garamond", "capital letters");
|
||||
assert_eq!(without_subset_tag("ABCDEF+"), "ABCDEF+", "nothing after the tag");
|
||||
assert_eq!(without_subset_tag("ÄBCDEF+x"), "ÄBCDEF+x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styles_match_within_a_quarter_point() {
|
||||
let s = |size| TextStyle { font: "Garamond".into(), size, bold: true, italic: false };
|
||||
assert!(s(12.0).matches(&s(11.8)));
|
||||
assert!(!s(12.0).matches(&s(11.7)));
|
||||
assert!(!s(12.0).matches(&TextStyle { bold: false, ..s(12.0) }));
|
||||
assert!(!s(12.0).matches(&TextStyle { font: "Times".into(), ..s(12.0) }));
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,7 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep console `lines`, up to [`MAX_CONSOLE_PER_EVENT`] for one event; the rest are counted.
|
||||
fn console(&mut self, lines: impl IntoIterator<Item = String>) {
|
||||
for l in lines {
|
||||
if self.run.out.console.len() < MAX_CONSOLE_PER_EVENT {
|
||||
@@ -398,7 +399,8 @@ impl<'a, 'b> Runner<'a, 'b> {
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(&mut self, m: String) {
|
||||
/// Show message `m`, up to [`MAX_ALERTS_PER_EVENT`] for one event; the rest are counted.
|
||||
fn alert(&mut self, m: crate::js::Message) {
|
||||
if self.run.out.alerts.len() < MAX_ALERTS_PER_EVENT {
|
||||
self.run.out.alerts.push(m);
|
||||
} else {
|
||||
@@ -847,7 +849,7 @@ pub(crate) fn on_changes(doc: &mut pdfcraft_cos::Document, tpl: &Template, names
|
||||
for ev in r.events_of(&som, "validate") {
|
||||
let Some(o) = r.run(&ev, &value)? else { continue };
|
||||
if (o.result_bool == Some(false) || o.error.is_some()) && !value.trim().is_empty() {
|
||||
r.alert(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid")));
|
||||
r.alert(crate::js::Message::plain(ev.message.clone().unwrap_or_else(|| format!("{name}: the value is not valid"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ mod look;
|
||||
mod tree;
|
||||
|
||||
pub use edit::set_props;
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, duplicate_field};
|
||||
pub use lifecycle::{add_field, delete_field, delete_field_object, delete_widget, duplicate_field};
|
||||
pub(crate) use look::button_appearance;
|
||||
pub use look::{BorderStyle, CheckStyle, FieldFont, Look, LookPatch, check_style, look, redraw_field, set_button_icon};
|
||||
pub(crate) use tree::{ensure_form, form_fonts_as_objects};
|
||||
|
||||
@@ -197,8 +197,78 @@ pub fn delete_field_object(doc: &mut Document, obj: ObjRef) -> Result<(), FormEr
|
||||
delete_found_field(doc, f)
|
||||
}
|
||||
|
||||
/// Delete widget `widget` (an index into the field's widgets) of the field `name`, keeping the
|
||||
/// field and its other widgets: one radio button of a group, or one copy of a duplicated field,
|
||||
/// as Acrobat's Prepare Form deletes the selected one (#4609). A field with one widget is deleted
|
||||
/// whole. A per-widget `/Opt` entry goes with its widget, and a check box or radio group that was
|
||||
/// on through the deleted widget alone turns off.
|
||||
pub fn delete_widget(doc: &mut Document, name: &str, widget: usize) -> Result<(), FormError> {
|
||||
let f = fields(doc).into_iter().find(|f| f.name == name).ok_or_else(|| FormError::NoSuchField(name.into()))?;
|
||||
let Some(w) = f.widgets.get(widget).cloned() else {
|
||||
return invalid(format!("{name} has no widget {}", widget.saturating_add(1)));
|
||||
};
|
||||
// A field merged with its widget is that widget's only one.
|
||||
if f.widgets.len() < 2 || w.obj == f.obj {
|
||||
return delete_found_field(doc, f);
|
||||
}
|
||||
let per_widget_opt = matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) && f.options.len() == f.widgets.len();
|
||||
// Turn the group off when no button left *shows* the deleted one's state: a twin with the same
|
||||
// on-state name that is currently off does not keep the choice visible.
|
||||
let on_only_here = w.on_state.as_deref().is_some_and(|on| {
|
||||
f.value.iter().any(|v| v == on) && !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.state.as_deref() == Some(on))
|
||||
});
|
||||
drop_from_pages(doc, &std::iter::once(w.obj).collect())?;
|
||||
detach(doc, w.obj)?;
|
||||
drop_from_calculation_order(doc, |r| r == w.obj)?;
|
||||
let mut d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut changed = false;
|
||||
// The field's own `/Kids` is what made the widget its: `detach` follows the widget's
|
||||
// `/Parent`, which a damaged file can leave out or point elsewhere, so the field would keep
|
||||
// a widget no page shows.
|
||||
if let Some(kids) = d.get(b"Kids").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
|
||||
&& kids.iter().any(|k| k.as_ref() == Some(w.obj))
|
||||
{
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids.into_iter().filter(|k| k.as_ref() != Some(w.obj)).collect()));
|
||||
changed = true;
|
||||
}
|
||||
if per_widget_opt
|
||||
&& let Some(opt) = d.get(b"Opt").map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned())
|
||||
&& opt.len() == f.widgets.len()
|
||||
{
|
||||
let kept: Vec<Object> = opt.into_iter().enumerate().filter(|(i, _)| *i != widget).map(|(_, o)| o).collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(kept));
|
||||
changed = true;
|
||||
}
|
||||
if on_only_here && d.get(b"V").is_some() {
|
||||
d.set(b"V".to_vec(), Object::name("Off"));
|
||||
changed = true;
|
||||
}
|
||||
// The default (`/DV`, what Reset puts back) can't name a state no button has any more.
|
||||
let dv_only_here = w.on_state.as_deref().is_some_and(|on| {
|
||||
d.get(b"DV").map(|v| doc.resolve(v)).and_then(|v| v.as_name().map(<[u8]>::to_vec)).is_some_and(|dv| dv == on.as_bytes())
|
||||
&& !f.widgets.iter().enumerate().any(|(i, o)| i != widget && o.on_state.as_deref() == Some(on))
|
||||
});
|
||||
if dv_only_here {
|
||||
d.set(b"DV".to_vec(), Object::name("Off"));
|
||||
changed = true;
|
||||
}
|
||||
if changed {
|
||||
doc.set(f.obj, Object::Dict(d));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
let widgets: std::collections::HashSet<ObjRef> = f.widgets.iter().map(|w| w.obj).collect();
|
||||
drop_from_pages(doc, &widgets)?;
|
||||
detach(doc, f.obj)?;
|
||||
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
|
||||
// widgets reachable for a full save.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
|
||||
}
|
||||
|
||||
/// Take the annotations `widgets` off every page's `/Annots`, held directly or by reference.
|
||||
fn drop_from_pages(doc: &mut Document, widgets: &std::collections::HashSet<ObjRef>) -> Result<(), FormError> {
|
||||
for p in page_refs(doc) {
|
||||
let Some(a) = doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()) else { continue };
|
||||
let list = doc.resolve(&a).as_array().cloned().unwrap_or_default();
|
||||
@@ -210,10 +280,7 @@ fn delete_found_field(doc: &mut Document, f: Field) -> Result<(), FormError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
detach(doc, f.obj)?;
|
||||
// Out of the calculation order too: a `/CO` entry would keep the field, its value and its
|
||||
// widgets reachable for a full save.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj || widgets.contains(&r))
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove the `/CO` entries `gone` matches, whether the form and the array are held directly or
|
||||
|
||||
@@ -27,7 +27,7 @@ pub mod table;
|
||||
pub use actions::{FieldAction, Trigger, field_action_chains, field_actions, set_field_actions};
|
||||
pub use author::{
|
||||
BorderStyle, CheckStyle, FieldFont, FieldProps, Look, LookPatch, MIN_FIELD_SIDE, NewField, add_field, check_style, delete_field,
|
||||
delete_field_object, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
delete_field_object, delete_widget, duplicate_field, look, redraw_field, set_button_icon, set_props,
|
||||
};
|
||||
pub use calc_order::{CalculatedField, CalculationIssue, CalculationOrder, calculation_order, set_calculation_order};
|
||||
pub use scripting::{
|
||||
@@ -46,7 +46,7 @@ pub use read::{Fields, acroform, adopted_page_fields, fields, fields_with_adopte
|
||||
#[cfg(test)]
|
||||
use read::{MAX_SCRIPT_BYTES, PageWalk, enumerate, form_reads};
|
||||
use read::{script, script_budget};
|
||||
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order};
|
||||
pub use tab_order::{TabOrder, move_in_tab_order, set_tab_order, tab_orders};
|
||||
#[cfg(test)]
|
||||
use values::has_password_flag;
|
||||
use values::write_value;
|
||||
|
||||
@@ -4,9 +4,11 @@ use super::{Document, Field, FormError, ObjRef, Object, invalid};
|
||||
/// A page's tab order (`/Tabs`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TabOrder {
|
||||
/// Rows, top to bottom, left to right within a row (`/R`).
|
||||
/// Rows, top to bottom, left to right within a row (`/R`; right to left in a document bound
|
||||
/// on the right).
|
||||
Row,
|
||||
/// Columns, left to right, top to bottom within a column (`/C`).
|
||||
/// Columns, left to right, top to bottom within a column (`/C`; columns right to left in a
|
||||
/// document bound on the right).
|
||||
Column,
|
||||
/// The document structure (`/S`; annotation order when the file has none).
|
||||
Structure,
|
||||
@@ -23,6 +25,12 @@ impl TabOrder {
|
||||
_ => TabOrder::Annotations,
|
||||
}
|
||||
}
|
||||
|
||||
/// The tab order of the page at `r`, with an indirect `/Tabs` resolved.
|
||||
fn of_page(doc: &Document, r: ObjRef) -> TabOrder {
|
||||
let tabs = doc.get(r).as_dict().and_then(|d| d.get(b"Tabs")).map(|o| doc.resolve(o));
|
||||
TabOrder::of(tabs.as_deref().and_then(Object::as_name))
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget on a page for tab ordering: (page, field, widget, rect, annotation index).
|
||||
@@ -38,13 +46,20 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col
|
||||
}
|
||||
}
|
||||
}
|
||||
// Rows and columns run right to left in a document bound on the right: rank the mirrored
|
||||
// rectangles there.
|
||||
if right_to_left(doc) {
|
||||
for i in &mut items {
|
||||
i.3 = [-i.3[2], i.3[1], -i.3[0], i.3[3]];
|
||||
}
|
||||
}
|
||||
let mut ordered: Vec<(usize, usize)> = Vec::with_capacity(items.len());
|
||||
for (pi, p) in pages.iter().enumerate() {
|
||||
let mut on: Vec<_> = items.iter().filter(|i| i.0 == pi).collect();
|
||||
if on.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tabs = TabOrder::of(doc.get(*p).as_dict().and_then(|d| d.name(b"Tabs")));
|
||||
let tabs = TabOrder::of_page(doc, *p);
|
||||
match tabs {
|
||||
TabOrder::Row | TabOrder::Column => {
|
||||
let row = tabs == TabOrder::Row;
|
||||
@@ -87,6 +102,13 @@ pub(crate) fn rank_tabs(doc: &Document, pages: &[ObjRef], annot_index: &std::col
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the document is bound on the right (`/ViewerPreferences /Direction /R2L`).
|
||||
fn right_to_left(doc: &Document) -> bool {
|
||||
let Some(root) = doc.root() else { return false };
|
||||
let Some(prefs) = doc.get(root).as_dict().and_then(|c| c.get(b"ViewerPreferences")).map(|o| doc.resolve(o)) else { return false };
|
||||
prefs.as_dict().and_then(|p| p.get(b"Direction")).map(|o| doc.resolve(o)).is_some_and(|d| d.as_name() == Some(b"R2L"))
|
||||
}
|
||||
|
||||
/// Set the tab order of pages (0-based).
|
||||
pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Result<(), FormError> {
|
||||
let refs = page_refs(doc);
|
||||
@@ -104,6 +126,11 @@ pub fn set_tab_order(doc: &mut Document, pages: &[usize], order: TabOrder) -> Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tab order of each page, in page order (`/Tabs`; a page without one is unspecified).
|
||||
pub fn tab_orders(doc: &Document) -> Vec<TabOrder> {
|
||||
page_refs(doc).into_iter().map(|r| TabOrder::of_page(doc, r)).collect()
|
||||
}
|
||||
|
||||
/// Order tabs manually (Acrobat: Fields ▸ Tab Order ▸ Order Tabs Manually, then drag a field):
|
||||
/// move field `name` one place earlier or later in its page's tab order. The page switches to
|
||||
/// annotation order (`/Tabs` removed) and its widgets are reordered in `/Annots`; other
|
||||
|
||||
@@ -1875,6 +1875,73 @@ fn tab_order_follows_the_page_setting() {
|
||||
assert!(set_tab_order(&mut doc.clone(), &[3], TabOrder::Row).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_and_columns_tab_right_to_left_in_a_document_bound_on_the_right() {
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
// A (50,700) B (300,700)
|
||||
// C (50,600) D (300,600)
|
||||
for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] {
|
||||
add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let order = |doc: &Document| -> String {
|
||||
let mut all: Vec<(usize, String)> = fields(doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect();
|
||||
all.sort();
|
||||
all.into_iter().map(|x| x.1).collect()
|
||||
};
|
||||
let root = doc.root().unwrap();
|
||||
let mut prefs = pdfcraft_cos::Dict::new();
|
||||
prefs.set(b"Direction".to_vec(), Object::name("R2L"));
|
||||
let prefs = doc.add(prefs);
|
||||
doc.update_dict(root, |c| c.set(b"ViewerPreferences".to_vec(), Object::Ref(prefs))).unwrap();
|
||||
set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap();
|
||||
assert_eq!(order(&doc), "BADC", "rows top to bottom, each right to left");
|
||||
set_tab_order(&mut doc, &[0], TabOrder::Column).unwrap();
|
||||
assert_eq!(order(&doc), "BDAC", "columns right to left, each top to bottom");
|
||||
assert_eq!(order(&reopen(&doc)), "BDAC");
|
||||
set_tab_order(&mut doc, &[0], TabOrder::Annotations).unwrap();
|
||||
assert_eq!(order(&doc), "CADB", "unspecified keeps annotation order");
|
||||
doc.update_dict(prefs, |p| p.set(b"Direction".to_vec(), Object::name("L2R"))).unwrap();
|
||||
set_tab_order(&mut doc, &[0], TabOrder::Row).unwrap();
|
||||
assert_eq!(order(&doc), "ABCD", "bound on the left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_orders_are_read_for_each_page() {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R 6 0 R 7 0 R] /Count 5 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Tabs /C >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Tabs /S >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Tabs /W >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Tabs 8 0 R >>".into(),
|
||||
"/R".into(),
|
||||
]);
|
||||
use TabOrder::{Annotations, Column, Row, Structure};
|
||||
assert_eq!(tab_orders(&doc), [Column, Annotations, Structure, Annotations, Row], "no /Tabs, or an unknown name: unspecified");
|
||||
set_tab_order(&mut doc, &[1], Row).unwrap();
|
||||
set_tab_order(&mut doc, &[0, 4], Annotations).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(tab_orders(&doc), [Annotations, Row, Structure, Annotations, Annotations]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indirect_tabs_value_orders_the_page() {
|
||||
let mut doc = one_page();
|
||||
let text = NewField::Text { multiline: false };
|
||||
for (name, x, y) in [("C", 50.0, 600.0), ("A", 50.0, 700.0), ("D", 300.0, 600.0), ("B", 300.0, 700.0)] {
|
||||
add_field(&mut doc, 0, [x, y, x + 200.0, y + 20.0], &text, Some(name)).unwrap();
|
||||
}
|
||||
let column = doc.add(Object::name("C"));
|
||||
let page = page_refs(&doc)[0];
|
||||
doc.update_dict(page, |p| p.set(b"Tabs".to_vec(), Object::Ref(column))).unwrap();
|
||||
let mut all: Vec<(usize, String)> = fields(&doc).into_iter().map(|f| (f.widgets[0].tab, f.name)).collect();
|
||||
all.sort();
|
||||
assert_eq!(all.into_iter().map(|x| x.1).collect::<String>(), "ACBD", "columns, as a direct /C orders them");
|
||||
assert_eq!(tab_orders(&doc), [TabOrder::Column]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_tab_flags_alignment_and_defaults() {
|
||||
let mut doc = one_page();
|
||||
@@ -4621,6 +4688,152 @@ fn set_values_reads_the_form_once_not_per_value() {
|
||||
assert_eq!(reads, 3, "one to fill, one when f0 comes again, one to recalculate");
|
||||
}
|
||||
|
||||
/// A radio group `size` with the buttons Small (on), Medium and Large, a per-widget `/Opt`, and
|
||||
/// the group's buttons in the calculation order; and a text field `name` with two widgets.
|
||||
fn radio_group_form() -> Document {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R 8 0 R 10 0 R 11 0 R] >>".into(), // 3
|
||||
"<< /Fields [5 0 R 9 0 R] /DA (/Helv 0 Tf 0 g) /CO [7 0 R] >>".into(), // 4
|
||||
"<< /FT /Btn /Ff 49152 /T (size) /V /S /Opt [(small) (medium) (large)] /Kids [6 0 R 7 0 R 8 0 R] >>".into(), // 5
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [50 700 64 714] /P 3 0 R /AS /S /AP << /N << /S 12 0 R /Off 12 0 R >> >> >>".into(), // 6
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [80 700 94 714] /P 3 0 R /AS /Off /AP << /N << /M 12 0 R /Off 12 0 R >> >> >>".into(), // 7
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 5 0 R /Rect [110 700 124 714] /P 3 0 R /AS /Off /AP << /N << /L 12 0 R /Off 12 0 R >> >> >>".into(), // 8
|
||||
"<< /FT /Tx /T (name) /V (Ann) /Kids [10 0 R 11 0 R] >>".into(), // 9
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [50 600 250 620] /P 3 0 R >>".into(), // 10
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 9 0 R /Rect [300 600 500 620] /P 3 0 R >>".into(), // 11
|
||||
"<< /Length 0 >>\nstream\n\nendstream".into(), // 12
|
||||
];
|
||||
document(&objs)
|
||||
}
|
||||
|
||||
fn annots(doc: &Document) -> Vec<u32> {
|
||||
let p = page_refs(doc)[0];
|
||||
let a = doc.resolve(doc.get(p).as_dict().unwrap().get(b"Annots").unwrap());
|
||||
a.as_array().unwrap().iter().map(|o| o.as_ref().unwrap().num).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_radio_button_keeps_the_group_and_its_other_buttons() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [6, 8], "only Medium is gone");
|
||||
assert_eq!(size.value, ["S"], "the group keeps its choice");
|
||||
// The per-widget /Opt loses Medium's entry, so the others keep their export values.
|
||||
assert_eq!((size.export_of(0), size.export_of(1)), (Some("small"), Some("large")));
|
||||
assert_eq!(annots(&doc), [6, 8, 10, 11], "the button left the page");
|
||||
let co = doc.resolve(doc.get(ObjRef::new(4, 0)).as_dict().unwrap().get(b"CO").unwrap()).as_array().cloned().unwrap();
|
||||
assert!(co.is_empty(), "and the calculation order");
|
||||
assert!(field(&all, "name").widgets.len() == 2, "other fields are untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_radio_button_turns_the_group_off() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let all = fields(&reopen(&doc));
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [7, 8]);
|
||||
assert!(size.value.is_empty(), "no button is on any more: {:?}", size.value);
|
||||
assert_eq!((size.export_of(0), size.export_of(1)), (Some("medium"), Some("large")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_chosen_radio_button_turns_the_group_off_when_a_twin_is_not_shown_on() {
|
||||
// Medium shares Small's on-state name but shows Off: once Small is gone, no button shows
|
||||
// the group's /V, so the group must not keep reporting it.
|
||||
let mut doc = radio_group_form();
|
||||
let twin = ObjRef::new(7, 0);
|
||||
let blank = Object::Ref(ObjRef::new(12, 0));
|
||||
let mut n = Dict::new();
|
||||
n.set(b"S".to_vec(), blank.clone());
|
||||
n.set(b"Off".to_vec(), blank);
|
||||
let mut ap = Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut w = doc.get(twin).as_dict().cloned().unwrap();
|
||||
w.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
doc.set(twin, Object::Dict(w));
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let all = fields(&reopen(&doc));
|
||||
let size = field(&all, "size");
|
||||
assert_eq!(size.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [7, 8]);
|
||||
assert!(size.value.is_empty(), "no button shows S any more: {:?}", size.value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_the_default_radio_button_clears_the_default_too() {
|
||||
// The group is off, but Reset would put back Small, which no button has once it is gone.
|
||||
let mut doc = radio_group_form();
|
||||
let size = ObjRef::new(5, 0);
|
||||
let mut d = doc.get(size).as_dict().cloned().unwrap();
|
||||
d.set(b"V".to_vec(), Object::name("Off"));
|
||||
d.set(b"DV".to_vec(), Object::name("S"));
|
||||
doc.set(size, Object::Dict(d));
|
||||
delete_widget(&mut doc, "size", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let dv = doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned());
|
||||
assert_eq!(dv, Some(Object::name("Off")));
|
||||
// A default another button still has stays.
|
||||
let mut doc = radio_group_form();
|
||||
let mut d = doc.get(size).as_dict().cloned().unwrap();
|
||||
d.set(b"DV".to_vec(), Object::name("S"));
|
||||
doc.set(size, Object::Dict(d));
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
assert_eq!(doc.get(size).as_dict().and_then(|d| d.get(b"DV").cloned()), Some(Object::name("S")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_one_copy_of_a_field_keeps_the_field_and_its_value() {
|
||||
let mut doc = radio_group_form();
|
||||
delete_widget(&mut doc, "name", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let name = field(&all, "name");
|
||||
assert_eq!((name.widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), name.value.clone()), (vec![11], vec!["Ann".to_string()]));
|
||||
assert_eq!(annots(&doc), [6, 7, 8, 11]);
|
||||
// The last widget takes the field with it, as deleting the field does.
|
||||
let mut doc = doc;
|
||||
delete_widget(&mut doc, "name", 0).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert!(!fields(&doc).iter().any(|f| f.name == "name"));
|
||||
assert_eq!(annots(&doc), [6, 7, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_radio_button_without_its_parent_link_takes_it_out_of_the_group() {
|
||||
// A damaged file: the group lists Medium in its /Kids, but Medium has no /Parent.
|
||||
for parent in [None, Some(Object::Ref(ObjRef::new(9, 0)))] {
|
||||
let mut doc = radio_group_form();
|
||||
let medium = ObjRef::new(7, 0);
|
||||
let mut w = doc.get(medium).as_dict().cloned().unwrap();
|
||||
w.remove(b"Parent");
|
||||
if let Some(p) = parent.clone() {
|
||||
w.set(b"Parent".to_vec(), p);
|
||||
}
|
||||
doc.set(medium, Object::Dict(w));
|
||||
delete_widget(&mut doc, "size", 1).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "size").widgets.iter().map(|w| w.obj.num).collect::<Vec<_>>(), [6, 8], "/Parent {parent:?}");
|
||||
let kids = doc.resolve(doc.get(ObjRef::new(5, 0)).as_dict().unwrap().get(b"Kids").unwrap()).as_array().cloned().unwrap();
|
||||
assert!(!kids.iter().any(|k| k.as_ref() == Some(medium)), "/Parent {parent:?}: {kids:?}");
|
||||
assert_eq!(field(&all, "name").widgets.len(), 2, "/Parent {parent:?}: the other field is untouched");
|
||||
assert_eq!(annots(&doc), [6, 8, 10, 11]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_widget_that_is_not_there_changes_nothing() {
|
||||
let mut doc = radio_group_form();
|
||||
assert_eq!(delete_widget(&mut doc, "size", 3), Err(FormError::Invalid("size has no widget 4".into())));
|
||||
assert_eq!(delete_widget(&mut doc, "nope", 0), Err(FormError::NoSuchField("nope".into())));
|
||||
assert_eq!(delete_widget(&mut doc, "size", usize::MAX), Err(FormError::Invalid(format!("size has no widget {}", usize::MAX))));
|
||||
assert_eq!(field(&fields(&doc), "size").widgets.len(), 3);
|
||||
}
|
||||
/// Two new radio groups on one page, "Group1" (export `a`) and "Group2" (export `b`).
|
||||
fn two_radio_groups(a: &str, b: &str) -> Document {
|
||||
let mut doc = one_page();
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::Limits;
|
||||
use crate::xfa::{
|
||||
HNode, XHost, XfaDoc, XfaEffect, XfaEvent, XfaKind, XfaNode, XfaOutcome, child_named, clip, clip_result, descendants_named, instances,
|
||||
reset_names, resolve_som, run_within,
|
||||
};
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
mod access;
|
||||
mod ast;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::ast::{Expr, Stmt};
|
||||
use super::value::{Val, is_numeric_text, num_text};
|
||||
use super::{
|
||||
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip,
|
||||
descendants_named, instances, reset_names, resolve_som,
|
||||
CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, Message, MessageButtons, MessageIcon, R, Rc,
|
||||
TOO_DEEP, XfaEffect, child_named, clip, descendants_named, instances, reset_names, resolve_som,
|
||||
};
|
||||
|
||||
impl Interp<'_> {
|
||||
@@ -237,13 +237,21 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Call `method` on `base` (`$host`, a form object, an instance manager…) with `args`.
|
||||
fn method(&mut self, base: Val, method: &str, args: &[Expr]) -> R<Val> {
|
||||
let m = method.to_ascii_lowercase();
|
||||
match base {
|
||||
Val::Host => match m.as_str() {
|
||||
"messagebox" => {
|
||||
let t = self.arg_str(args, 0)?;
|
||||
self.h.push(XfaEffect::MessageBox(t));
|
||||
// `$host.messageBox(message, title, icon, buttons)`; a missing number is 0,
|
||||
// the default icon and buttons.
|
||||
let text = self.arg_str(args, 0)?;
|
||||
let title = Some(self.arg_str(args, 1)?).filter(|t| !t.is_empty());
|
||||
let icon = MessageIcon::from_code(Some(self.arg_num(args, 2)?));
|
||||
let buttons = MessageButtons::from_code(Some(self.arg_num(args, 3)?));
|
||||
self.h.push(XfaEffect::MessageBox(Message { text, title, icon, buttons }));
|
||||
// Always OK for now: the viewer can't ask the user and run the script on
|
||||
// with the answer yet (#4291).
|
||||
Ok(Val::Num(1.0))
|
||||
}
|
||||
"resetdata" => {
|
||||
|
||||
@@ -2,27 +2,85 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie
|
||||
use super::format::{printd, printf, printx, scand};
|
||||
use super::host::{arg, function, host, s, text, text_line};
|
||||
use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request};
|
||||
use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string};
|
||||
use super::{
|
||||
Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, Message, MessageButtons, MessageIcon, NativeFunction, ObjectInitializer,
|
||||
js_string,
|
||||
};
|
||||
|
||||
// ── app, util, console ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// A message argument: a string, or an object with `cMsg`, cut to a message's length.
|
||||
fn message(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<String> {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_array() && !o.is_callable() => {
|
||||
match named_params(v, key, ctx)? {
|
||||
Some(o) => {
|
||||
let m = o.get(JsString::from(key), ctx)?;
|
||||
text_line(&m, ctx)
|
||||
}
|
||||
_ => text_line(v, ctx),
|
||||
None => text_line(v, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg)`: records the message, given as a string or as an object with `cMsg`, and
|
||||
/// returns 1 (the OK button).
|
||||
/// `v` as a method's single object of named parameters: an object (not an array or function)
|
||||
/// that has `key`, the parameter every such call names. Any other value, a `new String(…)` or a
|
||||
/// caught `Error` included, is the first positional argument, read as text.
|
||||
fn named_params(v: &JsValue, key: &str, ctx: &mut Context) -> JsResult<Option<JsObject>> {
|
||||
match v.as_object() {
|
||||
Some(o) if !o.is_array() && !o.is_callable() && o.has_property(JsString::from(key), ctx)? => Ok(Some(o.clone())),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A message's title: `v` as text cut to a message's length, `None` when it is missing or empty.
|
||||
fn title(v: &JsValue, ctx: &mut Context) -> JsResult<Option<String>> {
|
||||
Ok(Some(text_line(v, ctx)?).filter(|t| !t.is_empty()))
|
||||
}
|
||||
|
||||
/// An icon or buttons number: `v` as a number, `None` when it is missing or isn't one (a value
|
||||
/// that can't be converted counts as missing, so a bad argument never stops the message).
|
||||
fn code(v: &JsValue, ctx: &mut Context) -> Option<f64> {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return None;
|
||||
}
|
||||
v.to_number(ctx).ok()
|
||||
}
|
||||
|
||||
/// `app.alert`'s message: `(cMsg, nIcon, nType, cTitle)`, or one object with those keys (as
|
||||
/// the JavaScript for Acrobat API Reference allows for every method with named parameters).
|
||||
pub(crate) fn alert_message(args: &[JsValue], ctx: &mut Context) -> JsResult<Message> {
|
||||
let first = arg(args, 0);
|
||||
let (text, icon, buttons, t) = match named_params(&first, "cMsg", ctx)? {
|
||||
Some(o) => {
|
||||
let get = |k: &str, ctx: &mut Context| o.get(JsString::from(k), ctx);
|
||||
(get("cMsg", ctx)?, get("nIcon", ctx)?, get("nType", ctx)?, get("cTitle", ctx)?)
|
||||
}
|
||||
None => (first, arg(args, 1), arg(args, 2), arg(args, 3)),
|
||||
};
|
||||
Ok(Message {
|
||||
text: text_line(&text, ctx)?,
|
||||
title: title(&t, ctx)?,
|
||||
icon: MessageIcon::from_code(code(&icon, ctx)),
|
||||
buttons: MessageButtons::from_code(code(&buttons, ctx)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox`'s message: `(message, title, icon, buttons)`, as the XFA
|
||||
/// specification orders them.
|
||||
pub(crate) fn message_box_message(args: &[JsValue], ctx: &mut Context) -> JsResult<Message> {
|
||||
Ok(Message {
|
||||
text: text_line(&arg(args, 0), ctx)?,
|
||||
title: title(&arg(args, 1), ctx)?,
|
||||
icon: MessageIcon::from_code(code(&arg(args, 2), ctx)),
|
||||
buttons: MessageButtons::from_code(code(&arg(args, 3), ctx)),
|
||||
})
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)`: records the message with its title, icon and buttons,
|
||||
/// given in that order or as one object with those keys, and returns 1 (the OK button).
|
||||
fn alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = message(&arg(args, 0), "cMsg", ctx)?;
|
||||
let m = alert_message(args, ctx)?;
|
||||
host(ctx)?.borrow_mut().alert(m);
|
||||
// The OK button.
|
||||
// Always the OK button for now: the viewer can't ask the user and run the script on with
|
||||
// the answer yet (#4291).
|
||||
Ok(JsValue::from(1))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use super::model::{DocInfo, FieldState, FieldType, Request};
|
||||
use super::run::MAX_ARRAY_LENGTH;
|
||||
use super::{
|
||||
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell,
|
||||
xfa,
|
||||
Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, Message, NativeFunction,
|
||||
RefCell, xfa,
|
||||
};
|
||||
|
||||
pub(crate) struct Host {
|
||||
pub(crate) fields: Arc<Vec<FieldState>>,
|
||||
pub(crate) changed: BTreeSet<usize>,
|
||||
pub(crate) alerts: Vec<String>,
|
||||
pub(crate) alerts: Vec<Message>,
|
||||
pub(crate) console: Vec<String>,
|
||||
pub(crate) requests: Vec<Request>,
|
||||
pub(crate) doc: DocInfo,
|
||||
@@ -33,10 +33,12 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn alert(&mut self, m: String) {
|
||||
/// Keep message `m`, its text and title charged to the run's byte budget, up to
|
||||
/// [`xfa::MAX_ALERTS`] messages.
|
||||
pub(crate) fn alert(&mut self, m: Message) {
|
||||
if self.alerts.len() >= xfa::MAX_ALERTS {
|
||||
self.dropped = self.dropped.saturating_add(1);
|
||||
} else if self.room(m.len()) {
|
||||
} else if self.room(m.bytes()) {
|
||||
self.alerts.push(m);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ pub(crate) use globals::menu_item;
|
||||
use globals::platform;
|
||||
use host::{arg, as_number, error, function, s, text};
|
||||
pub use model::{
|
||||
DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Outcome, Panel, Request,
|
||||
DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, DocInfo, Event, FieldState, FieldType, Limits, Message, MessageButtons,
|
||||
MessageIcon, Outcome, Panel, Request,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use run::SCRIPT_STACK;
|
||||
|
||||
+107
-1
@@ -115,6 +115,111 @@ pub struct DocInfo {
|
||||
pub xfa: bool,
|
||||
}
|
||||
|
||||
/// The icon a script's message shows: `app.alert`'s `nIcon` and `xfa.host.messageBox`'s
|
||||
/// icon, numbered 0 to 3 in the JavaScript for Acrobat API Reference and the XFA
|
||||
/// specification alike.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MessageIcon {
|
||||
/// 0, the default.
|
||||
#[default]
|
||||
Error,
|
||||
/// 1.
|
||||
Warning,
|
||||
/// 2.
|
||||
Question,
|
||||
/// 3.
|
||||
Status,
|
||||
}
|
||||
|
||||
impl MessageIcon {
|
||||
/// The icon numbered `code`; a missing, fractional or out-of-range number is the default.
|
||||
pub fn from_code(code: Option<f64>) -> MessageIcon {
|
||||
match whole_code(code) {
|
||||
Some(1) => MessageIcon::Warning,
|
||||
Some(2) => MessageIcon::Question,
|
||||
Some(3) => MessageIcon::Status,
|
||||
_ => MessageIcon::Error,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable name for tools and logs: `error`, `warning`, `question` or `status`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MessageIcon::Error => "error",
|
||||
MessageIcon::Warning => "warning",
|
||||
MessageIcon::Question => "question",
|
||||
MessageIcon::Status => "status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The buttons a script's message offers: `app.alert`'s `nType` and `xfa.host.messageBox`'s
|
||||
/// buttons, numbered 0 to 3 in both references.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum MessageButtons {
|
||||
/// 0, the default.
|
||||
#[default]
|
||||
Ok,
|
||||
/// 1.
|
||||
OkCancel,
|
||||
/// 2.
|
||||
YesNo,
|
||||
/// 3.
|
||||
YesNoCancel,
|
||||
}
|
||||
|
||||
impl MessageButtons {
|
||||
/// The buttons numbered `code`; a missing, fractional or out-of-range number is the default.
|
||||
pub fn from_code(code: Option<f64>) -> MessageButtons {
|
||||
match whole_code(code) {
|
||||
Some(1) => MessageButtons::OkCancel,
|
||||
Some(2) => MessageButtons::YesNo,
|
||||
Some(3) => MessageButtons::YesNoCancel,
|
||||
_ => MessageButtons::Ok,
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable name for tools and logs: `ok`, `ok_cancel`, `yes_no` or `yes_no_cancel`.
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MessageButtons::Ok => "ok",
|
||||
MessageButtons::OkCancel => "ok_cancel",
|
||||
MessageButtons::YesNo => "yes_no",
|
||||
MessageButtons::YesNoCancel => "yes_no_cancel",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `code` as one of the numbers 0 to 3 the references define, when it is exactly one.
|
||||
fn whole_code(code: Option<f64>) -> Option<u8> {
|
||||
let c = code.filter(|c| c.is_finite() && c.fract() == 0.0 && (0.0..=3.0).contains(c))?;
|
||||
// In 0..=3 and whole, so the cast is exact.
|
||||
Some(c as u8)
|
||||
}
|
||||
|
||||
/// A message a script showed (`app.alert`, `xfa.host.messageBox`), with the title, icon and
|
||||
/// buttons it asked for. Its text and title are cut to a message's length.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Message {
|
||||
pub text: String,
|
||||
/// The dialog's title, when the script gave one.
|
||||
pub title: Option<String>,
|
||||
pub icon: MessageIcon,
|
||||
pub buttons: MessageButtons,
|
||||
}
|
||||
|
||||
impl Message {
|
||||
/// A message with only text: no title, the default icon and an OK button.
|
||||
pub fn plain(text: impl Into<String>) -> Message {
|
||||
Message { text: text.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
/// Bytes of text the message holds, charged to a run's budget.
|
||||
pub(crate) fn bytes(&self) -> usize {
|
||||
self.text.len().saturating_add(self.title.as_ref().map_or(0, String::len))
|
||||
}
|
||||
}
|
||||
|
||||
/// Something a script asked the viewer to do.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Request {
|
||||
@@ -173,7 +278,8 @@ pub struct Outcome {
|
||||
pub change: String,
|
||||
/// Fields whose value or properties the script changed, as they are now.
|
||||
pub changed: Vec<FieldState>,
|
||||
pub alerts: Vec<String>,
|
||||
/// The messages the script showed, in order.
|
||||
pub alerts: Vec<Message>,
|
||||
pub console: Vec<String>,
|
||||
pub requests: Vec<Request>,
|
||||
/// The script's error (syntax error, exception, or a limit reached), if it failed.
|
||||
|
||||
+182
-10
@@ -37,11 +37,12 @@ fn numbers_with_leading_zeros_stay_text() {
|
||||
assert_eq!(o.value, "string:number");
|
||||
}
|
||||
|
||||
/// A validate script refuses a value with `event.rc = false` and says why in a message.
|
||||
#[test]
|
||||
fn validate_scripts_reject_with_rc_and_alert() {
|
||||
let o = go("if (event.value > 10) { app.alert('Too many'); event.rc = false; }", &Event::field("Validate", "qty", "11"));
|
||||
assert!(!o.rc);
|
||||
assert_eq!(o.alerts, ["Too many"]);
|
||||
assert_eq!(o.alerts, [Message::plain("Too many")]);
|
||||
let o = go("if (event.value > 10) { event.rc = false; }", &Event::field("Validate", "qty", "3"));
|
||||
assert!(o.rc);
|
||||
}
|
||||
@@ -251,9 +252,11 @@ fn kept_bytes(o: &Outcome) -> usize {
|
||||
Request::Submit(u) | Request::LaunchUrl(u) | Request::Focus(u) => u.len(),
|
||||
_ => 0,
|
||||
};
|
||||
o.console.iter().chain(&o.alerts).map(String::len).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
o.console.iter().map(String::len).sum::<usize>() + o.alerts.iter().map(Message::bytes).sum::<usize>() + o.requests.iter().map(req).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Console lines, messages and requests are capped in number and in bytes, and what is left out is
|
||||
/// noted.
|
||||
#[test]
|
||||
fn exec_menu_item_requests_are_capped_like_other_requests() {
|
||||
let o = go("for (var i = 0; i < 12000; i++) app.execMenuItem('Print');", &Event::field("Mouse Up", "agree", ""));
|
||||
@@ -275,11 +278,99 @@ fn console_alerts_and_requests_are_capped_in_count_and_bytes() {
|
||||
assert!(o.console.len() <= crate::xfa::MAX_CONSOLE + 1, "{} console lines", o.console.len());
|
||||
assert!(o.alerts.len() <= crate::xfa::MAX_ALERTS, "{} alerts", o.alerts.len());
|
||||
assert!(o.requests.len() <= crate::xfa::MAX_EFFECTS, "{} requests", o.requests.len());
|
||||
assert!(o.console.iter().chain(&o.alerts).all(|m| m.chars().count() <= 4_097));
|
||||
assert!(o.console.iter().chain(o.alerts.iter().map(|m| &m.text)).all(|m| m.chars().count() <= 4_097));
|
||||
assert!(kept_bytes(&o) <= crate::xfa::MAX_PAYLOAD, "{} bytes kept", kept_bytes(&o));
|
||||
assert!(o.console.last().is_some_and(|l| l.contains("left out")), "{:?}", o.console.last());
|
||||
}
|
||||
|
||||
/// The message `app.alert(args)` shows, from a button's Mouse Up script.
|
||||
fn alerted(args: &str) -> Message {
|
||||
let o = go(&format!("app.alert({args});"), &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.error, None, "{args}");
|
||||
assert_eq!(o.alerts.len(), 1, "{args}");
|
||||
o.alerts.into_iter().next().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)` keeps every icon and button set the JavaScript for
|
||||
/// Acrobat API Reference numbers, and the title.
|
||||
#[test]
|
||||
fn app_alert_keeps_its_title_icon_and_buttons() {
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let m = alerted(&format!("'Sure?', {i}, {b}, 'Order form'"));
|
||||
assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}");
|
||||
}
|
||||
}
|
||||
// Only the message: no title, the Error icon and the OK button, the defaults.
|
||||
assert_eq!(alerted("'Saved'"), Message::plain("Saved"));
|
||||
assert_eq!(alerted("'Saved', 3"), Message { icon: MessageIcon::Status, ..Message::plain("Saved") });
|
||||
}
|
||||
|
||||
/// The single-object form names the same parameters, in any order, and reads nothing else.
|
||||
#[test]
|
||||
fn app_alert_reads_the_object_form() {
|
||||
let m = alerted("{ cTitle: 'Order form', nType: 2, cMsg: 'Clear the rows?', nIcon: 2, bBeep: true }");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Clear the rows?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::YesNo }
|
||||
);
|
||||
assert_eq!(alerted("{ cMsg: 'Done' }"), Message::plain("Done"));
|
||||
}
|
||||
|
||||
/// An object without `cMsg` isn't the object form: it is the message itself, as text, so a
|
||||
/// String object or a caught error shows what it says; the same for `app.launchURL` and `cURL`.
|
||||
#[test]
|
||||
fn objects_that_name_no_parameter_are_the_message_itself() {
|
||||
assert_eq!(alerted("new String('Saved')"), Message::plain("Saved"));
|
||||
let o = go("try { throw new Error('No such field'); } catch (e) { app.alert(e); }", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.alerts.first().map(|m| m.text.as_str()), Some("Error: No such field"), "{o:?}");
|
||||
let o = go("app.launchURL(new String('https://example.com/form'));", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.requests, vec![Request::LaunchUrl("https://example.com/form".into())], "{o:?}");
|
||||
}
|
||||
|
||||
/// An icon or button number that isn't one of the four (fractional, negative, too large, not a
|
||||
/// number, or a value that can't be converted) is the default, and the message still shows;
|
||||
/// a number given as text counts.
|
||||
#[test]
|
||||
fn app_alert_falls_back_on_bad_icon_and_button_numbers() {
|
||||
for bad in ["4", "-1", "1.5", "NaN", "Infinity", "'x'", "{}", "Symbol('s')", "null", "undefined"] {
|
||||
let m = alerted(&format!("'Hi', {bad}, {bad}, ''"));
|
||||
assert_eq!(m, Message::plain("Hi"), "{bad}");
|
||||
}
|
||||
assert_eq!(alerted("'Hi', '2', '3'"), Message { icon: MessageIcon::Question, buttons: MessageButtons::YesNoCancel, ..Message::plain("Hi") });
|
||||
// Every call still answers OK (1) until answers are asked for (#4291).
|
||||
let o = go("event.value = String(app.alert('Delete?', 2, 2));", &Event::field("Mouse Up", "agree", ""));
|
||||
assert_eq!(o.value, "1");
|
||||
}
|
||||
|
||||
/// A title is cut to a message's length like the text, and both are charged to the run's byte
|
||||
/// budget: titles can't carry more text out than messages can.
|
||||
#[test]
|
||||
fn app_alert_titles_are_clipped_and_charged() {
|
||||
use crate::host::Host;
|
||||
let m = alerted("'Hi', 0, 0, new Array(100000).join('t')");
|
||||
assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097), "{:?}", m.title.as_ref().map(String::len));
|
||||
// The title's bytes count against the budget with the text's, and a message the budget
|
||||
// has no room for is left out and counted.
|
||||
let mut h = Host {
|
||||
fields: Arc::new(Vec::new()),
|
||||
changed: BTreeSet::new(),
|
||||
alerts: Vec::new(),
|
||||
console: Vec::new(),
|
||||
requests: Vec::new(),
|
||||
doc: doc(),
|
||||
kept: 0,
|
||||
dropped: 0,
|
||||
};
|
||||
h.alert(Message { title: Some("Order form".into()), ..Message::plain("Saved") });
|
||||
assert_eq!(h.kept, "Saved".len() + "Order form".len());
|
||||
h.kept = crate::xfa::MAX_PAYLOAD - "Saved".len();
|
||||
h.alert(Message { title: Some("T".into()), ..Message::plain("Saved") });
|
||||
assert_eq!((h.alerts.len(), h.dropped), (1, 1), "a title past the budget leaves its message out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printd_reads_a_long_literal_run_in_linear_work() {
|
||||
crate::PRINTD_SCANNED.store(0, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -435,8 +526,8 @@ fn scripts_cannot_compile_code_from_strings() {
|
||||
}
|
||||
|
||||
mod xfa_model {
|
||||
use crate::Limits;
|
||||
use crate::xfa::*;
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
pub(super) fn field(name: &str, som: &str, value: &str, numeric: bool) -> XfaNode {
|
||||
XfaNode {
|
||||
@@ -670,6 +761,8 @@ mod xfa_model {
|
||||
assert_eq!(o.result.as_deref(), Some("4/1"));
|
||||
}
|
||||
|
||||
/// `xfa.host`, `xfa.layout`, `xfa.event` and `app` calls become effects, and messages keep
|
||||
/// their details.
|
||||
#[test]
|
||||
fn host_layout_event_and_app_calls_become_effects() {
|
||||
let o = go(
|
||||
@@ -681,7 +774,12 @@ mod xfa_model {
|
||||
assert_eq!(
|
||||
o.effects,
|
||||
vec![
|
||||
XfaEffect::MessageBox("Hi".into()),
|
||||
XfaEffect::MessageBox(Message {
|
||||
text: "Hi".into(),
|
||||
title: Some("T".into()),
|
||||
icon: MessageIcon::Status,
|
||||
buttons: MessageButtons::OkCancel
|
||||
}),
|
||||
XfaEffect::ResetData(vec!["qty".into(), "price".into()]),
|
||||
XfaEffect::Print,
|
||||
XfaEffect::SaveAs,
|
||||
@@ -726,6 +824,7 @@ mod xfa_model {
|
||||
assert_eq!(o.result.as_deref(), Some("1002"));
|
||||
}
|
||||
|
||||
/// A value set again replaces the earlier one, and hostile loops end at the caps.
|
||||
#[test]
|
||||
fn values_set_again_merge_and_hostile_loops_are_capped() {
|
||||
// A calculate that sets its own value in a loop leaves one effect: the last value.
|
||||
@@ -747,7 +846,56 @@ mod xfa_model {
|
||||
assert!(o.notes.iter().any(|n| n.contains("changes")), "{:?}", o.notes);
|
||||
// Long messages are cut.
|
||||
let o = go("xfa.host.messageBox(new Array(100000).join('x'));", "form1[0].page1[0]", "click");
|
||||
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.chars().count() <= 4_097));
|
||||
assert!(matches!(&o.effects[0], XfaEffect::MessageBox(m) if m.text.chars().count() <= 4_097));
|
||||
}
|
||||
|
||||
/// The message box `script` showed from a button's click script.
|
||||
fn boxed(script: &str) -> Message {
|
||||
let o = go(script, "form1[0].page1[0].go[0]", "click");
|
||||
assert_eq!(o.error, None, "{script}");
|
||||
match o.effects.as_slice() {
|
||||
[XfaEffect::MessageBox(m)] => m.clone(),
|
||||
other => panic!("{script}: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox(message, title, icon, buttons)` keeps every icon and button set the
|
||||
/// XFA specification numbers, and the title.
|
||||
#[test]
|
||||
fn message_box_keeps_its_title_icon_and_buttons() {
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let m = boxed(&format!("xfa.host.messageBox('Sure?', 'Order form', {i}, {b})"));
|
||||
assert_eq!(m, Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind }, "{i} {b}");
|
||||
}
|
||||
}
|
||||
assert_eq!(boxed("xfa.host.messageBox('Saved')"), Message::plain("Saved"));
|
||||
// A bad number is the default; the message still shows.
|
||||
assert_eq!(boxed("xfa.host.messageBox('Hi', '', 7, -2)"), Message::plain("Hi"));
|
||||
assert_eq!(boxed("xfa.host.messageBox('Hi', null, 2.5, 'x')"), Message::plain("Hi"));
|
||||
let m = boxed("xfa.host.messageBox('Hi', new Array(100000).join('t'))");
|
||||
assert!(m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097));
|
||||
// Answers are always OK for now (#4291).
|
||||
let o = go("this.rawValue = xfa.host.messageBox('Delete?', 'T', 2, 2)", "form1[0].page1[0].qty[0]", "click");
|
||||
assert!(o.effects.iter().any(|e| matches!(e, XfaEffect::SetValue { value, .. } if value == "1")), "{:?}", o.effects);
|
||||
}
|
||||
|
||||
/// `app.alert` in an XFA form's script reads its own order (`cMsg, nIcon, nType, cTitle`)
|
||||
/// and its object form, not `messageBox`'s.
|
||||
#[test]
|
||||
fn app_alert_in_an_xfa_script_reads_app_alerts_arguments() {
|
||||
let m = boxed("app.alert('Sure?', 1, 3, 'Order form')");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Warning, buttons: MessageButtons::YesNoCancel }
|
||||
);
|
||||
let m = boxed("app.alert({ cMsg: 'Sure?', cTitle: 'Order form', nIcon: 2, nType: 1 })");
|
||||
assert_eq!(
|
||||
m,
|
||||
Message { text: "Sure?".into(), title: Some("Order form".into()), icon: MessageIcon::Question, buttons: MessageButtons::OkCancel }
|
||||
);
|
||||
}
|
||||
|
||||
/// Past the byte budget, adding or removing a row is left out of the snapshot as well as the
|
||||
@@ -818,6 +966,7 @@ mod xfa_model {
|
||||
assert_eq!(names, 1);
|
||||
}
|
||||
|
||||
/// Time the engine takes to start is not taken from the script's own time.
|
||||
#[test]
|
||||
fn a_slow_engine_start_does_not_count_against_the_script() {
|
||||
let ms = std::time::Duration::from_millis;
|
||||
@@ -852,9 +1001,9 @@ mod xfa_model {
|
||||
|
||||
mod formcalc {
|
||||
use super::xfa_model::form;
|
||||
use crate::Limits;
|
||||
use crate::formcalc::run_formcalc;
|
||||
use crate::xfa::*;
|
||||
use crate::{Limits, Message, MessageButtons, MessageIcon};
|
||||
|
||||
fn go(script: &str, target: &str, activity: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: activity.into(), target: target.into(), ..Default::default() };
|
||||
@@ -1165,6 +1314,7 @@ s"#
|
||||
assert!(o.error.unwrap().contains("endif"));
|
||||
}
|
||||
|
||||
/// FormCalc resolves form objects, assigns to them and records the effects.
|
||||
#[test]
|
||||
fn form_objects_resolve_assign_and_record_effects() {
|
||||
assert_formcalc_reads_fields_and_rows();
|
||||
@@ -1242,7 +1392,7 @@ table._row.count"#,
|
||||
XfaEffect::SetValue { som: "form1[0].page1[0].qty[0]".into(), value: "7".into() },
|
||||
XfaEffect::SetPresence { som: "form1[0].page1[0].details[0]".into(), presence: "visible".into() },
|
||||
XfaEffect::SetAccess { som: "form1[0].page1[0].price[0]".into(), access: "readOnly".into() },
|
||||
XfaEffect::MessageBox("Total 17.5".into()),
|
||||
XfaEffect::MessageBox(Message::plain("Total 17.5")),
|
||||
XfaEffect::SetFocus("form1[0].page1[0].qty[0]".into()),
|
||||
XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() },
|
||||
]
|
||||
@@ -1311,6 +1461,7 @@ $host.gotoURL("https://example.org")"#,
|
||||
assert_eq!(run(&script).result.as_deref(), Some("2"));
|
||||
}
|
||||
|
||||
/// Hostile FormCalc scripts end with an error and bounded output.
|
||||
#[test]
|
||||
fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() {
|
||||
assert_hostile_control_flow_is_bounded();
|
||||
@@ -1361,7 +1512,7 @@ $host.gotoURL("https://example.org")"#,
|
||||
);
|
||||
assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long"));
|
||||
let o = run_hostile_formcalc("$host.messageBox(Space(100000))");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len());
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.text.chars().count() <= 4100), "{:?}", o.effects.len());
|
||||
let o = run_hostile_formcalc("qty = Space(100000)");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len());
|
||||
assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes);
|
||||
@@ -1467,6 +1618,27 @@ $host.gotoURL("https://example.org")"#,
|
||||
assert!(o.abandoned || o.error.is_some(), "{o:?}");
|
||||
}
|
||||
|
||||
/// FormCalc's `$host.messageBox(message, title, icon, buttons)` keeps every icon and button
|
||||
/// set and the title, as the JavaScript one does.
|
||||
#[test]
|
||||
fn formcalc_message_box_keeps_its_title_icon_and_buttons() {
|
||||
let fc = |s: &str| go(s, "form1[0].page1[0].go[0]", "click");
|
||||
let icons = [MessageIcon::Error, MessageIcon::Warning, MessageIcon::Question, MessageIcon::Status];
|
||||
let buttons = [MessageButtons::Ok, MessageButtons::OkCancel, MessageButtons::YesNo, MessageButtons::YesNoCancel];
|
||||
for (i, icon) in icons.iter().enumerate() {
|
||||
for (b, kind) in buttons.iter().enumerate() {
|
||||
let o = fc(&format!("$host.messageBox(\"Sure?\", \"Order form\", {i}, {b})"));
|
||||
let want = Message { text: "Sure?".into(), title: Some("Order form".into()), icon: *icon, buttons: *kind };
|
||||
assert_eq!(o.effects, vec![XfaEffect::MessageBox(want)], "{i} {b}");
|
||||
}
|
||||
}
|
||||
// Missing or bad numbers are the defaults; a long title is cut like the message.
|
||||
assert_eq!(fc("$host.messageBox(\"Hi\")").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]);
|
||||
assert_eq!(fc("$host.messageBox(\"Hi\", \"\", 9, 1.5)").effects, vec![XfaEffect::MessageBox(Message::plain("Hi"))]);
|
||||
let o = fc("$host.messageBox(\"Hi\", Space(100000))");
|
||||
assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.title.as_deref().is_some_and(|t| t.chars().count() <= 4_097)));
|
||||
}
|
||||
|
||||
fn run_hostile_formcalc(script: &str) -> XfaOutcome {
|
||||
let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() };
|
||||
let doc = XfaDoc::default();
|
||||
@@ -1884,5 +2056,5 @@ fn a_plain_form_sees_the_xfa_version_but_not_installed() {
|
||||
let o = go(XFA_CHECK, &Event::doc("Open"));
|
||||
assert_eq!(o.error, None);
|
||||
assert_eq!(o.value, "undefined number 3.5");
|
||||
assert_eq!(o.alerts, ["old viewer"]);
|
||||
assert_eq!(o.alerts, [Message::plain("old viewer")]);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ use boa_engine::object::builtins::{JsArray, JsProxy};
|
||||
use boa_engine::property::Attribute;
|
||||
use boa_engine::{Context, JsObject, JsResult, JsString, JsValue, NativeFunction, Source, js_string};
|
||||
|
||||
use crate::{Limits, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
use crate::{Limits, Message, arg, as_number, error, function, platform, printd, printf, printx, refuse, s, scand, text};
|
||||
|
||||
mod globals;
|
||||
mod host;
|
||||
|
||||
@@ -9,17 +9,28 @@ use super::{
|
||||
|
||||
// ── xfa.host, xfa.layout, xfa.event, app, console ───────────────────────────────────────────
|
||||
|
||||
/// Records effect `e` on the host, as [`XHost::push`] does.
|
||||
/// Records effect `e` on the host, against the run's limits, as [`XHost::push`] does.
|
||||
fn effect(ctx: &mut Context, e: XfaEffect) -> JsResult<()> {
|
||||
host(ctx)?.borrow_mut().push(e);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `xfa.host.messageBox(message)`: records the message, cut to [`MAX_LINE`] characters, and
|
||||
/// returns 1.
|
||||
/// `xfa.host.messageBox(message, title, icon, buttons)`: records the message with its title,
|
||||
/// icon and buttons, its text and title cut to [`MAX_LINE`] characters, and returns 1.
|
||||
fn message_box(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = clip(text(&arg(args, 0), ctx)?);
|
||||
let m = crate::globals::message_box_message(args, ctx)?;
|
||||
effect(ctx, XfaEffect::MessageBox(m))?;
|
||||
// Always OK for now: the viewer can't ask the user and run the script on with the answer
|
||||
// yet (#4291).
|
||||
Ok(JsValue::from(1.0))
|
||||
}
|
||||
|
||||
/// `app.alert(cMsg, nIcon, nType, cTitle)` in an XFA form's script: the same message box, its
|
||||
/// arguments in `app.alert`'s order (or one object with those keys).
|
||||
fn app_alert(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> JsResult<JsValue> {
|
||||
let m = crate::globals::alert_message(args, ctx)?;
|
||||
effect(ctx, XfaEffect::MessageBox(m))?;
|
||||
// The OK button, as for `messageBox` (#4291).
|
||||
Ok(JsValue::from(1.0))
|
||||
}
|
||||
|
||||
@@ -211,7 +222,7 @@ pub(super) fn install(ctx: &mut Context, event: &XfaEvent, doc: &XfaDoc, current
|
||||
ctx.register_global_property(js_string!("event"), ev, rw)?;
|
||||
ctx.register_global_property(js_string!("__xfa_this"), this_node, rw)?;
|
||||
let app = ObjectInitializer::new(ctx)
|
||||
.function(NativeFunction::from_fn_ptr(message_box), js_string!("alert"), 4)
|
||||
.function(NativeFunction::from_fn_ptr(app_alert), js_string!("alert"), 4)
|
||||
.function(NativeFunction::from_fn_ptr(beep), js_string!("beep"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(response), js_string!("response"), 1)
|
||||
.function(NativeFunction::from_fn_ptr(goto_url), js_string!("launchURL"), 2)
|
||||
|
||||
@@ -2,7 +2,7 @@ use super::model::{
|
||||
MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value,
|
||||
effect_key, payload,
|
||||
};
|
||||
use super::{Context, HashMap, JsResult, RefCell, error};
|
||||
use super::{Context, HashMap, JsResult, Message, RefCell, error};
|
||||
|
||||
/// One node of the flattened form, shared by the JavaScript and FormCalc engines.
|
||||
pub(crate) struct HNode {
|
||||
@@ -133,7 +133,7 @@ impl XHost {
|
||||
/// `e` with its text cut to the lengths an effect keeps.
|
||||
fn clip_effect(&mut self, e: XfaEffect) -> XfaEffect {
|
||||
match e {
|
||||
XfaEffect::MessageBox(m) => XfaEffect::MessageBox(clip(m)),
|
||||
XfaEffect::MessageBox(m) => XfaEffect::MessageBox(Message { text: clip(m.text), title: m.title.map(clip), ..m }),
|
||||
XfaEffect::LaunchUrl(u) => XfaEffect::LaunchUrl(clip(u)),
|
||||
XfaEffect::SetFocus(s) => XfaEffect::SetFocus(clip(s)),
|
||||
XfaEffect::ResetData(names) => XfaEffect::ResetData(names.into_iter().map(clip).collect()),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::HashSet;
|
||||
use super::{HashSet, Message};
|
||||
|
||||
/// What kind of template object a node is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -95,7 +95,8 @@ pub enum XfaEffect {
|
||||
RemoveInstance {
|
||||
som: String,
|
||||
},
|
||||
MessageBox(String),
|
||||
/// `xfa.host.messageBox(…)`, or `app.alert(…)` in an XFA form's script.
|
||||
MessageBox(Message),
|
||||
/// `xfa.host.resetData()`: the named fields, or every field when empty.
|
||||
ResetData(Vec<String>),
|
||||
/// `app.execMenuItem("ShowHide…")`: show or hide a side panel.
|
||||
@@ -209,7 +210,8 @@ pub(super) fn payload(e: &XfaEffect) -> usize {
|
||||
som.len().saturating_add(s.len())
|
||||
}
|
||||
XfaEffect::AddInstance { som } | XfaEffect::RemoveInstance { som } => som.len(),
|
||||
XfaEffect::MessageBox(s) | XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::MessageBox(m) => m.bytes(),
|
||||
XfaEffect::LaunchUrl(s) | XfaEffect::SetFocus(s) => s.len(),
|
||||
XfaEffect::ResetData(names) => names.iter().fold(0usize, |n, s| n.saturating_add(s.len()).saturating_add(std::mem::size_of::<String>())),
|
||||
XfaEffect::Print | XfaEffect::SaveAs | XfaEffect::Beep | XfaEffect::Recalculate | XfaEffect::Relayout | XfaEffect::ShowPanel(_) => 0,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
//! Import comments from another PDF (Acrobat: the Comments list's Import Data File, with a PDF
|
||||
//! chosen): each comment is copied onto the page with the same number, at the same place, with
|
||||
//! its appearance, pop-up and replies.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
|
||||
|
||||
use crate::import::{Copier, register_layers};
|
||||
use crate::{OrganizeError, walk};
|
||||
|
||||
/// The annotation subtypes copied as comments: the markup annotations of ISO 32000-2 Table 171,
|
||||
/// except redaction marks (`Redact`) and `Projection`.
|
||||
const COMMENTS: [&[u8]; 16] = [
|
||||
b"Text",
|
||||
b"FreeText",
|
||||
b"Line",
|
||||
b"Square",
|
||||
b"Circle",
|
||||
b"Polygon",
|
||||
b"PolyLine",
|
||||
b"Highlight",
|
||||
b"Underline",
|
||||
b"Squiggly",
|
||||
b"StrikeOut",
|
||||
b"Stamp",
|
||||
b"Caret",
|
||||
b"Ink",
|
||||
b"FileAttachment",
|
||||
b"Sound",
|
||||
];
|
||||
|
||||
/// The Locked annotation flag.
|
||||
const LOCKED: i64 = 128;
|
||||
|
||||
/// One comment of the source: its entry in the page's `/Annots` and its dictionary.
|
||||
struct Incoming {
|
||||
entry: Object,
|
||||
dict: Dict,
|
||||
}
|
||||
|
||||
/// Copy the comments of `src` onto the pages of `dst`: the comments of page 1 onto page 1, those of
|
||||
/// page 2 onto page 2, and so on, each at the position it has in `src`. Comments on pages past the
|
||||
/// last page of `dst` are left out, and so is a reply whose comment is left out. Each copy keeps its
|
||||
/// appearance, its pop-up, its replies and its layer. Links, form fields and redaction marks are
|
||||
/// not copied.
|
||||
///
|
||||
/// A comment named (`/NM`) like one already on its page replaces that one, which keeps its object
|
||||
/// so replies to it stay attached. A locked comment, or an annotation of that name that is no
|
||||
/// comment (a link, a redaction mark, a form widget), is not replaced: the copy is added beside it
|
||||
/// without the name. Returns the number of comments copied.
|
||||
pub fn import_comments(dst: &mut Document, src: &Document) -> Result<usize, OrganizeError> {
|
||||
let from = walk(src)?;
|
||||
let to = walk(dst)?;
|
||||
let pages: Vec<(ObjRef, ObjRef)> = from.iter().zip(&to).map(|((s, _), (d, _))| (*s, *d)).collect();
|
||||
let mut incoming: Vec<Vec<Incoming>> = pages.iter().map(|(s, _)| page_comments(src, *s)).collect();
|
||||
let kept = threads_kept(&incoming);
|
||||
for list in &mut incoming {
|
||||
list.retain(|c| c.dict.get(b"IRT").is_none_or(|irt| irt.as_ref().is_some_and(|p| kept.contains(&p))));
|
||||
}
|
||||
let mut copier = Copier::new(src, HashMap::new(), pages.iter().copied().collect());
|
||||
// Every comment's object is chosen before any is copied, so a comment that another one reaches
|
||||
// through `/IRT` is copied into the object chosen for it by name.
|
||||
let mut lists: Vec<Option<PageAnnots>> = Vec::new();
|
||||
let mut planned: Vec<(usize, ObjRef, Dict)> = Vec::new();
|
||||
for (i, ((_, page), list)) in pages.iter().zip(incoming).enumerate() {
|
||||
if list.is_empty() {
|
||||
lists.push(None);
|
||||
continue;
|
||||
}
|
||||
let mut annots = PageAnnots::read(dst, *page);
|
||||
for Incoming { entry, dict } in list {
|
||||
// The same comment listed twice on a page is copied once.
|
||||
if entry.as_ref().is_some_and(|r| copier.map.contains_key(&r)) {
|
||||
continue;
|
||||
}
|
||||
let (target, dict) = reserve(dst, src, &mut annots, dict);
|
||||
if let Some(r) = entry.as_ref() {
|
||||
copier.map.insert(r, target);
|
||||
}
|
||||
annots.push(target);
|
||||
planned.push((i, target, dict));
|
||||
}
|
||||
lists.push(Some(annots));
|
||||
}
|
||||
let mut annot_pages = HashMap::new();
|
||||
for (i, target, dict) in &planned {
|
||||
let copy = copier.copy_value(dst, &Object::Dict(dict.clone()), Some(*target))?;
|
||||
dst.set(*target, copy);
|
||||
annot_pages.insert(*target, pages[*i].1);
|
||||
}
|
||||
for (i, target, _) in &planned {
|
||||
if let Some(popup) = dst.get(*target).as_dict().and_then(|d| d.reference(b"Popup"))
|
||||
&& let Some(annots) = lists[*i].as_mut()
|
||||
{
|
||||
annots.push(popup);
|
||||
annot_pages.insert(popup, pages[*i].1);
|
||||
}
|
||||
}
|
||||
// A pop-up that only its `/Parent` ties to a copied comment comes along too.
|
||||
for (i, (from, to)) in pages.iter().enumerate() {
|
||||
let Some(annots) = lists[i].as_mut() else { continue };
|
||||
for (popup, parent) in page_popups(src, *from) {
|
||||
if copier.map.contains_key(&popup) || !copier.map.contains_key(&parent) {
|
||||
continue;
|
||||
}
|
||||
let r = dst.add(Object::Null);
|
||||
copier.map.insert(popup, r);
|
||||
let copy = copier.copy_value(dst, &src.get(popup), Some(r))?;
|
||||
dst.set(r, copy);
|
||||
annots.push(r);
|
||||
annot_pages.insert(r, *to);
|
||||
}
|
||||
}
|
||||
for ((_, page), annots) in pages.iter().zip(lists) {
|
||||
if let Some(annots) = annots {
|
||||
dst.update_dict(*page, |p| p.set(b"Annots".to_vec(), Object::Array(annots.into_list())))?;
|
||||
}
|
||||
}
|
||||
copier.fix_annotations(dst, &annot_pages)?;
|
||||
register_layers(dst, src, &copier.ocgs)?;
|
||||
Ok(planned.len())
|
||||
}
|
||||
|
||||
/// Choose the object a comment is copied into: the comment of `annots` with its name, or a new
|
||||
/// object. Returns it with the dictionary to copy.
|
||||
fn reserve(dst: &mut Document, src: &Document, annots: &mut PageAnnots, mut dict: Dict) -> (ObjRef, Dict) {
|
||||
let nm = dict.get(b"NM").and_then(|n| src.resolve(n).as_string().map(|s| s.to_text()));
|
||||
let same = nm.as_deref().and_then(|nm| annots.named(dst, nm));
|
||||
// A second incoming comment with the name of one added earlier is added without the name.
|
||||
let taken = nm.is_some_and(|nm| !annots.added.insert(nm));
|
||||
let target = match same {
|
||||
None if taken => {
|
||||
dict.remove(b"NM");
|
||||
dst.add(Object::Null)
|
||||
}
|
||||
Some((r, false)) => {
|
||||
annots.claimed.insert(r);
|
||||
// The replaced comment's pop-up goes with it: the one it names, and any whose `/Parent`
|
||||
// is the comment.
|
||||
if let Some(p) = dst.get(r).as_dict().and_then(|d| d.reference(b"Popup")) {
|
||||
annots.remove(p);
|
||||
}
|
||||
for p in annots.popups.get(&r).cloned().unwrap_or_default() {
|
||||
annots.remove(p);
|
||||
}
|
||||
r
|
||||
}
|
||||
Some((_, true)) => {
|
||||
dict.remove(b"NM");
|
||||
dst.add(Object::Null)
|
||||
}
|
||||
None => dst.add(Object::Null),
|
||||
};
|
||||
(target, dict)
|
||||
}
|
||||
|
||||
/// The comments of `incoming` that go: a reply goes only with the comment it answers, and that one
|
||||
/// only with the comment it answers, up to the first comment of the thread. A thread whose replies
|
||||
/// answer each other in a loop goes, as all of its comments are there. Each comment is visited once.
|
||||
fn threads_kept(incoming: &[Vec<Incoming>]) -> HashSet<ObjRef> {
|
||||
// For each comment: no parent, a parent comment, or a parent that is no comment of the source.
|
||||
let parents: HashMap<ObjRef, Option<Option<ObjRef>>> =
|
||||
incoming.iter().flatten().filter_map(|c| Some((c.entry.as_ref()?, c.dict.get(b"IRT").map(Object::as_ref)))).collect();
|
||||
let mut known: HashMap<ObjRef, bool> = HashMap::new();
|
||||
for &start in parents.keys() {
|
||||
let (mut path, mut on_path) = (Vec::new(), HashSet::new());
|
||||
let mut at = start;
|
||||
let kept = loop {
|
||||
if let Some(&k) = known.get(&at) {
|
||||
break k;
|
||||
}
|
||||
if !on_path.insert(at) {
|
||||
break true;
|
||||
}
|
||||
match parents.get(&at) {
|
||||
None => break false,
|
||||
Some(None) => {
|
||||
path.push(at);
|
||||
break true;
|
||||
}
|
||||
Some(Some(None)) => {
|
||||
path.push(at);
|
||||
break false;
|
||||
}
|
||||
Some(Some(Some(parent))) => {
|
||||
path.push(at);
|
||||
at = *parent;
|
||||
}
|
||||
}
|
||||
};
|
||||
for r in path {
|
||||
known.insert(r, kept);
|
||||
}
|
||||
}
|
||||
known.into_iter().filter_map(|(r, k)| k.then_some(r)).collect()
|
||||
}
|
||||
|
||||
/// The comments in `page`'s `/Annots`, in order.
|
||||
fn page_comments(src: &Document, page: ObjRef) -> Vec<Incoming> {
|
||||
let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned());
|
||||
list.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
let dict = src.resolve(&entry).as_dict().cloned()?;
|
||||
let subtype = dict.get(b"Subtype").map(|s| src.resolve(s)).and_then(|s| s.as_name().map(<[u8]>::to_vec))?;
|
||||
COMMENTS.contains(&subtype.as_slice()).then_some(Incoming { entry, dict })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The pop-ups in `page`'s `/Annots` with the comment each belongs to: (pop-up, its `/Parent`).
|
||||
fn page_popups(src: &Document, page: ObjRef) -> Vec<(ObjRef, ObjRef)> {
|
||||
let list = src.get(page).as_dict().and_then(|p| p.get(b"Annots").cloned()).map(|a| src.resolve(&a)).and_then(|a| a.as_array().cloned());
|
||||
list.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let r = entry.as_ref()?;
|
||||
let d = src.get(r).as_dict().cloned()?;
|
||||
(d.name(b"Subtype") == Some(b"Popup".as_slice())).then(|| d.reference(b"Parent").map(|p| (r, p)))?
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The annotation in `annots` named `nm` (compared as text, whatever the encoding), and whether it
|
||||
/// must not be replaced: a locked comment, or an annotation that is no comment, such as a link, a
|
||||
/// redaction mark or a form widget.
|
||||
/// A page's `/Annots` while comments are added to it, indexed so that placing each comment does not
|
||||
/// scan the whole list.
|
||||
struct PageAnnots {
|
||||
list: Vec<Object>,
|
||||
members: HashSet<ObjRef>,
|
||||
/// The annotations on the page before the import, by `/NM`, in list order.
|
||||
names: HashMap<String, Vec<ObjRef>>,
|
||||
/// The pop-ups on the page before the import, by `/Parent`.
|
||||
popups: HashMap<ObjRef, Vec<ObjRef>>,
|
||||
removed: HashSet<ObjRef>,
|
||||
/// The annotations that an incoming comment has already replaced.
|
||||
claimed: HashSet<ObjRef>,
|
||||
/// The names of the incoming comments placed so far.
|
||||
added: HashSet<String>,
|
||||
}
|
||||
|
||||
impl PageAnnots {
|
||||
/// The `/Annots` of `page`, with inline annotation dictionaries made indirect objects.
|
||||
fn read(dst: &mut Document, page: ObjRef) -> Self {
|
||||
let list: Vec<Object> = dst
|
||||
.get(page)
|
||||
.as_dict()
|
||||
.and_then(|p| p.get(b"Annots").cloned())
|
||||
.map(|a| dst.resolve(&a))
|
||||
.and_then(|a| a.as_array().cloned())
|
||||
.unwrap_or_default();
|
||||
let list: Vec<Object> = list
|
||||
.into_iter()
|
||||
.map(|a| match a {
|
||||
Object::Dict(_) => Object::Ref(dst.add(a)),
|
||||
a => a,
|
||||
})
|
||||
.collect();
|
||||
let (mut members, mut names, mut popups) = (HashSet::new(), HashMap::<String, Vec<ObjRef>>::new(), HashMap::<ObjRef, Vec<ObjRef>>::new());
|
||||
for r in list.iter().filter_map(Object::as_ref) {
|
||||
members.insert(r);
|
||||
let Some(d) = dst.get(r).as_dict().cloned() else { continue };
|
||||
if let Some(name) = d.get(b"NM").and_then(|n| dst.resolve(n).as_string().map(|s| s.to_text())) {
|
||||
names.entry(name).or_default().push(r);
|
||||
}
|
||||
if d.name(b"Subtype") == Some(b"Popup".as_slice())
|
||||
&& let Some(parent) = d.reference(b"Parent")
|
||||
{
|
||||
popups.entry(parent).or_default().push(r);
|
||||
}
|
||||
}
|
||||
PageAnnots { list, members, names, popups, removed: HashSet::new(), claimed: HashSet::new(), added: HashSet::new() }
|
||||
}
|
||||
|
||||
/// Add `r` at the end, unless the page already lists it.
|
||||
fn push(&mut self, r: ObjRef) {
|
||||
if self.removed.remove(&r) {
|
||||
self.list.retain(|a| a.as_ref() != Some(r));
|
||||
}
|
||||
if self.members.insert(r) {
|
||||
self.list.push(Object::Ref(r));
|
||||
}
|
||||
}
|
||||
|
||||
/// Take every entry for `r` off the page.
|
||||
fn remove(&mut self, r: ObjRef) {
|
||||
if self.members.remove(&r) {
|
||||
self.removed.insert(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// The first annotation on the page named `nm`, and whether it must be kept: it is locked, or
|
||||
/// it is no comment.
|
||||
fn named(&self, dst: &Document, nm: &str) -> Option<(ObjRef, bool)> {
|
||||
let mut listed = self.names.get(nm)?.iter().filter(|r| self.members.contains(r));
|
||||
let first = *listed.clone().next()?;
|
||||
// A second incoming comment with the name of one already replaced is added beside it.
|
||||
let Some(&r) = listed.find(|r| !self.claimed.contains(r)) else { return Some((first, true)) };
|
||||
let d = dst.get(r).as_dict().cloned()?;
|
||||
let flags = d.get(b"F").and_then(|f| dst.resolve(f).as_int()).unwrap_or(0);
|
||||
let subtype = d.get(b"Subtype").map(|s| dst.resolve(s));
|
||||
let comment = subtype.as_deref().and_then(Object::as_name).is_some_and(|s| COMMENTS.contains(&s));
|
||||
Some((r, flags & LOCKED != 0 || !comment || d.contains(b"FT")))
|
||||
}
|
||||
|
||||
/// The new `/Annots`.
|
||||
fn into_list(self) -> Vec<Object> {
|
||||
let removed = self.removed;
|
||||
self.list.into_iter().filter(|a| a.as_ref().is_none_or(|r| !removed.contains(&r))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_cos::{SaveOptions, write_full};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn build(bodies: &[&str]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, b) in bodies.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{b}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", bodies.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", bodies.len() + 1).as_bytes());
|
||||
Document::open(Arc::new(out)).expect("opens")
|
||||
}
|
||||
|
||||
/// The reviewer's copy: three pages of comments, a link and a pop-up among them.
|
||||
fn reviewed() -> Document {
|
||||
build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 13 0 R 7 0 R 8 0 R 9 0 R 15 0 R 16 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [10 0 R 11 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [12 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (from the reviewer) /Rect [10 10 30 30] /Popup 13 0 R /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Stamp /Name /Approved /Rect [50 50 150 100] /AP << /N 14 0 R >> /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 12 0 R /Contents (about page 3) /Rect [10 40 30 60] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Highlight /Rect [0 0 50 20] /QuadPoints [0 20 50 20 0 0 50 0] /Contents (marked) /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 100 50] /Length 13 >>\nstream\n0 0 100 50 re\nendstream",
|
||||
"<< /Type /Annot /Subtype /Text /NM (locked) /Contents (the reviewer's) /Rect [80 10 100 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Redact /Rect [0 200 20 220] /F 2 /P 3 0 R >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// The document receiving the comments: two pages, three comments of its own on page 1.
|
||||
fn original() -> Document {
|
||||
build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R 7 0 R 8 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-1) /Contents (older text) /Rect [10 10 30 30] /Popup 8 0 R /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (locked) /F 128 /Contents (mine) /Rect [80 10 100 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /NM (keep) /Rect [100 100 200 200] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 3 0 R >>",
|
||||
])
|
||||
}
|
||||
|
||||
fn page_annots(doc: &Document, page: usize) -> Vec<(ObjRef, Dict)> {
|
||||
let (p, _) = walk(doc).unwrap()[page].clone();
|
||||
let list =
|
||||
doc.get(p).as_dict().and_then(|d| d.get(b"Annots").cloned()).map(|a| doc.resolve(&a).as_array().cloned().unwrap()).unwrap_or_default();
|
||||
list.iter().map(|a| (a.as_ref().unwrap(), doc.resolve(a).as_dict().cloned().unwrap())).collect()
|
||||
}
|
||||
|
||||
fn text(d: &Dict, key: &[u8]) -> Option<String> {
|
||||
d.get(key).and_then(|v| v.as_string()).map(|s| s.to_text())
|
||||
}
|
||||
|
||||
fn by_contents<'a>(list: &'a [(ObjRef, Dict)], contents: &str) -> &'a (ObjRef, Dict) {
|
||||
list.iter().find(|(_, d)| text(d, b"Contents").as_deref() == Some(contents)).unwrap_or_else(|| panic!("no comment {contents:?}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_of_another_pdf_land_on_the_pages_with_the_same_numbers() {
|
||||
let mut doc = original();
|
||||
let src = reviewed();
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 5, "four comments on page 1 and the highlight on page 2");
|
||||
let pages: Vec<ObjRef> = walk(&doc).unwrap().into_iter().map(|(p, _)| p).collect();
|
||||
let one = page_annots(&doc, 0);
|
||||
for (_, d) in &one {
|
||||
assert_eq!(d.reference(b"P"), Some(pages[0]), "each annotation names the page it is on");
|
||||
}
|
||||
|
||||
// The note named like one already there replaces it in place, with its new pop-up.
|
||||
let (note, d) = by_contents(&one, "from the reviewer");
|
||||
assert_eq!(*note, ObjRef::new(5, 0), "the replaced comment keeps its object");
|
||||
let popup = d.reference(b"Popup").unwrap();
|
||||
assert_ne!(popup, ObjRef::new(8, 0));
|
||||
assert_eq!(doc.get(popup).as_dict().unwrap().reference(b"Parent"), Some(*note));
|
||||
let popups: Vec<ObjRef> = one.iter().filter(|(_, d)| d.name(b"Subtype") == Some(b"Popup".as_slice())).map(|(r, _)| *r).collect();
|
||||
assert_eq!(popups, [popup], "the replaced note's pop-up goes, the copy's is listed once");
|
||||
assert_eq!(by_contents(&one, "a reply").1.reference(b"IRT"), Some(*note), "the reply answers the note");
|
||||
|
||||
// The locked comment of that name stays; the copy is added beside it, unnamed.
|
||||
assert_eq!(text(&by_contents(&one, "mine").1, b"NM").as_deref(), Some("locked"));
|
||||
assert_eq!(text(&by_contents(&one, "the reviewer's").1, b"NM"), None);
|
||||
assert!(one.iter().any(|(_, d)| text(d, b"NM").as_deref() == Some("keep")), "the document's own comments stay");
|
||||
|
||||
// The stamp keeps its appearance; the redaction mark is not copied.
|
||||
let (_, stamp) = one.iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Stamp".as_slice())).unwrap();
|
||||
let ap = doc.resolve(stamp.get(b"AP").unwrap()).as_dict().cloned().unwrap();
|
||||
assert!(matches!(&*doc.resolve(ap.get(b"N").unwrap()), Object::Stream(_)), "the appearance stream is copied");
|
||||
assert!(one.iter().all(|(_, d)| d.name(b"Subtype") != Some(b"Redact".as_slice())));
|
||||
|
||||
// Page 3 is past the document's last page: its square and the reply to it are left out.
|
||||
assert!(one.iter().all(|(_, d)| text(d, b"Contents").as_deref() != Some("about page 3")));
|
||||
assert_eq!(one.len(), 7, "three of the document's own, three new copies and the note's new pop-up");
|
||||
|
||||
// Page 2 takes the highlight, not the link.
|
||||
let two = page_annots(&doc, 1);
|
||||
assert_eq!(two.len(), 1);
|
||||
assert_eq!(two[0].1.name(b"Subtype"), Some(b"Highlight".as_slice()));
|
||||
assert_eq!(two[0].1.reference(b"P"), Some(pages[1]));
|
||||
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!((page_annots(&saved, 0).len(), page_annots(&saved, 1).len()), (7, 1), "a saved copy reads back the same");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_on_an_earlier_page_reaches_its_comment_by_name() {
|
||||
// Page 1 holds a reply to a note on page 2, named like the document's own note there.
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R 7 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /IRT 6 0 R /Contents (a reply) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-2) /Contents (from the reviewer) /Rect [10 10 30 30] /P 4 0 R >>",
|
||||
// A pop-up that only its /Parent ties to the note.
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [50 50 210 130] /Open true /Parent 6 0 R /P 4 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R 6 0 R] >>",
|
||||
// The same name in UTF-16BE, and a pop-up the note does not name.
|
||||
"<< /Type /Annot /Subtype /Text /NM <FEFF006E006F00740065002D0032> /Contents (older text) /Rect [10 10 30 30] /P 4 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Popup /Rect [40 40 200 120] /Parent 5 0 R /P 4 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let two = page_annots(&doc, 1);
|
||||
assert_eq!(two.len(), 2, "the note replaces the document's own, whose pop-up goes, and its own pop-up comes");
|
||||
assert_eq!((two[0].0, text(&two[0].1, b"Contents").as_deref()), (ObjRef::new(5, 0), Some("from the reviewer")));
|
||||
assert_ne!(two[1].0, ObjRef::new(6, 0));
|
||||
assert_eq!(two[1].1.reference(b"Parent"), Some(ObjRef::new(5, 0)));
|
||||
assert_eq!(two[1].1.get(b"Open"), Some(&Object::Bool(true)));
|
||||
assert_eq!(two[1].1.reference(b"P"), Some(walk(&doc).unwrap()[1].0));
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert_eq!(one[0].1.reference(b"IRT"), Some(ObjRef::new(5, 0)), "the reply answers the replaced note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_keeps_its_layer_and_the_layer_its_visibility() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /OFF [6 0 R] >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /OCG /Name (Review) >>",
|
||||
]);
|
||||
let mut doc = original();
|
||||
import_comments(&mut doc, &src).unwrap();
|
||||
let (_, square) =
|
||||
page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap();
|
||||
let layer = square.reference(b"OC").unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
|
||||
assert_eq!(props.get(b"OCGs"), Some(&Object::Array(vec![Object::Ref(layer)])));
|
||||
let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_hidden_by_the_base_state_stays_hidden() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /OCProperties << /OCGs [6 0 R] /D << /BaseState /OFF >> >> >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>",
|
||||
"<< >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [0 0 50 50] /OC 6 0 R /P 3 0 R >>",
|
||||
"<< /Type /OCG /Name (Review) >>",
|
||||
]);
|
||||
let mut doc = original();
|
||||
import_comments(&mut doc, &src).unwrap();
|
||||
let (_, square) =
|
||||
page_annots(&doc, 0).into_iter().find(|(_, d)| d.name(b"Subtype") == Some(b"Square".as_slice()) && d.contains(b"OC")).unwrap();
|
||||
let layer = square.reference(b"OC").unwrap();
|
||||
let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let props = doc.resolve(catalog.get(b"OCProperties").unwrap()).as_dict().cloned().unwrap();
|
||||
let config = props.get(b"D").and_then(Object::as_dict).cloned().unwrap();
|
||||
assert_eq!(config.get(b"OFF"), Some(&Object::Array(vec![Object::Ref(layer)])), "the layer stays hidden");
|
||||
assert_eq!(config.get(b"ON"), Some(&Object::Array(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_thread_goes_with_its_first_comment_and_a_loop_of_replies_stays() {
|
||||
// Page 1: replies 6, 7, … each answering the next, the last answering the square on page 3,
|
||||
// which the document has no page for. Then two replies that answer each other.
|
||||
let n = 20_000;
|
||||
let first = 6;
|
||||
let mut bodies: Vec<String> =
|
||||
vec!["<< /Type /Catalog /Pages 2 0 R >>".into(), "<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 /MediaBox [0 0 300 400] >>".into()];
|
||||
let list: String = (first..first + n + 2).map(|o| format!("{o} 0 R ")).collect();
|
||||
bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{list}] >>"));
|
||||
bodies.push("<< /Type /Page /Parent 2 0 R >>".into());
|
||||
let square = first + n + 2;
|
||||
bodies.push(format!("<< /Type /Page /Parent 2 0 R /Annots [{square} 0 R] >>"));
|
||||
for o in first..first + n {
|
||||
let parent = if o + 1 == first + n { square } else { o + 1 };
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {parent} 0 R /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
}
|
||||
let (a, b) = (first + n, first + n + 1);
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {b} 0 R /Contents (one) /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
bodies.push(format!("<< /Type /Annot /Subtype /Text /IRT {a} 0 R /Contents (two) /Rect [0 0 10 10] /P 3 0 R >>"));
|
||||
bodies.push("<< /Type /Annot /Subtype /Square /Rect [0 0 100 100] /P 5 0 R >>".into());
|
||||
let src = build(&bodies.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
let mut doc = original();
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2, "only the two replies that answer each other");
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed());
|
||||
let one = page_annots(&doc, 0);
|
||||
let (one_ref, one_dict) = by_contents(&one, "one");
|
||||
let (two_ref, two_dict) = by_contents(&one, "two");
|
||||
assert_eq!((one_dict.reference(b"IRT"), two_dict.reference(b"IRT")), (Some(*two_ref), Some(*one_ref)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_comment_is_replaced_by_name() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (link-1) /Contents (from the reviewer) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Square /NM (movie-1) /Contents (a square) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (note-3) /Contents (newer text) /Rect [70 70 90 90] /P 3 0 R >>",
|
||||
]);
|
||||
// A link and a screen annotation named like two of the comments, and a comment whose
|
||||
// subtype is an indirect object.
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R 6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /NM (link-1) /Rect [0 0 10 10] /A << /S /URI /URI (https://example.com/) >> /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Screen /NM (movie-1) /Rect [100 100 200 200] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype 7 0 R /NM (note-3) /Contents (older text) /Rect [70 70 90 90] /P 3 0 R >>",
|
||||
"/Text",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 3);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 5, "the link and the screen annotation stay, two comments are added and one replaced");
|
||||
let (note, d) = by_contents(&one, "newer text");
|
||||
assert_eq!((*note, text(d, b"NM").as_deref()), (ObjRef::new(6, 0), Some("note-3")), "the comment is replaced in place");
|
||||
assert_eq!((one[0].0, one[0].1.name(b"Subtype")), (ObjRef::new(4, 0), Some(b"Link".as_slice())));
|
||||
assert_eq!(text(&one[0].1, b"NM").as_deref(), Some("link-1"));
|
||||
assert_eq!((one[1].0, one[1].1.name(b"Subtype")), (ObjRef::new(5, 0), Some(b"Screen".as_slice())));
|
||||
assert_eq!(text(&one[1].1, b"NM").as_deref(), Some("movie-1"));
|
||||
assert_eq!(text(&by_contents(&one, "from the reviewer").1, b"NM"), None, "the copy is added without the name");
|
||||
assert_eq!(text(&by_contents(&one, "a square").1, b"NM"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_comments_with_one_name_replace_one_comment_and_add_the_other() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (older text) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 2, "one comment replaced and one added");
|
||||
let (first, d) = by_contents(&one, "first");
|
||||
assert_eq!((*first, text(d, b"NM").as_deref()), (ObjRef::new(4, 0), Some("twin")));
|
||||
assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_new_comments_with_one_name_keep_it_once() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (first) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (twin) /Contents (second) /Rect [40 40 60 60] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 2);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 2);
|
||||
assert_eq!(text(&by_contents(&one, "first").1, b"NM").as_deref(), Some("twin"));
|
||||
assert_eq!(text(&by_contents(&one, "second").1, b"NM"), None, "the second copy is added without the name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_comment_is_replaced_by_name() {
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /NM (inline) /Contents (newer text) /Rect [10 10 30 30] /P 3 0 R >>",
|
||||
]);
|
||||
let mut doc = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [<< /Type /Annot /Subtype /Text /NM (inline) /Contents (older text) /Rect [10 10 30 30] >>] >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 1);
|
||||
let one = page_annots(&doc, 0);
|
||||
assert_eq!(one.len(), 1, "the inline comment is replaced, not kept beside the copy");
|
||||
assert_eq!(text(&by_contents(&one, "newer text").1, b"NM").as_deref(), Some("inline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pdf_without_comments_copies_none() {
|
||||
let mut doc = original();
|
||||
let before = page_annots(&doc, 0).len();
|
||||
let src = build(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 400] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /P 3 0 R >>",
|
||||
]);
|
||||
assert_eq!(import_comments(&mut doc, &src).unwrap(), 0);
|
||||
assert_eq!(page_annots(&doc, 0).len(), before);
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,8 @@ pub use combine::{
|
||||
CombineOutcome, Combiner, Run, SplitBy, combine, combine_grouped, combine_grouped_with, combine_selected, combine_selected_with, combine_with,
|
||||
extract_pages, split, split_ranges,
|
||||
};
|
||||
pub(crate) use copy::copy_object;
|
||||
use copy::{Copier, SKIP_ON_PAGE};
|
||||
use copy::SKIP_ON_PAGE;
|
||||
pub(crate) use copy::{Copier, copy_object};
|
||||
use fields::{add_kid, field_and_widget, register_fields, resolve_field_conflicts, split_own_field};
|
||||
pub use fields::{combine_field_conflicts, field_name_conflicts};
|
||||
|
||||
@@ -432,18 +432,22 @@ pub(crate) fn name_tree_entries(src: &Document, node: &Object, depth: u8, seen:
|
||||
|
||||
/// Register copied optional content groups in the destination's `/OCProperties`, keeping each
|
||||
/// layer's default visibility from the source.
|
||||
fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
|
||||
pub(crate) fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]) -> Result<(), OrganizeError> {
|
||||
if ocgs.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let src_off: Vec<Object> = src
|
||||
let src_config: Dict = src
|
||||
.root()
|
||||
.and_then(|r| src.get(r).as_dict().cloned())
|
||||
.and_then(|c| c.get(b"OCProperties").map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_dict().and_then(|d| d.get(b"D")).map(|d| src.resolve(d)))
|
||||
.and_then(|d| d.as_dict().and_then(|d| d.get(b"OFF")).map(|o| src.resolve(o)))
|
||||
.and_then(|o| o.as_array().cloned())
|
||||
.and_then(|d| d.as_dict().cloned())
|
||||
.unwrap_or_default();
|
||||
let src_list = |k: &[u8]| src_config.get(k).map(|v| src.resolve(v)).and_then(|v| v.as_array().cloned()).unwrap_or_default();
|
||||
let (src_on, src_off) = (src_list(b"ON"), src_list(b"OFF"));
|
||||
// With `/BaseState /OFF`, a layer is hidden unless `/ON` lists it (§8.11.4.3, Table 101).
|
||||
let base_off = src_config.get(b"BaseState").map(|b| src.resolve(b)).is_some_and(|b| b.as_name() == Some(b"OFF".as_slice()));
|
||||
let hidden = |r: &Object| src_off.contains(r) || (base_off && !src_on.contains(r));
|
||||
let root = dst.root().ok_or(OrganizeError::NoPageTree)?;
|
||||
let catalog = dst.get(root).as_dict().cloned().ok_or(OrganizeError::NoPageTree)?;
|
||||
let (props_ref, mut props) = match catalog.get(b"OCProperties") {
|
||||
@@ -462,7 +466,7 @@ fn register_layers(dst: &mut Document, src: &Document, ocgs: &[(ObjRef, ObjRef)]
|
||||
}
|
||||
all.push(r.clone());
|
||||
order.push(r.clone());
|
||||
if src_off.contains(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
|
||||
if hidden(&Object::Ref(*from)) { off.push(r) } else { on.push(r) }
|
||||
}
|
||||
props.set(b"OCGs".to_vec(), Object::Array(all));
|
||||
config.set(b"ON".to_vec(), Object::Array(on));
|
||||
|
||||
@@ -43,11 +43,11 @@ pub(super) const WIDGET_KEYS: &[&[u8]] = &[
|
||||
b"AF",
|
||||
];
|
||||
|
||||
pub(super) struct Copier<'a> {
|
||||
pub(crate) struct Copier<'a> {
|
||||
pub(super) src: &'a Document,
|
||||
/// Current recursion depth, bounded by [`MAX_COPY_DEPTH`].
|
||||
depth: usize,
|
||||
pub(super) map: HashMap<ObjRef, ObjRef>,
|
||||
pub(crate) map: HashMap<ObjRef, ObjRef>,
|
||||
/// Source page → destination page, for rewriting `/P` and destinations.
|
||||
pub(super) pages: HashMap<ObjRef, ObjRef>,
|
||||
/// Destination objects that are annotation dictionaries (fixed up after copying).
|
||||
@@ -55,11 +55,11 @@ pub(super) struct Copier<'a> {
|
||||
/// Top-level form fields reached through widgets.
|
||||
pub(super) fields: Vec<ObjRef>,
|
||||
/// Optional content groups copied: (source, destination).
|
||||
pub(super) ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
pub(crate) ocgs: Vec<(ObjRef, ObjRef)>,
|
||||
}
|
||||
|
||||
impl<'a> Copier<'a> {
|
||||
pub(super) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
|
||||
pub(crate) fn new(src: &'a Document, map: HashMap<ObjRef, ObjRef>, pages: HashMap<ObjRef, ObjRef>) -> Self {
|
||||
Copier { src, depth: 0, map, pages, annots: Vec::new(), fields: Vec::new(), ocgs: Vec::new() }
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ impl<'a> Copier<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Result<Object, OrganizeError> {
|
||||
pub(crate) fn copy_value(&mut self, dst: &mut Document, o: &Object, this: Option<ObjRef>) -> Result<Object, OrganizeError> {
|
||||
if !matches!(o, Object::Ref(_) | Object::Array(_) | Object::Dict(_) | Object::Stream(_)) {
|
||||
return Ok(o.clone());
|
||||
}
|
||||
@@ -163,7 +163,7 @@ impl<'a> Copier<'a> {
|
||||
}
|
||||
|
||||
/// Rewrite `/P`, `/Dest` and GoTo actions on copied annotations.
|
||||
pub(super) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
pub(crate) fn fix_annotations(&mut self, dst: &mut Document, annot_pages: &HashMap<ObjRef, ObjRef>) -> Result<(), OrganizeError> {
|
||||
for &a in &std::mem::take(&mut self.annots) {
|
||||
let src_dict = dst.get(a).as_dict().cloned();
|
||||
let Some(d) = src_dict else { continue };
|
||||
|
||||
@@ -15,6 +15,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, XrefEntry};
|
||||
|
||||
mod attachments;
|
||||
mod boxes;
|
||||
mod comments;
|
||||
mod dedupe;
|
||||
mod import;
|
||||
mod labels;
|
||||
@@ -27,6 +28,7 @@ mod xmp;
|
||||
|
||||
pub use attachments::{add_attachment, attachment_keys, delete_attachment, mime_for_name};
|
||||
pub use boxes::{BoxSpec, PageBox, page_boxes, set_page_box};
|
||||
pub use comments::import_comments;
|
||||
pub use dedupe::dedupe_resources;
|
||||
pub use import::{
|
||||
CombineOutcome, Combiner, FieldConflicts, ImportOutcome, Run, SplitBy, combine, combine_field_conflicts, combine_grouped, combine_grouped_with,
|
||||
|
||||
@@ -518,14 +518,10 @@ fn destination_at(doc: &Document, page: usize, left: Option<f64>, top: Option<f6
|
||||
Ok(go_to_at(*p, left, top))
|
||||
}
|
||||
|
||||
/// `/XYZ null null null`: go to the page, keeping the reader's zoom (what Acrobat writes for a
|
||||
/// new bookmark when the view has no specific position).
|
||||
fn go_to(page: ObjRef) -> Object {
|
||||
go_to_at(page, None, None)
|
||||
}
|
||||
|
||||
/// `/XYZ left top null`: the point (`left`, `top`) at the window's upper-left corner, at the
|
||||
/// reader's zoom; a coordinate that is left out keeps the reader's position.
|
||||
/// reader's zoom; a coordinate that is left out keeps the reader's position. With neither,
|
||||
/// `/XYZ null null null` goes to the page, keeping the reader's zoom (what Acrobat writes for a
|
||||
/// new bookmark when the view has no specific position).
|
||||
fn go_to_at(page: ObjRef, left: Option<f64>, top: Option<f64>) -> Object {
|
||||
let coordinate = |v: Option<f64>| v.map_or(Object::Null, Object::Real);
|
||||
Object::Array(vec![Object::Ref(page), Object::name("XYZ"), coordinate(left), coordinate(top), Object::Null])
|
||||
@@ -569,7 +565,7 @@ pub fn add_bookmark_at(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// One bookmark of a generated tree (New Bookmarks from Structure).
|
||||
/// One bookmark of a generated tree (New Bookmarks from Structure, or from text styles).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct OutlineEntry {
|
||||
/// 1 = directly under the new parent; deeper levels nest under the last shallower entry.
|
||||
@@ -578,6 +574,9 @@ pub struct OutlineEntry {
|
||||
pub page: usize,
|
||||
/// The structure element it stands for (`/SE`).
|
||||
pub element: Option<ObjRef>,
|
||||
/// Where on the page it goes: this height (user space) at the top of the window, at the
|
||||
/// reader's zoom. `None` goes to the page, keeping the reader's position.
|
||||
pub top: Option<f64>,
|
||||
}
|
||||
|
||||
/// Add a new top-level bookmark `parent_title` (first in the list) holding `entries` nested by
|
||||
@@ -601,7 +600,7 @@ pub fn add_bookmark_tree(doc: &mut Document, parent_title: &str, entries: &[Outl
|
||||
for (e, page) in entries.iter().zip(targets) {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Title".to_vec(), Object::String(PdfString::text(&e.title)));
|
||||
d.set(b"Dest".to_vec(), go_to(page));
|
||||
d.set(b"Dest".to_vec(), go_to_at(page, None, e.top));
|
||||
if let Some(se) = e.element {
|
||||
d.set(b"SE".to_vec(), Object::Ref(se));
|
||||
}
|
||||
|
||||
@@ -1789,7 +1789,7 @@ fn bookmarks_add_rename_move_delete() {
|
||||
fn a_bookmark_tree_nests_entries_by_level_under_a_new_first_bookmark() {
|
||||
let mut d = doc_a();
|
||||
crate::add_bookmark(&mut d, &[], 0, "Existing", 0).unwrap();
|
||||
let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element };
|
||||
let e = |level, title: &str, page, element| crate::OutlineEntry { level, title: title.into(), page, element, top: None };
|
||||
let se = ObjRef::new(4242, 0);
|
||||
let entries = [e(1, "One", 0, Some(se)), e(3, "Deep", 1, None), e(2, "Two", 1, None), e(1, "Next", 2, None)];
|
||||
assert_eq!(crate::add_bookmark_tree(&mut d, "Untitled", &entries).unwrap(), vec![0]);
|
||||
|
||||
@@ -27,9 +27,12 @@ pub(super) struct TextDevice {
|
||||
actual: Option<(usize, String, Vec<Drawn>)>,
|
||||
/// Every `/ActualText` replacement made on the page.
|
||||
pub(super) replaced: Vec<Replaced>,
|
||||
/// Visible page bounds in view space (`intersected_crop_box` after `initial_transform`).
|
||||
page: Rect,
|
||||
filter: TextFilter,
|
||||
excluded_depth: Option<usize>,
|
||||
actual_filtered: bool,
|
||||
actual_cropped: bool,
|
||||
partial_replacement: bool,
|
||||
}
|
||||
|
||||
@@ -109,9 +112,11 @@ impl TextDevice {
|
||||
depth: 0,
|
||||
actual: None,
|
||||
replaced: Vec::new(),
|
||||
page: Rect::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::INFINITY, f64::INFINITY),
|
||||
filter: TextFilter::default(),
|
||||
excluded_depth: None,
|
||||
actual_filtered: false,
|
||||
actual_cropped: false,
|
||||
partial_replacement: false,
|
||||
}
|
||||
}
|
||||
@@ -126,12 +131,17 @@ impl TextDevice {
|
||||
/// Unicode). An empty one (a soft hyphen at a line break, say) stands for no text at all.
|
||||
fn end_actual_text(&mut self) {
|
||||
let Some((_, text, drawn)) = self.actual.take() else { return };
|
||||
let cropped = std::mem::take(&mut self.actual_cropped);
|
||||
if std::mem::take(&mut self.actual_filtered) {
|
||||
// ActualText has no mapping from replacement words to individual glyphs. A mixed
|
||||
// span cannot be filtered honestly; a wholly excluded span can simply be omitted.
|
||||
self.partial_replacement |= !drawn.is_empty();
|
||||
return;
|
||||
}
|
||||
if cropped {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(|c| c.is_control())));
|
||||
return;
|
||||
}
|
||||
let Some(glyph) = merge(&drawn, text.clone()) else {
|
||||
self.glyphs.extend(drawn.into_iter().filter(|d| !d.2.text.chars().all(char::is_control)));
|
||||
return;
|
||||
@@ -316,7 +326,13 @@ impl<'a> Device<'a> for TextDevice {
|
||||
let b = corners.iter().fold(Rect::new(f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |b, p| {
|
||||
Rect::new(b.x0.min(p.x), b.y0.min(p.y), b.x1.max(p.x), b.y1.max(p.y))
|
||||
});
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) || b.width() > 10_000.0 || b.height() > 10_000.0 {
|
||||
let outside_crop = b.x0.max(self.page.x0) >= b.x1.min(self.page.x1) || b.y0.max(self.page.y0) >= b.y1.min(self.page.y1);
|
||||
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite())
|
||||
|| b.width() > 10_000.0
|
||||
|| b.height() > 10_000.0
|
||||
|| outside_crop
|
||||
{
|
||||
self.actual_cropped |= replaced && outside_crop;
|
||||
return;
|
||||
}
|
||||
let length = dx.hypot(dy);
|
||||
@@ -374,8 +390,10 @@ pub(crate) fn extract_page_filtered(pdf: &Pdf, page: usize, settings: &Interpret
|
||||
// as in Acrobat, and match what other extractors report.
|
||||
let settings = settings.clone();
|
||||
let initial = p.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, w as f64, h as f64), &cache, p.xref(), settings);
|
||||
let c = p.intersected_crop_box();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, f64::from(w), f64::from(h)), &cache, p.xref(), settings);
|
||||
let mut dev = TextDevice::new();
|
||||
dev.page = initial.transform_rect_bbox(Rect::new(c.x0, c.y0, c.x1, c.y1));
|
||||
dev.filter = filter;
|
||||
interpret_page(p, &mut ctx, &mut dev);
|
||||
// A replacement the content never closed ends with the page.
|
||||
|
||||
@@ -1759,3 +1759,78 @@ fn truetype_without_an_encoding_reads_its_codes_as_win_ansi() {
|
||||
assert_eq!(ocr_layer_text(4, false), expected, "symbolic, no /ToUnicode");
|
||||
assert_eq!(ocr_layer_text(32, true), expected, "nonsymbolic, /ToUnicode for ASCII only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_outside_the_crop_box_is_clipped() {
|
||||
use lopdf::{Document, Object, Stream, dictionary};
|
||||
let ops = [
|
||||
"BT /F1 12 Tf 1 0 0 1 100 200 Tm (Inside crop) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 150 Tm /Span <</ActualText (ActualIn)>> BDC (in) Tj EMC ET",
|
||||
// Keep every glyph of OutSpan outside the crop; a boundary-crossing glyph remains visible.
|
||||
"BT /F1 12 Tf 1 0 0 1 -100 125 Tm /Span <</ActualText (CroppedSpan KeptSpan)>> BDC (OutSpan) Tj 1 0 0 1 100 125 Tm (KeptSpan) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 5 200 Tm (LeftOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 360 200 Tm (RightOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm (BelowOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 370 Tm (AboveOut) Tj ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 100 20 Tm /Span <</ActualText (ActualOut)>> BDC (out) Tj EMC ET",
|
||||
"BT /F1 12 Tf 1 0 0 1 46 100 Tm (A) Tj ET",
|
||||
]
|
||||
.join("\n");
|
||||
for rotate in [0, 90, 180, 270] {
|
||||
for unit in [1, 2] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => vec![Object::Integer(50), Object::Integer(60), Object::Integer(350), Object::Integer(340)],
|
||||
"Rotate" => rotate,
|
||||
"UserUnit" => unit,
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert_eq!(text.plain_text(), "Inside crop\nActualIn\nKeptSpan\nA", "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("Inside").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("ActualIn").is_empty(), "rotate={rotate} unit={unit}");
|
||||
assert!(!text.find("KeptSpan").is_empty(), "rotate={rotate} unit={unit}");
|
||||
for hidden in ["LeftOut", "RightOut", "BelowOut", "AboveOut", "ActualOut", "CroppedSpan", "OutSpan"] {
|
||||
assert!(text.find(hidden).is_empty(), "{hidden} must be clipped at rotate={rotate} unit={unit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A CropBox that is zero-area or disjoint from the MediaBox leaves an empty visible region,
|
||||
// even when a glyph straddles the clamped intersection point.
|
||||
for crop in [[200, 200, 200, 200], [100, 200, 300, 200], [500, 500, 600, 600]] {
|
||||
let mut doc = Document::with_version("1.7");
|
||||
let font = doc.add_object(dictionary! { "Type" => "Font", "Subtype" => "Type1", "BaseFont" => "Helvetica" });
|
||||
let content = doc.add_object(Stream::new(dictionary! {}, ops.as_bytes().to_vec()));
|
||||
let pages = doc.new_object_id();
|
||||
let page = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => pages,
|
||||
"MediaBox" => vec![Object::Integer(0), Object::Integer(0), Object::Integer(400), Object::Integer(400)],
|
||||
"CropBox" => crop.map(Object::Integer).to_vec(),
|
||||
"Resources" => dictionary! { "Font" => dictionary! { "F1" => font } },
|
||||
"Contents" => content,
|
||||
});
|
||||
doc.objects.insert(pages, dictionary! { "Type" => "Pages", "Kids" => vec![Object::Reference(page)], "Count" => 1 }.into());
|
||||
let catalog = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages });
|
||||
doc.trailer.set("Root", catalog);
|
||||
let mut bytes = Vec::new();
|
||||
doc.save_to(&mut bytes).unwrap();
|
||||
let pdf = Pdf::new(std::sync::Arc::new(bytes)).unwrap();
|
||||
let text = extract_page(&pdf, 0, &InterpreterSettings::default()).unwrap();
|
||||
assert!(text.is_empty(), "expected no glyphs on {crop:?}, got {:?}", text.plain_text());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ impl PdfCraftApp {
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
toast: None,
|
||||
toast_script: None,
|
||||
script_messages: std::collections::VecDeque::new(),
|
||||
script_messages_dropped: 0,
|
||||
toast_dropped: 0,
|
||||
integrated_titlebar: false,
|
||||
window_controls: None,
|
||||
system_title_bar: false,
|
||||
@@ -190,6 +194,7 @@ impl PdfCraftApp {
|
||||
v_pressed: false,
|
||||
attach_override: None,
|
||||
duplicate_draft: None,
|
||||
page_props_draft: None,
|
||||
field_table_draft: None,
|
||||
form_preview: false,
|
||||
window_title: String::new(),
|
||||
@@ -217,8 +222,10 @@ impl PdfCraftApp {
|
||||
pending_full_screen: None,
|
||||
pending_remote_link: None,
|
||||
protect_draft: Default::default(),
|
||||
text_style_draft: Default::default(),
|
||||
boxes_draft: Default::default(),
|
||||
marks_draft: Default::default(),
|
||||
unmarked_page: Default::default(),
|
||||
export_draft: Default::default(),
|
||||
export_status: None,
|
||||
signature: None,
|
||||
@@ -284,8 +291,13 @@ impl PdfCraftApp {
|
||||
|| self.updates.open
|
||||
}
|
||||
|
||||
/// Show `msg` in the toast now, in place of whatever it showed. Script messages still waiting
|
||||
/// show after it.
|
||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||
// A left-out count it replaces is said again once the toast is free.
|
||||
self.script_messages_dropped = self.script_messages_dropped.saturating_add(std::mem::take(&mut self.toast_dropped));
|
||||
self.toast = Some((msg.into(), 0.0));
|
||||
self.toast_script = None;
|
||||
}
|
||||
|
||||
/// Notify with a static message in the UI language.
|
||||
|
||||
@@ -120,6 +120,11 @@ impl PdfCraftApp {
|
||||
self.execute("page.number");
|
||||
Some(Dialog::NumberPages)
|
||||
}
|
||||
"page-properties" => {
|
||||
// Same path as the panel, so the selected pages and their order are seeded.
|
||||
self.execute("page.properties");
|
||||
self.dialog
|
||||
}
|
||||
"none" => None,
|
||||
_ => Some(Dialog::About),
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Stroke, TextureHand
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
use pdfcraft_render::{DestView, DocInfo, LayerOp, LinkTarget, PRINT_TAG, PageText, RenderPool, RenderRequest, RequestKind, Tile, device_pixels};
|
||||
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, QuickTool, RightPanel, comments, icons, widgets};
|
||||
@@ -26,6 +27,7 @@ pub(super) mod fling;
|
||||
pub(super) mod image_pick;
|
||||
mod navigation;
|
||||
mod organize;
|
||||
pub(super) mod page_cursor;
|
||||
pub(super) mod page_pointer;
|
||||
pub mod scroll_speed;
|
||||
mod selection;
|
||||
@@ -38,8 +40,7 @@ pub(super) mod zoom_snap;
|
||||
|
||||
pub(crate) use area::signature_badges;
|
||||
use area::{
|
||||
SUPPRESS_SECS, SUPPRESS_TAIL_SECS, annotation_hover_text, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button,
|
||||
set_layers, zoom_anchor,
|
||||
SUPPRESS_SECS, SUPPRESS_TAIL_SECS, drawing_edge_pointer, glide_interrupted, page_scroll, presentation_click, run_button, set_layers, zoom_anchor,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use bars::in_size_hint_zone;
|
||||
@@ -470,6 +471,8 @@ pub struct DocView {
|
||||
/// A queued Fill & Sign signature: select it after its edit succeeds, then leave placement.
|
||||
pub(crate) fill_signature_page: Option<usize>,
|
||||
pub(crate) signature_drag: crate::signature_drag::SignatureDrag,
|
||||
/// What the page modules claim for the cursor this frame; `document_area` sets it once.
|
||||
pub(crate) cursor: crate::page_cursor::PageCursor,
|
||||
/// A non-edit action requested by the organize toolbar, handled by the app.
|
||||
pub pending_action: Option<ViewAction>,
|
||||
/// The grid gap the next inserted files go to (set by a "+" between pages); otherwise they
|
||||
@@ -722,6 +725,7 @@ impl DocView {
|
||||
fill_text: None,
|
||||
fill_signature_page: None,
|
||||
signature_drag: Default::default(),
|
||||
cursor: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,7 +877,6 @@ struct PageEnv<'a> {
|
||||
/// What the page-scroll closure produces for the rest of the frame.
|
||||
#[derive(Default)]
|
||||
struct PageOut {
|
||||
badge_hover: Option<(Pos2, String)>,
|
||||
canvas_action: Option<comments::CanvasAction>,
|
||||
clicked_link: Option<LinkTarget>,
|
||||
copy_selected: bool,
|
||||
@@ -1065,8 +1068,6 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
|
||||
struct PageInput {
|
||||
/// A tool, a field or a comment took the pointer: text selection must leave it alone.
|
||||
consumed: bool,
|
||||
/// A form field is under the pointer and has set its cursor.
|
||||
over_field: bool,
|
||||
/// Redact or Highlight draws boxes off text.
|
||||
area_tool: bool,
|
||||
}
|
||||
@@ -1129,7 +1130,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
&& allowed
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
// Centred on the click, upright as the page is shown.
|
||||
let (vx, vy) = xf.screen_to_view(p);
|
||||
@@ -1153,7 +1154,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
&& allowed
|
||||
&& let Some(p) = ptr.within(xf.rect)
|
||||
{
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
if resp.clicked() {
|
||||
// Centred on the click at its natural size (the engine sizes it).
|
||||
let (vx, vy) = xf.screen_to_view(p);
|
||||
@@ -1205,7 +1206,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view);
|
||||
}
|
||||
if tool == QuickTool::EnhanceCamera {
|
||||
crate::camera_ui::page_input(ui, resp, &xf, ptr, i, view, can_modify);
|
||||
crate::camera_ui::page_input(resp, &xf, ptr, i, view, can_modify);
|
||||
}
|
||||
if tool == QuickTool::Crop
|
||||
&& crate::crop::page_input(ui, crate::crop::CropPageInput { resp, xf: &xf, ptr, page: i, info, view, allowed: can_crop })
|
||||
@@ -1213,8 +1214,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
view.current = i;
|
||||
frame.out.open_boxes = true;
|
||||
}
|
||||
// `over_field`: a form field is under the pointer and has set its cursor.
|
||||
let (on_field, over_field) = if preparing {
|
||||
let on_field = if preparing {
|
||||
let field_tool = match tool {
|
||||
QuickTool::Field(f) => Some(f),
|
||||
_ => None,
|
||||
@@ -1225,18 +1225,12 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
frame.out.field_props |= o.properties;
|
||||
frame.out.field_placed |= o.placed;
|
||||
frame.out.open_table = frame.out.open_table.or(o.table);
|
||||
(o.consumed || field_tool.is_some(), false)
|
||||
o.consumed || field_tool.is_some()
|
||||
} else {
|
||||
use crate::forms_ui::FieldPointer;
|
||||
let field = if tool == QuickTool::Select
|
||||
tool == QuickTool::Select
|
||||
&& !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view))
|
||||
&& !comments::select_grabs(&pcx, view)
|
||||
{
|
||||
crate::forms_ui::page_input(cx, form, can_fill, view)
|
||||
} else {
|
||||
FieldPointer::Away
|
||||
};
|
||||
(field == FieldPointer::Owned, field == FieldPointer::Over)
|
||||
&& crate::forms_ui::page_input(cx, form, can_fill, view)
|
||||
};
|
||||
// Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat).
|
||||
let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed;
|
||||
@@ -1328,7 +1322,7 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
view.links.context = Some((i, l.index));
|
||||
}
|
||||
let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view);
|
||||
PageInput { consumed, over_field, area_tool }
|
||||
PageInput { consumed, area_tool }
|
||||
}
|
||||
|
||||
/// Text layer: find matches, selection, I-beam and drag-to-select.
|
||||
@@ -1342,12 +1336,7 @@ fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>,
|
||||
&& !input.consumed
|
||||
&& let Some(p) = pointer.filter(|p| r.contains(*p))
|
||||
{
|
||||
text_pointer_input(
|
||||
ui,
|
||||
view,
|
||||
&text,
|
||||
TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p, over_field: input.over_field },
|
||||
);
|
||||
text_pointer_input(ui, view, &text, TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1397,7 +1386,6 @@ struct TextPointer<'a> {
|
||||
resp: &'a egui::Response,
|
||||
ptr: PagePointer,
|
||||
pointer: Pos2,
|
||||
over_field: bool,
|
||||
}
|
||||
|
||||
struct TextGesture<'a> {
|
||||
@@ -1416,24 +1404,24 @@ struct TextGesture<'a> {
|
||||
}
|
||||
|
||||
fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) {
|
||||
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer, over_field } = pointer;
|
||||
let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer } = pointer;
|
||||
let (vx, vy) = xf.screen_to_view(screen_pointer);
|
||||
let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]);
|
||||
let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer));
|
||||
let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt);
|
||||
let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column };
|
||||
update_text_cursor(ui, resp, over_field, &gesture);
|
||||
update_text_cursor(view, &gesture);
|
||||
update_selection_on_press(ui, view, text, &gesture);
|
||||
update_dragged_selection(view, text, &gesture);
|
||||
finish_text_pointer_gesture(ui, view, text, &gesture);
|
||||
}
|
||||
|
||||
fn update_text_cursor(ui: &egui::Ui, resp: &egui::Response, over_field: bool, gesture: &TextGesture<'_>) {
|
||||
let cursor_free = !gesture.over_link && !over_field && (resp.contains_pointer() || resp.dragged());
|
||||
if gesture.column && cursor_free {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if gesture.over_text && cursor_free {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
fn update_text_cursor(view: &mut DocView, gesture: &TextGesture<'_>) {
|
||||
// A press on a link selects nothing (it is the link's), so no cursor here says it would.
|
||||
if gesture.column && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Crosshair);
|
||||
} else if gesture.over_text && !gesture.over_link {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1611,16 +1599,13 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
|
||||
// in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a
|
||||
// radio button's own) is left as it is drawn.
|
||||
paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields);
|
||||
// Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips
|
||||
// draw above the palette, so none shows while it is open, even beside it.
|
||||
if !matches!(tool, QuickTool::Fill(_))
|
||||
&& unobstructed
|
||||
&& let Some(p) = pointer
|
||||
{
|
||||
// Links: the hand and the link's target, which count only for the tools that follow or
|
||||
// edit links (`Layer::reached_by`). Tips draw above the palette, so none shows while it is
|
||||
// open, even beside it.
|
||||
if unobstructed && let Some(p) = pointer {
|
||||
for l in info.links.iter().filter(|l| l.page == i) {
|
||||
let sr = xf.user_rect(info, i, l.rect);
|
||||
if sr.contains(p) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
let label = match &l.target {
|
||||
LinkTarget::Page(n, _) => {
|
||||
crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))])
|
||||
@@ -1630,32 +1615,15 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
|
||||
LinkTarget::RemoteGoTo { file, .. } => file.clone(),
|
||||
LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]),
|
||||
};
|
||||
frame.out.hover_text = Some((p, label));
|
||||
view.cursor.claim_with_tip(Layer::Link, egui::CursorIcon::PointingHand, Some(label));
|
||||
if resp.clicked() && tool == QuickTool::Select && !input.consumed {
|
||||
frame.out.clicked_link = Some(l.target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Annotation hover shows the comment, as Acrobat's popups do; not while drawing
|
||||
// freehand, where it would cover the next stroke (#429).
|
||||
let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser));
|
||||
let gesturing = view.comments.gesture.is_some() || freehand;
|
||||
// Inspection sorts annotations spatially, but the renderer paints in /Annots
|
||||
// order. Keep the shared list intact and choose the last eligible raw index.
|
||||
if frame.out.hover_text.is_none()
|
||||
&& !gesturing
|
||||
&& let Some((_, text)) = info
|
||||
.annotations
|
||||
.iter()
|
||||
.filter(|a| a.page == i)
|
||||
.filter(|a| !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)))
|
||||
.filter(|a| pcx.screen_rects(a).any(|r| r.contains(p)))
|
||||
.filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text)))
|
||||
.max_by_key(|(index, _)| *index)
|
||||
{
|
||||
frame.out.hover_text = Some((p, text));
|
||||
}
|
||||
}
|
||||
// Comments say what they are with any tool, beside its cursor, as Acrobat's do.
|
||||
comments::describe(&pcx, view);
|
||||
for (mp, mr, mc) in &view.compare_marks {
|
||||
if *mp == i {
|
||||
let sr = xf.user_rect(info, i, *mr).expand(1.5);
|
||||
@@ -1700,15 +1668,12 @@ fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_
|
||||
let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0);
|
||||
let mark = Rect::from_center_size(sr.center(), vec2(side, side));
|
||||
icons::paint(ui, mark, icon, side * 0.64, color);
|
||||
if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) {
|
||||
frame.out.badge_hover = Some((p, badge.summary.to_string()));
|
||||
// Overlapping widgets paint in order, so the topmost badge's verdict is the tip, with
|
||||
// any tool; a link's or a comment's tip wins over any badge.
|
||||
if pointer.is_some_and(|p| mark.expand(2.0).contains(p)) {
|
||||
view.cursor.describe(Subject::SignatureBadge, badge.summary.to_string());
|
||||
}
|
||||
}
|
||||
// Overlapping widgets paint in order, so the topmost badge owns the hover; an
|
||||
// earlier link or annotation hover still wins over any badge.
|
||||
if frame.out.hover_text.is_none() {
|
||||
frame.out.hover_text = frame.out.badge_hover.take();
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) {
|
||||
@@ -1737,7 +1702,6 @@ fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInf
|
||||
fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) {
|
||||
let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env;
|
||||
let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame;
|
||||
frame.out.badge_hover = None;
|
||||
let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp);
|
||||
if !r.intersects(visible.expand(400.0)) {
|
||||
return;
|
||||
@@ -1824,6 +1788,7 @@ fn draw_pages(
|
||||
preparing,
|
||||
selection_wheel,
|
||||
single_paging,
|
||||
tool,
|
||||
unobstructed,
|
||||
visible_pages,
|
||||
y_shift,
|
||||
@@ -1856,9 +1821,9 @@ fn draw_pages(
|
||||
// The Hand tool pans by this widget's drag, before the pages are laid out (above).
|
||||
if hand {
|
||||
if resp.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
} else if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
let origin = resp_rect.min - vec2(0.0, y_shift);
|
||||
@@ -1903,14 +1868,13 @@ fn draw_pages(
|
||||
wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, ..
|
||||
} = frame;
|
||||
let PageOut {
|
||||
badge_hover,
|
||||
mut canvas_action,
|
||||
clicked_link,
|
||||
mut copy_selected,
|
||||
mut field_menu,
|
||||
field_placed,
|
||||
field_props,
|
||||
hover_text,
|
||||
hover_text: _,
|
||||
image_action,
|
||||
open_boxes,
|
||||
open_initials,
|
||||
@@ -1926,6 +1890,8 @@ fn draw_pages(
|
||||
let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str());
|
||||
crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected);
|
||||
}
|
||||
// The pages' one cursor, from the claim on the highest layer the tool reaches, and the tip.
|
||||
let hover_text = pointer.filter(|_| unobstructed).zip(view.cursor.resolve(ui.ctx(), tool));
|
||||
// ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag
|
||||
// scrolls the page itself (#721). After the page loop, so it keeps scrolling once the
|
||||
// pointer leaves the selection's page for the gap or the next page; over the whole
|
||||
@@ -2077,7 +2043,6 @@ fn draw_pages(
|
||||
canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image);
|
||||
});
|
||||
*out = PageOut {
|
||||
badge_hover,
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
copy_selected,
|
||||
@@ -3074,7 +3039,6 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
|
||||
});
|
||||
let PageOut {
|
||||
badge_hover: _,
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
mut copy_selected,
|
||||
|
||||
@@ -24,17 +24,6 @@ pub(crate) fn signature_badges(signatures: &[pdfcraft_engine::SignatureInfo], pa
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The hover popup for an annotation. Replies and Fill & Sign marks (typed text, checks, signatures)
|
||||
/// show none: they are page content being edited, not comments.
|
||||
pub(super) fn annotation_hover_text(a: &pdfcraft_render::Annotation) -> Option<String> {
|
||||
if a.is_reply() || a.fill_sign {
|
||||
return None;
|
||||
}
|
||||
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
|
||||
let body = a.contents.clone().unwrap_or_default();
|
||||
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
|
||||
}
|
||||
|
||||
/// Suppression window for egui's eased copy of point input.
|
||||
///
|
||||
/// egui 0.36.2 drains `WheelState::after_events` tail deltas by 90% per 0.1 s.
|
||||
|
||||
@@ -6,6 +6,7 @@ use pdfcraft_engine::{BoxSpec, Edit, PageBox};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
|
||||
/// A crop rectangle being dragged: (page, start on screen).
|
||||
@@ -32,7 +33,7 @@ pub(crate) fn page_input(ui: &egui::Ui, input: CropPageInput<'_>) -> bool {
|
||||
let CropPageInput { resp, xf, ptr, page, info, view, allowed } = input;
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
|
||||
view.cursor.claim(Layer::ToolArea, if allowed { egui::CursorIcon::Crosshair } else { egui::CursorIcon::NotAllowed });
|
||||
}
|
||||
if !allowed {
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
//! The cursor the pages show, and the tip beside it, set once a frame.
|
||||
//!
|
||||
//! A page module claims the cursor for what it has under the pointer, on a [`Layer`], and never
|
||||
//! sets egui's cursor itself: where each module set it, the last write of a frame won, so the
|
||||
//! order of the per-page chain decided the cursor and not what a press there does. A link's hand
|
||||
//! showed over every tool though only the Select tool follows links, and the I-beam of the text
|
||||
//! under a comment hid that a drag there moves the comment. `canvas::document_area` sets the
|
||||
//! cursor once, from the claim on the highest layer the active tool reaches. The tip beside it is
|
||||
//! that claim's own (a link's target, so only where a press acts on the link: the Select tool
|
||||
//! follows it, the Link tool edits it), or else what lies under the pointer says (a comment, or a
|
||||
//! signature's validation badge), with any tool, as Acrobat shows a comment's tooltip. A test in
|
||||
//! `page_pointer/tests.rs` keeps page modules from setting the cursor.
|
||||
|
||||
use egui::CursorIcon;
|
||||
|
||||
use crate::QuickTool;
|
||||
|
||||
/// What a claim is for, from the bottom up: a claim on a higher layer wins.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Layer {
|
||||
/// The page under the active tool: the crosshair it places or draws with, its I-beam or hand.
|
||||
ToolArea,
|
||||
/// The page's text, to select, and Edit text's paragraphs.
|
||||
Text,
|
||||
/// Links.
|
||||
Link,
|
||||
/// Comments: notes, shapes and markup.
|
||||
Comment,
|
||||
/// Form fields.
|
||||
Field,
|
||||
/// What a drag moves whole: signatures and Fill & Sign's marks, added text and images,
|
||||
/// selected objects, pictures in Edit text.
|
||||
Placed,
|
||||
/// The grips of the selected item: resize, rewrap and crop handles, paragraph edges.
|
||||
Handle,
|
||||
/// An open editor: a paragraph being typed in, a picture being cropped.
|
||||
Editor,
|
||||
/// A drag in progress: what is dragged keeps its cursor until the release.
|
||||
Drag,
|
||||
}
|
||||
|
||||
/// Every layer, bottom first.
|
||||
const LAYERS: [Layer; 9] =
|
||||
[Layer::ToolArea, Layer::Text, Layer::Link, Layer::Comment, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag];
|
||||
|
||||
impl Layer {
|
||||
/// Whether a press with `tool` acts on what lies on this layer, so that the layer's claims
|
||||
/// count. Only the Select tool follows links (the Link tool edits them) and picks up
|
||||
/// comments: every other tool draws, places or selects text over them. The other layers are
|
||||
/// claimed only by the modules of the tools that act on them, so they count for every tool.
|
||||
pub(crate) fn reached_by(self, tool: QuickTool) -> bool {
|
||||
match self {
|
||||
Layer::Link => matches!(tool, QuickTool::Select | QuickTool::Link),
|
||||
Layer::Comment => tool == QuickTool::Select,
|
||||
Layer::ToolArea | Layer::Text | Layer::Field | Layer::Placed | Layer::Handle | Layer::Editor | Layer::Drag => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A claim on the cursor: the icon, and the tip that goes with it (a link's target).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct Claim {
|
||||
icon: CursorIcon,
|
||||
tip: Option<String>,
|
||||
}
|
||||
|
||||
/// What a description tells about, from the least up: a higher one shows over a lower one.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub(crate) enum Subject {
|
||||
/// A signature's validation badge: viewer chrome over the signer's own artwork.
|
||||
SignatureBadge,
|
||||
/// A comment: who wrote it, and what it says.
|
||||
Comment,
|
||||
}
|
||||
|
||||
/// What the page modules claim for the cursor this frame. `canvas::document_area` sets the
|
||||
/// cursor from it once, after the pages ([`PageCursor::resolve`]).
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PageCursor {
|
||||
/// The last claim on each layer, bottom layer first.
|
||||
claims: [Option<Claim>; LAYERS.len()],
|
||||
/// What lies under the pointer says ([`PageCursor::describe`]).
|
||||
description: Option<(Subject, String)>,
|
||||
}
|
||||
|
||||
impl PageCursor {
|
||||
/// Shows `icon` while the pointer is over what lies on `layer`, unless the active tool
|
||||
/// reaches a higher layer that is claimed too.
|
||||
pub(crate) fn claim(&mut self, layer: Layer, icon: CursorIcon) {
|
||||
self.claim_with_tip(layer, icon, None);
|
||||
}
|
||||
|
||||
/// As [`Self::claim`], with `tip` beside the pointer where the claim wins.
|
||||
pub(crate) fn claim_with_tip(&mut self, layer: Layer, icon: CursorIcon, tip: Option<String>) {
|
||||
if let Some(slot) = self.claims.get_mut(layer as usize) {
|
||||
*slot = Some(Claim { icon, tip });
|
||||
}
|
||||
}
|
||||
|
||||
/// Says what lies under the pointer without claiming the cursor: the tip shows with any tool,
|
||||
/// beside that tool's cursor, as Acrobat shows a comment's tooltip, unless the winning claim
|
||||
/// has a tip of its own. The description of the highest subject stands, and the last of
|
||||
/// equals (the topmost of overlapping badges).
|
||||
pub(crate) fn describe(&mut self, subject: Subject, text: String) {
|
||||
if self.description.as_ref().is_none_or(|(held, _)| *held <= subject) {
|
||||
self.description = Some((subject, text));
|
||||
}
|
||||
}
|
||||
|
||||
/// The claim on the highest layer `tool` reaches.
|
||||
fn winner(claims: [Option<Claim>; LAYERS.len()], tool: QuickTool) -> Option<Claim> {
|
||||
LAYERS.into_iter().zip(claims).rev().find_map(|(layer, claim)| claim.filter(|_| layer.reached_by(tool)))
|
||||
}
|
||||
|
||||
/// Sets the cursor, once, to the icon of the claim on the highest layer `tool` reaches, and
|
||||
/// returns the tip: that claim's own, or else the description. With no such claim the cursor
|
||||
/// is left as it is. The claims are spent: the next frame starts with none.
|
||||
pub(crate) fn resolve(&mut self, ctx: &egui::Context, tool: QuickTool) -> Option<String> {
|
||||
let PageCursor { claims, description } = std::mem::take(self);
|
||||
let winner = Self::winner(claims, tool);
|
||||
if let Some(w) = &winner {
|
||||
ctx.set_cursor_icon(w.icon);
|
||||
}
|
||||
winner.and_then(|w| w.tip).or(description.map(|(_, text)| text))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::comments::CommentTool;
|
||||
use crate::fill_sign::FillTool;
|
||||
use crate::prepare::FieldTool;
|
||||
|
||||
/// One of each tool. The match stops compiling when a tool is added, so that it gets a row
|
||||
/// in [`Layer::reached_by`]'s tests.
|
||||
fn tools() -> Vec<QuickTool> {
|
||||
let tools = vec![
|
||||
QuickTool::Select,
|
||||
QuickTool::Hand,
|
||||
QuickTool::Comment(CommentTool::Rectangle),
|
||||
QuickTool::Comment(CommentTool::Highlight),
|
||||
QuickTool::Measure(crate::measure_ui::Tool::Distance),
|
||||
QuickTool::Crop,
|
||||
QuickTool::EnhanceCamera,
|
||||
QuickTool::Fill(FillTool::Signature),
|
||||
QuickTool::Field(FieldTool::Text),
|
||||
QuickTool::Redact,
|
||||
QuickTool::AddText,
|
||||
QuickTool::EditText,
|
||||
QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved),
|
||||
QuickTool::CustomStamp(0),
|
||||
QuickTool::Link,
|
||||
QuickTool::SignArea { certify: false },
|
||||
QuickTool::MarqueeZoom,
|
||||
QuickTool::Snapshot,
|
||||
QuickTool::ColumnSelect,
|
||||
];
|
||||
for tool in &tools {
|
||||
match tool {
|
||||
QuickTool::Select
|
||||
| QuickTool::Hand
|
||||
| QuickTool::Comment(_)
|
||||
| QuickTool::Measure(_)
|
||||
| QuickTool::Crop
|
||||
| QuickTool::EnhanceCamera
|
||||
| QuickTool::Fill(_)
|
||||
| QuickTool::Field(_)
|
||||
| QuickTool::Redact
|
||||
| QuickTool::AddText
|
||||
| QuickTool::EditText
|
||||
| QuickTool::Stamp(_)
|
||||
| QuickTool::CustomStamp(_)
|
||||
| QuickTool::Link
|
||||
| QuickTool::SignArea { .. }
|
||||
| QuickTool::MarqueeZoom
|
||||
| QuickTool::Snapshot
|
||||
| QuickTool::ColumnSelect => {}
|
||||
}
|
||||
}
|
||||
tools
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_are_listed_bottom_first_in_their_order() {
|
||||
for (i, layer) in LAYERS.into_iter().enumerate() {
|
||||
assert_eq!(layer as usize, i, "{layer:?}");
|
||||
}
|
||||
assert!(LAYERS.windows(2).all(|w| w[0] < w[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_count_for_the_select_and_link_tools_only() {
|
||||
for tool in tools() {
|
||||
assert_eq!(Layer::Link.reached_by(tool), matches!(tool, QuickTool::Select | QuickTool::Link), "{tool:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_count_for_the_select_tool_only() {
|
||||
for tool in tools() {
|
||||
assert_eq!(Layer::Comment.reached_by(tool), tool == QuickTool::Select, "{tool:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_other_layers_count_for_every_tool() {
|
||||
for tool in tools() {
|
||||
for layer in [Layer::ToolArea, Layer::Text, Layer::Field, Layer::Placed, Layer::Handle, Layer::Editor, Layer::Drag] {
|
||||
assert!(layer.reached_by(tool), "{layer:?} for {tool:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claims(claims: &[(Layer, CursorIcon, Option<&str>)]) -> PageCursor {
|
||||
let mut cursor = PageCursor::default();
|
||||
for (layer, icon, tip) in claims {
|
||||
cursor.claim_with_tip(*layer, *icon, tip.map(str::to_string));
|
||||
}
|
||||
cursor
|
||||
}
|
||||
|
||||
fn winner(cursor: PageCursor, tool: QuickTool) -> Option<Claim> {
|
||||
PageCursor::winner(cursor.claims, tool)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_layer_the_tool_reaches_wins_with_its_tip() {
|
||||
let on_text_under_a_link = [(Layer::Text, CursorIcon::Text, None), (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"))];
|
||||
let select = winner(claims(&on_text_under_a_link), QuickTool::Select);
|
||||
assert_eq!(select, Some(Claim { icon: CursorIcon::PointingHand, tip: Some("https://example.org/".into()) }));
|
||||
// A markup tool selects the text there, and the link's tip goes with its hand.
|
||||
let highlight = winner(claims(&on_text_under_a_link), QuickTool::Comment(CommentTool::Highlight));
|
||||
assert_eq!(highlight, Some(Claim { icon: CursorIcon::Text, tip: None }));
|
||||
// A tool reaching nothing claimed leaves the cursor alone.
|
||||
assert_eq!(winner(claims(&[(Layer::Link, CursorIcon::PointingHand, None)]), QuickTool::Hand), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_higher_claim_without_a_tip_hides_a_lower_ones_tip() {
|
||||
let field_over_a_link = [(Layer::Link, CursorIcon::PointingHand, Some("Go to page 2")), (Layer::Field, CursorIcon::Text, None)];
|
||||
assert_eq!(winner(claims(&field_over_a_link), QuickTool::Select), Some(Claim { icon: CursorIcon::Text, tip: None }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_claim_on_a_layer_wins() {
|
||||
let two_links = [(Layer::Link, CursorIcon::PointingHand, Some("first")), (Layer::Link, CursorIcon::PointingHand, Some("second"))];
|
||||
assert_eq!(winner(claims(&two_links), QuickTool::Select).and_then(|c| c.tip).as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
/// Runs one frame that sets the cursor to `before`, if any, then resolves `claims` for
|
||||
/// `tool`. Returns the frame's cursor and the tip.
|
||||
fn frame(ctx: &egui::Context, before: Option<CursorIcon>, claims: &mut PageCursor, tool: QuickTool) -> (CursorIcon, Option<String>) {
|
||||
let mut tip = None;
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
if let Some(icon) = before {
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
}
|
||||
tip = claims.resolve(ui.ctx(), tool);
|
||||
});
|
||||
// An input-only test has no renderer to take the font texture.
|
||||
output.textures_delta.clear();
|
||||
(output.platform_output.cursor_icon, tip)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_sets_the_winners_cursor_and_spends_the_claims() {
|
||||
let ctx = egui::Context::default();
|
||||
let on_a_link = || claims(&[(Layer::ToolArea, CursorIcon::Crosshair, None), (Layer::Link, CursorIcon::PointingHand, Some("tip"))]);
|
||||
assert_eq!(frame(&ctx, None, &mut on_a_link(), QuickTool::Select), (CursorIcon::PointingHand, Some("tip".into())));
|
||||
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
|
||||
assert_eq!(frame(&ctx, None, &mut on_a_link(), stamp), (CursorIcon::Crosshair, None), "the stamp's crosshair, and no link tip");
|
||||
// Nothing the tool reaches: the cursor stays as the frame set it.
|
||||
let mut only_a_link = claims(&[(Layer::Link, CursorIcon::PointingHand, Some("tip"))]);
|
||||
only_a_link.describe(Subject::Comment, "Ana".into());
|
||||
assert_eq!(frame(&ctx, Some(CursorIcon::Help), &mut only_a_link, QuickTool::Hand), (CursorIcon::Help, Some("Ana".into())));
|
||||
// Resolving spends the claims, reached or not, and the description.
|
||||
assert_eq!(only_a_link.description, None);
|
||||
assert_eq!(winner(only_a_link, QuickTool::Select), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_description_shows_with_any_tool_unless_the_winner_has_a_tip_of_its_own() {
|
||||
let ctx = egui::Context::default();
|
||||
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
|
||||
let link = (Layer::Link, CursorIcon::PointingHand, Some("https://example.org/"));
|
||||
let described = |claimed: &[(Layer, CursorIcon, Option<&str>)]| {
|
||||
let mut cursor = claims(claimed);
|
||||
cursor.describe(Subject::Comment, "Ana\nCheck this".into());
|
||||
cursor
|
||||
};
|
||||
let comment = Some("Ana\nCheck this".to_string());
|
||||
// A comment under the stamp's crosshair: the tool's cursor, and what the comment says.
|
||||
let crosshair = (Layer::ToolArea, CursorIcon::Crosshair, None);
|
||||
assert_eq!(frame(&ctx, None, &mut described(&[crosshair]), stamp), (CursorIcon::Crosshair, comment.clone()));
|
||||
// A link the tool follows speaks for itself; one it doesn't keeps its tip to itself.
|
||||
assert_eq!(
|
||||
frame(&ctx, None, &mut described(&[crosshair, link]), QuickTool::Select),
|
||||
(CursorIcon::PointingHand, Some("https://example.org/".into()))
|
||||
);
|
||||
assert_eq!(frame(&ctx, None, &mut described(&[crosshair, link]), stamp), (CursorIcon::Crosshair, comment.clone()));
|
||||
// A winner without a tip of its own, such as the Select tool's comment claim.
|
||||
let picked_up = (Layer::Comment, CursorIcon::Move, None);
|
||||
assert_eq!(frame(&ctx, None, &mut described(&[link, picked_up]), QuickTool::Select), (CursorIcon::Move, comment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comment_says_more_than_a_signature_badge_and_the_last_badge_stands() {
|
||||
let badge_then_comment = {
|
||||
let mut cursor = PageCursor::default();
|
||||
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
|
||||
cursor.describe(Subject::Comment, "Ana".into());
|
||||
cursor
|
||||
};
|
||||
let comment_then_badges = {
|
||||
let mut cursor = PageCursor::default();
|
||||
cursor.describe(Subject::Comment, "Ana".into());
|
||||
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
|
||||
cursor
|
||||
};
|
||||
let two_badges = {
|
||||
let mut cursor = PageCursor::default();
|
||||
cursor.describe(Subject::SignatureBadge, "Signature is valid".into());
|
||||
cursor.describe(Subject::SignatureBadge, "Signature is invalid".into());
|
||||
cursor
|
||||
};
|
||||
let said = |cursor: PageCursor| cursor.description.map(|(_, text)| text);
|
||||
assert_eq!(said(badge_then_comment).as_deref(), Some("Ana"));
|
||||
assert_eq!(said(comment_then_badges).as_deref(), Some("Ana"), "whichever came first");
|
||||
assert_eq!(said(two_badges).as_deref(), Some("Signature is invalid"), "the badge painted last is on top");
|
||||
}
|
||||
}
|
||||
@@ -247,22 +247,32 @@ fn declares(line: &str) -> Option<&str> {
|
||||
rest.find(|c: char| !(c.is_alphanumeric() || c == '_')).map(|end| &rest[..end])
|
||||
}
|
||||
|
||||
/// Whether `line` calls `read`, and not a longer name that ends in it.
|
||||
fn reads(line: &str, read: &str) -> bool {
|
||||
line.match_indices(read).any(|(at, _)| !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_'))
|
||||
/// Ways to set egui's cursor.
|
||||
const CURSOR_WRITES: [&str; 4] = ["set_cursor_icon(", ".cursor_icon = ", "on_hover_cursor(", "on_hover_and_drag_cursor("];
|
||||
|
||||
/// Functions in the page modules that set a cursor other than the pages': (file, function, why).
|
||||
const NOT_PAGE_CURSOR: [(&str, &str, &str); 3] = [
|
||||
("tools/fill_sign/scan.rs", "scan_tab", "the signature dialog's Scan tab"),
|
||||
("tools/edit_text_ui.rs", "overlay", "the paragraph editor is an Area of its own over the page, drawn after the pages' cursor is set"),
|
||||
("tools/calc_order_ui.rs", "panel", "the Calculation order panel, whose rows are dragged to reorder them"),
|
||||
];
|
||||
|
||||
/// Whether `line` uses `name`, and not a longer name that ends in it (a field, after its `.`, is
|
||||
/// never part of a longer name).
|
||||
fn uses(line: &str, name: &str) -> bool {
|
||||
line.match_indices(name).any(|(at, _)| name.starts_with('.') || !line[..at].ends_with(|c: char| c.is_alphanumeric() || c == '_'))
|
||||
}
|
||||
|
||||
/// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is
|
||||
/// drawn over the pages and where a touch lifted, so a raw read in a page module brings back
|
||||
/// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing.
|
||||
#[test]
|
||||
fn page_modules_read_the_pointer_only_through_page_pointer() {
|
||||
/// Each use of one of `names` in the page modules' code (their tests left out) outside the
|
||||
/// functions `allowed` to, as "src/file:line: `name` why"; then each allowed function that uses
|
||||
/// none of them any more, as "src/file: fn function unused".
|
||||
fn misuses(names: &[&str], allowed: &[(&str, &str, &str)], why: &str, unused: &str) -> Vec<String> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut problems = Vec::new();
|
||||
let mut needed = vec![false; NOT_PAGE_INPUT.len()];
|
||||
let mut needed = vec![false; allowed.len()];
|
||||
for file in PAGE_MODULES {
|
||||
let source = std::fs::read_to_string(src.join(file)).expect("page module").replace("\r\n", "\n");
|
||||
// Tests drive egui's input themselves.
|
||||
// Tests drive egui's input and read its output themselves.
|
||||
let code = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default();
|
||||
// The top-level function, or method of an `impl`, that each line is in.
|
||||
let (mut function, mut in_impl) = (None, false);
|
||||
@@ -277,24 +287,36 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
|
||||
if line.trim_start().starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for read in RAW_READS.into_iter().filter(|read| reads(line, read)) {
|
||||
match NOT_PAGE_INPUT.iter().position(|(f, name, _)| *f == file && function == Some(*name)) {
|
||||
for name in names.iter().filter(|name| uses(line, name)) {
|
||||
match allowed.iter().position(|(f, function_name, _)| *f == file && function == Some(*function_name)) {
|
||||
Some(k) => needed[k] = true,
|
||||
None => problems.push(format!(
|
||||
"src/{file}:{}: `{read}` reads egui's pointer state in a page module. Read `PagePointer` \
|
||||
(src/page_pointer.rs) instead: it knows what is drawn over the pages and where a touch \
|
||||
lifted. Not page input? Add the function to NOT_PAGE_INPUT there, with why.",
|
||||
n + 1
|
||||
)),
|
||||
None => problems.push(format!("src/{file}:{}: `{name}` {why}", n + 1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for ((file, name, _), needed) in NOT_PAGE_INPUT.iter().zip(needed) {
|
||||
for ((file, name, _), needed) in allowed.iter().zip(needed) {
|
||||
if !needed {
|
||||
problems.push(format!("src/{file}: fn {name} reads no pointer state any more: remove it from NOT_PAGE_INPUT."));
|
||||
problems.push(format!("src/{file}: fn {name} {unused}"));
|
||||
}
|
||||
}
|
||||
problems
|
||||
}
|
||||
|
||||
/// Page modules read the pointer only through `PagePointer`. egui's raw state misses what is
|
||||
/// drawn over the pages and where a touch lifted, so a raw read in a page module brings back
|
||||
/// the bugs `PagePointer` fixed: a cursor or a press through a dialog, a tap that does nothing.
|
||||
#[test]
|
||||
fn page_modules_read_the_pointer_only_through_page_pointer() {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut problems = misuses(
|
||||
&RAW_READS,
|
||||
&NOT_PAGE_INPUT,
|
||||
"reads egui's pointer state in a page module. Read `PagePointer` (src/canvas/page_pointer.rs) instead: it \
|
||||
knows what is drawn over the pages and where a touch lifted. Not page input? Add the function to \
|
||||
NOT_PAGE_INPUT there, with why.",
|
||||
"reads no pointer state any more: remove it from NOT_PAGE_INPUT.",
|
||||
);
|
||||
// A module that takes the page pointer is a page module, so it is checked too.
|
||||
let mut dirs = vec![src.clone()];
|
||||
while let Some(dir) = dirs.pop() {
|
||||
@@ -322,3 +344,20 @@ fn page_modules_read_the_pointer_only_through_page_pointer() {
|
||||
}
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
/// Page modules claim the cursor and never set it. Where each set it, the last write of a frame
|
||||
/// won, so the order of the per-page chain decided the cursor and not what a press does: a
|
||||
/// link's hand over every tool, the I-beam over a comment that a drag moves.
|
||||
#[test]
|
||||
fn page_modules_claim_the_cursor_and_never_set_it() {
|
||||
let problems = misuses(
|
||||
&CURSOR_WRITES,
|
||||
&NOT_PAGE_CURSOR,
|
||||
"sets the cursor in a page module, where the last write of a frame would win. Claim it instead, on the layer \
|
||||
of what is under the pointer (`view.cursor.claim`, src/canvas/page_cursor.rs): `document_area` sets it once, \
|
||||
from the claim on the highest layer the active tool reaches. Not the pages' cursor? Add the function to \
|
||||
NOT_PAGE_CURSOR in src/canvas/page_pointer/tests.rs, with why.",
|
||||
"sets no cursor any more: remove it from NOT_PAGE_CURSOR.",
|
||||
);
|
||||
assert!(problems.is_empty(), "{}", problems.join("\n"));
|
||||
}
|
||||
|
||||
@@ -57,36 +57,6 @@ fn grid_pages_are_centred_and_as_large_as_the_cell_allows() {
|
||||
}
|
||||
}
|
||||
|
||||
fn annotation(fill_sign: bool) -> pdfcraft_render::Annotation {
|
||||
pdfcraft_render::Annotation {
|
||||
page: 0,
|
||||
subtype: "FreeText".into(),
|
||||
author: None,
|
||||
contents: Some("Hello".into()),
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [0.0; 4],
|
||||
color: None,
|
||||
index: 0,
|
||||
state: None,
|
||||
quads: Vec::new(),
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign,
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hover_popup_skips_fill_and_sign_marks() {
|
||||
assert_eq!(annotation_hover_text(&annotation(false)).as_deref(), Some("FreeText\nHello"));
|
||||
assert_eq!(annotation_hover_text(&annotation(true)), None);
|
||||
}
|
||||
|
||||
fn signature(signed: bool, visible: bool, page: Option<usize>, rect: Option<[f64; 4]>) -> pdfcraft_engine::SignatureInfo {
|
||||
pdfcraft_engine::SignatureInfo {
|
||||
field: "Signature1".into(),
|
||||
|
||||
@@ -5,6 +5,7 @@ use egui::{Color32, CornerRadius, Pos2, Rect, Stroke};
|
||||
use pdfcraft_render::{RenderConfig, RenderRequest, RequestKind, Tile};
|
||||
|
||||
use crate::canvas::{DocView, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::{PdfCraftApp, QuickTool};
|
||||
|
||||
@@ -16,7 +17,7 @@ pub type Marquee = (usize, Rect, [f32; 4]);
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
|
||||
|
||||
@@ -69,10 +69,10 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
app.active = None;
|
||||
app.combine_tab.focused = false;
|
||||
}
|
||||
// Keep the two icon buttons, Discord's text/padding and three gaps outside the scrolling tabs.
|
||||
// Reserve the three icon buttons, Discord and their gaps outside the scrolling tabs.
|
||||
let controls_width = ui.fonts_mut(|f| f.layout_no_wrap("Discord".into(), theme::medium(13.0), t.text).size().x)
|
||||
+ 2.0 * button
|
||||
+ 50.0
|
||||
+ 3.0 * button
|
||||
+ 54.0
|
||||
+ window_controls::width(&controls.right);
|
||||
// Open stays in sight after the tabs: its icon, text and padding, and the gaps around it.
|
||||
let open_width = ui.fonts_mut(|f| f.layout_no_wrap(tl!("Open").into(), theme::medium(13.0), t.text).size().x) + 38.0 + 12.0;
|
||||
@@ -287,6 +287,9 @@ pub fn tab_strip(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
ui.add_space(window_controls::SIDE_GAP - 4.0);
|
||||
}
|
||||
if icons::button(ui, "settings", button, false, tl!("Preferences")).clicked() {
|
||||
app.execute("app.preferences");
|
||||
}
|
||||
let (icon, label) = match app.theme_preference {
|
||||
ThemePreference::System => ("monitor", tl!("Use system setting")),
|
||||
ThemePreference::Light => ("sun", tl!("Light gray")),
|
||||
|
||||
@@ -355,8 +355,23 @@ impl PdfCraftApp {
|
||||
}
|
||||
self.dialog = Some(Dialog::NumberPages);
|
||||
}
|
||||
"page.properties" => {
|
||||
if let Some(i) = active
|
||||
&& let Some(doc) = self.session.get(self.views[i].id)
|
||||
{
|
||||
let pages = self.views[i].target_pages();
|
||||
let orders = doc.tab_orders();
|
||||
let mut theirs = pages.iter().map(|p| orders.get(*p).copied());
|
||||
let first = theirs.next().flatten();
|
||||
let before = if theirs.all(|o| o == first) { first } else { None };
|
||||
let generation = doc.edit_generation();
|
||||
self.page_props_draft = Some(crate::PagePropsDraft { doc: self.views[i].id, generation, pages, before, tab_order: before });
|
||||
self.dialog = Some(Dialog::PageProps);
|
||||
}
|
||||
}
|
||||
"bookmark.add" => self.bookmark_action(crate::panels::BmAction::New),
|
||||
"bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure),
|
||||
"bookmark.from_text_styles" => self.bookmark_action(crate::panels::BmAction::FromTextStyles),
|
||||
"page.organize" => {
|
||||
if let Some(v) = active.and_then(|i| self.views.get_mut(i)) {
|
||||
if v.organize {
|
||||
@@ -528,7 +543,8 @@ impl PdfCraftApp {
|
||||
}
|
||||
"edit.links_from_urls" => self.links_from_urls(),
|
||||
"edit.remove_links" => self.remove_all_links(),
|
||||
"comment.import" | "form.import_data" => self.import_data_dialog(),
|
||||
"comment.import" => self.import_comments_dialog(),
|
||||
"form.import_data" => self.import_data_dialog(),
|
||||
"comment.stamp" => {
|
||||
self.left = crate::LeftPanel::Tool("stamp");
|
||||
self.reveal_tools();
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::Edit;
|
||||
use crate::{CloseRequest, Dialog, PdfCraftApp, PropsTab, panels::human_size, widgets};
|
||||
|
||||
pub(super) mod appearance;
|
||||
pub mod bookmark_styles_ui;
|
||||
pub(super) mod comment_props;
|
||||
pub(super) mod credits;
|
||||
pub(super) mod marks_ui;
|
||||
@@ -52,6 +53,7 @@ struct Pending {
|
||||
summarize_now: bool,
|
||||
optimize_now: bool,
|
||||
duplicate_now: Option<Edit>,
|
||||
page_props_now: Option<(pdfcraft_engine::DocId, u64, Edit)>,
|
||||
replace_now: bool,
|
||||
open_revision: Option<usize>,
|
||||
a11y_now: bool,
|
||||
@@ -218,6 +220,19 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Dialog::PageProps => {
|
||||
let Some(d) = app.page_props_draft.as_mut() else {
|
||||
req.close = true;
|
||||
return false;
|
||||
};
|
||||
let (ok, cancel) = crate::organize_panel::page_props_body(ui, d);
|
||||
// An order every page already has adds no undo step.
|
||||
if ok && let Some(order) = d.tab_order.filter(|o| d.before != Some(*o)) {
|
||||
req.page_props_now = Some((d.doc, d.generation, Edit::SetTabOrder { pages: d.pages.clone(), order }));
|
||||
}
|
||||
req.close = ok || cancel;
|
||||
return false;
|
||||
}
|
||||
Dialog::DuplicateField => {
|
||||
if !dialog_duplicate_field(ui, app, req) {
|
||||
return false;
|
||||
@@ -407,6 +422,10 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
|
||||
req.close = req.apply || cancel;
|
||||
return false;
|
||||
}
|
||||
Dialog::BookmarksFromTextStyles => {
|
||||
req.close = crate::bookmark_styles_ui::body(ui, app, &t);
|
||||
return false;
|
||||
}
|
||||
Dialog::NumberPages => {
|
||||
if !dialog_number_pages(ui, app, req) {
|
||||
return false;
|
||||
@@ -1643,6 +1662,15 @@ fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
app.duplicate_draft = None;
|
||||
app.apply_edit(e);
|
||||
}
|
||||
// Another document became active, or the document changed, while the dialog was open: the
|
||||
// pages at those indices may not be the ones chosen.
|
||||
if let Some((doc, generation, e)) = req.page_props_now.take() {
|
||||
if app.active_ids().is_some_and(|(_, id)| id == doc) && app.session.get(doc).is_some_and(|d| d.edit_generation() == generation) {
|
||||
app.apply_edit(e);
|
||||
} else {
|
||||
app.notify_tr("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.");
|
||||
}
|
||||
}
|
||||
if req.extract_now {
|
||||
app.extract_selection();
|
||||
}
|
||||
@@ -1801,6 +1829,7 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
app.view_draft = None;
|
||||
app.custom_draft = None;
|
||||
app.bulk_field_props = None;
|
||||
app.unmarked_page = Default::default();
|
||||
} else {
|
||||
app.dialog = Some(req.next.unwrap_or(dialog));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Bookmarks ▸ New bookmarks from text styles (#1693): for documents whose headings aren't
|
||||
//! tagged, a style per bookmark level (the chapters' font and size for level 1, the sections'
|
||||
//! for level 2, …), and every line set in it becomes a bookmark of that level
|
||||
//! ([`pdfcraft_engine::text_style_bookmarks`]).
|
||||
|
||||
use pdfcraft_engine::text_style_bookmarks::{TextStyle, TextStyleUse};
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
/// The levels offered: deeper outlines are rare, and each level needs a style of its own.
|
||||
const LEVELS: usize = 4;
|
||||
|
||||
/// What the dialog shows and what has been chosen in it.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TextStyleDraft {
|
||||
/// The document the styles were read from.
|
||||
pub doc: Option<DocId>,
|
||||
/// Its text styles, the largest first.
|
||||
pub styles: Vec<TextStyleUse>,
|
||||
/// The style (an index into `styles`) for each level, level 1 first; a level without one
|
||||
/// ends the list.
|
||||
pub levels: [Option<usize>; LEVELS],
|
||||
}
|
||||
|
||||
impl TextStyleDraft {
|
||||
/// The styles for the chosen levels, level 1 first, up to the first level left without one.
|
||||
pub fn chosen(&self) -> Vec<TextStyle> {
|
||||
self.levels.iter().map_while(|l| l.and_then(|i| self.styles.get(i)).map(|u| u.style.clone())).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Open New bookmarks from text styles for document `id`: its styles are read now, and the
|
||||
/// largest is offered for level 1 when there is more than one (headings are larger than the
|
||||
/// text around them; a single style has no headings to tell apart).
|
||||
pub fn open_text_style_bookmarks(&mut self, id: DocId) {
|
||||
// The style list includes samples copied from the document's text. Enforce the PDF's
|
||||
// copy permission before extracting any of that text, not only when creating bookmarks.
|
||||
let can_copy = self.session.get(id).is_some_and(|d| d.permissions().is_none_or(|p| p.copy()));
|
||||
if !can_copy {
|
||||
self.notify_tr("The document's security settings don't allow copying text and images");
|
||||
return;
|
||||
}
|
||||
let styles = self.session.get(id).map(|d| d.text_styles()).unwrap_or_default();
|
||||
let first = (styles.len() > 1).then_some(0);
|
||||
self.text_style_draft = TextStyleDraft { doc: Some(id), styles, levels: [first, None, None, None] };
|
||||
self.dialog = Some(crate::Dialog::BookmarksFromTextStyles);
|
||||
}
|
||||
}
|
||||
|
||||
/// How a style is named in the lists: font, size, bold or italic, how many lines, and a sample.
|
||||
fn style_label(u: &TextStyleUse) -> String {
|
||||
let mut s = crate::i18n::fmt(tl!("{font}, {size} pt"), &[("font", &u.style.font), ("size", &format!("{}", u.style.size))]);
|
||||
if u.style.bold {
|
||||
s.push_str(", ");
|
||||
s.push_str(tl!("bold"));
|
||||
}
|
||||
if u.style.italic {
|
||||
s.push_str(", ");
|
||||
s.push_str(tl!("italic"));
|
||||
}
|
||||
let lines = if u.lines == 1 { tl!("1 line").to_string() } else { crate::i18n::fmt(tl!("{n} lines"), &[("n", &u.lines.to_string())]) };
|
||||
let sample: String = u.sample.chars().take(40).collect();
|
||||
let more = if sample.chars().count() < u.sample.chars().count() { "…" } else { "" };
|
||||
format!("{s} · “{sample}{more}” ({lines})")
|
||||
}
|
||||
|
||||
/// The dialog's contents and its buttons. Returns whether it closes; Create makes the
|
||||
/// bookmarks and shows them in the Bookmarks panel.
|
||||
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool {
|
||||
ui.label(egui::RichText::new(tl!("New bookmarks from text styles")).font(theme::semibold(18.0)));
|
||||
ui.add_space(6.0);
|
||||
let d = &mut app.text_style_draft;
|
||||
let id = d.doc;
|
||||
if d.styles.is_empty() {
|
||||
ui.label(tl!("This document has no text to make bookmarks from."));
|
||||
ui.add_space(12.0);
|
||||
return widgets::pill_button(ui, tl!("Close"), true).clicked();
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(tl!("Every line set in the style chosen for a level becomes a bookmark of that level, under a new first bookmark."))
|
||||
.color(t.text_muted),
|
||||
);
|
||||
ui.add_space(8.0);
|
||||
let none = tl!("None");
|
||||
egui::Grid::new("text_style_levels").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
for level in 0..LEVELS {
|
||||
// A level takes a style only once the one above it has one.
|
||||
let open = level == 0 || d.levels.get(level - 1).copied().flatten().is_some();
|
||||
let label = ui.label(crate::i18n::fmt(tl!("Level {n}"), &[("n", &(level + 1).to_string())]));
|
||||
ui.add_enabled_ui(open, |ui| {
|
||||
let Some(slot) = d.levels.get_mut(level) else { return };
|
||||
let shown = slot.and_then(|i| d.styles.get(i)).map_or_else(|| none.to_string(), style_label);
|
||||
craft_ui::ComboBox::from_id_salt(("text_style_level", level))
|
||||
.selected_text(shown)
|
||||
.width(460.0)
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(slot, None, none);
|
||||
for (i, u) in d.styles.iter().enumerate() {
|
||||
ui.selectable_value(slot, Some(i), style_label(u));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
// A level left without a style ends the list: the ones below it don't count.
|
||||
if let Some(gap) = d.levels.iter().position(Option::is_none) {
|
||||
d.levels.iter_mut().skip(gap).for_each(|l| *l = None);
|
||||
}
|
||||
let levels = d.chosen();
|
||||
ui.add_space(16.0);
|
||||
let mut close = false;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.add_enabled_ui(!levels.is_empty(), |ui| widgets::pill_button(ui, tl!("Create"), true)).inner.clicked() {
|
||||
close = true;
|
||||
// The document the styles came from, even if another tab is active by now.
|
||||
if let Some(i) = id.and_then(|id| app.views.iter().position(|v| v.id == id)) {
|
||||
app.active = Some(i);
|
||||
if app.apply_edit(Edit::BookmarksFromTextStyles { levels }) {
|
||||
app.right = Some(crate::RightPanel::Bookmarks);
|
||||
}
|
||||
}
|
||||
}
|
||||
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
close
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
//! page the range selects, with the marks laid out on it. "Update" opens the same dialog in
|
||||
//! replace mode.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Rect, Stroke, pos2, vec2};
|
||||
use pdfcraft_engine::{Background, Edit, FontFamily, HeaderFooter, MarkKind, Watermark};
|
||||
|
||||
@@ -12,6 +14,9 @@ use crate::{PdfCraftApp, icons, widgets};
|
||||
/// How far a watermark may be moved from the page centre, in points.
|
||||
const MAX_OFFSET: f64 = 20_000.0;
|
||||
|
||||
/// The preview page's longer side, in points.
|
||||
const PREVIEW_SIDE: f32 = 190.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Subset {
|
||||
All,
|
||||
@@ -152,7 +157,7 @@ fn preview(
|
||||
draw: impl FnOnce(&egui::Painter, Rect, f32),
|
||||
) {
|
||||
let (w, h) = (size.0.max(1.0), size.1.max(1.0));
|
||||
let s = (190.0 / w.max(h)) as f32;
|
||||
let s = (f64::from(PREVIEW_SIDE) / w.max(h)) as f32;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(210.0, 210.0), egui::Sense::hover());
|
||||
let page = Rect::from_center_size(r.center(), vec2(w as f32 * s, h as f32 * s));
|
||||
let painter = ui.painter_at(r);
|
||||
@@ -180,6 +185,119 @@ fn behind(color: [f64; 3], opacity: f64) -> Color32 {
|
||||
rgb32(color.map(|c| 1.0 - a * (1.0 - c)))
|
||||
}
|
||||
|
||||
/// In Update, the pages previewed without the marks the dialog replaces. Their thumbnails still
|
||||
/// have them, so the new marks would be drawn over the old ones, and a new background colour
|
||||
/// would tint the old one. The document without them is made once per dialog (it costs about a
|
||||
/// save). The page shown is drawn from it straight away, at the preview's small size, so neither
|
||||
/// a blank sheet nor the old marks show while it renders; the pages a step goes to next render
|
||||
/// ahead, off the UI thread (not on the web, where the pool renders on it), so stepping rarely
|
||||
/// waits.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct UnmarkedPage {
|
||||
/// The document, its edit generation and the kind of mark taken away.
|
||||
key: Option<(pdfcraft_engine::DocId, u64, MarkKind)>,
|
||||
pages: Option<pdfcraft_engine::UnmarkedPages>,
|
||||
/// Renders the pages next to the one shown, ahead. None on the web, where the pool renders on
|
||||
/// the UI thread inside `try_recv`: it would draw them in the same frame too.
|
||||
renderer: Option<pdfcraft_render::RenderPool>,
|
||||
/// The pages rendered, with the scale (×1000) they were rendered at: the page shown and the
|
||||
/// pages next to it.
|
||||
textures: HashMap<usize, (u64, egui::TextureHandle)>,
|
||||
/// Pages that failed to render when shown, so they aren't tried every frame: their
|
||||
/// thumbnails show instead, the other pages still don't.
|
||||
failed: HashSet<usize>,
|
||||
/// The pages asked of the renderer ahead, and not back yet. Nothing waits for them: a step
|
||||
/// is a frame, which takes what has come back first and draws the rest itself.
|
||||
ahead: Vec<(usize, u64)>,
|
||||
/// Pages (at a scale) that came back from rendering ahead with an error, not asked ahead
|
||||
/// again: the error needn't be the page's, so drawing it when shown still decides.
|
||||
not_ahead: HashSet<(usize, u64)>,
|
||||
}
|
||||
|
||||
impl UnmarkedPage {
|
||||
/// The picture to preview `page` on in place of its thumbnail, or `None` for the thumbnail:
|
||||
/// not Update, no marks of `kind` in the document, the pages can't be prepared, or `page`
|
||||
/// fails to render and was never drawn (a picture of it at another scale is shown rather than
|
||||
/// the old marks). `near` are the pages a step goes to from it, rendered ahead.
|
||||
fn picture(
|
||||
&mut self,
|
||||
ctx: &egui::Context,
|
||||
(doc, id): (&pdfcraft_engine::Document, pdfcraft_engine::DocId),
|
||||
page: usize,
|
||||
near: &[usize],
|
||||
kind: MarkKind,
|
||||
replace: bool,
|
||||
) -> Option<egui::TextureId> {
|
||||
let key = replace.then(|| (id, doc.edit_generation(), kind));
|
||||
if self.key != key {
|
||||
*self = Self { key, ..Default::default() };
|
||||
if replace {
|
||||
self.pages = doc.without_marks(kind).ok().flatten();
|
||||
self.renderer = self.pages.as_ref().filter(|_| !cfg!(target_arch = "wasm32")).map(|p| p.renderer());
|
||||
}
|
||||
}
|
||||
self.pages.as_ref()?;
|
||||
let near = if self.renderer.is_some() { near } else { &[][..] };
|
||||
// At the preview's size in pixels.
|
||||
let scale = |page: usize| {
|
||||
let p = doc.info.pages.get(page)?;
|
||||
let s = PREVIEW_SIDE * ctx.pixels_per_point() / p.width.max(p.height).max(1.0);
|
||||
Some((s, (s * 1000.0) as u64))
|
||||
};
|
||||
let load = |ctx: &egui::Context, out: pdfcraft_render::RenderedPage| {
|
||||
let image = crate::canvas::texture_image([out.width as usize, out.height as usize], out.rgba);
|
||||
ctx.load_texture("marks-preview-unmarked", image, egui::TextureOptions::LINEAR)
|
||||
};
|
||||
while let Some(out) = self.renderer.as_ref().and_then(|r| r.try_recv()) {
|
||||
let at = (out.request.page, out.request.tag);
|
||||
self.ahead.retain(|a| *a != at);
|
||||
// An error rendering ahead needn't be the page's (the pool retires its parser, a
|
||||
// request is cancelled): the page is drawn here when it is shown, and that decides.
|
||||
// It isn't asked ahead again, so a page that always fails isn't rendered every frame.
|
||||
if out.error.is_none() {
|
||||
self.textures.insert(at.0, (at.1, load(ctx, out)));
|
||||
} else {
|
||||
self.not_ahead.insert(at);
|
||||
}
|
||||
}
|
||||
let (s, tag) = scale(page)?;
|
||||
if !self.failed.contains(&page)
|
||||
&& self.textures.get(&page).is_none_or(|(t, _)| *t != tag)
|
||||
&& let Some(pages) = self.pages.as_mut()
|
||||
{
|
||||
match pages.render_page(page, s) {
|
||||
Ok(out) => {
|
||||
self.textures.insert(page, (tag, load(ctx, out)));
|
||||
}
|
||||
Err(_) => {
|
||||
self.failed.insert(page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ahead: the pages a step goes to, not rendered yet at their scale.
|
||||
let wanted: Vec<(usize, u64)> = near
|
||||
.iter()
|
||||
.filter(|p| !self.failed.contains(p))
|
||||
.filter_map(|&p| scale(p).map(|(_, t)| (p, t)))
|
||||
.filter(|at| !self.not_ahead.contains(at))
|
||||
.filter(|(p, t)| self.textures.get(p).is_none_or(|(have, _)| have != t))
|
||||
.collect();
|
||||
if let Some(renderer) = self.renderer.as_ref()
|
||||
&& !wanted.is_empty()
|
||||
&& wanted != self.ahead
|
||||
{
|
||||
let requests = wanted
|
||||
.iter()
|
||||
.filter_map(|&(p, _)| scale(p).map(|(s, tag)| pdfcraft_render::RenderRequest { page: p, scale: s, tag, ..Default::default() }));
|
||||
renderer.set_queue(requests.collect());
|
||||
self.ahead = wanted;
|
||||
}
|
||||
self.textures.retain(|p, _| *p == page || near.contains(p));
|
||||
// A picture rendered at another scale beats the thumbnail with the old marks.
|
||||
self.textures.get(&page).map(|(_, t)| t.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// The page (0-based) the preview shows: the current one when the range selects it, otherwise the
|
||||
/// first page the range selects (the current one again when it selects none).
|
||||
fn preview_page(range: &PageRange, current: usize, count: usize) -> usize {
|
||||
@@ -378,12 +496,16 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M
|
||||
ui.add_space(8.0);
|
||||
// The preview: the current page (or the page stepped to), or the first one the range selects
|
||||
// if it leaves that out, drawn from its thumbnail at its displayed size (after /Rotate), the
|
||||
// frame the engine lays the marks out in.
|
||||
// frame the engine lays the marks out in. Update draws it without the marks it replaces.
|
||||
let target = shown_preview(&d.range, &mut d.preview, current, count);
|
||||
let page = doc.info.pages.get(target).map(|p| (p.width as f64, p.height as f64)).unwrap_or((612.0, 792.0));
|
||||
let picture = app.views.get_mut(i).and_then(|view| {
|
||||
view.need_thumbnail(target, true);
|
||||
view.thumb_id(target)
|
||||
let near: Vec<usize> = [false, true].into_iter().filter_map(|forward| step_preview(&d.range, target, count, forward)).collect();
|
||||
let unmarked = app.unmarked_page.picture(ui.ctx(), (doc, id), target, &near, kind, d.replace);
|
||||
let picture = unmarked.or_else(|| {
|
||||
app.views.get_mut(i).and_then(|view| {
|
||||
view.need_thumbnail(target, true);
|
||||
view.thumb_id(target)
|
||||
})
|
||||
});
|
||||
let tint = if kind == MarkKind::Background && !d.use_file { behind(d.bg.color, d.bg.opacity) } else { Color32::WHITE };
|
||||
let hf = d.hf.clone();
|
||||
|
||||
@@ -668,6 +668,7 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
| Edit::ResetForm { .. }
|
||||
| Edit::AddField { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::SetFieldImage { .. }
|
||||
@@ -730,6 +731,11 @@ impl PdfCraftApp {
|
||||
self.right = Some(crate::RightPanel::Bookmarks);
|
||||
Edit::BookmarksFromStructure
|
||||
}
|
||||
// A dialog chooses the styles; it makes the edit.
|
||||
A::FromTextStyles => {
|
||||
self.open_text_style_bookmarks(id);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.apply_edit(edit);
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ use canvas::autoscroll;
|
||||
use canvas::crop;
|
||||
use canvas::fling;
|
||||
use canvas::image_pick;
|
||||
use canvas::page_cursor;
|
||||
use canvas::page_pointer;
|
||||
pub use canvas::scroll_speed;
|
||||
use canvas::signature_drag;
|
||||
@@ -82,6 +83,7 @@ use canvas::wheel_boost;
|
||||
use canvas::wheel_pager;
|
||||
use canvas::zoom_snap;
|
||||
use dialogs::appearance;
|
||||
pub use dialogs::bookmark_styles_ui;
|
||||
use dialogs::comment_props;
|
||||
use dialogs::credits;
|
||||
use dialogs::marks_ui;
|
||||
@@ -127,8 +129,8 @@ use state::CopyPage;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use state::OpenRequest;
|
||||
pub use state::{
|
||||
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PasswordPrompt, PendingLink,
|
||||
PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt,
|
||||
CustomDraft, Dialog, DuplicateDraft, LeftPanel, LinkOrigin, Mode, NumberDraft, OpenOutcome, PageClip, PagePropsDraft, PasswordPrompt,
|
||||
PendingLink, PendingRemoteLink, PropsTab, QuickTool, RecentFile, RightPanel, UnlockPrompt,
|
||||
};
|
||||
use state::{DocumentCopyJob, GridFile};
|
||||
pub use tools::bulk_fields;
|
||||
@@ -359,6 +361,17 @@ pub struct PdfCraftApp {
|
||||
/// Folders pinned to Home, and what they held when last listed.
|
||||
pub pinned: folders_ui::PinnedFolders,
|
||||
pub toast: Option<(String, f64)>,
|
||||
/// The form script message the toast shows now, if it shows one: drawn with its title and
|
||||
/// icon (#4293).
|
||||
pub toast_script: Option<pdfcraft_engine::js::Message>,
|
||||
/// Form script messages waiting for the toast, in the order the scripts showed them: each
|
||||
/// takes the toast when the one before it is gone, so every one is seen (#4294).
|
||||
pub script_messages: std::collections::VecDeque<pdfcraft_engine::js::Message>,
|
||||
/// Form script messages the waiting queue had no room for, since the toast last said how many.
|
||||
pub script_messages_dropped: u64,
|
||||
/// The left-out count the toast is saying now ([`Self::next_script_message`]): put back to be
|
||||
/// said again if another notice replaces the toast before it has been shown its time.
|
||||
pub toast_dropped: u64,
|
||||
/// Whether the macOS title bar is drawn by us (traffic lights over our tab strip).
|
||||
pub integrated_titlebar: bool,
|
||||
/// Set when the desktop leaves the window frame to the app (GNOME on Wayland): the tab strip
|
||||
@@ -518,6 +531,8 @@ pub struct PdfCraftApp {
|
||||
pub attach_override: Option<(String, Vec<u8>)>,
|
||||
/// Duplicate Field: which field and onto which pages.
|
||||
pub duplicate_draft: Option<DuplicateDraft>,
|
||||
/// Page Properties: which pages, and the tab order chosen for them.
|
||||
pub page_props_draft: Option<PagePropsDraft>,
|
||||
/// Text field table: the table being set up, and the page it goes on.
|
||||
pub field_table_draft: Option<field_table_ui::FieldTableDraft>,
|
||||
/// Prepare a form ▸ Preview: fill the form instead of editing its fields.
|
||||
@@ -560,10 +575,14 @@ pub struct PdfCraftApp {
|
||||
pub(crate) word_count_draft: word_count_ui::Draft,
|
||||
/// Protect Using Password dialog state.
|
||||
pub protect_draft: protect::ProtectDraft,
|
||||
/// New bookmarks from text styles dialog state (#1693).
|
||||
pub text_style_draft: bookmark_styles_ui::TextStyleDraft,
|
||||
/// Set Page Boxes dialog state.
|
||||
pub boxes_draft: pageboxes::BoxesDraft,
|
||||
/// Header & footer / watermark / background dialog state.
|
||||
pub marks_draft: marks_ui::MarksDraft,
|
||||
/// The Update dialogs' preview page without the marks they replace.
|
||||
pub(crate) unmarked_page: marks_ui::UnmarkedPage,
|
||||
/// Export dialog settings.
|
||||
pub export_draft: export_ui::ExportDraft,
|
||||
/// A running export's progress.
|
||||
|
||||
@@ -190,9 +190,11 @@ const PAGES: &[&str] = &[
|
||||
"page.crop",
|
||||
"page.boxes",
|
||||
"page.number",
|
||||
"page.properties",
|
||||
SEP,
|
||||
"bookmark.add",
|
||||
"bookmark.from_structure",
|
||||
"bookmark.from_text_styles",
|
||||
];
|
||||
/// Sign ▸ Fill & Sign, with the short labels of the Fill & Sign toolbar.
|
||||
const FILL: &[(&str, &str)] = &[
|
||||
@@ -254,6 +256,10 @@ fn english(labeled: Labeled<'_>) -> Option<String> {
|
||||
}
|
||||
|
||||
fn translate(english: &'static str) -> String {
|
||||
// The bundled UI fonts do not all cover the menu's mathematical ellipsis.
|
||||
if english == "\u{22ef}" {
|
||||
return "...".to_owned();
|
||||
}
|
||||
tl!(english).to_owned()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ pub enum BmAction {
|
||||
Outdent(Vec<usize>),
|
||||
/// Bookmarks from the tagged headings, under a new first "Untitled" bookmark.
|
||||
FromStructure,
|
||||
/// Open New bookmarks from text styles, to choose a style per bookmark level (#1693).
|
||||
FromTextStyles,
|
||||
}
|
||||
|
||||
/// Matching titles plus their ancestors, keeping document paths rather than filtered indexes.
|
||||
|
||||
@@ -159,7 +159,9 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
if row(ui, t, "tag", tl!("Page labels"), Availability::Ready, false).clicked() {
|
||||
act = Some(Act::Command("page.number"));
|
||||
}
|
||||
row(ui, t, "info", tl!("Page properties"), Availability::Later, false);
|
||||
if row(ui, t, "info", tl!("Page properties"), Availability::Ready, false).clicked() {
|
||||
act = Some(Act::Command("page.properties"));
|
||||
}
|
||||
});
|
||||
|
||||
if close {
|
||||
@@ -182,6 +184,30 @@ pub(crate) fn panel(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Page Properties ▸ Tab Order for the pages it opened on: Use Row Order, Use Column Order, Use
|
||||
/// Document Structure and Unspecified, then OK and Cancel. OK is off until a choice is made.
|
||||
/// Returns (OK, Cancel).
|
||||
pub(crate) fn page_props_body(ui: &mut egui::Ui, d: &mut crate::PagePropsDraft) -> (bool, bool) {
|
||||
use pdfcraft_engine::TabOrder;
|
||||
ui.label(egui::RichText::new(tl!("Page Properties")).font(theme::semibold(18.0)));
|
||||
widgets::section_title(ui, tl!("Tab Order"));
|
||||
for (order, label) in [
|
||||
(TabOrder::Row, tl!("Use Row Order")),
|
||||
(TabOrder::Column, tl!("Use Column Order")),
|
||||
(TabOrder::Structure, tl!("Use Document Structure")),
|
||||
(TabOrder::Annotations, tl!("Unspecified")),
|
||||
] {
|
||||
widgets::radio_value(ui, &mut d.tab_order, Some(order), label);
|
||||
}
|
||||
let (mut ok, mut cancel) = (false, false);
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ok = ui.add_enabled_ui(d.tab_order.is_some(), |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked();
|
||||
cancel = widgets::pill_button(ui, tl!("Cancel"), false).clicked();
|
||||
});
|
||||
(ok, cancel)
|
||||
}
|
||||
|
||||
/// Whether a row works in this build.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Availability {
|
||||
|
||||
@@ -107,6 +107,10 @@ pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
bm_action = Some(BmAction::FromStructure);
|
||||
ui.close();
|
||||
}
|
||||
if bm_editable && ui.button(tl!("New bookmarks from text styles…")).clicked() {
|
||||
bm_action = Some(BmAction::FromTextStyles);
|
||||
ui.close();
|
||||
}
|
||||
if !info.outline.is_empty() {
|
||||
for (levels, label) in [
|
||||
(usize::MAX, tl!("Expand all bookmarks")),
|
||||
|
||||
@@ -109,8 +109,12 @@ pub enum Dialog {
|
||||
Split,
|
||||
/// Pages ▸ Number pages… (page labels).
|
||||
NumberPages,
|
||||
/// Organize pages ▸ Page properties (the tab order of the selected pages).
|
||||
PageProps,
|
||||
/// Protect Using Password.
|
||||
Protect,
|
||||
/// Bookmarks ▸ New bookmarks from text styles (#1693).
|
||||
BookmarksFromTextStyles,
|
||||
/// Set Page Boxes (crop, trim, bleed, art, media).
|
||||
PageBoxes,
|
||||
/// Add / Update Header and Footer, Watermark, Background.
|
||||
@@ -213,6 +217,18 @@ pub struct DuplicateDraft {
|
||||
pub to: usize,
|
||||
}
|
||||
|
||||
/// Page Properties: the document and pages it applies to (0-based), the document's edit
|
||||
/// generation when the dialog opened, and the pages' tab order as they have it (`None` when they
|
||||
/// differ) and as chosen (`None` until one is chosen).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct PagePropsDraft {
|
||||
pub doc: DocId,
|
||||
pub generation: u64,
|
||||
pub pages: Vec<usize>,
|
||||
pub before: Option<pdfcraft_engine::TabOrder>,
|
||||
pub tab_order: Option<pdfcraft_engine::TabOrder>,
|
||||
}
|
||||
|
||||
/// Copied pages: their document's name and bytes (as it was when copied) and the pages.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PageClip {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2};
|
||||
|
||||
use crate::canvas::{DocView, PageXform, ViewAction};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
pub use pdfcraft_engine::camera_clean::Look;
|
||||
@@ -59,11 +60,11 @@ fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 {
|
||||
}
|
||||
|
||||
/// Corner dragging on one page. A press on a page without the quadrilateral moves it there.
|
||||
pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
|
||||
pub(crate) fn page_input(resp: &egui::Response, xf: &PageXform, ptr: &PagePointer, page: usize, view: &mut DocView, allowed: bool) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if !allowed {
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::NotAllowed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -93,7 +94,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
|
||||
let Some(q) = view.camera.as_mut().filter(|q| q.page == page) else { return };
|
||||
let near = pointer.is_some_and(|p| q.corners.iter().any(|&c| screen(xf, c).distance(p) <= GRAB));
|
||||
if let Some(i) = q.grab {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
if let Some(p) = pointer {
|
||||
let (x, y) = xf.screen_to_view(p);
|
||||
if let Some(c) = q.corners.get_mut(i) {
|
||||
@@ -104,7 +105,7 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, xf: &PageXform, p
|
||||
q.grab = None;
|
||||
}
|
||||
} else if near {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,9 @@ use geometry::handle_pos;
|
||||
pub(crate) use geometry::{caret_snap, snapped_caret};
|
||||
pub(crate) use geometry::{corner_ratio, page_rotation, resized};
|
||||
pub use geometry::{drawn_text_box_rect, text_box_rect};
|
||||
pub(crate) use input::{fill_grabs, keys, nudge_keys, page_after_text, page_input, select_grabs};
|
||||
#[cfg(test)]
|
||||
use input::hover_tip;
|
||||
pub(crate) use input::{describe, fill_grabs, keys, nudge_keys, page_after_text, page_input, select_grabs};
|
||||
pub use menu::{CanvasAction, fill_picker, recent_custom_colours, remember_custom_colour, status_badge, swatch_grid};
|
||||
#[cfg(test)]
|
||||
use menu::{RECENT_CUSTOM, same_colour};
|
||||
|
||||
@@ -4,7 +4,8 @@ use super::geometry::{
|
||||
};
|
||||
use super::paint::{drawn_shape, edit_anchor, erase, new_comment};
|
||||
use super::state::{Composer, ComposerKind, Gesture, PageCx};
|
||||
use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use super::{Annotation, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2};
|
||||
use crate::page_cursor::{Layer, Subject};
|
||||
|
||||
/// Comment input on one page, before text selection runs. Returns `true` when the pointer
|
||||
/// gesture belongs to commenting (text selection must ignore it).
|
||||
@@ -108,6 +109,46 @@ fn on_handle(cx: &PageCx<'_>, view: &DocView, p: Pos2, pick: impl Fn(&Annotation
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `p` is on one of this page's links.
|
||||
fn on_link(cx: &PageCx<'_>, p: Pos2) -> bool {
|
||||
cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p))
|
||||
}
|
||||
|
||||
/// The tip for the comment under the pointer, with any tool, beside that tool's own cursor: Acrobat
|
||||
/// shows a comment's tooltip whatever tool is active. Not with Fill & Sign, whose placement stays
|
||||
/// clear; not while drawing freehand, where it would cover the next stroke (#429); and not while a
|
||||
/// comment is moved, resized or drawn.
|
||||
pub(crate) fn describe(cx: &PageCx<'_>, view: &mut DocView) {
|
||||
let freehand = matches!(cx.tool, QuickTool::Comment(CommentTool::Ink | CommentTool::Eraser));
|
||||
if matches!(cx.tool, QuickTool::Fill(_)) || freehand || view.comments.gesture.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(p) = cx.ptr.pos else { return };
|
||||
// Inspection sorts comments by position, but the renderer paints them in `/Annots` order: the
|
||||
// tip is that of the one drawn on top, among those with something to say (#3620).
|
||||
let top = cx
|
||||
.comments()
|
||||
.filter(|a| cx.screen_rects(a).any(|r| r.contains(p)))
|
||||
.filter_map(|a| hover_tip(a, cx.prefs).map(|text| (a.index, text)))
|
||||
.max_by_key(|(index, _)| *index);
|
||||
if let Some((_, text)) = top {
|
||||
view.cursor.describe(Subject::Comment, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// What hovering `a` says, as Acrobat's popups do. Replies and Fill & Sign's marks (typed text,
|
||||
/// checks, signatures) say nothing: they are page content being edited, not comments. Neither
|
||||
/// does a highlight with no text of its own, when the preferences say so.
|
||||
pub(super) fn hover_tip(a: &Annotation, prefs: &CommentPrefs) -> Option<String> {
|
||||
let empty_highlight = a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty);
|
||||
if a.is_reply() || a.fill_sign || prefs.hide_empty_highlight_hover && empty_highlight {
|
||||
return None;
|
||||
}
|
||||
let who = a.author.clone().unwrap_or_else(|| a.subtype.clone());
|
||||
let body = a.contents.clone().unwrap_or_default();
|
||||
Some(if body.is_empty() { who } else { format!("{who}\n{body}") })
|
||||
}
|
||||
|
||||
/// The Select tool: hover, select, move, resize, edit, delete.
|
||||
fn select_input(
|
||||
ui: &egui::Ui,
|
||||
@@ -132,17 +173,26 @@ fn select_input(
|
||||
let mut consumed = false;
|
||||
if let Some(p) = pointer {
|
||||
if point_at(p).is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::Handle, egui::CursorIcon::Crosshair);
|
||||
} else if let Some(h) = handle_at(p) {
|
||||
ui.ctx().set_cursor_icon(match h {
|
||||
(0, _) => egui::CursorIcon::ResizeVertical,
|
||||
(_, 0) => egui::CursorIcon::ResizeHorizontal,
|
||||
(-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
|
||||
_ => egui::CursorIcon::ResizeNeSw,
|
||||
});
|
||||
} else if let Some(a) = cx.hit(p) {
|
||||
view.cursor.claim(
|
||||
Layer::Handle,
|
||||
match h {
|
||||
(0, _) => egui::CursorIcon::ResizeVertical,
|
||||
(_, 0) => egui::CursorIcon::ResizeHorizontal,
|
||||
(-1, -1) | (1, 1) => egui::CursorIcon::ResizeNwSe,
|
||||
_ => egui::CursorIcon::ResizeNeSw,
|
||||
},
|
||||
);
|
||||
} else if let Some(a) = cx.hit(p)
|
||||
// A click on a highlight over a link follows the link, so the link's hand and tip
|
||||
// show there.
|
||||
&& !(a.subtype == "Highlight" && on_link(cx, p))
|
||||
{
|
||||
let movable = cx.allowed && !is_markup(&a.subtype);
|
||||
ui.ctx().set_cursor_icon(if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand });
|
||||
let icon = if movable { egui::CursorIcon::Move } else { egui::CursorIcon::PointingHand };
|
||||
// Signatures and Fill & Sign's marks are placed on the page rather than comments on it.
|
||||
view.cursor.claim(if a.fill_sign { Layer::Placed } else { Layer::Comment }, icon);
|
||||
}
|
||||
}
|
||||
if resp.drag_started()
|
||||
@@ -216,12 +266,8 @@ fn select_input(
|
||||
// (egui clears the press origin on release, so clicks are located by the pointer.)
|
||||
let on_page = pointer.is_some_and(|p| cx.xf.rect.contains(p));
|
||||
if resp.clicked() && on_page {
|
||||
let follow_link = cx.tool == QuickTool::Select
|
||||
&& !consumed
|
||||
&& cv.gesture.is_none()
|
||||
&& !resp.double_clicked()
|
||||
&& pointer
|
||||
.is_some_and(|p| cx.info.links.iter().any(|link| link.page == cx.page && cx.xf.user_rect(cx.info, cx.page, link.rect).contains(p)));
|
||||
let follow_link =
|
||||
cx.tool == QuickTool::Select && !consumed && cv.gesture.is_none() && !resp.double_clicked() && pointer.is_some_and(|p| on_link(cx, p));
|
||||
match pointer.and_then(|p| cx.hit(p)) {
|
||||
Some(a) if a.subtype == "Highlight" && follow_link => {
|
||||
// A plain click follows the link beneath text highlighting. Keep other
|
||||
@@ -301,7 +347,7 @@ impl Gestures<'_> {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& pressed_here
|
||||
@@ -363,7 +409,7 @@ impl Gestures<'_> {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
// Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too.
|
||||
@@ -417,14 +463,14 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn callout(&self, view: &mut DocView) -> bool {
|
||||
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self;
|
||||
let Self { resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
// Drag from what the callout points at to where its text box goes.
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& pressed_here
|
||||
@@ -455,13 +501,13 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn attach(&self, view: &mut DocView) -> bool {
|
||||
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
|
||||
let Self { resp, cx, pointer, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
@@ -474,13 +520,13 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool {
|
||||
let Self { ui, resp, cx, pointer, over_page, .. } = *self;
|
||||
let Self { resp, cx, pointer, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
|
||||
}
|
||||
if resp.clicked()
|
||||
&& over_page
|
||||
@@ -496,13 +542,13 @@ impl Gestures<'_> {
|
||||
}
|
||||
|
||||
fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool {
|
||||
let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self;
|
||||
let Self { resp, cx, pointer, origin, page_rect, pressed_here, over_page, .. } = *self;
|
||||
let cv = &mut view.comments;
|
||||
if !cx.allowed {
|
||||
return false;
|
||||
}
|
||||
if over_page {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
// A text box also takes a drag, as in Acrobat: the box is drawn at that size and its
|
||||
// text wraps inside it (#2669).
|
||||
|
||||
@@ -95,6 +95,44 @@ fn line(chars: &str, x: f32, y: f32, w: f32, em: f32, direction: [f32; 2]) -> Pa
|
||||
PageText { glyphs, line_of: vec![0; n], space_before: vec![false; n] }
|
||||
}
|
||||
|
||||
fn annotation(subtype: &str, contents: Option<&str>, fill_sign: bool) -> Annotation {
|
||||
Annotation {
|
||||
page: 0,
|
||||
subtype: subtype.into(),
|
||||
author: None,
|
||||
contents: contents.map(str::to_string),
|
||||
modified: None,
|
||||
name: None,
|
||||
in_reply_to: None,
|
||||
parent: None,
|
||||
grouped: false,
|
||||
rect: [0.0; 4],
|
||||
color: None,
|
||||
index: 0,
|
||||
state: None,
|
||||
quads: Vec::new(),
|
||||
locked: false,
|
||||
intent: None,
|
||||
fill_sign,
|
||||
no_rotate: false,
|
||||
points: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hover_tip_skips_fill_and_sign_marks_replies_and_bare_highlights_when_asked() {
|
||||
let prefs = CommentPrefs::default();
|
||||
assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), false), &prefs).as_deref(), Some("FreeText\nHello"));
|
||||
assert_eq!(hover_tip(&annotation("FreeText", Some("Hello"), true), &prefs), None);
|
||||
let reply = Annotation { in_reply_to: Some("1 0 R".into()), parent: Some((0, 1)), ..annotation("Text", Some("Agreed"), false) };
|
||||
assert_eq!(hover_tip(&reply, &prefs), None);
|
||||
let bare = Annotation { author: Some("Ana".into()), ..annotation("Highlight", None, false) };
|
||||
assert_eq!(hover_tip(&bare, &prefs).as_deref(), Some("Ana"));
|
||||
let hide = CommentPrefs { hide_empty_highlight_hover: true, ..CommentPrefs::default() };
|
||||
assert_eq!(hover_tip(&bare, &hide), None);
|
||||
assert_eq!(hover_tip(&annotation("Highlight", Some("Check this"), false), &hide).as_deref(), Some("Highlight\nCheck this"));
|
||||
}
|
||||
|
||||
fn near(a: Option<([f32; 2], f32)>, b: ([f32; 2], f32)) -> bool {
|
||||
a.is_some_and(|([x, y], s)| (x - b.0[0]).abs() < 1e-3 && (y - b.0[1]).abs() < 1e-3 && (s - b.1).abs() < 1e-3)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Added, AddedContent, AddedText, Edit};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
@@ -353,7 +354,7 @@ pub(crate) fn page_input(
|
||||
style: &AddedText,
|
||||
view: &mut DocView,
|
||||
) -> bool {
|
||||
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
|
||||
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
|
||||
let mut consumed = false;
|
||||
let pointer = ptr.pos;
|
||||
let cv = &mut view.content;
|
||||
@@ -393,13 +394,8 @@ pub(crate) fn page_input(
|
||||
.and_then(|(_, si)| items.iter().find(|(i, _)| *i == si))
|
||||
.map(|(_, a)| screen_rect(xf, info, page, a.content.rect()));
|
||||
let corner = selected_rect.and_then(|r| handles(r).into_iter().find(|(c, ..)| c.distance(p) <= 6.0));
|
||||
// Cursors only where the page shows: the mode bar and side panels over a page scrolled under
|
||||
// them keep their own (#2755).
|
||||
let shows = resp.contains_pointer();
|
||||
if adding_text && hit.is_none() {
|
||||
if shows {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Text);
|
||||
if resp.clicked() {
|
||||
// Text that can't be drawn stays where it is being typed (#125).
|
||||
if cv.hold_blocked() {
|
||||
@@ -424,10 +420,10 @@ pub(crate) fn page_input(
|
||||
}
|
||||
return consumed;
|
||||
}
|
||||
if let Some((_, left, top)) = corner.filter(|_| shows) {
|
||||
ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
} else if hit.is_some() && shows {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
if let Some((_, left, top)) = corner {
|
||||
view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
} else if hit.is_some() {
|
||||
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
|
||||
@@ -12,6 +12,7 @@ use pdfcraft_engine::{Edit, ObjectKind, ObjectTarget};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
|
||||
mod background;
|
||||
@@ -387,7 +388,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
|
||||
if resizing {
|
||||
draw_handles(preview);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
|
||||
view.cursor.claim(Layer::Drag, if resizing { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::Move });
|
||||
if resp.drag_stopped() || !ptr.any_down {
|
||||
view.block_drag = None;
|
||||
let (from, to) = (user_box(xf, info, page, b), user_box(xf, info, page, preview));
|
||||
@@ -436,17 +437,13 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
|
||||
draw_handles(boxes[hit]);
|
||||
}
|
||||
// A closed paragraph moves when dragged (the four-way arrow says so; a click still opens it
|
||||
// for typing); an edge rewraps it. The open one takes the text cursor. Only where the page
|
||||
// shows, so the mode bar and side panels over a paragraph scrolled under them keep their own
|
||||
// cursor (#2755).
|
||||
if resp.contains_pointer() {
|
||||
ui.ctx().set_cursor_icon(if on_edge {
|
||||
egui::CursorIcon::ResizeHorizontal
|
||||
} else if active.is_none() {
|
||||
egui::CursorIcon::Move
|
||||
} else {
|
||||
egui::CursorIcon::Text
|
||||
});
|
||||
// for typing); an edge rewraps it. The open one takes the text cursor.
|
||||
if on_edge {
|
||||
view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeHorizontal);
|
||||
} else if active.is_none() {
|
||||
view.cursor.claim(Layer::Text, egui::CursorIcon::Move);
|
||||
} else {
|
||||
view.cursor.claim(if active == Some(hit) { Layer::Editor } else { Layer::Text }, egui::CursorIcon::Text);
|
||||
}
|
||||
if resp.clicked() {
|
||||
view.image_selection = None;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box};
|
||||
use crate::page_cursor::Layer;
|
||||
|
||||
/// A selected page image, and what the pointer is doing to it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -163,7 +164,9 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool {
|
||||
painter.text(done.center(), egui::Align2::CENTER_CENTER, tl!("Done"), font.clone(), Color32::WHITE);
|
||||
painter.rect(cancel, CornerRadius::same(11), t.panel, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
painter.text(cancel.center(), egui::Align2::CENTER_CENTER, tl!("Cancel"), font, t.text);
|
||||
if let Some(p) = hover_pos {
|
||||
// Cropping takes over its page's cursor, and the buttons' wherever they are; other pages
|
||||
// keep theirs.
|
||||
if let Some(p) = hover_pos.filter(|p| xf.rect.contains(*p) || done.contains(*p) || cancel.contains(*p)) {
|
||||
let icon = if let Some(g) = crop_handles(k).iter().position(|h| h.distance(p) < 8.0) {
|
||||
match g {
|
||||
0 | 3 => egui::CursorIcon::ResizeNwSe,
|
||||
@@ -178,7 +181,7 @@ fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool {
|
||||
} else {
|
||||
egui::CursorIcon::Default
|
||||
};
|
||||
ui.ctx().set_cursor_icon(icon);
|
||||
view.cursor.claim(Layer::Editor, icon);
|
||||
}
|
||||
let clicked = |r: Rect| allow_interaction && resp.clicked() && hover_pos.is_some_and(|p| r.contains(p));
|
||||
let keys = |key: egui::Key| allow_interaction && ui.input(|inp| inp.key_pressed(key)) && !ui.ctx().egui_wants_keyboard_input();
|
||||
@@ -350,7 +353,7 @@ fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: P
|
||||
let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0));
|
||||
painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle);
|
||||
painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing);
|
||||
if resp.drag_stopped() || !ptr.primary_down {
|
||||
if let Some(s) = view.image_selection.as_mut() {
|
||||
s.drag = None;
|
||||
@@ -441,11 +444,14 @@ fn drag_preview(
|
||||
Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) });
|
||||
}
|
||||
}
|
||||
ui.ctx().set_cursor_icon(match opposite {
|
||||
Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw,
|
||||
Some(_) => egui::CursorIcon::ResizeNwSe,
|
||||
None => egui::CursorIcon::Move,
|
||||
});
|
||||
view.cursor.claim(
|
||||
Layer::Drag,
|
||||
match opposite {
|
||||
Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw,
|
||||
Some(_) => egui::CursorIcon::ResizeNwSe,
|
||||
None => egui::CursorIcon::Move,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used.
|
||||
@@ -503,7 +509,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
draw_handles(painter, b);
|
||||
}
|
||||
if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) {
|
||||
ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
view.cursor.claim(Layer::Handle, if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
}
|
||||
// The rotate handle: a knob above the top edge, joined to it by a short stem.
|
||||
let (knob, stem) = rotate_knob(b, ui.clip_rect());
|
||||
@@ -511,7 +517,7 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
painter.line_segment([stem, knob], Stroke::new(1.0, ACCENT));
|
||||
painter.circle(knob, 5.0, Color32::WHITE, Stroke::new(1.0, ACCENT));
|
||||
if allow_interaction && hover_pos.is_some_and(|p| p.distance(knob) < 8.0) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||
view.cursor.claim(Layer::Handle, egui::CursorIcon::Grab);
|
||||
}
|
||||
}
|
||||
if allow_interaction
|
||||
@@ -580,12 +586,13 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
}
|
||||
let Some(b) = boxes.get(hit).copied() else { return false };
|
||||
let corner = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()].iter().position(|c| c.distance(p) < 8.0);
|
||||
ui.ctx().set_cursor_icon(match (selected == Some(hit), corner) {
|
||||
(true, Some(0 | 3)) => egui::CursorIcon::ResizeNwSe,
|
||||
(true, Some(_)) => egui::CursorIcon::ResizeNeSw,
|
||||
(true, None) => egui::CursorIcon::Move,
|
||||
_ => egui::CursorIcon::PointingHand,
|
||||
});
|
||||
match (selected == Some(hit), corner) {
|
||||
(true, Some(0 | 3)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNwSe),
|
||||
(true, Some(_)) => view.cursor.claim(Layer::Handle, egui::CursorIcon::ResizeNeSw),
|
||||
(true, None) => view.cursor.claim(Layer::Placed, egui::CursorIcon::Move),
|
||||
// A click selects it.
|
||||
_ => view.cursor.claim(Layer::Placed, egui::CursorIcon::PointingHand),
|
||||
}
|
||||
if resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = Some(ImageSelection { page, index: hit, drag: None, crop: None, turning: false, settled: None });
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use pdfcraft_engine::{Edit, FillMark, NewAnnotation, PhotoRect, Shape, Signature
|
||||
use pdfcraft_render::{DocInfo, PageInfo};
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
mod scan;
|
||||
@@ -455,10 +456,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, input: PageInput<'_>, view: &m
|
||||
if let Some(rect) = image.rect(p, at, tool == FillTool::Initials) {
|
||||
let tex = image_texture(ui, image, preview);
|
||||
xf.paint_user_image(ui.painter(), tex, p, rect, i64::from(p.rotation), Color32::WHITE);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::None);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::None);
|
||||
}
|
||||
} else {
|
||||
ui.ctx().set_cursor_icon(if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
|
||||
view.cursor.claim(Layer::ToolArea, if tool == FillTool::Text { egui::CursorIcon::Text } else { egui::CursorIcon::Crosshair });
|
||||
}
|
||||
if !resp.clicked() {
|
||||
return None;
|
||||
|
||||
@@ -14,6 +14,7 @@ use pdfcraft_engine::{Edit, FieldValue, FormField, FormFieldKind, FormWidget, fi
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
const FOCUS_BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
@@ -150,23 +151,10 @@ fn toggle_edit(f: &FormField, w: &FormWidget) -> Option<Edit> {
|
||||
Some(Edit::SetFieldValue { name: f.name.clone(), value })
|
||||
}
|
||||
|
||||
/// Where the pointer is relative to one page's form widgets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum FieldPointer {
|
||||
/// Not over a field.
|
||||
Away,
|
||||
/// Over a field, which has set the cursor. Nothing is pressed, so the layers below still see
|
||||
/// the hover, but they must not replace the cursor: a check box drawn with a glyph, or set
|
||||
/// on printed text, is a button, not text to select.
|
||||
Over,
|
||||
/// The field owns the press or click.
|
||||
Owned,
|
||||
}
|
||||
|
||||
/// Clicks on form widgets of one page.
|
||||
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> FieldPointer {
|
||||
/// Clicks on form widgets of one page. `true` when a field owns the press or click.
|
||||
pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: bool, view: &mut DocView) -> bool {
|
||||
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
|
||||
let Some(p) = ptr.within(xf.rect) else { return FieldPointer::Away };
|
||||
let Some(p) = ptr.within(xf.rect) else { return false };
|
||||
let hit = form.iter().find_map(|f| {
|
||||
f.widgets
|
||||
.iter()
|
||||
@@ -174,7 +162,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
|
||||
.find(|(_, w)| w.page == Some(page) && !w.hidden && widget_rect(xf, info, page, w.rect).contains(p))
|
||||
.map(|(i, w)| (f, i, w))
|
||||
});
|
||||
let Some((f, wi, w)) = hit else { return FieldPointer::Away };
|
||||
let Some((f, wi, w)) = hit else { return false };
|
||||
// The field's tooltip (`/TU`), as Acrobat shows it on hover (#3854). ISO 32000-2 Table 226
|
||||
// makes it the name the user interface identifies the field by.
|
||||
if let Some(tip) = f.tooltip.as_deref().filter(|t| !t.trim().is_empty()) {
|
||||
@@ -185,15 +173,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
|
||||
let pressable = f.kind == FormFieldKind::PushButton && !f.read_only() && f.button.is_some();
|
||||
// A radio button of a group can be read-only on its own.
|
||||
let usable = allowed && !w.read_only && (fillable(f) || pressable || f.kind == FormFieldKind::Signature);
|
||||
// Only where the page shows: the mode bar and side panels over a field scrolled under them
|
||||
// keep their own cursor (#2755).
|
||||
if resp.contains_pointer() {
|
||||
ui.ctx().set_cursor_icon(match (usable, f.kind) {
|
||||
// Over the page's text too: a check box drawn with a glyph, or set on printed text, is a
|
||||
// button, not text to select.
|
||||
view.cursor.claim(
|
||||
Layer::Field,
|
||||
match (usable, f.kind) {
|
||||
(false, _) => egui::CursorIcon::NotAllowed,
|
||||
(true, FormFieldKind::Text) => egui::CursorIcon::Text,
|
||||
_ => egui::CursorIcon::PointingHand,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
// A text field opens on the press rather than the click, so a drag that starts on it
|
||||
// selects its text (#2213).
|
||||
let pressed = f.kind == FormFieldKind::Text && usable && resp.is_pointer_button_down_on() && ptr.pressed().is_some();
|
||||
@@ -203,11 +192,11 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
|
||||
view.forms.pressed = true;
|
||||
}
|
||||
if !resp.clicked() {
|
||||
return if resp.is_pointer_button_down_on() { FieldPointer::Owned } else { FieldPointer::Over };
|
||||
return resp.is_pointer_button_down_on();
|
||||
}
|
||||
if !allowed {
|
||||
view.forms.notice = Some(FormNotice::Security);
|
||||
return FieldPointer::Owned;
|
||||
return true;
|
||||
}
|
||||
match f.kind {
|
||||
_ if f.read_only() || w.read_only => view.forms.notice = Some(FormNotice::ReadOnly(f.name.clone())),
|
||||
@@ -278,7 +267,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], allowed: b
|
||||
}
|
||||
}
|
||||
}
|
||||
FieldPointer::Owned
|
||||
true
|
||||
}
|
||||
|
||||
/// The edit the focused field's draft makes, if it differs from the field's value.
|
||||
|
||||
@@ -7,6 +7,7 @@ use pdfcraft_engine::{Edit, LinkAction, LinkHighlight, LinkItem, LinkStyle};
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
@@ -76,7 +77,10 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, links: &[LinkItem], view: &mut
|
||||
}
|
||||
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
let hit = links.iter().filter(|l| l.page == page).find(|l| screen_rect(xf, info, page, l.rect).expand(2.0).contains(p));
|
||||
ui.ctx().set_cursor_icon(if hit.is_some() { egui::CursorIcon::PointingHand } else { egui::CursorIcon::Crosshair });
|
||||
match hit {
|
||||
Some(_) => view.cursor.claim(Layer::Link, egui::CursorIcon::PointingHand),
|
||||
None => view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair),
|
||||
}
|
||||
if resp.secondary_clicked()
|
||||
&& let Some(l) = hit
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Measure objects: page-space clicks, live readings and persistent drawing scales.
|
||||
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, theme::Tokens, widgets};
|
||||
use crate::{LeftPanel, PdfCraftApp, QuickTool, canvas::DocView, page_cursor::Layer, theme::Tokens, widgets};
|
||||
use egui::{Color32, Pos2, Stroke};
|
||||
use pdfcraft_engine::{
|
||||
Document, Edit, Style,
|
||||
@@ -236,7 +236,7 @@ pub(crate) fn page_input(
|
||||
let mut hover = None;
|
||||
let mut snap = None;
|
||||
if let Some(pos) = pos {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
let (vx, vy) = xf.screen_to_view(pos);
|
||||
let [x, y] = info.view_to_user(vx, vy);
|
||||
let mut point = [f64::from(x), f64::from(y)];
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Document, Edit, EditableObject, MAX_MOVE_OBJECTS, ObjectKi
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
|
||||
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
|
||||
|
||||
@@ -238,7 +239,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
|
||||
} else {
|
||||
s.gesture = Some(gesture);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move });
|
||||
view.cursor.claim(Layer::Drag, if gesture.marquee { egui::CursorIcon::Crosshair } else { egui::CursorIcon::Move });
|
||||
return true;
|
||||
}
|
||||
let Some(p) = pointer.filter(|p| xf.rect.contains(*p) && ui.clip_rect().contains(*p) && resp.rect.contains(*p)) else {
|
||||
@@ -248,7 +249,7 @@ fn input(s: &mut ObjectSelection, cx: PageToolContext<'_>, view: &mut DocView) -
|
||||
if s.page == Some(page) && !s.selected.is_empty() {
|
||||
let union = s.selected.iter().fold(Rect::NOTHING, |r, o| r.union(screen(xf, info, page, o)));
|
||||
if union.contains(p) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
view.cursor.claim(Layer::Placed, egui::CursorIcon::Move);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::comments;
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::theme;
|
||||
use crate::widgets;
|
||||
|
||||
@@ -340,7 +341,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
|
||||
return out;
|
||||
}
|
||||
if let Some(tool) = tool {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
if resp.drag_started() || resp.clicked() {
|
||||
let origin = ptr.origin.filter(|o| xf.rect.contains(*o)).unwrap_or(p);
|
||||
prep.grab = Some((page, Grab::Draw(origin), origin));
|
||||
@@ -393,9 +394,9 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, form: &[FormField], tool: Opti
|
||||
f.widgets.iter().enumerate().find(|(_, w)| w.page == Some(page) && screen_rect(xf, info, page, w.rect).contains(p)).map(|(i, _)| (f, i))
|
||||
});
|
||||
if let Some((_, left, top)) = corner {
|
||||
ui.ctx().set_cursor_icon(if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
view.cursor.claim(Layer::Handle, if left == top { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw });
|
||||
} else if hit.is_some() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Move);
|
||||
view.cursor.claim(Layer::Field, egui::CursorIcon::Move);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
@@ -593,20 +594,24 @@ pub(crate) fn paint_page(cx: PageToolContext<'_>, form: &[FormField], view: &Doc
|
||||
}
|
||||
|
||||
/// Delete the selected field and every field selected with it (#95), as one undoable step, and
|
||||
/// clear the selection.
|
||||
/// clear the selection. Each selection is one widget, so selecting one radio button of a group,
|
||||
/// or one copy of a duplicated field, deletes only that one, as in Acrobat (#4609); a field's
|
||||
/// last widget takes the field with it.
|
||||
pub(crate) fn delete_selected(view: &mut DocView) -> Option<Edit> {
|
||||
let (first, _) = view.prepare.selected.take()?;
|
||||
let mut names = vec![first];
|
||||
for (name, _) in std::mem::take(&mut view.prepare.also) {
|
||||
if !names.contains(&name) {
|
||||
names.push(name);
|
||||
let first = view.prepare.selected.take()?;
|
||||
let mut picked = vec![first];
|
||||
for w in std::mem::take(&mut view.prepare.also) {
|
||||
if !picked.contains(&w) {
|
||||
picked.push(w);
|
||||
}
|
||||
}
|
||||
if names.len() == 1 {
|
||||
return names.pop().map(|name| Edit::DeleteField { name });
|
||||
// A field's later widgets first, so the indices of the ones still to go don't shift.
|
||||
picked.sort_by_key(|w| std::cmp::Reverse(w.1));
|
||||
if picked.len() == 1 {
|
||||
return picked.pop().map(|(name, widget)| Edit::DeleteWidget { name, widget });
|
||||
}
|
||||
let label = format!("Delete {} fields", names.len());
|
||||
Some(Edit::Batch { label, edits: names.into_iter().map(|name| Edit::DeleteField { name }).collect() })
|
||||
let label = format!("Delete {} fields", picked.len());
|
||||
Some(Edit::Batch { label, edits: picked.into_iter().map(|(name, widget)| Edit::DeleteWidget { name, widget }).collect() })
|
||||
}
|
||||
|
||||
/// The arrow keys move the selected field a point, Shift and an arrow ten points, as in Acrobat,
|
||||
|
||||
@@ -8,6 +8,7 @@ use pdfcraft_engine::{Edit, NewAnnotation, REDACT_PATTERNS, REDACTION_CODE_SETS,
|
||||
use pdfcraft_render::DocInfo;
|
||||
|
||||
use crate::canvas::{DocView, PageToolContext, PageXform};
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
@@ -235,7 +236,7 @@ fn to_user(xf: &PageXform, info: &DocInfo, page: usize, p: Pos2) -> [f64; 2] {
|
||||
/// marked when it ends, see [`after_text`]); presses elsewhere draw a box. Returns `true` when
|
||||
/// the gesture is a box.
|
||||
pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bool, view: &mut DocView) -> bool {
|
||||
let PageToolContext { ui, resp, xf, ptr, page, info, .. } = cx;
|
||||
let PageToolContext { resp, xf, ptr, page, info, .. } = cx;
|
||||
let pointer = ptr.pos;
|
||||
if let Some((dp, start)) = view.redact_drag
|
||||
&& dp == page
|
||||
@@ -253,7 +254,7 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, over_text: impl Fn(Pos2) -> bo
|
||||
}
|
||||
let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) else { return false };
|
||||
if !over_text(p) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started() {
|
||||
let origin = ptr.origin.unwrap_or(p);
|
||||
|
||||
@@ -10,6 +10,7 @@ use pdfcraft_engine::{SignOptions, SignatureImage, SignatureInfo, SignatureStatu
|
||||
|
||||
use crate::canvas::{DocView, PageXform, Snapshot, SnapshotKind};
|
||||
use crate::fill_sign::SavedSig;
|
||||
use crate::page_cursor::Layer;
|
||||
use crate::page_pointer::PagePointer;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, icons, widgets};
|
||||
@@ -173,7 +174,7 @@ pub(crate) fn page_input(
|
||||
) {
|
||||
let (pointer, origin) = (ptr.pos, ptr.origin);
|
||||
if pointer.is_some_and(|p| xf.rect.contains(p)) {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
view.cursor.claim(Layer::ToolArea, egui::CursorIcon::Crosshair);
|
||||
}
|
||||
if resp.drag_started()
|
||||
&& let Some(o) = origin.filter(|o| xf.rect.contains(*o))
|
||||
|
||||
@@ -433,14 +433,28 @@ fn wrapped_lines(ui: &egui::Ui, text: &str, font: &egui::FontId, width: f32, tru
|
||||
|
||||
/// Transient message at the bottom centre.
|
||||
pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
// A form script message waiting its turn takes the toast once it is free.
|
||||
if app.toast.is_none() {
|
||||
app.toast_script = None;
|
||||
// A left-out count whose toast was cleared before its time is said again.
|
||||
app.script_messages_dropped = app.script_messages_dropped.saturating_add(std::mem::take(&mut app.toast_dropped));
|
||||
app.next_script_message();
|
||||
}
|
||||
let Some((msg, start)) = app.toast.clone() else { return };
|
||||
let now = ctx.input(|i| i.time);
|
||||
let start = if start == 0.0 { now } else { start };
|
||||
app.toast = Some((msg.clone(), start));
|
||||
if now - start > 3.5 {
|
||||
app.toast = None;
|
||||
app.toast_script = None;
|
||||
// Shown its time: a left-out count it said is done.
|
||||
app.toast_dropped = 0;
|
||||
if app.next_script_message() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let script = app.toast_script.as_ref().map(|m| (m.title.clone(), crate::js_ui::message_icon(m.icon)));
|
||||
let t = Tokens::get(ctx);
|
||||
let screen = ctx.content_rect();
|
||||
// Above the progress notice while a job runs.
|
||||
@@ -461,10 +475,26 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
// Allow natural short labels; wrap longer ones within the viewport,
|
||||
// including the frame's 32 points and 16-point outside margins.
|
||||
ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0));
|
||||
ui.add(
|
||||
egui::Label::new(egui::RichText::new(msg).color(if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE }))
|
||||
.wrap(),
|
||||
);
|
||||
let ink = if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE };
|
||||
let text = |ui: &mut egui::Ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap());
|
||||
};
|
||||
match script {
|
||||
// A form script's message: its icon, then its title (when it gave one)
|
||||
// over its text.
|
||||
Some((title, icon)) => {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.add(icons::image(icon, 16.0, ink));
|
||||
ui.vertical(|ui| {
|
||||
if let Some(title) = title {
|
||||
ui.add(egui::Label::new(egui::RichText::new(title).strong().color(ink)).wrap());
|
||||
}
|
||||
text(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
None => text(ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
|
||||
@@ -868,10 +868,24 @@ pub(crate) fn strip_pdf(name: &str) -> &str {
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Comments ▸ Import comments / Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text.
|
||||
/// Prepare a form ▸ Import data: XFDF, FDF, XML, CSV or text.
|
||||
pub fn import_data_dialog(&mut self) {
|
||||
self.import_dialog(false);
|
||||
}
|
||||
|
||||
/// Comments ▸ Import comments: the files Import data takes, or another PDF to copy the
|
||||
/// comments of.
|
||||
pub fn import_comments_dialog(&mut self) {
|
||||
self.import_dialog(true);
|
||||
}
|
||||
|
||||
fn import_dialog(&mut self, pdf: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut extensions = vec!["xfdf", "fdf", "xml", "xdp", "csv", "txt"];
|
||||
if pdf {
|
||||
extensions.insert(2, "pdf");
|
||||
}
|
||||
let import = |app: &mut Self, path: std::path::PathBuf| match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
@@ -880,11 +894,11 @@ impl PdfCraftApp {
|
||||
Err(e) => app.notify_fmt("Couldn't read {name}: {e}", &[("name", &path.display().to_string()), ("e", &e.to_string())]),
|
||||
};
|
||||
match self.save_override.clone() {
|
||||
Some(p) if [".xfdf", ".fdf", ".xml", ".xdp", ".csv", ".txt"].iter().any(|e| p.ends_with(e)) => import(self, p.into()),
|
||||
Some(p) if extensions.iter().any(|e| p.ends_with(&format!(".{e}"))) => import(self, p.into()),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let dialog = rfd::AsyncFileDialog::new()
|
||||
.add_filter(tl!("Comment and form data").to_string(), &["xfdf", "fdf", "xml", "xdp", "csv", "txt"])
|
||||
.add_filter(tl!("Comment and form data").to_string(), &extensions)
|
||||
.set_title(tl!("Import data").to_string());
|
||||
let target = self.active_ids().map(|(_, id)| id);
|
||||
self.ask_one(crate::pickers::Ask::File(dialog), target, import);
|
||||
@@ -892,7 +906,10 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.notify_tr("Importing data arrives on the web with file pickers for data files");
|
||||
{
|
||||
let _ = pdf;
|
||||
self.notify_tr("Importing data arrives on the web with file pickers for data files");
|
||||
}
|
||||
}
|
||||
|
||||
/// Export all comments / form data: the format follows the file name's extension.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! the JavaScript console (⌘J), Document JavaScripts, and Preferences ▸ JavaScript.
|
||||
|
||||
use egui::{Align, Layout};
|
||||
use pdfcraft_engine::js::{JsOutput, Panel, Request};
|
||||
use pdfcraft_engine::js::{JsOutput, Message, MessageIcon, Panel, Request};
|
||||
use pdfcraft_engine::{DocId, Edit};
|
||||
|
||||
use crate::theme::{self, Tokens};
|
||||
@@ -22,6 +22,21 @@ pub struct DocJsDraft {
|
||||
pub script: String,
|
||||
}
|
||||
|
||||
/// Most form script messages that wait for the toast; past it, more are left out and counted, and
|
||||
/// the toast says how many once the rest have been shown. One run shows at most this many (the
|
||||
/// script engine's own limit), so only runs in quick succession reach it.
|
||||
const MAX_WAITING_MESSAGES: usize = 100;
|
||||
|
||||
/// The icon a script message's toast shows for `icon`.
|
||||
pub(crate) fn message_icon(icon: MessageIcon) -> &'static str {
|
||||
match icon {
|
||||
MessageIcon::Error => "circle-x",
|
||||
MessageIcon::Warning => "triangle-alert",
|
||||
MessageIcon::Question => "circle-help",
|
||||
MessageIcon::Status => "info",
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user is told when a form's scripts were turned off: in a toast as it happens, then
|
||||
/// on the notice bar for as long as the document is open.
|
||||
pub(crate) fn scripts_off_notice() -> String {
|
||||
@@ -81,11 +96,47 @@ impl PdfCraftApp {
|
||||
Request::Focus(_) | Request::Beep | Request::Reset(_) => {}
|
||||
}
|
||||
}
|
||||
if let Some(a) = out.alerts.last() {
|
||||
self.notify(a.clone());
|
||||
self.show_script_messages(out.alerts);
|
||||
}
|
||||
|
||||
/// Show form script messages `messages` in order, each in the toast with its title and icon.
|
||||
/// The first takes the toast at once when it is free; otherwise it waits, like the rest, for
|
||||
/// what the toast shows (another message, or a notice such as the run's own submit request)
|
||||
/// to go ([`Self::next_script_message`]).
|
||||
pub fn show_script_messages(&mut self, messages: Vec<Message>) {
|
||||
let room = MAX_WAITING_MESSAGES.saturating_sub(self.script_messages.len());
|
||||
let left_out = u64::try_from(messages.len().saturating_sub(room)).unwrap_or(u64::MAX);
|
||||
self.script_messages_dropped = self.script_messages_dropped.saturating_add(left_out);
|
||||
self.script_messages.extend(messages.into_iter().take(room));
|
||||
if self.toast.is_none() {
|
||||
self.next_script_message();
|
||||
}
|
||||
}
|
||||
|
||||
/// Put the next waiting script message in the toast, or, once none waits, a notice of how many
|
||||
/// the queue had no room for. False when there is neither.
|
||||
pub fn next_script_message(&mut self) -> bool {
|
||||
let Some(m) = self.script_messages.pop_front() else {
|
||||
// Every message the queue held has been shown: say how many it had no room for.
|
||||
if self.script_messages_dropped == 0 {
|
||||
return false;
|
||||
}
|
||||
let n = std::mem::take(&mut self.script_messages_dropped);
|
||||
self.notify(crate::i18n::trn(
|
||||
crate::i18n::current(),
|
||||
n,
|
||||
"{n} more message from the form's scripts was left out.",
|
||||
"{n} more messages from the form's scripts were left out.",
|
||||
));
|
||||
// Kept with the toast until it has been shown its time (the toast's own expiry).
|
||||
self.toast_dropped = n;
|
||||
return true;
|
||||
};
|
||||
self.toast = Some((m.text.clone(), 0.0));
|
||||
self.toast_script = Some(m);
|
||||
true
|
||||
}
|
||||
|
||||
/// Run a push button's JavaScript (its Mouse Up action).
|
||||
pub fn run_button_script(&mut self, id: DocId, field: &str, script: &str) {
|
||||
// A script reads the fields: include what's still being typed in one (#166).
|
||||
@@ -265,18 +316,6 @@ pub(crate) fn document_js_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
close
|
||||
}
|
||||
|
||||
/// The label for a [`pdfcraft_render::ReadingColors`] mode name (`mode_name`).
|
||||
fn reading_mode_label(mode: &str) -> &'static str {
|
||||
match mode {
|
||||
"black-on-white" => "Black on white",
|
||||
"white-on-black" => "White on black",
|
||||
"yellow-on-black" => "Yellow on black",
|
||||
"green-on-black" => "Green on black",
|
||||
"custom" => "Custom",
|
||||
_ => "Off",
|
||||
}
|
||||
}
|
||||
|
||||
/// The height the Preferences dialog keeps below its scrolling settings for the OK row: the
|
||||
/// button, and the space above it.
|
||||
pub(crate) const PREFERENCES_BUTTON_ROW: f32 = 52.0;
|
||||
@@ -394,6 +433,8 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
ui.add_space(8.0);
|
||||
crate::appearance::preferences_section(ui, app, t);
|
||||
ui.add_space(8.0);
|
||||
reading_colours_section(ui, app, t);
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(tl!("Documents and view")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(tl!("Default workspace mode"));
|
||||
@@ -478,70 +519,6 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok
|
||||
.on_hover_text(tl!("Show a basic title bar for the window buttons instead of the tab strip. GNOME doesn't draw title bars for apps, so this one doesn't follow its theme."));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are
|
||||
// remapped while rendering, and the page clears to the background colour. The preference
|
||||
// is app-wide: every open document follows it, and documents open with it.
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Replace document colours"));
|
||||
let mode = app.reading_colors.mode_name();
|
||||
craft_ui::ComboBox::from_id_salt("reading-colors")
|
||||
.selected_text(tl!(reading_mode_label(mode)))
|
||||
.show_ui(ui, |ui| {
|
||||
for (m, label) in [
|
||||
("off", "Off"),
|
||||
("black-on-white", "Black on white"),
|
||||
("white-on-black", "White on black"),
|
||||
("yellow-on-black", "Yellow on black"),
|
||||
("green-on-black", "Green on black"),
|
||||
("custom", "Custom"),
|
||||
] {
|
||||
if ui.selectable_label(mode == m, tl!(label)).clicked() {
|
||||
match m {
|
||||
"off" => app.set_reading_colors(pdfcraft_render::ReadingColors::default()),
|
||||
"custom" => {
|
||||
// Custom starts from the colours already chosen, or black on white.
|
||||
let colors = app.reading_colors.params.map_or(pdfcraft_render::ReadingColors::BLACK_ON_WHITE, |params| {
|
||||
pdfcraft_render::ReadingColors { params: Some(params) }
|
||||
});
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
// `parse` knows these names; a preset comes with its own flags.
|
||||
preset => {
|
||||
if let Ok(colors) = pdfcraft_render::ReadingColors::parse(preset, None, None, None, None) {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.labelled_by(label.id);
|
||||
});
|
||||
if app.reading_colors.params.is_some() {
|
||||
let mut changed = false;
|
||||
if let Some(params) = app.reading_colors.params.as_mut() {
|
||||
ui.horizontal(|ui| {
|
||||
let text = ui.label(tl!("Text"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed();
|
||||
let background = ui.label(tl!("Page background"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed();
|
||||
});
|
||||
if ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed() {
|
||||
changed = true;
|
||||
}
|
||||
if ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed() {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
app.set_reading_colors(app.reading_colors);
|
||||
}
|
||||
}
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted));
|
||||
ui.add_space(8.0);
|
||||
// Identity: the author of new comments (Acrobat: Preferences ▸ Identity).
|
||||
ui.label(egui::RichText::new(tl!("Identity")).font(theme::semibold(13.0)));
|
||||
ui.horizontal(|ui| {
|
||||
@@ -679,6 +656,75 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
);
|
||||
}
|
||||
|
||||
fn reading_colours_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
// Reading colours (Acrobat: Replace Document Colors): solid text and line-art paints are
|
||||
// remapped while rendering, and the page clears to the background colour. The preference
|
||||
// is app-wide: every open document follows it, and documents open with it.
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).font(theme::semibold(13.0)));
|
||||
let mode = app.reading_colors.mode_name();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (name, label, colors) in [
|
||||
("off", "Off", pdfcraft_render::ReadingColors::default()),
|
||||
("black-on-white", "Black on white", pdfcraft_render::ReadingColors::BLACK_ON_WHITE),
|
||||
("white-on-black", "White on black", pdfcraft_render::ReadingColors::WHITE_ON_BLACK),
|
||||
("yellow-on-black", "Yellow on black", pdfcraft_render::ReadingColors::YELLOW_ON_BLACK),
|
||||
("green-on-black", "Green on black", pdfcraft_render::ReadingColors::GREEN_ON_BLACK),
|
||||
] {
|
||||
let params = colors.params.unwrap_or(pdfcraft_render::ReadingParams {
|
||||
text: [0, 0, 0],
|
||||
background: [255, 255, 255],
|
||||
only_black: false,
|
||||
line_art: true,
|
||||
});
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(104.0);
|
||||
let sample = egui::RichText::new("Aa").size(22.0).color(egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]));
|
||||
let button = egui::Button::new(sample)
|
||||
.fill(egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]))
|
||||
.min_size(egui::vec2(94.0, 44.0))
|
||||
.stroke(egui::Stroke::new(if mode == name { 2.0 } else { 1.0 }, if mode == name { t.accent } else { t.border }));
|
||||
let response = ui.add(button);
|
||||
response.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, mode == name, tl!(label)));
|
||||
if response.clicked() {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
ui.label(tl!(label));
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if let Some(params) = app.reading_colors.params {
|
||||
let text = egui::Color32::from_rgb(params.text[0], params.text[1], params.text[2]);
|
||||
let background = egui::Color32::from_rgb(params.background[0], params.background[1], params.background[2]);
|
||||
egui::Frame::new().fill(background).inner_margin(16.0).show(ui, |ui| {
|
||||
ui.set_min_width((ui.available_width() - 32.0).max(0.0));
|
||||
ui.label(egui::RichText::new(tl!("Reading colours")).size(18.0).color(text));
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).color(text));
|
||||
});
|
||||
} else {
|
||||
ui.label(tl!("Original"));
|
||||
}
|
||||
egui::CollapsingHeader::new(tl!("Custom")).default_open(mode == "custom").show(ui, |ui| {
|
||||
let mut colors = if app.reading_colors.params.is_some() { app.reading_colors } else { pdfcraft_render::ReadingColors::BLACK_ON_WHITE };
|
||||
let mut changed = false;
|
||||
if let Some(params) = colors.params.as_mut() {
|
||||
ui.horizontal(|ui| {
|
||||
let text = ui.label(tl!("Text"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.text).labelled_by(text.id).changed();
|
||||
let background = ui.label(tl!("Page background"));
|
||||
changed |= ui.color_edit_button_srgb(&mut params.background).labelled_by(background.id).changed();
|
||||
});
|
||||
changed |= ui.add(crate::widgets::checkbox(&mut params.only_black, tl!("Only change the colour of black text or line art"))).changed();
|
||||
changed |= ui.add(crate::widgets::checkbox(&mut params.line_art, tl!("Change the colour of line art as well as text"))).changed();
|
||||
}
|
||||
if changed {
|
||||
app.set_reading_colors(colors);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new(tl!("Colours are remapped while rendering; the document itself never changes.")).small().color(t.text_muted));
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -250,6 +250,97 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// Two pages whose chapter and section headings are set apart only by style (#1693).
|
||||
fn styled_pdf() -> Vec<u8> {
|
||||
let page1 = "BT /FB 18 Tf 72 700 Td (Introduction) Tj ET BT /FR 11 Tf 72 670 Td (Some text) Tj ET BT /FB 14 Tf 72 640 Td (Background) Tj ET";
|
||||
let page2 = "BT /FB 18 Tf 72 700 Td (Results) Tj ET BT /FR 11 Tf 72 670 Td (More text) Tj ET";
|
||||
format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 /MediaBox [0 0 612 792] /Resources << /Font << /FB 7 0 R /FR 8 0 R >> >> >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /Contents 5 0 R >> endobj
|
||||
4 0 obj << /Type /Page /Parent 2 0 R /Contents 6 0 R >> endobj
|
||||
5 0 obj << /Length {} >>
|
||||
stream
|
||||
{page1}
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj << /Length {} >>
|
||||
stream
|
||||
{page2}
|
||||
endstream
|
||||
endobj
|
||||
7 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >> endobj
|
||||
8 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page1.len(),
|
||||
page2.len()
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// #1693: Bookmark options ▸ New bookmarks from text styles… lists the document's styles with a
|
||||
/// sample, offers the largest for level 1, and Create makes the bookmarks (sections under
|
||||
/// their chapter) as one undo step. Cancel changes nothing; a document without text says so.
|
||||
#[test]
|
||||
fn new_bookmarks_from_text_styles_from_the_bookmark_options() {
|
||||
let mut h = harness();
|
||||
h.state_mut().open_bytes("styled.pdf", None, styled_pdf()).unwrap();
|
||||
h.run_steps(3);
|
||||
let id = h.state().active.and_then(|i| h.state().views.get(i)).unwrap().id;
|
||||
let open = |h: &mut Harness<'static, PdfCraftApp>| {
|
||||
h.get_by_label("Bookmark options").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("New bookmarks from text styles…").click();
|
||||
h.run_steps(3);
|
||||
};
|
||||
open(&mut h);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles));
|
||||
assert_eq!(h.state().text_style_draft.levels, [Some(0), None, None, None], "the largest style is offered for level 1");
|
||||
// Cancel: nothing made.
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert!(h.state().session.get(id).unwrap().info.outline.is_empty());
|
||||
// Level 2 from its list: the sections' style.
|
||||
open(&mut h);
|
||||
// Each level's list is named after it, for screen readers too.
|
||||
h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Level 2").click();
|
||||
h.run_steps(2);
|
||||
// It lists every style, by font, size, weight and a sample.
|
||||
h.get_by_label("Helvetica-Bold, 18 pt, bold · “Introduction” (2 lines)");
|
||||
h.get_by_label("Helvetica, 11 pt · “Some text” (2 lines)");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Helvetica-Bold, 14 pt, bold · “Background” (1 line)").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().text_style_draft.levels, [Some(0), Some(1), None, None]);
|
||||
h.get_by_label("Create").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let outline = h.state().session.get(id).unwrap().info.outline.clone();
|
||||
assert_eq!(outline.len(), 1);
|
||||
assert_eq!(outline[0].title, "Untitled");
|
||||
let titles: Vec<(&str, Option<usize>)> = outline[0].children.iter().map(|o| (o.title.as_str(), o.page)).collect();
|
||||
assert_eq!(titles, [("Introduction", Some(0)), ("Results", Some(1))]);
|
||||
assert_eq!(outline[0].children[0].children[0].title, "Background");
|
||||
assert_eq!(h.state().session.get(id).unwrap().can_undo(), Some("New bookmarks from text styles"));
|
||||
assert_eq!(h.state().right, Some(pdfcraft_ui_egui::RightPanel::Bookmarks));
|
||||
// A document with no text: nothing to choose from.
|
||||
h.state_mut().open_bytes("pages.pdf", None, PAGES.to_vec()).unwrap();
|
||||
h.run_steps(3);
|
||||
open(&mut h);
|
||||
let message = h.get_by_label("This document has no text to make bookmarks from.").rect();
|
||||
// The dialog's Close, below the message (the panel has a Close of its own).
|
||||
h.get_all_by_label("Close").find(|n| n.rect().top() > message.bottom()).expect("the dialog's Close").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
// The command (Pages menu, command palette) opens it too.
|
||||
assert!(h.state_mut().execute("bookmark.from_text_styles"));
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().dialog, Some(pdfcraft_ui_egui::Dialog::BookmarksFromTextStyles));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanding_and_collapsing_all_bookmarks() {
|
||||
use pdfcraft_engine::Edit;
|
||||
|
||||
@@ -3975,12 +3975,14 @@ fn dragging_the_rotate_knob_turns_a_page_image() {
|
||||
let to = egui::pos2(c.x + 100.0, c.y);
|
||||
h.hover_at(knob);
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "the knob offers to turn the picture");
|
||||
h.drag_at(knob);
|
||||
h.run_steps(1);
|
||||
for k in 1..=4 {
|
||||
h.hover_at(knob + (to - knob) * (k as f32 / 4.0));
|
||||
h.run_steps(1);
|
||||
}
|
||||
assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing, "and holds it while it turns");
|
||||
h.drop_at(to);
|
||||
h.run_steps(4);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
@@ -6206,6 +6208,125 @@ fn organize_panel_comes_with_the_grid_and_closes_it() {
|
||||
assert!(h.query_by_label("Page properties").is_none(), "the panel is gone");
|
||||
}
|
||||
|
||||
/// Organize pages ▸ Page properties ▸ Tab Order sets the tab order of the selected pages only,
|
||||
/// as one undo step, and opens on the order the pages have.
|
||||
#[test]
|
||||
fn page_properties_sets_the_tab_order_of_the_selected_pages() {
|
||||
use pdfcraft_engine::TabOrder::{Annotations, Column, Row};
|
||||
let tabs = |h: &Harness<'static, PdfCraftApp>| h.state().session.get(h.state().views[0].id).unwrap().tab_orders();
|
||||
// The undo and redo labels: a new step replaces the redo step an undo left.
|
||||
let undo = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let doc = h.state().session.get(h.state().views[0].id).unwrap();
|
||||
(doc.can_undo().map(str::to_owned), doc.can_redo().map(str::to_owned))
|
||||
};
|
||||
let mut h = organize(3);
|
||||
h.get_by_label("Select page 1").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Select page 3").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Page properties").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, Some(Dialog::PageProps));
|
||||
assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, Some(Annotations), "opens on Unspecified, as the pages have it");
|
||||
h.get_by_label("Use Column Order").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!(tabs(&h), [Column, Annotations, Column]);
|
||||
assert_eq!(undo(&h).0.as_deref(), Some("Set tab order"));
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
||||
h.run_steps(3);
|
||||
assert_eq!(tabs(&h), [Annotations; 3], "one undo step");
|
||||
|
||||
// Pages with different orders open with no choice made, and OK is off until one is.
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::SetTabOrder { pages: vec![0], order: Row });
|
||||
h.run_steps(2);
|
||||
h.state_mut().views[0].select_pages(&[0, 1]);
|
||||
assert!(h.state_mut().execute("page.properties"));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().page_props_draft.as_ref().unwrap().tab_order, None);
|
||||
assert!(h.get_by_label("OK").accesskit_node().is_disabled());
|
||||
h.get_by_label("Use Row Order").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(tabs(&h), [Row, Row, Annotations]);
|
||||
|
||||
// OK on the order the pages already have adds no undo step, and Cancel changes nothing.
|
||||
h.key_press_modifiers(Modifiers::COMMAND, Key::Z);
|
||||
h.run_steps(3);
|
||||
let before = (tabs(&h), undo(&h));
|
||||
assert_eq!(before.1.1.as_deref(), Some("Set tab order"), "the undone step can be redone");
|
||||
h.state_mut().views[0].select_pages(&[2]);
|
||||
assert!(h.state_mut().execute("page.properties"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!((tabs(&h), undo(&h)), before);
|
||||
assert!(h.state_mut().execute("page.properties"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Use Document Structure").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
assert_eq!((tabs(&h), undo(&h)), before);
|
||||
}
|
||||
|
||||
/// Page properties sets the tab order of the document it was opened on: when another document
|
||||
/// is active by OK, neither document changes.
|
||||
#[test]
|
||||
fn page_properties_does_not_apply_to_another_active_document() {
|
||||
use pdfcraft_engine::TabOrder::Annotations;
|
||||
let mut h = organize(3);
|
||||
let first = h.state().views[0].id;
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
assert!(h.state_mut().execute("page.properties"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Use Column Order").click();
|
||||
h.run_steps(2);
|
||||
h.state_mut().open_bytes("other.pdf", None, fixture(3)).unwrap();
|
||||
h.run_steps(4);
|
||||
let other = h.state().views[1].id;
|
||||
assert_eq!(h.state().active_ids().map(|(_, id)| id), Some(other));
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
for id in [first, other] {
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.tab_orders(), [Annotations; 3]);
|
||||
assert!(doc.can_undo().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// Page properties sets the tab order of the pages it was opened on: when an edit changes the
|
||||
/// document while the dialog is open, OK closes it without changing a page.
|
||||
#[test]
|
||||
fn page_properties_does_not_apply_after_the_document_changes() {
|
||||
use pdfcraft_engine::TabOrder::Annotations;
|
||||
let mut h = organize(3);
|
||||
let id = h.state().views[0].id;
|
||||
h.state_mut().views[0].select_pages(&[0]);
|
||||
assert!(h.state_mut().execute("page.properties"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Use Column Order").click();
|
||||
h.run_steps(2);
|
||||
// Page 1 becomes page 3, so index 0 is another page now.
|
||||
assert!(h.state_mut().execute("page.reverse"));
|
||||
h.run_steps(3);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog, None);
|
||||
let doc = h.state().session.get(id).unwrap();
|
||||
assert_eq!(doc.tab_orders(), [Annotations; 3]);
|
||||
assert_ne!(doc.can_undo(), Some("Set tab order"));
|
||||
assert_eq!(
|
||||
h.state().toast.as_ref().map(|t| t.0.as_str()),
|
||||
Some("The document changed while the dialog was open, so nothing was changed. Open Page properties again to set the tab order.")
|
||||
);
|
||||
}
|
||||
|
||||
/// Leaving Organize (a page opened from the grid, or the grid's Close) goes back to the
|
||||
/// workspace's panel: under the Edit tab, the Edit panel, not All tools.
|
||||
#[test]
|
||||
|
||||
@@ -70,6 +70,7 @@ fn console_runs_scripts_against_the_document() {
|
||||
assert!(h.state().js_console.log.is_empty(), "{:?} {:?}", h.state().js_console.log, h.state().dialog);
|
||||
}
|
||||
|
||||
/// A button's script fills a field and its message takes the toast.
|
||||
#[test]
|
||||
fn button_scripts_run_and_alert() {
|
||||
let mut h = harness();
|
||||
@@ -80,6 +81,80 @@ fn button_scripts_run_and_alert() {
|
||||
assert_eq!(h.state().toast.clone().unwrap().0, "Filled in");
|
||||
}
|
||||
|
||||
/// One run's messages are all shown, in order (#4294): the first takes the toast with its title
|
||||
/// and icon (#4293), the next takes it when the first is gone, and a notice shown meanwhile
|
||||
/// doesn't lose the ones still waiting. Messages past the queue's room are counted and reported
|
||||
/// once the rest have been shown.
|
||||
#[test]
|
||||
fn every_message_a_script_shows_takes_the_toast_in_turn() {
|
||||
use pdfcraft_engine::js::{MessageButtons, MessageIcon};
|
||||
let mut h = harness();
|
||||
let id = h.state().active_ids().unwrap().1;
|
||||
h.state_mut().run_button_script(id, "hello", "app.alert('First', 1, 0, 'Order form'); app.alert('Second', 3); app.alert('Third');");
|
||||
h.run_steps(2);
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>| h.state().toast.clone().map(|t| t.0);
|
||||
assert_eq!(shown(&h).as_deref(), Some("First"));
|
||||
let m = h.state().toast_script.clone().expect("the toast shows the script's message");
|
||||
assert_eq!((m.title.as_deref(), m.icon, m.buttons), (Some("Order form"), MessageIcon::Warning, MessageButtons::Ok));
|
||||
assert!(h.query_by_label("Order form").is_some(), "the title is drawn");
|
||||
assert_eq!(h.state().script_messages.len(), 2);
|
||||
// The first is gone: the second takes its place, with its own icon and no title.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Second"));
|
||||
assert_eq!(h.state().toast_script.as_ref().map(|m| (m.title.clone(), m.icon)), Some((None, MessageIcon::Status)));
|
||||
// A notice of the app's own replaces it; the third still follows.
|
||||
h.state_mut().notify("Saved");
|
||||
assert_eq!(h.state().toast_script, None);
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Third"));
|
||||
assert!(h.state().script_messages.is_empty());
|
||||
// A notice the same run raises first (its submit request) isn't replaced: the message
|
||||
// follows it.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
h.state_mut().run_button_script(id, "hello", "this.submitForm('https://example.com/form'); app.alert('Fourth');");
|
||||
h.run_steps(2);
|
||||
let notice = shown(&h).unwrap_or_default();
|
||||
assert!(notice.contains("submitted"), "the submit notice stays: {notice}");
|
||||
assert_eq!(h.state().script_messages.len(), 1);
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Fourth"));
|
||||
// Past the 100 the queue holds, messages are counted, and the toast says how many once the
|
||||
// rest have been shown.
|
||||
h.state_mut().toast = None;
|
||||
h.run_steps(2);
|
||||
let many: Vec<_> = (0..105).map(|i| pdfcraft_engine::js::Message::plain(format!("Message {i}"))).collect();
|
||||
h.state_mut().show_script_messages(many);
|
||||
assert_eq!(shown(&h).as_deref(), Some("Message 0"));
|
||||
for i in 1..100 {
|
||||
assert!(h.state_mut().next_script_message());
|
||||
assert_eq!(shown(&h), Some(format!("Message {i}")));
|
||||
}
|
||||
assert!(h.state_mut().next_script_message(), "the notice comes after the last message");
|
||||
assert_eq!(shown(&h).as_deref(), Some("5 more messages from the form's scripts were left out."));
|
||||
assert_eq!(h.state().toast_script, None, "an app notice, not a script message");
|
||||
assert!(!h.state_mut().next_script_message(), "said once");
|
||||
// Another notice replacing it before its time doesn't lose the count: once that notice has
|
||||
// been shown its time, the count is said again, and then, its own time over, not again.
|
||||
h.state_mut().notify("Saved");
|
||||
assert_eq!(shown(&h).as_deref(), Some("Saved"));
|
||||
let left_out = "5 more messages from the form's scripts were left out.";
|
||||
let mut said_again = false;
|
||||
for _ in 0..600 {
|
||||
h.run_steps(1);
|
||||
said_again |= shown(&h).as_deref() == Some(left_out);
|
||||
if said_again && shown(&h).is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(said_again, "the count is said again after the notice that replaced it");
|
||||
assert_eq!(shown(&h), None, "and once only");
|
||||
assert!(!h.state_mut().next_script_message());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_scripts_and_preferences() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -295,6 +295,168 @@ fn the_preview_steps_through_the_pages_and_starts_at_the_current_one() {
|
||||
h.get_by_label("Preview: page 2 of 3");
|
||||
}
|
||||
|
||||
/// Keeps every texture the app uploads, so a test can read what a picture it paints shows.
|
||||
#[derive(Clone, Default)]
|
||||
struct Uploads(std::sync::Arc<std::sync::Mutex<std::collections::HashMap<egui::TextureId, std::sync::Arc<egui::ColorImage>>>>);
|
||||
|
||||
impl egui_kittest::TestRenderer for Uploads {
|
||||
fn handle_delta(&mut self, delta: &mut egui::TexturesDelta) {
|
||||
let mut seen = self.0.lock().unwrap();
|
||||
for (id, images) in delta.set.drain() {
|
||||
for d in images.into_iter().filter(|d| d.pos.is_none()) {
|
||||
let egui::ImageData::Color(image) = d.image;
|
||||
seen.insert(id, image);
|
||||
}
|
||||
}
|
||||
// Freed textures stay readable: ids are never reused.
|
||||
delta.free.clear();
|
||||
}
|
||||
|
||||
fn render(&mut self, _: &egui::Context, _: &egui::FullOutput) -> Result<image::RgbaImage, String> {
|
||||
Err("this renderer only keeps the uploads".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// The page picture the dialog's preview painted last frame, left of its "Preview: page …" label,
|
||||
/// once its texture is in: its tint and its image.
|
||||
fn preview_picture(h: &Harness<'static, PdfCraftApp>, uploads: &Uploads) -> Option<(egui::Color32, std::sync::Arc<egui::ColorImage>)> {
|
||||
let label = h.query_by_label_contains("Preview: page")?.rect();
|
||||
h.output().shapes.iter().find_map(|s| {
|
||||
let egui::Shape::Mesh(m) = &s.shape else { return None };
|
||||
let r = m.calc_bounds();
|
||||
// The size of the 210-point box left of the label (which is centred down its side), and
|
||||
// not the stepper's arrow beside the label. A landscape page is shorter than the box, so
|
||||
// it can sit below the label: within half the box of it.
|
||||
let beside = r.width() > 50.0
|
||||
&& r.height() <= 215.0
|
||||
&& r.right() <= label.left()
|
||||
&& label.left() - r.right() < 260.0
|
||||
&& (r.center().y - label.center().y).abs() <= 105.0;
|
||||
if !beside {
|
||||
return None;
|
||||
}
|
||||
let image = uploads.0.lock().unwrap().get(&m.texture_id).cloned()?;
|
||||
Some((m.vertices.first()?.color, image))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`preview_picture`], waiting until `ready` accepts it (the page renders off the UI thread).
|
||||
fn wait_for_preview(
|
||||
h: &mut Harness<'static, PdfCraftApp>,
|
||||
uploads: &Uploads,
|
||||
what: &str,
|
||||
ready: impl Fn(&egui::ColorImage) -> bool,
|
||||
) -> (egui::Color32, std::sync::Arc<egui::ColorImage>) {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
loop {
|
||||
h.step();
|
||||
let shown = preview_picture(h, uploads);
|
||||
if let Some((tint, image)) = shown.clone().filter(|(_, image)| ready(image)) {
|
||||
return (tint, image);
|
||||
}
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the preview never showed {what}; last (tint, size, middle of the lower half): {:?}",
|
||||
shown.map(|(tint, i)| (tint, i.size, at(&i, 0.5, 0.75)))
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixel of `image` at (u, v), from 0 to 1 across and down.
|
||||
fn at(image: &egui::ColorImage, u: f32, v: f32) -> egui::Color32 {
|
||||
let [w, h] = image.size;
|
||||
let (x, y) = (((u * w as f32) as usize).min(w - 1), ((v * h as f32) as usize).min(h - 1));
|
||||
image.pixels[y * w + x]
|
||||
}
|
||||
|
||||
fn count(image: &egui::ColorImage, f: impl Fn(egui::Color32) -> bool) -> usize {
|
||||
image.pixels.iter().filter(|&&c| f(c)).count()
|
||||
}
|
||||
|
||||
/// Update draws its preview on the page without the marks it replaces, which the page's thumbnail
|
||||
/// still has: a pale yellow background updated to light blue previews light blue, not blue
|
||||
/// multiplied onto the yellow, and a new watermark or header isn't drawn over the old one. The
|
||||
/// page's content and its other kinds of mark stay.
|
||||
#[test]
|
||||
fn update_previews_the_page_without_the_marks_it_replaces() {
|
||||
let uploads = Uploads::default();
|
||||
let pdf = content_fixture();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).renderer(uploads.clone()).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("doc.pdf", None, pdf.clone()).unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let yellow = egui::Color32::from_rgb(255, 255, 153);
|
||||
let red = |c: egui::Color32| c.r() > 200 && c.g() < 80 && c.b() < 80;
|
||||
let blue = |c: egui::Color32| c.b() > 150 && c.r() < 100 && c.g() < 100;
|
||||
let dark = |c: egui::Color32| c.r() < 60 && c.g() < 60 && c.b() < 60;
|
||||
let apply = |h: &mut Harness<'static, PdfCraftApp>, command: &str, draft: &dyn Fn(&mut pdfcraft_ui_egui::marks::MarksDraft)| {
|
||||
assert!(h.state_mut().execute(command));
|
||||
h.run_steps(2);
|
||||
draft(&mut h.state_mut().marks_draft);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(3);
|
||||
};
|
||||
// A pale yellow background, a red watermark and a blue header.
|
||||
apply(&mut h, "edit.background", &|d| d.bg.color = [1.0, 1.0, 0.6]);
|
||||
apply(&mut h, "edit.watermark", &|d| {
|
||||
d.wm.text = "OLD".into();
|
||||
d.wm.color = [1.0, 0.0, 0.0];
|
||||
d.wm.opacity = 1.0;
|
||||
d.wm.rotation = 0.0;
|
||||
});
|
||||
apply(&mut h, "edit.header_footer", &|d| {
|
||||
d.hf.text[1] = "OLD".into();
|
||||
d.hf.color = [0.0, 0.0, 1.0];
|
||||
d.hf.font_size = 40.0;
|
||||
});
|
||||
// Add keeps the marks there, and previews on the thumbnail, which has all three.
|
||||
assert!(h.state_mut().execute("edit.watermark"));
|
||||
h.run_steps(2);
|
||||
let all = |i: &egui::ColorImage| at(i, 0.5, 0.75) == yellow && count(i, red) > 200 && count(i, blue) > 40;
|
||||
wait_for_preview(&mut h, &uploads, "the page with its marks", all);
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state_mut().execute("edit.background.update"));
|
||||
h.run_steps(2);
|
||||
h.state_mut().marks_draft.bg.color = [0.6, 0.8, 1.0];
|
||||
h.step();
|
||||
// Update shows the page without the marks from the frame it opens: neither a blank sheet nor
|
||||
// the old marks while it renders.
|
||||
let shown = |h: &Harness<'static, PdfCraftApp>| preview_picture(h, &uploads).expect("a page is previewed");
|
||||
let (tint, page) = shown(&h);
|
||||
assert_eq!(tint, egui::Color32::from_rgb(153, 204, 255), "the new colour tints the page");
|
||||
assert_eq!(at(&page, 0.5, 0.75), egui::Color32::WHITE, "the page is previewed without the yellow background");
|
||||
assert!(count(&page, red) > 200 && count(&page, blue) > 40 && count(&page, dark) > 1000, "the watermark, header and page content stay");
|
||||
// So does a step to page 2: the pages are prepared once, and each is drawn as it is shown.
|
||||
h.state_mut().marks_draft.preview = Some(1);
|
||||
h.step();
|
||||
assert_eq!(at(&shown(&h).1, 0.5, 0.75), egui::Color32::WHITE, "page 2 is previewed without its background at once");
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state_mut().execute("edit.watermark.update"));
|
||||
h.run_steps(2);
|
||||
h.state_mut().marks_draft.wm.text = "NEW".into();
|
||||
h.step();
|
||||
let (_, page) = shown(&h);
|
||||
assert_eq!(count(&page, red), 0, "the new watermark isn't previewed over the old one");
|
||||
assert!(at(&page, 0.5, 0.75) == yellow && count(&page, blue) > 40, "the background and header stay");
|
||||
h.get_by_label("Cancel").click();
|
||||
h.run_steps(2);
|
||||
|
||||
assert!(h.state_mut().execute("edit.header_footer.update"));
|
||||
h.run_steps(2);
|
||||
let (_, page) = shown(&h);
|
||||
assert_eq!(count(&page, blue), 0, "the new header isn't previewed over the old one");
|
||||
assert!(at(&page, 0.5, 0.75) == yellow && count(&page, red) > 200, "the background and watermark stay");
|
||||
}
|
||||
|
||||
/// Pages from 1 to 1 stays one page: the dialog used to turn "to" back into the last page on
|
||||
/// every frame while both were 1, so only page 1 could not be chosen alone. The field starts on
|
||||
/// the last page.
|
||||
|
||||
@@ -376,6 +376,45 @@ fn export_comments_writes_on_a_later_frame() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_comments_takes_the_comments_of_another_pdf() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Text /Rect [10 10 30 30] /Contents (from the review) /P 3 0 R >>",
|
||||
];
|
||||
let mut review = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(review.len());
|
||||
review.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = review.len();
|
||||
review.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
review.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
review.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let dir = scratch("import-pdf-comments");
|
||||
let path = dir.join("review.pdf");
|
||||
std::fs::write(&path, review).unwrap();
|
||||
let mut h = harness(&[1]);
|
||||
// Through the save path a test uses in place of the picker, and through the picker.
|
||||
for chosen in [Some(path.to_string_lossy().into_owned()), None] {
|
||||
h.state_mut().save_override = chosen.clone();
|
||||
h.state_mut().pick_override = chosen.is_none().then(|| vec![path.to_string_lossy().into_owned()]);
|
||||
h.state_mut().run_command("comment.import");
|
||||
h.run_steps(2);
|
||||
let app = h.state();
|
||||
let contents: Vec<Option<String>> = app.session.get(app.views[0].id).unwrap().info.annotations.iter().map(|a| a.contents.clone()).collect();
|
||||
assert_eq!(contents, [Some("from the review".to_string())], "the review's comment is imported once ({chosen:?})");
|
||||
h.state_mut().run_command("edit.undo");
|
||||
h.run_steps(1);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracted_pages_are_not_deleted_if_the_document_was_edited_while_choosing_the_folder() {
|
||||
let dir = scratch("extract-edited");
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
//! dialog, the palette, the quick bar, the chrome over a page scrolled under it) lets the page's
|
||||
//! cursor, tips or presses through.
|
||||
//!
|
||||
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one cursor
|
||||
//! writer (step 2), one hit test (step 3), and press-time capture with cancel (step 4).
|
||||
//! The ignored tests pin live bugs that later steps of the page pointer router fix: one hit test
|
||||
//! (step 3), and press-time capture with cancel (step 4).
|
||||
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use pdfcraft_engine::StampKind;
|
||||
use pdfcraft_ui_egui::comments::CommentTool;
|
||||
use pdfcraft_ui_egui::fill_sign::FillTool;
|
||||
use pdfcraft_ui_egui::{PdfCraftApp, QuickTool, UnlockPrompt};
|
||||
|
||||
use crate::support::input::{coalesced_click, double_click, one_frame, touch_drag, touch_tap};
|
||||
@@ -76,6 +78,18 @@ fn comment_on_text_pdf() -> Vec<u8> {
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with two rectangle comments overlapping at x 100..150, y 200..250: "Bottom
|
||||
/// note" first in `/Annots`, then "Top note" over it.
|
||||
fn stacked_comments_pdf() -> Vec<u8> {
|
||||
pdf(&[
|
||||
CATALOG,
|
||||
PAGES,
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [4 0 R 5 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [50 150 150 250] /C [1 0 0] /F 4 /T (Ana) /Contents (Bottom note) >>",
|
||||
"<< /Type /Annot /Subtype /Square /Rect [100 200 200 300] /C [0 0 1] /F 4 /T (Ben) /Contents (Top note) >>",
|
||||
])
|
||||
}
|
||||
|
||||
/// A 300×400 page with one line of text at x 40, y 300.
|
||||
fn text_pdf() -> Vec<u8> {
|
||||
let text = stream(LINE);
|
||||
@@ -173,6 +187,12 @@ fn agree(h: &Harness<'_, PdfCraftApp>) -> Vec<String> {
|
||||
s.session.get(s.views[0].id).unwrap().form.iter().find(|f| f.name == "agree").unwrap().value.clone()
|
||||
}
|
||||
|
||||
/// Whether the page's hover tip shows and says `text`.
|
||||
fn tip_says(h: &Harness<'_, PdfCraftApp>, text: &str) -> bool {
|
||||
let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover"))).filter(|_| tip_shown(h)) else { return false };
|
||||
h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center()))
|
||||
}
|
||||
|
||||
/// Hovers `p` until `want` accepts the cursor there (text layers and paragraph boxes arrive from
|
||||
/// worker threads), and returns the last cursor seen.
|
||||
fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl Fn(egui::CursorIcon) -> bool) -> egui::CursorIcon {
|
||||
@@ -191,7 +211,6 @@ fn wait_for_cursor(h: &mut Harness<'_, PdfCraftApp>, p: egui::Pos2, want: impl F
|
||||
// The cursor promises one thing and a press does another.
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn stamp_tool_over_a_link_shows_its_crosshair() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
stamp_tool(&mut h);
|
||||
@@ -205,7 +224,6 @@ fn stamp_tool_over_a_link_shows_its_crosshair() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn rectangle_tool_over_a_link_shows_its_crosshair() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::Comment(CommentTool::Rectangle);
|
||||
@@ -218,7 +236,6 @@ fn rectangle_tool_over_a_link_shows_its_crosshair() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn edit_text_over_a_linked_word_offers_the_move_arrow() {
|
||||
let mut h = open(linked_text_pdf(), "150", false);
|
||||
h.state_mut().quick_tool = QuickTool::EditText;
|
||||
@@ -242,7 +259,6 @@ fn edit_text_over_a_linked_word_offers_the_move_arrow() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "step 2 of the page pointer router: one cursor writer"]
|
||||
fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
|
||||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||||
// Inside the rectangle where there is no text, and inside it over a word.
|
||||
@@ -259,6 +275,71 @@ fn select_tool_over_a_comment_on_text_offers_the_move_arrow() {
|
||||
assert_eq!(over_text, egui::CursorIcon::Move, "so it offers the arrow where text lies under it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_links_tip_shows_only_with_its_hand() {
|
||||
let mut h = open(link_pdf(), "150", false);
|
||||
let on_link = at(&h, 150.0, 325.0);
|
||||
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, nudge: f32| {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(4);
|
||||
// A new position each time, so the hover is fresh.
|
||||
let cursor = cursor_at(h, on_link + egui::vec2(nudge, 0.0));
|
||||
h.run_steps(2);
|
||||
(cursor, tip_says(h, "https://example.org/"))
|
||||
};
|
||||
use egui::CursorIcon::{Crosshair, PointingHand};
|
||||
assert_eq!(hover(&mut h, QuickTool::Select, 0.0), (PointingHand, true), "the Select tool follows the link, and the tip says where to");
|
||||
assert_eq!(hover(&mut h, QuickTool::Link, 1.0), (PointingHand, true), "the Link tool edits it");
|
||||
let stamp = QuickTool::Stamp(StampKind::DynApproved);
|
||||
assert_eq!(hover(&mut h, stamp, 2.0), (Crosshair, false), "a click places a stamp, so neither hand nor tip offers the link");
|
||||
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
|
||||
assert_eq!(hover(&mut h, rectangle, 3.0), (Crosshair, false), "a drag draws a rectangle");
|
||||
}
|
||||
|
||||
/// Over two comments the tip names the one drawn on top (#3620), and a click should select that
|
||||
/// one too. The renderer paints in `/Annots` order, but the hit test takes the last comment in
|
||||
/// inspection's order, which is by top edge, and selects the one underneath.
|
||||
#[test]
|
||||
#[ignore = "step 3 of the page pointer router: one hit test"]
|
||||
fn a_click_on_stacked_comments_selects_the_one_drawn_on_top() {
|
||||
let mut h = open(stacked_comments_pdf(), "150", false);
|
||||
let overlap = at(&h, 125.0, 225.0);
|
||||
assert_eq!(cursor_at(&mut h, overlap), egui::CursorIcon::Move);
|
||||
h.run_steps(2);
|
||||
assert!(tip_says(&h, "Top note") && !tip_says(&h, "Bottom note"), "the tip names the comment drawn on top");
|
||||
mouse_click(&mut h, overlap);
|
||||
assert_eq!(h.state().views[0].comments.selected, Some((0, 1)), "a click selects it too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_comments_tip_shows_with_every_tool_beside_its_cursor() {
|
||||
let mut h = open(comment_on_text_pdf(), "150", false);
|
||||
// Inside the rectangle comment by "Reviewer": where there is no text, and over a word.
|
||||
let (blank, on_text) = (at(&h, 120.0, 315.0), at(&h, 60.0, 303.0));
|
||||
let hover = |h: &mut Harness<'_, PdfCraftApp>, tool: QuickTool, p: egui::Pos2, want: egui::CursorIcon| {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(4);
|
||||
let cursor = wait_for_cursor(h, p, |c| c == want);
|
||||
h.run_steps(2);
|
||||
(cursor, tip_says(h, "Reviewer"))
|
||||
};
|
||||
use egui::CursorIcon::{Crosshair, Move, Text};
|
||||
// A new position each time, so the hover is fresh.
|
||||
let nudge = |n: f32| blank + egui::vec2(n, 0.0);
|
||||
assert_eq!(hover(&mut h, QuickTool::Select, blank, Move), (Move, true), "the Select tool picks the comment up");
|
||||
let stamp = QuickTool::Stamp(StampKind::DynApproved);
|
||||
assert_eq!(hover(&mut h, stamp, nudge(1.0), Crosshair), (Crosshair, true), "a stamp keeps its crosshair, and the comment still says whose it is");
|
||||
let rectangle = QuickTool::Comment(CommentTool::Rectangle);
|
||||
assert_eq!(hover(&mut h, rectangle, nudge(2.0), Crosshair), (Crosshair, true), "so does a drawing tool");
|
||||
let highlight = QuickTool::Comment(CommentTool::Highlight);
|
||||
assert_eq!(hover(&mut h, highlight, on_text, Text), (Text, true), "and the highlighter its I-beam");
|
||||
// Not while drawing freehand, where it would cover the next stroke (#429), nor with Fill & Sign.
|
||||
let ink = QuickTool::Comment(CommentTool::Ink);
|
||||
assert_eq!(hover(&mut h, ink, nudge(3.0), Crosshair), (Crosshair, false), "freehand");
|
||||
let fill = QuickTool::Fill(FillTool::Check);
|
||||
assert_eq!(hover(&mut h, fill, nudge(4.0), Crosshair), (Crosshair, false), "Fill & Sign");
|
||||
}
|
||||
|
||||
/// Narrows the paragraph by dragging its left edge 100 pt inward, starting `dx` screen points
|
||||
/// left of the edge. Returns the cursor shown at the press point and the line count after.
|
||||
fn narrow_from_left(dx: f32) -> (egui::CursorIcon, usize) {
|
||||
|
||||
@@ -29,7 +29,7 @@ fn ok_is_on_screen_without_scrolling_at_any_window_height() {
|
||||
let h = harness(height);
|
||||
let ok = h.get_by_label("OK").rect();
|
||||
assert!(on_screen(ok, height), "{height}: OK at {ok:?} is off the {height} pt window");
|
||||
let title = h.get_by_label("Preferences").rect();
|
||||
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
|
||||
assert!(on_screen(title, height), "{height}: the title at {title:?} is off the window");
|
||||
// The OK row sits below the scrolled settings, never over them: the settings' last visible
|
||||
// row ends above it. The title is above everything.
|
||||
@@ -43,13 +43,13 @@ fn ok_closes_the_dialog_in_a_short_window_and_the_settings_still_scroll() {
|
||||
// A setting far down the list starts out below the visible settings; scrolling brings it in
|
||||
// between the title and the OK row, and OK did not move.
|
||||
let ok_before = h.get_by_label("OK").rect();
|
||||
let title = h.get_by_label("Preferences").rect();
|
||||
let far = h.get_by_label_contains("Reading colours").rect();
|
||||
assert!(far.min.y >= ok_before.min.y, "Reading colours starts out of sight: {far:?} vs OK {ok_before:?}");
|
||||
h.get_by_label_contains("Reading colours").scroll_to_me();
|
||||
let title = h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect();
|
||||
let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect();
|
||||
assert!(far.min.y >= ok_before.min.y, "JavaScript setting starts out of sight: {far:?} vs OK {ok_before:?}");
|
||||
h.get_by_label_contains("Enable Acrobat JavaScript").scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let far = h.get_by_label_contains("Reading colours").rect();
|
||||
assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "Reading colours scrolled in between the title and OK: {far:?}");
|
||||
let far = h.get_by_label_contains("Enable Acrobat JavaScript").rect();
|
||||
assert!(far.min.y >= title.max.y && far.max.y <= ok_before.min.y, "JavaScript setting scrolled in between the title and OK: {far:?}");
|
||||
assert_eq!(h.get_by_label("OK").rect(), ok_before, "OK stays put while the settings scroll");
|
||||
assert!(h.state().dialog.is_some());
|
||||
h.get_by_label("OK").click();
|
||||
@@ -64,9 +64,15 @@ fn the_dialog_opens_at_its_full_size_and_does_not_grow_afterwards() {
|
||||
// in its first frames landed on controls that were still moving.
|
||||
for height in [260.0, 560.0, 900.0] {
|
||||
let mut h = harness(height);
|
||||
let settled = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect());
|
||||
let settled = (
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(),
|
||||
h.get_by_label("OK").rect(),
|
||||
);
|
||||
h.run_steps(8);
|
||||
let later = (h.get_by_label("Preferences").rect(), h.get_by_label("OK").rect());
|
||||
let later = (
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Label && n.value().as_deref() == Some("Preferences")).rect(),
|
||||
h.get_by_label("OK").rect(),
|
||||
);
|
||||
assert_eq!(settled, later, "{height}: the dialog moved after it had been shown for six frames");
|
||||
}
|
||||
// And it is in place within a few frames of opening, for the first click: a frame or two of
|
||||
|
||||
@@ -1279,3 +1279,77 @@ fn a_loop_has_its_own_card_that_opens_the_formula() {
|
||||
let draft = h.state().field_props.as_ref().map(|d| (d.field.clone(), d.tab));
|
||||
assert_eq!(draft, Some(("a".to_owned(), FieldTab::Calculate)));
|
||||
}
|
||||
|
||||
/// A radio group `shirt` of three buttons, S, M and L, with L chosen, while preparing the form.
|
||||
fn radio_group_harness() -> Harness<'static, PdfCraftApp> {
|
||||
let mut h = harness();
|
||||
for (x, export) in [(20.0, "S"), (60.0, "M"), (100.0, "L")] {
|
||||
h.state_mut().apply_edit(pdfcraft_engine::Edit::AddField {
|
||||
page: 0,
|
||||
rect: [x, 300.0, x + 14.0, 314.0],
|
||||
kind: pdfcraft_engine::NewField::Radio { group: Some("shirt".into()), export: export.into() },
|
||||
name: Some("shirt".into()),
|
||||
});
|
||||
}
|
||||
h.state_mut()
|
||||
.apply_edit(pdfcraft_engine::Edit::SetFieldValue { name: "shirt".into(), value: pdfcraft_engine::FieldValue::Radio(Some("L".into())) });
|
||||
assert!(h.state_mut().execute("form.prepare"));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn buttons(h: &Harness<'static, PdfCraftApp>) -> (Vec<String>, Vec<String>) {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == "shirt").expect("the group is still there");
|
||||
(f.widgets.iter().filter_map(|w| w.on_state.clone()).collect(), f.value.clone())
|
||||
}
|
||||
|
||||
/// #4609: Delete removes the one radio button clicked, not its whole group, as in Acrobat.
|
||||
#[test]
|
||||
fn deleting_one_radio_button_keeps_the_rest_of_its_group() {
|
||||
let mut h = radio_group_harness();
|
||||
assert_eq!(buttons(&h).0, ["S", "M", "L"]);
|
||||
let p = {
|
||||
let s = h.state();
|
||||
let doc = s.session.get(s.views[0].id).unwrap();
|
||||
let f = doc.form.iter().find(|f| f.name == "shirt").unwrap();
|
||||
pdfcraft_ui_egui::forms_ui::field_screen_rect(&s.views[0], &doc.info, f, 1).expect("on screen").center()
|
||||
};
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
h.run_steps(1);
|
||||
h.drop_at(p);
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().views[0].prepare.selected, Some(("shirt".into(), 1)), "the middle button is selected");
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(buttons(&h), (vec!["S".into(), "L".into()], vec!["L".into()]), "only M is gone; L stays chosen");
|
||||
assert_eq!(h.state().session.get(h.state().views[0].id).unwrap().can_undo(), Some("Delete field"));
|
||||
h.state_mut().execute("edit.undo");
|
||||
h.run_steps(2);
|
||||
assert_eq!(buttons(&h).0, ["S", "M", "L"]);
|
||||
}
|
||||
|
||||
/// Two buttons of one group selected together both go, whichever was selected first.
|
||||
#[test]
|
||||
fn deleting_two_radio_buttons_of_a_group_at_once() {
|
||||
let mut h = radio_group_harness();
|
||||
{
|
||||
let p = &mut h.state_mut().views[0].prepare;
|
||||
p.selected = Some(("shirt".into(), 0));
|
||||
p.also = vec![("shirt".into(), 2)];
|
||||
}
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert_eq!(buttons(&h), (vec!["M".into()], vec![]), "M is left, and nothing is chosen");
|
||||
// Selecting every button deletes the group.
|
||||
{
|
||||
let p = &mut h.state_mut().views[0].prepare;
|
||||
p.selected = Some(("shirt".into(), 0));
|
||||
}
|
||||
h.key_press(egui::Key::Delete);
|
||||
h.run_steps(3);
|
||||
assert!(!names(&h).iter().any(|n| n == "shirt"));
|
||||
}
|
||||
|
||||
@@ -100,17 +100,6 @@ fn png() -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The Reading colours combo of the preferences dialog: it shows the chosen mode as its value,
|
||||
/// and no other combo in the dialog shows `Off` while the setting is off.
|
||||
fn combo(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
// The preferences dialog grew taller than the window, which leaves this combo below the
|
||||
// fold where a click lands outside the dialog and dismisses it; scroll it into view and
|
||||
// let the scroll settle before clicking.
|
||||
h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::ComboBox && n.value().is_some_and(|v| v == "Off")).click();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_colours_preference_round_trips() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
@@ -122,11 +111,9 @@ fn reading_colours_preference_round_trips() {
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().reading_colors, ReadingColors::default());
|
||||
// Switch the preset to white-on-black in the dialog's Reading colours combo. The open
|
||||
// document keeps repainting, so run a fixed number of steps and let the popup settle.
|
||||
combo(&mut h);
|
||||
h.run_steps(20);
|
||||
h.get_by_label("White on black").click();
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().reading_colors, ReadingColors::WHITE_ON_BLACK);
|
||||
// The preference is app-wide: the already open document follows it too.
|
||||
@@ -151,6 +138,41 @@ fn reading_colours_preference_round_trips() {
|
||||
assert_eq!(fresh.reading_colors, ReadingColors::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_presets_follow_appearance_and_do_not_change_the_interface_theme() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 1000.0)).build_eframe(|_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.set_option("dialog", "preferences").unwrap();
|
||||
app
|
||||
});
|
||||
h.run_steps(6);
|
||||
assert!(h.query_by_label("Original").is_some());
|
||||
let theme = h.state().theme_preference;
|
||||
let appearance = h.get_by_label("Light theme").rect();
|
||||
let preset = h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some("White on black")).rect();
|
||||
let documents = h.get_by_label("Documents and view").rect();
|
||||
assert!(appearance.bottom() < preset.top() && preset.bottom() < documents.top());
|
||||
for (label, expected) in [
|
||||
("White on black", ReadingColors::WHITE_ON_BLACK),
|
||||
("Yellow on black", ReadingColors::YELLOW_ON_BLACK),
|
||||
("Green on black", ReadingColors::GREEN_ON_BLACK),
|
||||
("Black on white", ReadingColors::BLACK_ON_WHITE),
|
||||
("Off", ReadingColors::default()),
|
||||
] {
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).scroll_to_me();
|
||||
h.run_steps(4);
|
||||
h.get_by(|n| n.role() == Role::Button && n.label().as_deref() == Some(label)).click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().reading_colors, expected);
|
||||
assert_eq!(h.state().theme_preference, theme);
|
||||
assert_eq!(h.query_by_label("Original").is_some(), expected.params.is_none());
|
||||
}
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_READING_SHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_colours_seed_on_open() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
|
||||
@@ -369,13 +369,25 @@ fn clicking_an_empty_signature_field_signs_it() {
|
||||
let dir = dir().join("field");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
sign_the_empty_field(&mut h);
|
||||
let s = h.state();
|
||||
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
|
||||
assert!(sig.signed && sig.visible);
|
||||
}
|
||||
|
||||
/// The centre of `WITH_FIELD`'s signature field, opened as the second document.
|
||||
fn field_centre(h: &Harness<'static, PdfCraftApp>) -> Pos2 {
|
||||
let r = h.state().views[1].page_screen_rect(0).expect("on screen");
|
||||
pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height())
|
||||
}
|
||||
|
||||
/// Opens `WITH_FIELD` as the second document and signs its empty field with the test ID, by
|
||||
/// clicking the field.
|
||||
fn sign_the_empty_field(h: &mut Harness<'static, PdfCraftApp>) {
|
||||
h.state_mut().open_bytes("form.pdf", None, WITH_FIELD.to_vec()).unwrap();
|
||||
h.state_mut().active = Some(1);
|
||||
h.run_steps(6);
|
||||
let p = {
|
||||
let r = h.state().views[1].page_screen_rect(0).expect("on screen");
|
||||
pos2(r.left() + 150.0 / 300.0 * r.width(), r.top() + (200.0 - 50.0) / 200.0 * r.height())
|
||||
};
|
||||
let p = field_centre(h);
|
||||
h.hover_at(p);
|
||||
h.run_steps(1);
|
||||
h.drag_at(p);
|
||||
@@ -399,11 +411,41 @@ fn clicking_an_empty_signature_field_signs_it() {
|
||||
d.password = "test".into();
|
||||
}
|
||||
h.run_steps(2);
|
||||
support::outside_menus(&h, "Sign").click();
|
||||
support::outside_menus(h, "Sign").click();
|
||||
h.run_steps(6);
|
||||
let s = h.state();
|
||||
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
|
||||
assert!(sig.signed && sig.visible);
|
||||
}
|
||||
|
||||
/// Whether the page's hover tip shows and says `text`.
|
||||
fn tip_says(h: &Harness<'static, PdfCraftApp>, text: &str) -> bool {
|
||||
let layer = egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas-hover"));
|
||||
let Some(tip) = h.ctx.memory(|m| m.area_rect(egui::Id::unique("canvas-hover")).filter(|_| m.areas().is_visible(&layer))) else { return false };
|
||||
h.query_all_by_label_contains(text).any(|n| tip.contains(n.rect().center()))
|
||||
}
|
||||
|
||||
/// A signed field's validation badge is the viewer's own mark, not part of the page: its verdict
|
||||
/// is the tip with any tool, beside that tool's cursor.
|
||||
#[test]
|
||||
fn a_signature_badge_says_its_verdict_with_any_tool() {
|
||||
let dir = dir().join("badge");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut h = harness(dir.clone());
|
||||
sign_the_empty_field(&mut h);
|
||||
let verdict = {
|
||||
let s = h.state();
|
||||
let sig = s.session.get(s.views[1].id).unwrap().signatures.iter().find(|x| x.field == "Approver").cloned().unwrap();
|
||||
sig.summary()
|
||||
};
|
||||
let p = field_centre(&h);
|
||||
let hover = |h: &mut Harness<'static, PdfCraftApp>, tool: QuickTool, at: Pos2| {
|
||||
h.state_mut().quick_tool = tool;
|
||||
h.run_steps(4);
|
||||
h.hover_at(at);
|
||||
h.run_steps(4);
|
||||
(h.output().platform_output.cursor_icon, tip_says(h, verdict))
|
||||
};
|
||||
assert!(hover(&mut h, QuickTool::Select, p).1, "the Select tool shows the verdict");
|
||||
let stamp = QuickTool::Stamp(pdfcraft_engine::StampKind::DynApproved);
|
||||
assert_eq!(hover(&mut h, stamp, p + egui::vec2(1.0, 0.0)), (egui::CursorIcon::Crosshair, true), "so does a stamp, beside its crosshair");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -19,6 +19,20 @@ fn preference(h: &Harness<'static, PdfCraftApp>) -> String {
|
||||
format!("{:?}", h.state().theme_preference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_gear_is_right_of_theme_and_opens_preferences() {
|
||||
let mut h = harness();
|
||||
let theme = h.get_by_label("Display theme: Use system setting").rect();
|
||||
let gear = h.get_by_label("Preferences").rect();
|
||||
assert!(gear.left() >= theme.right());
|
||||
h.get_by_label("Preferences").click();
|
||||
h.run_steps(6);
|
||||
assert!(h.query_by_label("Appearance mode").is_some());
|
||||
assert!(h.query_by_label("Light theme").is_some());
|
||||
assert!(h.query_by_label("Dark theme").is_some());
|
||||
assert_eq!(preference(&h), "System");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_cycles_the_theme_and_a_right_click_opens_the_menu() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -706,11 +706,11 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let last = h.get_by_label("narrow-11.pdf").rect();
|
||||
let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect();
|
||||
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "active tab: {last:?}");
|
||||
h.state_mut().active = Some(0);
|
||||
h.run_steps(4);
|
||||
let first = h.get_by_label("narrow-00.pdf").rect();
|
||||
let first = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-00.pdf")).rect();
|
||||
assert!(first.center().x > 28.0 && first.right() < width - 100.0);
|
||||
h.event(egui::Event::PointerMoved(first.center()));
|
||||
for _ in 0..8 {
|
||||
@@ -723,9 +723,9 @@ fn narrow_tab_strips_accept_vertical_wheel_scrolling() {
|
||||
});
|
||||
h.run_steps(8);
|
||||
}
|
||||
let last = h.get_by_label("narrow-11.pdf").rect();
|
||||
let last = h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).rect();
|
||||
assert!(last.center().x > 28.0 && last.right() < width - 100.0, "scrolled tab: {last:?}");
|
||||
h.get_by_label("narrow-11.pdf").click();
|
||||
h.get_by(|n| n.role() == egui::accesskit::Role::Tab && n.label().as_deref() == Some("narrow-11.pdf")).click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(h.state().active, Some(11));
|
||||
assert!(h.get_by_label_contains("Display theme:").rect().right() <= width);
|
||||
|
||||
@@ -40,7 +40,9 @@ including CDATA and character references. Nested-element and XFA interpretation
|
||||
|
||||
CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV
|
||||
delimiters without changing field identities. Automatic detection of externally authored, ambiguous
|
||||
tables remains heuristic.
|
||||
tables remains heuristic. Both write a cell that a spreadsheet would run as a formula (`=` or `@`,
|
||||
or `+` or `-` when it isn't a plain number) with a leading `'`; CSV import takes off the `'` that
|
||||
export added, so exported data imports back unchanged.
|
||||
|
||||
Import replaces comments with the same `/NM` (and drops the replaced comment's pop-up), draws
|
||||
appearances PdfCraft can draw, and sets values through the form's own checks (formats,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user