mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-31 15:49:36 +00:00
Merge origin/main into fix/delete-single-reply
No conflicts. main's changes to the files this branch touches (the page cursor router in canvas.rs, hover tips in tools/comments, new tools and document methods) don't meet this branch's hunks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
192 files changed
+21212
-908
No files matched your search
@@ -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
+37
-38
@@ -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",
|
||||
@@ -4338,6 +4338,7 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-xfa",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"tiff",
|
||||
@@ -4346,7 +4347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4358,7 +4359,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4373,7 +4374,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4414,14 +4415,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 +4432,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4441,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4454,11 +4455,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 +4467,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4479,14 +4480,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 +4498,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4512,11 +4513,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 +4528,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4537,7 +4538,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4548,7 +4549,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4560,7 +4561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4576,7 +4577,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4595,7 +4596,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4631,11 +4632,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 +4688,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4699,7 +4700,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4713,7 +4714,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.14.0"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
@@ -7350,8 +7351,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "vello_cpu"
|
||||
version = "0.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"glifo 0.1.1",
|
||||
|
||||
+35
-34
@@ -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"
|
||||
@@ -25,39 +25,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
|
||||
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"
|
||||
@@ -128,6 +128,7 @@ hayro-postscript = { path = "vendor/hayro-postscript" }
|
||||
hayro-jbig2 = { path = "vendor/hayro-jbig2" }
|
||||
lopdf = { path = "vendor/lopdf" }
|
||||
vello_common = { path = "vendor/vello_common" }
|
||||
vello_cpu = { path = "vendor/vello_cpu" }
|
||||
# The egui and winit crates below are pinned to immutable storytold git revisions (egui
|
||||
# 6d518fc799681571963842f95b55a6b68431b15a, winit a517b3846ee8457d84ee4975110bfda6797c44dc).
|
||||
# Only the retained `vendor/winit` and `vendor/egui-wgpu` copies are provenance for the app's
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,10 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
// Where each id's last glyph ended, in user space.
|
||||
let mut ends: HashMap<i64, (f64, f64)> = HashMap::new();
|
||||
let mut gs = TextState::default();
|
||||
let mut saved: Vec<TextState> = Vec::new();
|
||||
// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
// errata, issue 368), as the renderer does.
|
||||
let mut saved: Vec<(TextState, Matrix, Matrix)> = Vec::new();
|
||||
let mut unsaved = 0usize;
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
|
||||
@@ -209,12 +212,12 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
|
||||
b"EMC" => {
|
||||
stack.pop();
|
||||
}
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push(gs.clone()),
|
||||
b"q" if saved.len() < MAX_SAVED => saved.push((gs.clone(), tm, tlm)),
|
||||
b"q" => unsaved += 1,
|
||||
b"Q" if unsaved > 0 => unsaved -= 1,
|
||||
b"Q" => {
|
||||
if let Some(saved) = saved.pop() {
|
||||
gs = saved;
|
||||
(gs, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -1235,6 +1235,19 @@ fn headings_drawn_glyph_by_glyph_keep_their_words() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text matrices (ISO 32000-2 §9.4.1, as
|
||||
/// corrected by the PDF Association's errata, issue 368), as the renderer does: a word whose
|
||||
/// glyphs continue after a `Q` stays one word.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
let doc = heading_page(&[
|
||||
"BT /F1 20 Tf 72 700 Td (Chap) Tj q 0 -100 Td Q (ter One) Tj ET".into(),
|
||||
"BT /F1 20 Tf 72 650 Td (Chap) Tj q 1 0 0 1 300 300 Tm Q (ter Two) Tj ET".into(),
|
||||
]);
|
||||
let titles: Vec<String> = headings(&doc).into_iter().map(|h| h.title).collect();
|
||||
assert_eq!(titles, ["Chapter One", "Chapter Two"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untagged_document_has_no_headings() {
|
||||
let mut objs = headings_doc();
|
||||
|
||||
@@ -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![
|
||||
@@ -2597,6 +2669,29 @@ fn orphan_form_fields_through_tools() {
|
||||
assert_ne!(v, "Filled by an agent");
|
||||
}
|
||||
|
||||
/// An auto-sized (`0 Tf`) single-line field fills to the size its height gives, as Acrobat
|
||||
/// draws it, not a 12 pt cap: 30 pt tall, less border and padding, is about 21 pt text.
|
||||
#[test]
|
||||
fn auto_sized_text_follows_a_tall_field() {
|
||||
let objs: Vec<&str> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>", // 1
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>", // 2
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [5 0 R] >>", // 3
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 6 0 R >> >> >>", // 4
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (tall) /Rect [10 200 290 230] /P 3 0 R >>", // 5
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>", // 6
|
||||
];
|
||||
let dir = workdir("auto-size-tall");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "tall": "Hi" } }));
|
||||
let found = ok(&mut a, "text_find", json!({ "doc": doc, "query": "Hi" }));
|
||||
let r = &found["matches"][0]["rects"][0];
|
||||
let height = r[3].as_f64().unwrap() - r[1].as_f64().unwrap();
|
||||
assert!(height > 16.0, "12 pt text would be about 12 tall, ~21 pt about 21: {height} ({found})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversing_pages_through_tools() {
|
||||
let dir = workdir("reverse");
|
||||
@@ -4059,6 +4154,29 @@ fn preparing_a_form_through_tools() {
|
||||
assert!(matches!(a.call("form_set_image", &json!({ "doc": doc, "field": "size", "path": "pics/a_page_1.png" })), Err(ToolError::Failed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_radio_button_to_a_groups_name_joins_the_group_through_tools() {
|
||||
// #4949: two buttons placed separately are two groups; naming one after the other makes
|
||||
// them one group, and a clashing export value is renamed instead of shared.
|
||||
let dir = workdir("radio-join");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [20, 100, 34, 114], "group": "size", "export": "S" }));
|
||||
ok(&mut a, "form_add_field", json!({ "doc": doc, "page": 1, "type": "radio", "rect": [40, 100, 54, 114], "group": "other", "export": "S" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "S" } }));
|
||||
let r = ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "other", "name": "size" }));
|
||||
assert_eq!(r["field"], "size");
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "joined.pdf" }));
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "joined.pdf" }))["doc"].as_u64().unwrap();
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let f = fields["fields"].as_array().unwrap();
|
||||
assert_eq!(f.iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["size"], "one group");
|
||||
assert_eq!(f[0]["options"], json!(["S", "Choice1"]), "the joining button takes a free export value");
|
||||
assert_eq!(f[0]["value"], "S", "the group keeps its selection");
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "size": "Choice1" } }));
|
||||
assert_eq!(ok(&mut a, "form_fields", json!({ "doc": doc }))["fields"][0]["value"], "Choice1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotating_a_field_through_tools() {
|
||||
let dir = workdir("rotate-field");
|
||||
@@ -4969,6 +5087,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");
|
||||
@@ -6542,6 +6677,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");
|
||||
@@ -6575,10 +6712,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();
|
||||
@@ -7499,6 +7643,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");
|
||||
@@ -7527,7 +7673,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]
|
||||
@@ -7540,7 +7686,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();
|
||||
@@ -7550,7 +7696,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);
|
||||
@@ -7974,7 +8120,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);
|
||||
@@ -9268,3 +9414,75 @@ 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");
|
||||
}
|
||||
/// `/I` of the top-level field `name` in the saved file `path`.
|
||||
fn saved_selection_indices(path: &Path, name: &str) -> Option<Vec<i64>> {
|
||||
let d = pdfcraft_cos::Document::open(std::sync::Arc::new(std::fs::read(path).unwrap())).unwrap();
|
||||
let root = d.get(d.root().unwrap()).as_dict().cloned().unwrap();
|
||||
let form = d.resolve(root.get(b"AcroForm").unwrap()).as_dict().cloned().unwrap();
|
||||
let all = d.resolve(form.get(b"Fields").unwrap()).as_array().cloned().unwrap();
|
||||
let f = all
|
||||
.iter()
|
||||
.filter_map(|o| d.resolve(o).as_dict().cloned())
|
||||
.find(|f| f.get(b"T").and_then(|t| d.resolve(t).as_string().map(|s| s.to_text())).as_deref() == Some(name))
|
||||
.unwrap();
|
||||
f.get(b"I").map(|i| d.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_through_tools_keeps_its_saved_selection_on_the_same_items() {
|
||||
let dir = workdir("choice-index");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let args = json!({ "doc": doc, "page": 1, "type": "list", "rect": [20, 60, 180, 140], "options": ["A", "B", "C"], "multi_select": true, "name": "letters" });
|
||||
ok(&mut a, "form_add_field", args);
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "letters": ["B", "C"] } }));
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "letters", "options": ["B", "C", "D"] }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "letters.pdf" }));
|
||||
assert_eq!(saved_selection_indices(&dir.join("letters.pdf"), "letters"), Some(vec![0, 1]), "B and C are now items 0 and 1");
|
||||
}
|
||||
|
||||
/// Clearing a field's default through `form_set_props` (`"default": ""`) leaves it with none,
|
||||
/// even when its parent field has one it would otherwise inherit.
|
||||
#[test]
|
||||
fn clearing_a_kids_default_through_tools_does_not_bring_back_its_parents() {
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 400 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>",
|
||||
"<< /Fields [5 0 R] /DA (/Helv 12 Tf 0 g) /DR << /Font << /Helv 7 0 R >> >> >>",
|
||||
"<< /T (address) /FT /Tx /DV (Main St) /Kids [6 0 R] >>",
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /DV (Elm) /V (Elm) /Rect [10 250 150 268] /P 3 0 R >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
|
||||
];
|
||||
let dir = workdir("kid-default");
|
||||
std::fs::write(dir.join("form.pdf"), pdf_of(&objs)).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "form.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "form_set_props", json!({ "doc": doc, "field": "address.city", "default": "" }));
|
||||
ok(&mut a, "form_fill", json!({ "doc": doc, "values": { "address.city": "Oak" } }));
|
||||
ok(&mut a, "form_reset", json!({ "doc": doc }));
|
||||
let fields = ok(&mut a, "form_fields", json!({ "doc": doc }));
|
||||
let city = fields["fields"].as_array().unwrap().iter().find(|f| f["name"] == "address.city").unwrap().clone();
|
||||
assert!(city["value"].is_null() || city["value"] == "", "reset clears it rather than restoring the parent's Main St: {}", city["value"]);
|
||||
}
|
||||
@@ -8,3 +8,5 @@ mod button_caption;
|
||||
mod form_bulk;
|
||||
mod hostile_args;
|
||||
mod optimize_content_boundaries;
|
||||
mod redact_after_stray_q_form;
|
||||
mod redact_text_after_q;
|
||||
@@ -0,0 +1,67 @@
|
||||
//! Search-and-redact of page text drawn after a form XObject with more `Q` than `q`. Find's boxes
|
||||
//! come from the renderer's text and redaction measures the content itself, so the two must agree
|
||||
//! that the form's stray `Q`s do not undo the page's `cm` (vendored hayro-interpret patch 68).
|
||||
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const CONTENT: &str = "q 1 0 0 1 20 -100 cm BT /F1 12 Tf 72 700 Td (AAAA) Tj ET /X Do \
|
||||
BT /F1 12 Tf 72 680 Td (SECRET) Tj ET Q";
|
||||
const FORM: &str = "BT /F1 12 Tf 200 0 Td ET Q Q Q";
|
||||
|
||||
fn pdf() -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << /X 5 0 R >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{CONTENT}\nendstream", CONTENT.len()),
|
||||
format!("<< /Type /XObject /Subtype /Form /BBox [0 0 612 792] /Length {} >>\nstream\n{FORM}\nendstream", FORM.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_drawn_after_a_form_with_stray_q() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-stray-q-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf()).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1);
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET"), "{after:?}");
|
||||
assert!(after.contains("AAAA"), "the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Search-and-redact of text that follows a `q`/`Q` inside a text object. `Q` restores the text
|
||||
//! matrices there (ISO 32000-2 §9.4.1 as corrected by the PDF Association's errata, issue 368), so
|
||||
//! "SECRET" is drawn from the pen position before `q`, over the "B". Find's boxes come from the
|
||||
//! renderer's text, which places it there; redaction measures the content itself, and while it
|
||||
//! carried the pen across `Q` the two disagreed and `redact_apply` refused to change anything.
|
||||
use pdfcraft_automation::{Automation, Content};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The content, the text that stays after SECRET is redacted, and the text that goes with it.
|
||||
const CASES: [(&str, &str, &str); 3] = [
|
||||
// A larger font inside `q`/`Q`: the font and the pen are both restored, so SECRET lies over
|
||||
// the B and the B goes with it.
|
||||
("BT /F1 12 Tf 72 700 Td (AAAA) Tj q /F2 24 Tf (B) Tj Q (SECRET) Tj ET", "AAAA", "B"),
|
||||
// A move inside `q`/`Q` is undone.
|
||||
("BT /F1 12 Tf 72 700 Td (AA) Tj q 100 0 Td Q (SECRET) Tj ET", "AA", "SECRET"),
|
||||
// The text line matrix is restored too: `T*` starts the next line from the line before `q`.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td (AA) Tj q 100 0 Td Q T* (SECRET) Tj ET", "AA", "SECRET"),
|
||||
];
|
||||
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_owned(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_owned(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << \
|
||||
/F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> \
|
||||
/F2 << /Type /Font /Subtype /Type1 /BaseFont /Courier >> >> >> >>"
|
||||
.to_owned(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut out = b"%PDF-2.0\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn ok(a: &mut Automation, tool: &str, args: Value) -> Value {
|
||||
match a.call(tool, &args) {
|
||||
Ok(mut c) => match c.remove(0) {
|
||||
Content::Json(v) => v,
|
||||
other => panic!("{tool}: expected JSON, got {other:?}"),
|
||||
},
|
||||
Err(e) => panic!("{tool} {args}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text(a: &mut Automation, doc: &Value) -> String {
|
||||
ok(a, "text_extract", json!({ "doc": doc }))["pages"][0]["text"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_and_redact_removes_text_after_a_q_q_inside_its_text_object() {
|
||||
for (case, (content, kept, gone)) in CASES.iter().enumerate() {
|
||||
let dir = std::env::temp_dir().join(format!("pdfcraft-redact-after-q-{}-{case}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("in.pdf"), pdf(content)).unwrap();
|
||||
let mut a = Automation::new().with_root(&dir).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "in.pdf" }))["doc"].clone();
|
||||
assert_eq!(ok(&mut a, "redact_mark", json!({ "doc": doc, "find": "SECRET" }))["marked"], 1, "{content}");
|
||||
ok(&mut a, "redact_apply", json!({ "doc": doc }));
|
||||
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "out.pdf", "full": true }));
|
||||
let saved = ok(&mut a, "doc_open", json!({ "path": "out.pdf" }))["doc"].clone();
|
||||
let after = text(&mut a, &saved);
|
||||
assert!(!after.contains("SECRET") && !after.contains(gone), "{content}: {after:?}");
|
||||
assert!(after.contains(kept), "{content}: the text before the mark stays: {after:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ workspace = true
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
|
||||
@@ -562,7 +562,7 @@ fn embed(name: &str, bytes: &[u8], wanted: usize) -> Result<Vec<Embedded>, Creat
|
||||
} else if is_tiff(bytes) {
|
||||
tiff_pages(name, bytes, wanted)
|
||||
} else if bytes.starts_with(b"BM") {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Bmp)?])
|
||||
Ok(vec![decoded(name, &pdfcraft_xfa::image::bmp_with_pixel_offset(bytes), image::ImageFormat::Bmp)?])
|
||||
} else if is_gif(bytes) {
|
||||
Ok(vec![decoded(name, bytes, image::ImageFormat::Gif)?])
|
||||
} else if bytes.starts_with(JP2_SIGNATURE) || bytes.starts_with(&[0xFF, 0x4F, 0xFF, 0x51]) {
|
||||
|
||||
@@ -206,6 +206,26 @@ fn bmp_gif_and_multi_page_tiff_images() {
|
||||
assert!(matches!(from_images(&[("x.webp".into(), b"RIFF0000WEBP".to_vec())]), Err(CreateError::Image(..))));
|
||||
}
|
||||
|
||||
/// #4937: a BMP whose header gives the pixel data's offset as 0 is placed as the same image as
|
||||
/// the intact file, not shifted by the header bytes.
|
||||
#[test]
|
||||
fn a_bmp_with_a_zero_pixel_offset_is_placed_like_the_intact_file() {
|
||||
use image::ImageEncoder;
|
||||
let mut bmp = Vec::new();
|
||||
let pixels: Vec<u8> = (0..18).map(|i| i * 13).collect();
|
||||
image::codecs::bmp::BmpEncoder::new(&mut bmp).write_image(&pixels, 3, 2, image::ExtendedColorType::Rgb8).unwrap();
|
||||
let mut zero = bmp.clone();
|
||||
zero[10..14].copy_from_slice(&0u32.to_le_bytes());
|
||||
let samples = |data: Vec<u8>| {
|
||||
let doc = reopen(&from_images(&[("a.bmp".into(), data)]).unwrap());
|
||||
let res = doc.resolve(pages(&doc)[0].get(b"Resources").unwrap()).as_dict().cloned().unwrap();
|
||||
let xo = doc.resolve(res.get(b"XObject").unwrap()).as_dict().cloned().unwrap();
|
||||
let Object::Stream(s) = &*doc.resolve(xo.get(b"Im0").unwrap()) else { panic!("not an image") };
|
||||
s.decoded().unwrap()
|
||||
};
|
||||
assert_eq!(samples(zero), samples(bmp));
|
||||
}
|
||||
|
||||
/// #665 lifted the tiff crate's 256 MiB decode limit; a few bytes claiming a 60000x60000 page
|
||||
/// (3.6 GB of gray pixels) must still be refused, not allocated.
|
||||
#[test]
|
||||
|
||||
@@ -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]
|
||||
@@ -7500,3 +7536,24 @@ fn a_run_of_spaces_in_a_subset_without_a_space_glyph_keeps_its_width() {
|
||||
let content = String::from_utf8_lossy(&page_content_bytes(&reopen(&doc), 0)).into_owned();
|
||||
assert_eq!(content.matches("[-330] TJ").count(), 5, "{content}");
|
||||
}
|
||||
|
||||
/// Inside a text object `q`/`Q` save and restore the text and text line matrices (ISO 32000-2
|
||||
/// §9.4.1, as corrected by the PDF Association's errata, issue 368), as the renderer does: the
|
||||
/// text after `Q` is found, and stays, where it is drawn.
|
||||
#[test]
|
||||
fn q_inside_a_text_object_restores_the_text_matrices() {
|
||||
for (content, text, baseline) in [
|
||||
("BT /F1 12 Tf 72 700 Td q 0 -100 Td Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
("BT /F1 12 Tf 72 700 Td q 1 0 0 1 300 300 Tm Q (After) Tj ET", "After", [72.0, 700.0]),
|
||||
// `T*` moves from the restored line, not the one set inside q/Q.
|
||||
("BT /F1 12 Tf 14 TL 72 700 Td q 0 -100 Td Q T* (Next) Tj ET", "Next", [72.0, 686.0]),
|
||||
] {
|
||||
let mut doc = text_page(content);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), (text, baseline), "{content}");
|
||||
text::replace_line(&mut doc, 0, 0, "Edited").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let lines = text::text_lines(&doc, 0).unwrap();
|
||||
assert_eq!((lines[0].text.as_str(), lines[0].baseline()), ("Edited", baseline), "{content} after editing");
|
||||
}
|
||||
}
|
||||
@@ -425,7 +425,10 @@ fn positioned_tracking(shown: &mut [Shown]) {
|
||||
/// stream), an unbalanced `q`, the font and the colour still apply in the streams after it.
|
||||
pub(super) struct Carry {
|
||||
pub(super) ts: Ts,
|
||||
pub(super) stack: Vec<Ts>,
|
||||
/// Each saved state with the text and text line matrices: inside a text object `q`/`Q`
|
||||
/// save and restore them too (ISO 32000-2 §9.4.1, as corrected by the PDF Association's
|
||||
/// errata, issue 368), as the renderer does.
|
||||
pub(super) stack: Vec<(Ts, Matrix, Matrix)>,
|
||||
pub(super) marked: Vec<Option<usize>>,
|
||||
/// For each open marked-content sequence, its optional content group when it is `/OC`.
|
||||
pub(super) layers: Vec<Option<Vec<u8>>>,
|
||||
@@ -761,7 +764,7 @@ pub(super) fn interpret(
|
||||
let mut out: Vec<Shown> = Vec::new();
|
||||
let mut ts = carry.ts.clone();
|
||||
let mut at_bt = (0usize, ts.clone());
|
||||
let mut stack: Vec<Ts> = std::mem::take(&mut carry.stack);
|
||||
let mut stack: Vec<(Ts, Matrix, Matrix)> = std::mem::take(&mut carry.stack);
|
||||
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
|
||||
let mut bt = 0usize;
|
||||
let mut marked =
|
||||
@@ -776,10 +779,10 @@ pub(super) fn interpret(
|
||||
b"EMC" => {
|
||||
marked.end(i, &mut out);
|
||||
}
|
||||
b"q" => stack.push(ts.clone()),
|
||||
b"q" => stack.push((ts.clone(), tm, tlm)),
|
||||
b"Q" => {
|
||||
if let Some(s) = stack.pop() {
|
||||
ts = s;
|
||||
if let Some(saved) = stack.pop() {
|
||||
(ts, tm, tlm) = saved;
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -88,7 +89,7 @@ pub use pdfcraft_forms::{
|
||||
BorderStyle, CalculatedField, CalculationIssue, CalculationOrder, CheckStyle, Field as FormField, FieldAction, FieldChange, FieldFont,
|
||||
FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, LookPatch as FieldLookPatch, NewField, TabOrder, Trigger as FieldTrigger,
|
||||
Widget as FormWidget, af as form_scripts, appearance as form_appearance, appearance::auto_text_size_by as field_auto_text_size_by,
|
||||
calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
appearance::comb_auto_text_size as field_comb_auto_text_size, calculations_changed as form_calculations_changed, flags as field_flags,
|
||||
};
|
||||
|
||||
pub use pdfcraft_a11y as a11y;
|
||||
|
||||
@@ -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);
|
||||
@@ -4826,6 +5012,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);
|
||||
@@ -4844,7 +5032,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());
|
||||
@@ -4908,17 +5096,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();
|
||||
@@ -4943,7 +5131,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]
|
||||
@@ -4960,7 +5148,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")]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6268,6 +6456,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()));
|
||||
@@ -6292,7 +6482,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();
|
||||
@@ -6307,7 +6497,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));
|
||||
@@ -6322,7 +6512,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();
|
||||
@@ -6543,6 +6733,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()));
|
||||
@@ -6561,7 +6752,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();
|
||||
@@ -6572,7 +6763,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();
|
||||
@@ -6707,6 +6898,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
|
||||
@@ -6738,6 +6931,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
|
||||
@@ -8731,6 +8954,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();
|
||||
@@ -8755,69 +9042,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"))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ pub fn fmt(v: f64) -> String {
|
||||
}
|
||||
|
||||
/// The font size an auto-sized (`0 Tf`) text field uses for `text` in a `width` × `height` box
|
||||
/// (inside the padding), measured by `width_of(text, size)`: at most 12 pt and at least 4 pt.
|
||||
/// Single-line text shrinks until it fits the width; multiline text takes the largest size whose
|
||||
/// wrapped lines fit the height. The page appearance and the on-screen editor share this.
|
||||
/// (inside the padding), measured by `width_of(text, size)`, never under 4 pt. Single-line text
|
||||
/// takes the size the height gives, as Acrobat Reader draws it however tall the box (the size is
|
||||
/// implementation-defined, ISO 32000-2 §12.7.4.3), and shrinks until it fits the width; multiline
|
||||
/// text takes the largest size up to 12 pt whose wrapped lines fit the height. The page
|
||||
/// appearance and the on-screen editor share this.
|
||||
pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, width_of: impl Fn(&str, f64) -> f64) -> f64 {
|
||||
let wrap_width = if width.is_finite() { width.max(1.0) } else { 1.0 };
|
||||
auto_text_size_by(
|
||||
@@ -54,6 +56,17 @@ pub fn auto_text_size(text: &str, width: f64, height: f64, multiline: bool, widt
|
||||
)
|
||||
}
|
||||
|
||||
/// The automatic font size of a comb field (`Field::is_comb`) whose cells are `height` tall inside
|
||||
/// the padding: 4 to 12 pt, as only the height bounds a one-character cell. The page appearance
|
||||
/// and the on-screen editor share this.
|
||||
pub fn comb_auto_text_size(height: f64) -> f64 {
|
||||
if height.is_finite() { (height / 1.15).clamp(4.0, 12.0) } else { 4.0 }
|
||||
}
|
||||
|
||||
/// The tallest box single-line auto-size follows, as a sanity bound: the largest page side
|
||||
/// ISO 32000-1 Annex C recommends.
|
||||
pub(crate) const MAX_AUTO_HEIGHT: f64 = 14_400.0;
|
||||
|
||||
/// [`auto_text_size`] with the text measured by the caller: `line_width(size)` is its width on
|
||||
/// one line, `wrapped_height(size)` its height wrapped at `width`. The screen measures in its
|
||||
/// own font, whose widths and line breaks differ from the page's.
|
||||
@@ -67,7 +80,7 @@ pub fn auto_text_size_by(width: f64, height: f64, multiline: bool, line_width: i
|
||||
}
|
||||
return size;
|
||||
}
|
||||
let size = (height / 1.15).clamp(4.0, 12.0);
|
||||
let size = (height.min(MAX_AUTO_HEIGHT) / 1.15).max(4.0);
|
||||
let tw = line_width(size);
|
||||
if tw.is_finite() && tw > width { (size * width / tw).max(4.0) } else { size }
|
||||
}
|
||||
@@ -109,7 +122,7 @@ pub fn text_box(doc: &Document, f: &Field, w: &Widget) -> TextBox {
|
||||
size: (da.size > 0.0 && da.size.is_finite()).then_some(da.size),
|
||||
multiline,
|
||||
password: f.has(flags::PASSWORD),
|
||||
comb: f.has(flags::COMB) && !multiline && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0),
|
||||
comb: f.is_comb(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,7 +826,7 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let comb = f.has(flags::COMB) && !f.has(flags::MULTILINE) && !f.has(flags::PASSWORD) && f.max_len.is_some_and(|m| m > 0);
|
||||
let comb = f.is_comb();
|
||||
if f.kind == FieldKind::Text && f.has(flags::MULTILINE) {
|
||||
if size == 0.0 {
|
||||
size = auto_text_size(&text, inner_w, height - 2.0 * pad, true, width_of);
|
||||
@@ -840,9 +853,8 @@ fn field_appearance_fit(doc: &Document, f: &Field, w: &Widget, values: &[String]
|
||||
}
|
||||
} else {
|
||||
if size == 0.0 {
|
||||
// Comb cells hold one character each, so only the height limits them.
|
||||
size = if comb {
|
||||
((height - 2.0 * pad) / 1.15).clamp(4.0, 12.0)
|
||||
comb_auto_text_size(height - 2.0 * pad)
|
||||
} else {
|
||||
auto_text_size(&text, inner_w, height - 2.0 * pad, false, width_of)
|
||||
};
|
||||
|
||||
@@ -11,12 +11,15 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
|
||||
use crate::{Field, FieldKind, FormError, Widget, appearance, fields, flags, page_refs};
|
||||
|
||||
mod edit;
|
||||
mod join;
|
||||
mod lifecycle;
|
||||
mod look;
|
||||
mod tree;
|
||||
|
||||
pub(crate) use tree::ancestor_sets;
|
||||
|
||||
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};
|
||||
|
||||
+143
-22
@@ -1,5 +1,6 @@
|
||||
use super::join::{check_join, join_radio_group};
|
||||
use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets};
|
||||
use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::tree::{ancestor_sets, attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited};
|
||||
use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid};
|
||||
|
||||
/// Swap width and height, keeping the center. `None` when that is not a usable widget.
|
||||
@@ -14,6 +15,68 @@ fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> {
|
||||
(out.iter().all(|v| v.is_finite()) && out[2] - out[0] >= MIN_FIELD_SIDE && out[3] - out[1] >= MIN_FIELD_SIDE).then_some(out)
|
||||
}
|
||||
|
||||
/// Re-point a choice field's `/I`, positions in `/Opt`, at the new list `exports` after `/Opt` was
|
||||
/// replaced (ISO 32000-2 §12.7.5.4): the positions of the selected `values` still offered. `kept`
|
||||
/// are the old `/I` positions carried into the new list, which tell apart options sharing an
|
||||
/// export value; a selected value none of them covers takes its first position, by `set_value`'s
|
||||
/// rule. Returns them.
|
||||
fn repoint_selection(d: &mut Dict, values: &[String], exports: &[String], kept: &[usize]) -> Vec<usize> {
|
||||
let mut indices: Vec<usize> = kept.iter().copied().filter(|&i| exports.get(i).is_some_and(|e| values.contains(e))).collect();
|
||||
for v in values {
|
||||
if !indices.iter().any(|&i| exports.get(i) == Some(v)) {
|
||||
indices.extend(exports.iter().position(|e| e == v));
|
||||
}
|
||||
}
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
if indices.is_empty() {
|
||||
d.remove(b"I");
|
||||
} else {
|
||||
d.set(b"I".to_vec(), Object::Array(indices.iter().map(|&i| Object::Int(i64::try_from(i).unwrap_or(i64::MAX))).collect()));
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
/// The positions in `/Opt` that a choice field's `/I` holds, those below `count`.
|
||||
fn selected_indices(doc: &Document, field: ObjRef, count: usize) -> Vec<usize> {
|
||||
let Some(i) = doc.get(field).as_dict().and_then(|d| d.get(b"I").cloned()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let positions = doc.resolve(&i);
|
||||
positions.as_array().map(|a| a.iter().filter_map(|o| usize::try_from(o.as_int()?).ok()).filter(|&n| n < count).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The items (export value, display text) "Sort items" orders: the new `props.options`, or else
|
||||
/// the field's own, whose selection, `/I` included, moves with them.
|
||||
fn items_to_sort(doc: &Document, f: &Field, props: &FieldProps) -> (Vec<(String, String)>, Vec<usize>) {
|
||||
match &props.options {
|
||||
Some(o) => (o.iter().map(|x| (x.clone(), x.clone())).collect(), Vec::new()),
|
||||
None => (f.options.clone(), selected_indices(doc, f.obj, f.options.len())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sort a choice field's items (export value, display text) by display text, ignoring case, and
|
||||
/// write them as `/Opt`; export values stay with their items. Returns the export values in their
|
||||
/// new order and the `old` positions carried through the sort.
|
||||
fn write_sorted_opt(d: &mut Dict, items: &[(String, String)], old: &[usize]) -> (Vec<String>, Vec<usize>) {
|
||||
let mut order: Vec<usize> = (0..items.len()).collect();
|
||||
order.sort_by_key(|&i| items.get(i).map(|(_, display)| display.to_lowercase()));
|
||||
let sorted: Vec<&(String, String)> = order.iter().filter_map(|&i| items.get(i)).collect();
|
||||
let opt = sorted
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let carried = old.iter().filter_map(|o| order.iter().position(|i| i == o)).collect();
|
||||
(sorted.into_iter().map(|(e, _)| e.clone()).collect(), carried)
|
||||
}
|
||||
|
||||
/// Change a field's properties (General and Options tabs) and redraw it.
|
||||
pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
if props.font_size.is_some_and(|size| !size.is_finite() || size < 0.0) {
|
||||
@@ -70,7 +133,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if let Some(acts) = &props.actions {
|
||||
crate::set_field_actions(doc, name, acts)?;
|
||||
}
|
||||
let new_name = rename_field(doc, &all, &f, name, props)?;
|
||||
// A radio button renamed to another radio group's name joins that group (#4949). The other
|
||||
// properties are written first, to this field, and its buttons then move.
|
||||
let (new_name, join) = rename_field(doc, &all, &f, name, props)?;
|
||||
// The new default appearance when the size or the look changes.
|
||||
let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size);
|
||||
let new_da = match &props.look {
|
||||
@@ -121,6 +186,9 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
if props.check_style.is_some() && !matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) {
|
||||
return invalid("only check boxes and radio buttons have a check style");
|
||||
}
|
||||
let mut list_picked = None;
|
||||
let (sort_items, old_selection) = items_to_sort(doc, &f, props);
|
||||
let parent_default = ancestor_sets(doc, f.obj, b"DV");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Ff".to_vec(), Object::Int(ff as i64));
|
||||
if let Some(t) = &props.tooltip {
|
||||
@@ -138,27 +206,23 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
}
|
||||
}
|
||||
}
|
||||
// The export values in `/Opt` order once it is rewritten, for re-pointing `/I`.
|
||||
let mut new_exports: Option<Vec<String>> = None;
|
||||
let mut kept = Vec::new();
|
||||
if let Some(opts) = &props.options {
|
||||
d.set(b"Opt".to_vec(), Object::Array(opts.iter().map(|o| Object::String(PdfString::text(o))).collect()));
|
||||
new_exports = Some(opts.clone());
|
||||
}
|
||||
// "Sort items": the list itself is kept in order (export values stay with their items).
|
||||
if ff & flags::SORT != 0 && matches!(f.kind, FieldKind::Combo | FieldKind::List) {
|
||||
let mut items: Vec<(String, String)> = match &props.options {
|
||||
Some(o) => o.iter().map(|x| (x.clone(), x.clone())).collect(),
|
||||
None => f.options.clone(),
|
||||
};
|
||||
items.sort_by_key(|(_, display)| display.to_lowercase());
|
||||
let opt = items
|
||||
.iter()
|
||||
.map(|(e, disp)| {
|
||||
if e == disp {
|
||||
Object::String(PdfString::text(disp))
|
||||
} else {
|
||||
Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(disp))])
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
d.set(b"Opt".to_vec(), Object::Array(opt));
|
||||
let (exports, carried) = write_sorted_opt(d, &sort_items, &old_selection);
|
||||
new_exports = Some(exports);
|
||||
kept = carried;
|
||||
}
|
||||
if let Some(exports) = &new_exports
|
||||
&& matches!(f.kind, FieldKind::Combo | FieldKind::List)
|
||||
{
|
||||
list_picked = Some((repoint_selection(d, &f.value, exports, &kept), exports.len()));
|
||||
}
|
||||
if let Some(q) = props.quadding {
|
||||
d.set(b"Q".to_vec(), Object::Int(q));
|
||||
@@ -167,6 +231,12 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
// Buttons name their default state; text and choices hold a string.
|
||||
Some(Some(v)) if matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => d.set(b"DV".to_vec(), Object::Name(crate::name_bytes(v))),
|
||||
Some(Some(v)) => d.set(b"DV".to_vec(), PdfString::text(v)),
|
||||
// `/DV` is inheritable: with an ancestor's in place, removing the field's own would
|
||||
// bring that one back, so the field keeps an empty default of its own instead.
|
||||
Some(None) if parent_default && matches!(f.kind, FieldKind::CheckBox | FieldKind::Radio) => {
|
||||
d.set(b"DV".to_vec(), Object::Name(b"Off".to_vec()))
|
||||
}
|
||||
Some(None) if parent_default => d.set(b"DV".to_vec(), PdfString::text("")),
|
||||
Some(None) => {
|
||||
d.remove(b"DV");
|
||||
}
|
||||
@@ -246,15 +316,20 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result<S
|
||||
})?;
|
||||
}
|
||||
let only = apply_late_widget_props(doc, &f, props, &new_da, rotated_rect)?;
|
||||
redraw_widgets(doc, &new_name, only)?;
|
||||
scroll_list(doc, f.obj, list_picked)?;
|
||||
redraw_or_join(doc, &f, name, &new_name, join.as_ref(), only)?;
|
||||
if props.calculate.is_some() {
|
||||
crate::recalculate(doc)?;
|
||||
}
|
||||
Ok(new_name)
|
||||
}
|
||||
|
||||
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<String, FormError> {
|
||||
/// Apply a new name from `props`: rename the field, move it under the parents a full name
|
||||
/// names, or, for a radio button named after another radio group, pick that group to join.
|
||||
/// Returns the field's new full name and the group it joins.
|
||||
fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props: &FieldProps) -> Result<(String, Option<Field>), FormError> {
|
||||
let mut new_name = name.to_string();
|
||||
let mut join = None;
|
||||
if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) {
|
||||
// A full name under the same parents only renames the field's own part (the editor
|
||||
// shows nested fields by their full name): it isn't moved, so it keeps its place and
|
||||
@@ -263,7 +338,16 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props:
|
||||
(Some((parents, own)), Some((current, _))) if parents == current => own,
|
||||
_ => n,
|
||||
};
|
||||
if n.contains('.') {
|
||||
let full = match f.name.rsplit_once('.') {
|
||||
Some((parents, _)) if !n.contains('.') => format!("{parents}.{n}"),
|
||||
_ => n.to_string(),
|
||||
};
|
||||
let group = all.iter().find(|x| x.obj != f.obj && x.kind == FieldKind::Radio && x.name == full);
|
||||
if let Some(group) = group.filter(|_| f.kind == FieldKind::Radio) {
|
||||
check_join(doc, f, group)?;
|
||||
join = Some(group.clone());
|
||||
new_name = full;
|
||||
} else if n.contains('.') {
|
||||
// A full name: the field moves under the parent fields it names (#2696).
|
||||
let parts = name_parts(n)?;
|
||||
if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) {
|
||||
@@ -307,7 +391,7 @@ fn rename_field(doc: &mut Document, all: &[Field], f: &Field, name: &str, props:
|
||||
rename_in_calculations(doc, all, &f.name, &new_name)?;
|
||||
}
|
||||
}
|
||||
Ok(new_name)
|
||||
Ok((new_name, join))
|
||||
}
|
||||
|
||||
fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> {
|
||||
@@ -359,6 +443,43 @@ fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &F
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Redraw the edited widgets. A radio button joining another group (`join`) is redrawn under its
|
||||
/// old name `name` first; then its buttons move, and only they are redrawn as `new_name`, so the
|
||||
/// group's own buttons keep their appearances.
|
||||
fn redraw_or_join(doc: &mut Document, f: &Field, name: &str, new_name: &str, join: Option<&Field>, only: Option<usize>) -> Result<(), FormError> {
|
||||
if let Some(group) = join {
|
||||
redraw_widgets(doc, name, only)?;
|
||||
join_radio_group(doc, f, group)?;
|
||||
let moved: Vec<usize> = fields(doc)
|
||||
.into_iter()
|
||||
.find(|g| g.name == new_name)
|
||||
.map(|g| g.widgets.iter().enumerate().filter(|(_, w)| f.widgets.iter().any(|m| m.obj == w.obj)).map(|(i, _)| i).collect())
|
||||
.unwrap_or_default();
|
||||
for index in moved {
|
||||
redraw_widgets(doc, new_name, Some(index))?;
|
||||
}
|
||||
Ok(())
|
||||
} else {
|
||||
redraw_widgets(doc, new_name, only)
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box scrolls, as for a new value, so its first row is still an option and the
|
||||
/// selection is in view. Its rows are measured with the edited font size and rectangle.
|
||||
fn scroll_list(doc: &mut Document, field: ObjRef, list_picked: Option<(Vec<usize>, usize)>) -> Result<(), FormError> {
|
||||
if let Some((picked, count)) = list_picked
|
||||
&& let Some(g) = fields(doc).into_iter().find(|g| g.obj == field)
|
||||
{
|
||||
if let Some((old, new)) = crate::values::list_scroll(&g, &picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(field, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
crate::values::scroll_widgets(doc, &g, &picked, count)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_late_widget_props(
|
||||
doc: &mut Document,
|
||||
f: &Field,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Renaming a radio button to an existing radio group's name makes it a button of that group
|
||||
//! (#4949): radio buttons are one group when they are widgets of one field (ISO 32000-2
|
||||
//! §12.7.5.2.4), and a button's export value is the name of its "on" appearance state.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::lifecycle::{drop_from_calculation_order, split_merged, split_triggers};
|
||||
use super::tree::{detach, list_of};
|
||||
use super::{Dict, Document, Field, FormError, ObjRef, Object, invalid};
|
||||
|
||||
/// The keys a field that is also its own widget sheds when it becomes only a widget. Its `/AA`
|
||||
/// keeps only the widget's own triggers.
|
||||
const FIELD_ONLY_KEYS: [&[u8]; 15] = [b"T", b"FT", b"Kids", b"Ff", b"V", b"DV", b"Opt", b"TU", b"TM", b"MaxLen", b"Q", b"DS", b"RV", b"TI", b"I"];
|
||||
|
||||
/// Whether `field` holds `/Opt`, which its buttons' export values index (ISO 32000-2 §12.7.5.2).
|
||||
fn has_opt(doc: &Document, field: ObjRef) -> bool {
|
||||
doc.get(field).as_dict().is_some_and(|d| d.contains(b"Opt"))
|
||||
}
|
||||
|
||||
/// Refuse what [`join_radio_group`] can't do, before anything is written.
|
||||
pub(super) fn check_join(doc: &Document, f: &Field, target: &Field) -> Result<(), FormError> {
|
||||
for x in [f, target] {
|
||||
if has_opt(doc, x.obj) {
|
||||
return invalid(format!("{} lists its export values in /Opt, so its buttons can't move between groups", x.name));
|
||||
}
|
||||
if x.widgets.is_empty() {
|
||||
return invalid(format!("{} has no button", x.name));
|
||||
}
|
||||
}
|
||||
if target.locked() {
|
||||
return invalid(format!("{} is locked: unlock it first", target.name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Move every button of radio field `f` into radio field `target`. Each keeps its export value
|
||||
/// unless a button of `target` already has it; then it takes the first free "ChoiceN", as a
|
||||
/// button added to a group does, and buttons of `f` that shared a value still share the new one. A moved button shows selected only if it is `target`'s
|
||||
/// selected value. `f` leaves the form; its own value and field-level properties go with it,
|
||||
/// and `target`'s stay. Call [`check_join`] first.
|
||||
pub(super) fn join_radio_group(doc: &mut Document, f: &Field, target: &Field) -> Result<(), FormError> {
|
||||
let theirs: HashSet<String> = target.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
|
||||
let mut taken = theirs.clone();
|
||||
taken.extend(f.widgets.iter().filter_map(|w| w.on_state.clone()));
|
||||
// The group's value counts only when one of its buttons has it.
|
||||
let selected = target.value.first().filter(|v| theirs.contains(*v)).cloned();
|
||||
// Each clashing value of `f` gets one fresh name, shared by every button that had it. Of
|
||||
// `taken.len() + 1` names at least one is free, so the search always ends in one.
|
||||
let mut renamed: HashMap<String, String> = HashMap::new();
|
||||
for old in f.widgets.iter().filter_map(|w| w.on_state.as_deref()).filter(|s| theirs.contains(*s)) {
|
||||
if renamed.contains_key(old) {
|
||||
continue;
|
||||
}
|
||||
let Some(fresh) = (1..=taken.len().saturating_add(1)).map(|i| format!("Choice{i}")).find(|c| !taken.contains(c)) else {
|
||||
return invalid(format!("no free export value for {old}"));
|
||||
};
|
||||
taken.insert(fresh.clone());
|
||||
renamed.insert(old.to_owned(), fresh);
|
||||
}
|
||||
// A group that is its own one button gets a field with a separate widget first, so it can
|
||||
// hold more than one.
|
||||
if let [only] = target.widgets.as_slice()
|
||||
&& only.obj == target.obj
|
||||
{
|
||||
split_merged(doc, target, only)?;
|
||||
}
|
||||
detach(doc, f.obj)?;
|
||||
// `f` is no longer a field, even when it stays on as a widget.
|
||||
drop_from_calculation_order(doc, |r| r == f.obj)?;
|
||||
let mut kids = Vec::with_capacity(f.widgets.len());
|
||||
for w in &f.widgets {
|
||||
let mut d = doc.get(w.obj).as_dict().cloned().unwrap_or_default();
|
||||
if w.obj == f.obj {
|
||||
for k in FIELD_ONLY_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
|
||||
match aa.as_ref().map(split_triggers) {
|
||||
Some((Some(widget_aa), _)) => d.set(b"AA".to_vec(), Object::Dict(widget_aa)),
|
||||
Some((None, _)) => {
|
||||
d.remove(b"AA");
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let mut state = w.on_state.clone();
|
||||
if let Some(old) = w.on_state.as_deref()
|
||||
&& let Some(fresh) = renamed.get(old)
|
||||
&& rename_state(doc, &mut d, old, fresh)
|
||||
{
|
||||
state = Some(fresh.clone());
|
||||
}
|
||||
let on = state.is_some() && state == selected;
|
||||
let shown = match (&state, on) {
|
||||
(Some(s), true) => Object::Name(crate::name_bytes(s)),
|
||||
_ => Object::name("Off"),
|
||||
};
|
||||
d.set(b"AS".to_vec(), shown);
|
||||
d.set(b"Parent".to_vec(), Object::Ref(target.obj));
|
||||
doc.set(w.obj, Object::Dict(d));
|
||||
kids.push(Object::Ref(w.obj));
|
||||
}
|
||||
// `/Kids` may be an indirect array: read through it so the group's own buttons stay.
|
||||
let mut list = list_of(doc, target.obj, b"Kids");
|
||||
list.extend(kids);
|
||||
doc.update_dict(target.obj, |d| d.set(b"Kids".to_vec(), Object::Array(list)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename the appearance state `old` to `new` in the widget dictionary `d` (`/AP` `/N`, `/D`,
|
||||
/// `/R`). A shared `/AP` or state dictionary is copied into `d` rather than renamed for its other
|
||||
/// users. Whether `/N` had the state: when it didn't, nothing is changed.
|
||||
fn rename_state(doc: &Document, d: &mut Dict, old: &str, new: &str) -> bool {
|
||||
let (old, new) = (crate::name_bytes(old), crate::name_bytes(new));
|
||||
let Some(mut ap) = d.get(b"AP").and_then(|a| doc.resolve(a).as_dict().cloned()) else { return false };
|
||||
let states = |ap: &Dict, key: &[u8]| ap.get(key).and_then(|s| doc.resolve(s).as_dict().cloned());
|
||||
if !states(&ap, b"N").is_some_and(|n| n.contains(&old)) {
|
||||
return false;
|
||||
}
|
||||
for key in [&b"N"[..], b"D", b"R"] {
|
||||
let Some(mut sub) = states(&ap, key) else { continue };
|
||||
if let Some(v) = sub.remove(&old) {
|
||||
sub.set(new.clone(), v);
|
||||
ap.set(key.to_vec(), Object::Dict(sub));
|
||||
}
|
||||
}
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
true
|
||||
}
|
||||
@@ -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,15 +280,12 @@ 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
|
||||
/// by reference.
|
||||
fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
|
||||
pub(super) fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool) -> Result<(), FormError> {
|
||||
let Some(root) = doc.root() else { return Ok(()) };
|
||||
let Some(af_obj) = doc.get(root).as_dict().and_then(|d| d.get(b"AcroForm").cloned()) else { return Ok(()) };
|
||||
let Some(mut af) = doc.resolve(&af_obj).as_dict().cloned() else { return Ok(()) };
|
||||
@@ -240,7 +307,23 @@ fn drop_from_calculation_order(doc: &mut Document, gone: impl Fn(ObjRef) -> bool
|
||||
}
|
||||
|
||||
/// Widget-only keys: they stay with each widget when a merged field is split.
|
||||
const WIDGET_KEYS: [&[u8]; 12] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent"];
|
||||
const WIDGET_KEYS: [&[u8]; 13] = [b"Type", b"Subtype", b"Rect", b"P", b"AP", b"AS", b"MK", b"F", b"BS", b"Border", b"H", b"StructParent", b"A"];
|
||||
|
||||
/// The `/AA` triggers of a widget annotation (ISO 32000-2 §12.6.3, Table 197). The rest (`K`,
|
||||
/// `F`, `V`, `C`, Table 199) belong to the field.
|
||||
const WIDGET_TRIGGERS: [&[u8]; 10] = [b"E", b"X", b"D", b"U", b"Fo", b"Bl", b"PO", b"PC", b"PV", b"PI"];
|
||||
|
||||
/// Split additional-actions dictionary `aa` into the widget's triggers and the field's.
|
||||
/// An empty half is `None`.
|
||||
pub(super) fn split_triggers(aa: &Dict) -> (Option<Dict>, Option<Dict>) {
|
||||
let (mut widget, mut field) = (Dict::new(), Dict::new());
|
||||
for (k, v) in aa.iter() {
|
||||
let to = if WIDGET_TRIGGERS.contains(&k.as_slice()) { &mut widget } else { &mut field };
|
||||
to.set(k.clone(), v.clone());
|
||||
}
|
||||
let some = |d: Dict| (!d.is_empty()).then_some(d);
|
||||
(some(widget), some(field))
|
||||
}
|
||||
|
||||
/// Duplicate a field onto other pages (Acrobat: right-click a field ▸ Duplicate): each page
|
||||
/// gets a widget at the same place, belonging to the same field, so they share one value.
|
||||
@@ -261,40 +344,7 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
|
||||
return Ok(0);
|
||||
}
|
||||
// A merged field/widget becomes a field with one widget kid.
|
||||
let mut model_ref = model.obj;
|
||||
if model.obj == f.obj {
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut kid = Dict::new();
|
||||
for k in WIDGET_KEYS {
|
||||
if let Some(v) = d.get(k) {
|
||||
kid.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
|
||||
let kr = doc.add(Object::Dict(kid));
|
||||
doc.update_dict(f.obj, |d| {
|
||||
for k in WIDGET_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
|
||||
})?;
|
||||
// The page lists the widget now, not the field.
|
||||
if let Some(p) = model.page {
|
||||
let pr = refs[p];
|
||||
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
|
||||
let swap =
|
||||
|a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
|
||||
match annots {
|
||||
Some(Object::Ref(ar)) => {
|
||||
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
|
||||
doc.set(ar, Object::Array(swap(a)));
|
||||
}
|
||||
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
model_ref = kr;
|
||||
}
|
||||
let model_ref = if model.obj == f.obj { split_merged(doc, &f, &model)? } else { model.obj };
|
||||
let template = doc.get(model_ref).as_dict().cloned().unwrap_or_default();
|
||||
let mut added = Vec::new();
|
||||
for p in &targets {
|
||||
@@ -327,3 +377,52 @@ pub fn duplicate_field(doc: &mut Document, name: &str, pages: &[usize]) -> Resul
|
||||
redraw_field(doc, name)?;
|
||||
Ok(added.len())
|
||||
}
|
||||
|
||||
/// Split field `f`, which is also its only widget `model`, into a field and one widget kid that
|
||||
/// takes the widget's keys and its place in the page's `/Annots`. Returns the kid.
|
||||
pub(super) fn split_merged(doc: &mut Document, f: &Field, model: &Widget) -> Result<ObjRef, FormError> {
|
||||
let refs = page_refs(doc);
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap_or_default();
|
||||
let mut kid = Dict::new();
|
||||
for k in WIDGET_KEYS {
|
||||
if let Some(v) = d.get(k) {
|
||||
kid.set(k.to_vec(), v.clone());
|
||||
}
|
||||
}
|
||||
// The widget's own triggers go with it; the field's stay.
|
||||
let aa = d.get(b"AA").and_then(|a| doc.resolve(a).as_dict().cloned());
|
||||
let (widget_aa, field_aa) = aa.as_ref().map(split_triggers).unwrap_or_default();
|
||||
if let Some(w) = widget_aa {
|
||||
kid.set(b"AA".to_vec(), Object::Dict(w));
|
||||
}
|
||||
kid.set(b"Parent".to_vec(), Object::Ref(f.obj));
|
||||
let kr = doc.add(Object::Dict(kid));
|
||||
doc.update_dict(f.obj, |d| {
|
||||
for k in WIDGET_KEYS {
|
||||
d.remove(k);
|
||||
}
|
||||
if aa.is_some() {
|
||||
match field_aa {
|
||||
Some(fa) => d.set(b"AA".to_vec(), Object::Dict(fa)),
|
||||
None => {
|
||||
d.remove(b"AA");
|
||||
}
|
||||
}
|
||||
}
|
||||
d.set(b"Kids".to_vec(), Object::Array(vec![Object::Ref(kr)]));
|
||||
})?;
|
||||
// The page lists the widget now, not the field.
|
||||
if let Some(&pr) = model.page.and_then(|p| refs.get(p)) {
|
||||
let annots = doc.get(pr).as_dict().and_then(|d| d.get(b"Annots").cloned());
|
||||
let swap = |a: Vec<Object>| -> Vec<Object> { a.into_iter().map(|o| if o.as_ref() == Some(f.obj) { Object::Ref(kr) } else { o }).collect() };
|
||||
match annots {
|
||||
Some(Object::Ref(ar)) => {
|
||||
let a = doc.get(ar).as_array().cloned().unwrap_or_default();
|
||||
doc.set(ar, Object::Array(swap(a)));
|
||||
}
|
||||
Some(Object::Array(a)) => doc.update_dict(pr, |d| d.set(b"Annots".to_vec(), Object::Array(swap(a))))?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(kr)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ pub(super) fn clash(name: &str, other: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The array `key` of `holder` (`/Fields` of the form, `/Kids` of a field).
|
||||
fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
|
||||
pub(super) fn list_of(doc: &Document, holder: ObjRef, key: &[u8]) -> Vec<Object> {
|
||||
let d = doc.get(holder).as_dict().cloned().unwrap_or_default();
|
||||
d.get(key).map(|o| doc.resolve(o)).and_then(|o| o.as_array().cloned()).unwrap_or_default()
|
||||
}
|
||||
@@ -317,6 +317,24 @@ pub(super) fn inherited(doc: &Document, field: ObjRef) -> Dict {
|
||||
add
|
||||
}
|
||||
|
||||
/// Whether an ancestor of `field` sets the inheritable `key`, which `field` would take on without
|
||||
/// its own entry.
|
||||
pub(crate) fn ancestor_sets(doc: &Document, field: ObjRef, key: &[u8]) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut parent = doc.get(field).as_dict().and_then(|d| d.reference(b"Parent"));
|
||||
while let Some(p) = parent {
|
||||
if seen.len() > 64 || !seen.insert(p) {
|
||||
return false;
|
||||
}
|
||||
let Some(d) = doc.get(p).as_dict().cloned() else { return false };
|
||||
if d.contains(key) {
|
||||
return true;
|
||||
}
|
||||
parent = d.reference(b"Parent");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Copy onto `field` what it inherits (`add`, from [`inherited`]), so it keeps it when it moves.
|
||||
pub(super) fn own_inherited(doc: &mut Document, field: ObjRef, add: &Dict) -> Result<(), FormError> {
|
||||
if add.iter().next().is_some() {
|
||||
|
||||
@@ -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;
|
||||
@@ -176,6 +176,12 @@ impl Field {
|
||||
self.flags & flag != 0
|
||||
}
|
||||
|
||||
/// A comb field: the Comb flag set with a `/MaxLen`, neither multiline nor password (ISO
|
||||
/// 32000-2 Table 228). Each cell holds one character.
|
||||
pub fn is_comb(&self) -> bool {
|
||||
self.has(flags::COMB) && !self.has(flags::MULTILINE) && !self.has(flags::PASSWORD) && self.max_len.is_some_and(|m| m > 0)
|
||||
}
|
||||
|
||||
pub fn read_only(&self) -> bool {
|
||||
self.has(flags::READ_ONLY)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+740
-4
@@ -1796,7 +1796,7 @@ fn two_fields_with_one_name_keep_their_places_in_the_order() {
|
||||
#[test]
|
||||
fn new_text_fields_fit_their_box() {
|
||||
// #1888: a fixed 12 pt was clipped in a short box. New text and date fields take Acrobat's
|
||||
// Auto size: 12 pt where there is room, smaller where there isn't.
|
||||
// Auto size: the size the box's height gives, smaller where the text is too wide.
|
||||
let mut doc = one_page();
|
||||
let tf_size = |ap: &str| {
|
||||
ap.split("/Helv ")
|
||||
@@ -1815,7 +1815,9 @@ fn new_text_fields_fit_their_box() {
|
||||
}
|
||||
add_field(&mut doc, 0, [20.0, 50.0, 200.0, 72.0], &NewField::Text { multiline: false }, Some("tall")).unwrap();
|
||||
set_value(&mut doc, "tall", &FieldValue::Text("Hello world".into())).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0])), 12.0, "a box with room keeps the 12 pt look");
|
||||
// 22 pt tall, less a 1 pt border and 2 pt padding each side: 16 / 1.15.
|
||||
let tall = tf_size(&ap(&doc, &field(&fields(&doc), "tall").widgets[0]));
|
||||
assert!((tall - 16.0 / 1.15).abs() < 0.01, "a box with room fills its height, not 12 pt: {tall}");
|
||||
// An explicit size still wins.
|
||||
set_props(&mut doc, "short", &FieldProps { font_size: Some(12.0), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(tf_size(&ap(&doc, &field(&fields(&doc), "short").widgets[0])), 12.0);
|
||||
@@ -1875,6 +1877,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();
|
||||
@@ -3158,8 +3227,12 @@ fn auto_text_size_fits_single_and_multiline_text() {
|
||||
assert_eq!(auto_text_size("Hi", 94.0, 8.0, false, helvetica_width), 8.0 / 1.15);
|
||||
let s = auto_text_size(long, 194.0, 8.0, false, helvetica_width);
|
||||
assert!(s > 4.0 && s < 8.0 / 1.15 && helvetica_width(long, s) <= 194.0 + 1e-9, "{s}");
|
||||
// Never past 12 pt or under 4 pt.
|
||||
assert_eq!(auto_text_size("Hi", 500.0, 500.0, false, helvetica_width), 12.0);
|
||||
// A tall single-line box gets text as tall as it allows (Acrobat has no 12 pt cap there),
|
||||
// bounded by the largest page; never under 4 pt.
|
||||
assert_eq!(auto_text_size("Hi", 500.0, 24.0, false, helvetica_width), 24.0 / 1.15);
|
||||
assert_eq!(auto_text_size("", 500.0, 1e300, false, helvetica_width), crate::appearance::MAX_AUTO_HEIGHT / 1.15);
|
||||
let s = auto_text_size("Hi", 500.0, 500.0, false, helvetica_width);
|
||||
assert!(s > 12.0 && helvetica_width("Hi", s) <= 500.0 + 1e-9, "{s}");
|
||||
assert_eq!(auto_text_size(&long.repeat(20), 20.0, 8.0, false, helvetica_width), 4.0);
|
||||
// Multiline: the largest size whose wrapped lines fit the height.
|
||||
let m = auto_text_size(long, 114.0, 44.0, true, helvetica_width);
|
||||
@@ -4620,3 +4693,666 @@ fn set_values_reads_the_form_once_not_per_value() {
|
||||
let reads = form_reads() - before;
|
||||
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();
|
||||
let radio = |export: &str| NewField::Radio { group: None, export: export.into() };
|
||||
assert_eq!(add_field(&mut doc, 0, [50.0, 600.0, 64.0, 614.0], &radio(a), None).unwrap(), "Group1");
|
||||
assert_eq!(add_field(&mut doc, 0, [80.0, 600.0, 94.0, 614.0], &radio(b), None).unwrap(), "Group2");
|
||||
doc
|
||||
}
|
||||
|
||||
fn rename_to(doc: &mut Document, from: &str, to: &str) -> Result<String, FormError> {
|
||||
set_props(doc, from, &FieldProps { name: Some(to.into()), ..FieldProps::default() })
|
||||
}
|
||||
|
||||
fn page_widgets(doc: &Document) -> Vec<ObjRef> {
|
||||
let page = page_refs(doc)[0];
|
||||
let annots = doc.get(page).as_dict().unwrap().get(b"Annots").cloned().unwrap();
|
||||
doc.resolve(&annots).as_array().unwrap().iter().filter_map(|o| o.as_ref()).collect()
|
||||
}
|
||||
|
||||
/// #4949: a radio button given its group's name is one of the group's buttons, so the group can
|
||||
/// be built up by naming buttons alike, as in Acrobat.
|
||||
#[test]
|
||||
fn a_radio_button_renamed_to_a_groups_name_joins_the_group() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
set_value(&mut doc, "Group1", &FieldValue::Radio(Some("Yes".into()))).unwrap();
|
||||
let before = page_widgets(&doc);
|
||||
assert_eq!(rename_to(&mut doc, "Group2", "Group1").unwrap(), "Group1");
|
||||
assert_eq!(page_widgets(&doc), before, "the buttons stay on the page where they were");
|
||||
let mut doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["Group1"]);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
|
||||
assert_eq!(group.value, ["Yes"], "the group keeps its value");
|
||||
assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Yes".into()), Some("Off".into())]);
|
||||
assert_eq!(page_widgets(&doc), group.widgets.iter().map(|w| w.obj).collect::<Vec<_>>());
|
||||
// Choosing the moved button turns the other off: they are one group.
|
||||
set_value(&mut doc, "Group1", &FieldValue::Radio(Some("No".into()))).unwrap();
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
assert_eq!(group.widgets.iter().map(|w| w.state.clone()).collect::<Vec<_>>(), [Some("Off".into()), Some("No".into())]);
|
||||
}
|
||||
|
||||
/// Joining redraws only the buttons that moved: the group's own keep their appearances, which may
|
||||
/// be custom ones a redraw would replace.
|
||||
#[test]
|
||||
fn joining_a_group_leaves_its_own_buttons_appearances_as_they_are() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
let own = field(&fields(&doc), "Group1").widgets[0].obj;
|
||||
let look = |doc: &Document| doc.get(own).as_dict().and_then(|d| d.get(b"AP").cloned());
|
||||
let before = look(&doc);
|
||||
assert!(before.is_some());
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
assert_eq!(look(&doc), before);
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
assert!(group.widgets[1].on_state.as_deref() == Some("No"), "the moved button is drawn with its states");
|
||||
}
|
||||
|
||||
/// A joining button whose export value a button of the group already has takes the next free
|
||||
/// "ChoiceN", in its appearance states too, so the two can be chosen apart.
|
||||
#[test]
|
||||
fn a_joining_radio_button_with_a_taken_export_value_takes_a_free_one() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2"]);
|
||||
let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
|
||||
let normal = doc.resolve(moved.get(b"AP").unwrap()).as_dict().unwrap().get(b"N").cloned().unwrap();
|
||||
let normal = doc.resolve(&normal).as_dict().unwrap().clone();
|
||||
assert!(normal.contains(b"Choice2") && !normal.contains(b"Choice1"));
|
||||
}
|
||||
|
||||
/// A group whose `/Kids` is an indirect array keeps its own buttons when another joins it.
|
||||
#[test]
|
||||
fn a_group_with_indirect_kids_keeps_its_buttons_when_joined() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
let group = field(&fields(&doc), "Group1").obj;
|
||||
let kids = doc.get(group).as_dict().unwrap().get(b"Kids").cloned().unwrap();
|
||||
let held = doc.add(kids);
|
||||
doc.update_dict(group, |d| d.set(b"Kids".to_vec(), Object::Ref(held))).unwrap();
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(field(&all, "Group1").widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Yes", "No"]);
|
||||
}
|
||||
|
||||
/// Buttons of the joining group that share an export value (a button repeated on another page,
|
||||
/// on together) still share it after the join, under one fresh name.
|
||||
#[test]
|
||||
fn joining_radio_buttons_that_share_a_value_keep_sharing_it() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
let pages = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Pages").unwrap();
|
||||
let mut p = pdfcraft_cos::Dict::new();
|
||||
p.set(b"Type".to_vec(), Object::name("Page"));
|
||||
p.set(b"Parent".to_vec(), Object::Ref(pages));
|
||||
p.set(b"MediaBox".to_vec(), Object::Array(vec![0.into(), 0.into(), 600.into(), 800.into()]));
|
||||
let r = doc.add(Object::Dict(p));
|
||||
doc.update_dict(pages, |d| {
|
||||
let mut kids = d.get(b"Kids").unwrap().as_array().unwrap().clone();
|
||||
kids.push(Object::Ref(r));
|
||||
d.set(b"Count".to_vec(), Object::Int(kids.len() as i64));
|
||||
d.set(b"Kids".to_vec(), Object::Array(kids));
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(duplicate_field(&mut doc, "Group2", &[1]).unwrap(), 1);
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
let group = field(&all, "Group1");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["Choice1", "Choice2", "Choice2"]);
|
||||
}
|
||||
|
||||
/// One page holding radio fields that are each their own only widget, as other writers make
|
||||
/// one-button groups: `(name, export value, x)`.
|
||||
fn merged_radios(specs: &[(&str, &str, f64)]) -> Document {
|
||||
let mut doc = one_page();
|
||||
let page = page_refs(&doc)[0];
|
||||
let af = crate::author::ensure_form(&mut doc).unwrap();
|
||||
let mut made = Vec::new();
|
||||
for &(name, state, x) in specs {
|
||||
let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
|
||||
let mut n = pdfcraft_cos::Dict::new();
|
||||
n.set(state.as_bytes().to_vec(), Object::Ref(look));
|
||||
n.set(b"Off".to_vec(), Object::Ref(look));
|
||||
let mut ap = pdfcraft_cos::Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut d = pdfcraft_cos::Dict::new();
|
||||
d.set(b"Type".to_vec(), Object::name("Annot"));
|
||||
d.set(b"Subtype".to_vec(), Object::name("Widget"));
|
||||
d.set(b"FT".to_vec(), Object::name("Btn"));
|
||||
d.set(b"Ff".to_vec(), Object::Int(i64::from(flags::RADIO)));
|
||||
d.set(b"T".to_vec(), pdfcraft_cos::PdfString::text(name));
|
||||
d.set(b"Rect".to_vec(), Object::Array(vec![x.into(), 600.into(), (x + 14.0).into(), 614.into()]));
|
||||
d.set(b"P".to_vec(), Object::Ref(page));
|
||||
d.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
d.set(b"AS".to_vec(), Object::name("Off"));
|
||||
let r = doc.add(Object::Dict(d));
|
||||
doc.update_dict(af, |a| {
|
||||
let mut list = a.get(b"Fields").and_then(|l| l.as_array().cloned()).unwrap_or_default();
|
||||
list.push(Object::Ref(r));
|
||||
a.set(b"Fields".to_vec(), Object::Array(list));
|
||||
})
|
||||
.unwrap();
|
||||
made.push(r);
|
||||
}
|
||||
doc.update_dict(page, |p| p.set(b"Annots".to_vec(), Object::Array(made.iter().map(|r| Object::Ref(*r)).collect()))).unwrap();
|
||||
doc
|
||||
}
|
||||
|
||||
/// A radio field that is its own (only) widget, as other writers make one-button groups, can
|
||||
/// both join a group and be joined.
|
||||
#[test]
|
||||
fn radio_fields_that_are_their_own_widget_join_and_are_joined() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
assert_eq!(rename_to(&mut doc, "other", "solo").unwrap(), "solo");
|
||||
let doc = reopen(&doc);
|
||||
let all = fields(&doc);
|
||||
assert_eq!(all.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["solo"]);
|
||||
let group = field(&all, "solo");
|
||||
assert_eq!(group.widgets.iter().filter_map(|w| w.on_state.clone()).collect::<Vec<_>>(), ["A", "B"]);
|
||||
// The joined field's widget became a kid; the moved one shed its field entries.
|
||||
let kids: Vec<ObjRef> = group.widgets.iter().map(|w| w.obj).collect();
|
||||
assert!(!kids.contains(&group.obj));
|
||||
let moved = doc.get(group.widgets[1].obj).as_dict().unwrap().clone();
|
||||
assert!(!moved.contains(b"T") && !moved.contains(b"FT") && moved.reference(b"Parent") == Some(group.obj));
|
||||
let mut on_page = page_widgets(&doc);
|
||||
on_page.sort();
|
||||
let mut kids = kids;
|
||||
kids.sort();
|
||||
assert_eq!(on_page, kids, "the page lists the widgets, not the field");
|
||||
}
|
||||
|
||||
/// A group that is its own only widget keeps its button's actions when another button joins
|
||||
/// it: the new widget takes `/A` and the widget triggers of `/AA`, and the field keeps its own.
|
||||
#[test]
|
||||
fn a_joined_merged_groups_button_keeps_its_actions() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let solo = field(&fields(&doc), "solo").obj;
|
||||
let js = |code: &str| {
|
||||
let mut a = pdfcraft_cos::Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
|
||||
Object::Dict(a)
|
||||
};
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"Fo".to_vec(), js("focus();"));
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
let aa = doc.add(Object::Dict(aa));
|
||||
doc.update_dict(solo, |d| {
|
||||
d.set(b"A".to_vec(), js("up();"));
|
||||
d.set(b"AA".to_vec(), Object::Ref(aa));
|
||||
})
|
||||
.unwrap();
|
||||
rename_to(&mut doc, "other", "solo").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(
|
||||
field_actions(&doc, "solo").unwrap(),
|
||||
[(Trigger::MouseUp, FieldAction::JavaScript("up();".into())), (Trigger::OnFocus, FieldAction::JavaScript("focus();".into()))]
|
||||
);
|
||||
let group = field(&fields(&doc), "solo").clone();
|
||||
let fd = doc.get(group.obj).as_dict().unwrap().clone();
|
||||
let field_aa = doc.resolve(fd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(!fd.contains(b"A") && field_aa.contains(b"V") && !field_aa.contains(b"Fo"), "the field keeps only its own trigger");
|
||||
let wd = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
|
||||
let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(widget_aa.contains(b"Fo") && !widget_aa.contains(b"V"));
|
||||
}
|
||||
|
||||
/// A joining button that was its own field keeps its widget triggers and drops the field's.
|
||||
#[test]
|
||||
fn a_joining_merged_button_sheds_its_fields_triggers() {
|
||||
let mut doc = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let other = field(&fields(&doc), "other").obj;
|
||||
let js = |code: &str| {
|
||||
let mut a = pdfcraft_cos::Dict::new();
|
||||
a.set(b"S".to_vec(), Object::name("JavaScript"));
|
||||
a.set(b"JS".to_vec(), pdfcraft_cos::PdfString::text(code));
|
||||
Object::Dict(a)
|
||||
};
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"U".to_vec(), js("up();"));
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
aa.set(b"C".to_vec(), js("calc();"));
|
||||
let aa = doc.add(Object::Dict(aa));
|
||||
doc.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Ref(aa))).unwrap();
|
||||
rename_to(&mut doc, "other", "solo").unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let group = field(&fields(&doc), "solo").clone();
|
||||
let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
|
||||
let wd = doc.get(moved.obj).as_dict().unwrap().clone();
|
||||
let widget_aa = doc.resolve(wd.get(b"AA").unwrap()).as_dict().unwrap().clone();
|
||||
assert!(widget_aa.contains(b"U") && !widget_aa.contains(b"V") && !widget_aa.contains(b"C"), "only the widget's trigger stays");
|
||||
let mut once = merged_radios(&[("solo", "A", 50.0), ("other", "B", 80.0)]);
|
||||
let other = field(&fields(&once), "other").obj;
|
||||
let mut aa = pdfcraft_cos::Dict::new();
|
||||
aa.set(b"V".to_vec(), js("validate();"));
|
||||
once.update_dict(other, |d| d.set(b"AA".to_vec(), Object::Dict(aa))).unwrap();
|
||||
rename_to(&mut once, "other", "solo").unwrap();
|
||||
let once = reopen(&once);
|
||||
let group = field(&fields(&once), "solo").clone();
|
||||
let moved = group.widgets.iter().find(|w| w.on_state.as_deref() == Some("B")).unwrap();
|
||||
let wd = once.get(moved.obj).as_dict().unwrap().clone();
|
||||
assert!(!wd.contains(b"AA"), "a widget left with no trigger has no /AA");
|
||||
}
|
||||
|
||||
/// However many "ChoiceN" names a group already uses, a joining button with a clashing export
|
||||
/// value gets a free one, so the two buttons can still be chosen apart.
|
||||
#[test]
|
||||
fn a_joining_button_gets_a_free_export_value_when_choice_names_run_long() {
|
||||
let mut doc = two_radio_groups("Choice1", "Choice1");
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
let model = doc.get(group.widgets[0].obj).as_dict().unwrap().clone();
|
||||
let look = doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), b"0 g".to_vec())));
|
||||
let mut kids = vec![Object::Ref(group.widgets[0].obj)];
|
||||
for i in 2..=10_000 {
|
||||
let mut n = pdfcraft_cos::Dict::new();
|
||||
n.set(format!("Choice{i}").into_bytes(), Object::Ref(look));
|
||||
n.set(b"Off".to_vec(), Object::Ref(look));
|
||||
let mut ap = pdfcraft_cos::Dict::new();
|
||||
ap.set(b"N".to_vec(), Object::Dict(n));
|
||||
let mut w = model.clone();
|
||||
w.set(b"AP".to_vec(), Object::Dict(ap));
|
||||
w.remove(b"P");
|
||||
kids.push(Object::Ref(doc.add(Object::Dict(w))));
|
||||
}
|
||||
doc.update_dict(group.obj, |d| d.set(b"Kids".to_vec(), Object::Array(kids))).unwrap();
|
||||
rename_to(&mut doc, "Group2", "Group1").unwrap();
|
||||
let group = field(&fields(&doc), "Group1").clone();
|
||||
let states: Vec<String> = group.widgets.iter().filter_map(|w| w.on_state.clone()).collect();
|
||||
assert_eq!(states.len(), 10_001);
|
||||
assert_eq!(states.last().map(String::as_str), Some("Choice10001"));
|
||||
}
|
||||
|
||||
/// Only a radio button joins by name: other clashes are refused as before, and a locked group
|
||||
/// isn't changed.
|
||||
#[test]
|
||||
fn joining_by_name_is_only_for_radio_buttons_and_unlocked_groups() {
|
||||
let mut doc = two_radio_groups("Yes", "No");
|
||||
add_field(&mut doc, 0, [50.0, 700.0, 250.0, 720.0], &NewField::Text { multiline: false }, Some("note")).unwrap();
|
||||
assert!(rename_to(&mut doc, "note", "Group1").is_err(), "a text field doesn't join a radio group");
|
||||
assert!(rename_to(&mut doc, "Group2", "note").is_err(), "a radio button doesn't join a text field");
|
||||
set_props(&mut doc, "Group1", &FieldProps { locked: Some(true), ..FieldProps::default() }).unwrap();
|
||||
assert!(rename_to(&mut doc, "Group2", "Group1").is_err(), "a locked group");
|
||||
assert_eq!(fields(&doc).len(), 3);
|
||||
}
|
||||
|
||||
/// `/I` of field `name`: the selected positions in `/Opt`, or `None` when absent.
|
||||
fn selection_indices(doc: &Document, name: &str) -> Option<Vec<i64>> {
|
||||
let f = field(&fields(doc), name).clone();
|
||||
let d = doc.get(f.obj).as_dict().cloned().unwrap();
|
||||
d.get(b"I").map(|i| doc.resolve(i).as_array().unwrap().iter().map(|o| o.as_int().unwrap()).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_a_lists_options_repoints_its_selected_indices() {
|
||||
let mut doc = one_page();
|
||||
let list =
|
||||
add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["A".into(), "B".into(), "C".into()], multi: true }, None)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &list, &FieldValue::Choice(vec!["C".into(), "B".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![1, 2]));
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
doc.update_dict(f.obj, |d| d.set(b"TI".to_vec(), Object::Int(2))).unwrap();
|
||||
// Removing A moves B and C up one place.
|
||||
let opts = |o: &[&str]| FieldProps { options: Some(o.iter().map(|s| s.to_string()).collect()), ..FieldProps::default() };
|
||||
set_props(&mut doc, &list, &opts(&["B", "C", "D"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0, 1]));
|
||||
// Removing B leaves only C selected; the top row would point past the end.
|
||||
set_props(&mut doc, &list, &opts(&["C"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![0]));
|
||||
assert_eq!(top_row(&doc, f.obj), Some(0), "the list scrolls back to its only row");
|
||||
// No selected value is offered any more.
|
||||
set_props(&mut doc, &list, &opts(&["X", "Y"])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(selection_indices(&doc, &list), None);
|
||||
}
|
||||
|
||||
fn top_row(doc: &Document, obj: ObjRef) -> Option<i64> {
|
||||
doc.get(obj).as_dict().and_then(|d| d.get(b"TI")).and_then(|o| o.as_int())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shrinking_a_lists_options_scrolls_a_widget_with_its_own_top_row() {
|
||||
let objs: Vec<String> = vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] /V (F) /I [5] /Kids [7 0 R] >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 5 /Rect [50 700 250 720] /P 3 0 R >>".into(),
|
||||
];
|
||||
let mut doc = document(&objs);
|
||||
let widget = ObjRef::new(7, 0);
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["F".into(), "A".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(selection_indices(&doc, "pick"), Some(vec![0]));
|
||||
// The widget's own /TI is read before the field's; row 5 no longer exists.
|
||||
assert_eq!(top_row(&doc, widget), Some(0), "the widget scrolls to F, now row 0");
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(top_row(&doc, widget), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_widget_with_its_own_top_row_scrolls_by_itself() {
|
||||
// Widget 7 shows from the field's top row; widget 8 keeps its own /TI 4.
|
||||
let objs = |v: &str| -> Vec<String> {
|
||||
vec![
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [7 0 R 8 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
format!("<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E) (F)] {v} /Kids [7 0 R 8 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
"<< /Type /Annot /Subtype /Widget /Parent 6 0 R /TI 4 /Rect [50 600 250 620] /P 3 0 R >>".into(),
|
||||
]
|
||||
};
|
||||
let (first, other) = (ObjRef::new(7, 0), ObjRef::new(8, 0));
|
||||
// The options shrink: the first widget already shows A, the other's row 4 is gone.
|
||||
let mut doc = document(&objs("/V (A) /I [0]"));
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(vec!["A".into(), "B".into()]), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(top_row(&doc, first), None);
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "options: the other widget scrolls to A");
|
||||
// A new value the first widget already shows, below the other's view.
|
||||
let mut doc = document(&objs("/V (B) /I [1]"));
|
||||
set_value(&mut doc, "pick", &FieldValue::Choice(vec!["A".into()])).unwrap();
|
||||
assert_eq!(top_row(&doc, ObjRef::new(6, 0)), None, "the field's top row did not move");
|
||||
assert_eq!(top_row(&reopen(&doc), other), Some(0), "value: the other widget scrolls to A");
|
||||
}
|
||||
|
||||
/// A list box's top row is chosen with the font size set in the same edit: at the larger size
|
||||
/// fewer rows fit, so it scrolls further to keep the selection in view.
|
||||
#[test]
|
||||
fn a_list_boxs_top_row_follows_a_font_size_changed_with_its_items() {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
|
||||
"<< /Fields [6 0 R] /DA (/Helv 10 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >>".into(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(),
|
||||
"<< /FT /Ch /T (pick) /Opt [(A) (B) (C) (D) (E)] /V (C) /I [2] /Type /Annot /Subtype /Widget /Rect [50 700 250 740] /P 3 0 R >>".into(),
|
||||
]);
|
||||
let items = ["Z", "A", "B", "C", "D"].map(String::from).to_vec();
|
||||
set_props(&mut doc, "pick", &FieldProps { options: Some(items), font_size: Some(20.0), ..FieldProps::default() }).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let g = field(&fields(&doc), "pick").clone();
|
||||
let w = &g.widgets[0];
|
||||
let rows = w.list.unwrap();
|
||||
let shown = rows.top..rows.top + rows.visible(w.rect[3] - w.rect[1]);
|
||||
assert!(shown.contains(&3), "C is row 3; rows {shown:?} show");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_a_combo_boxs_items_repoints_its_selected_index() {
|
||||
let mut doc = one_page();
|
||||
let combo = add_field(
|
||||
&mut doc,
|
||||
0,
|
||||
[50.0, 600.0, 250.0, 620.0],
|
||||
&NewField::Combo { options: vec!["Pear".into(), "apple".into(), "Fig".into()], editable: false },
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
set_value(&mut doc, &combo, &FieldValue::Choice(vec!["Pear".into()])).unwrap();
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![0]));
|
||||
set_props(&mut doc, &combo, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// apple, Fig, Pear: the selection follows Pear to the end.
|
||||
assert_eq!(selection_indices(&doc, &combo), Some(vec![2]));
|
||||
assert_eq!(field(&fields(&doc), &combo).value, vec!["Pear".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_keeps_which_of_two_items_with_one_export_value_is_selected() {
|
||||
let mut doc = one_page();
|
||||
let list = add_field(&mut doc, 0, [50.0, 600.0, 250.0, 680.0], &NewField::List { options: vec!["a".into()], multi: false }, None).unwrap();
|
||||
let f = field(&fields(&doc), &list).clone();
|
||||
let item = |e: &str, shown: &str| Object::Array(vec![Object::String(PdfString::text(e)), Object::String(PdfString::text(shown))]);
|
||||
// Zeta and Alpha both export "x"; `/I` says Zeta is the one selected.
|
||||
doc.update_dict(f.obj, |d| {
|
||||
d.set(b"Opt".to_vec(), Object::Array(vec![item("x", "Zeta"), item("x", "Alpha"), item("y", "Mid")]));
|
||||
d.set(b"V".to_vec(), PdfString::text("x"));
|
||||
d.set(b"I".to_vec(), Object::Array(vec![Object::Int(0)]));
|
||||
})
|
||||
.unwrap();
|
||||
set_props(&mut doc, &list, &FieldProps { flags: vec![(flags::SORT, true)], ..FieldProps::default() }).unwrap();
|
||||
// Alpha, Mid, Zeta: the selection follows Zeta, not the first item exporting "x".
|
||||
assert_eq!(selection_indices(&doc, &list), Some(vec![2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_default_does_not_bring_back_its_parents() {
|
||||
let mut doc = fixture();
|
||||
let all = fields(&doc);
|
||||
let city = field(&all, "address.city").clone();
|
||||
let parent = doc.get(city.obj).as_dict().unwrap().reference(b"Parent").unwrap();
|
||||
doc.update_dict(parent, |d| d.set(b"DV".to_vec(), PdfString::text("Main St"))).unwrap();
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(Some("Elm".into())), ..FieldProps::default() }).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").default, ["Elm"]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let mut doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.default.iter().all(String::is_empty), "no default, not the parent's: {:?}", f.default);
|
||||
let own = doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_string()).map(PdfString::to_text);
|
||||
assert_eq!(own.as_deref(), Some(""), "the kid holds an empty default of its own");
|
||||
set_value(&mut doc, "address.city", &FieldValue::Text("Oak".into())).unwrap();
|
||||
reset(&mut doc, None).unwrap();
|
||||
assert!(field(&fields(&doc), "address.city").value.iter().all(String::is_empty), "reset clears it");
|
||||
// Without a parent default, clearing still removes the key.
|
||||
set_props(&mut doc, "name", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let name = field(&fields(&doc), "name").clone();
|
||||
assert!(doc.get(name.obj).as_dict().unwrap().get(b"DV").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_kids_value_does_not_bring_back_its_parents() {
|
||||
// A text kid, then a list box kid, under a parent with both a value and a default.
|
||||
for (ft, set) in [("/FT /Tx", FieldValue::Text("Oak".into())), ("/FT /Ch /Opt [(Main St) (Oak)]", FieldValue::Choice(vec!["Oak".into()]))] {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (address) {ft} /V (Main St) /DV (Main St) /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (city) /Parent 5 0 R /Rect [10 10 110 60] /P 3 0 R >>".into(),
|
||||
]);
|
||||
set_props(&mut doc, "address.city", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "address.city").value, ["Oak"], "{ft}");
|
||||
reset(&mut doc, None).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let f = field(&fields(&doc), "address.city").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: reset leaves it empty, not the parent's: {:?}", f.value);
|
||||
let mut doc = doc;
|
||||
set_value(&mut doc, "address.city", &set).unwrap();
|
||||
let clear = if ft.contains("Ch") { FieldValue::Choice(Vec::new()) } else { FieldValue::Text(String::new()) };
|
||||
set_value(&mut doc, "address.city", &clear).unwrap();
|
||||
let f = field(&fields(&reopen(&doc)), "address.city").clone();
|
||||
if ft.contains("Ch") {
|
||||
assert!(f.value.is_empty(), "{ft}: clearing leaves no choice selected: {:?}", f.value);
|
||||
} else {
|
||||
assert!(f.value.iter().all(String::is_empty), "{ft}: clearing leaves it empty: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_a_buttons_default_under_a_parent_with_one_leaves_it_off() {
|
||||
// A check box, then a radio button (flags 49152, inherited from the parent).
|
||||
for flags in ["", "/Ff 49152"] {
|
||||
let mut doc = document(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(),
|
||||
"<< /Type /Page /Parent 2 0 R /Annots [6 0 R] >>".into(),
|
||||
"<< /Fields [5 0 R] /DA (/Helv 0 Tf 0 g) >>".into(),
|
||||
format!("<< /T (opts) /FT /Btn {flags} /DV /Yes /Kids [6 0 R] >>"),
|
||||
"<< /Type /Annot /Subtype /Widget /T (a) /Parent 5 0 R /V /Off /AS /Off /Rect [10 10 25 25] /P 3 0 R /AP << /N << /Yes 7 0 R /Off 7 0 R >> >> >>".into(),
|
||||
"<< /Length 0 >>
|
||||
stream
|
||||
|
||||
endstream".into(),
|
||||
]);
|
||||
let kind = field(&fields(&doc), "opts.a").kind;
|
||||
assert_eq!(kind == FieldKind::Radio, !flags.is_empty());
|
||||
set_props(&mut doc, "opts.a", &FieldProps { default_value: Some(None), ..FieldProps::default() }).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert_eq!(doc.get(f.obj).as_dict().and_then(|d| d.get(b"DV")).and_then(|o| o.as_name()), Some(&b"Off"[..]), "{kind:?}");
|
||||
let on = if kind == FieldKind::Radio { FieldValue::Radio(Some("Yes".into())) } else { FieldValue::Check(true) };
|
||||
set_value(&mut doc, "opts.a", &on).unwrap();
|
||||
assert_eq!(field(&fields(&doc), "opts.a").value, ["Yes"]);
|
||||
reset(&mut doc, None).unwrap();
|
||||
let f = field(&fields(&doc), "opts.a").clone();
|
||||
assert!(f.value.iter().all(String::is_empty), "{kind:?}: reset leaves it off, not the parent's Yes: {:?}", f.value);
|
||||
}
|
||||
}
|
||||
+39
-21
@@ -1,8 +1,8 @@
|
||||
use super::appearances::{declares_pdfa, need_appearances};
|
||||
use super::read::{acroform, field_flags, fields, page_refs};
|
||||
use super::{
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author,
|
||||
calc_order, flags, invalid, name_obj, scripting,
|
||||
Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, Widget, af, appearance,
|
||||
author, calc_order, flags, invalid, name_obj, scripting,
|
||||
};
|
||||
|
||||
// ── writing ─────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -210,8 +210,9 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
let t = &af::keystroke(&f.actions.format, &f.name, t).map_err(FormError::Format)?;
|
||||
af::validate(&f.actions.validate, t).map_err(FormError::Invalid)?;
|
||||
let t = &scripting::accept(doc, f, t, scripts)?;
|
||||
let shadow = t.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
if t.is_empty() {
|
||||
if t.is_empty() && !shadow {
|
||||
d.remove(b"V");
|
||||
} else {
|
||||
d.set(b"V".to_vec(), PdfString::text(t));
|
||||
@@ -291,16 +292,13 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
indices.sort_unstable_by_key(Object::as_int);
|
||||
// A list box scrolls (`/TI`) so the picked options are among the rows it draws, as
|
||||
// in other viewers: a pick below the last visible row would otherwise not show (#1524).
|
||||
let top = match (f.kind, f.widgets.first()) {
|
||||
(FieldKind::List, Some(w)) => w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, &picked, rows.visible(height), f.options.len()))
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let picked: Vec<usize> = indices.iter().filter_map(|i| i.as_int()).filter_map(|i| usize::try_from(i).ok()).collect();
|
||||
let top = list_scroll(f, &picked, f.options.len());
|
||||
// An empty value of its own keeps a parent field's /V from showing through.
|
||||
let shadow = exports.is_empty() && crate::author::ancestor_sets(doc, f.obj, b"V");
|
||||
doc.update_dict(f.obj, |d| {
|
||||
match exports.as_slice() {
|
||||
[] if shadow => d.set(b"V".to_vec(), PdfString::text("")),
|
||||
[] => {
|
||||
d.remove(b"V");
|
||||
}
|
||||
@@ -318,16 +316,7 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0)));
|
||||
}
|
||||
})?;
|
||||
// A widget with its own `/TI` (read before the field's) scrolls too.
|
||||
if let Some((old, new)) = top
|
||||
&& old != new
|
||||
{
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI")) {
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
scroll_widgets(doc, f, &picked, f.options.len())?;
|
||||
redraw(doc, f, &exports, scripts)
|
||||
}
|
||||
(FieldKind::PushButton, _) => invalid(format!("{:?} is a button; it has no value", f.name)),
|
||||
@@ -336,6 +325,35 @@ pub(crate) fn write_value(doc: &mut Document, f: &Field, value: &FieldValue, scr
|
||||
}
|
||||
}
|
||||
|
||||
/// A list box's first visible row (`/TI`) before and after scrolling so the `picked` positions of
|
||||
/// its `count` options are among the rows it draws, as in other viewers: a pick below the last
|
||||
/// visible row would otherwise not show (#1524). `None` for any other field.
|
||||
pub(crate) fn list_scroll(f: &Field, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
f.widgets.first().filter(|_| f.kind == FieldKind::List).and_then(|w| widget_scroll(w, picked, count))
|
||||
}
|
||||
|
||||
/// A list box widget's first row before and after scrolling so `picked` shows.
|
||||
fn widget_scroll(w: &Widget, picked: &[usize], count: usize) -> Option<(usize, usize)> {
|
||||
w.list.map(|rows| {
|
||||
let height = if matches!(w.rotation, 90 | 270) { w.rect[2] - w.rect[0] } else { w.rect[3] - w.rect[1] };
|
||||
(rows.top, appearance::ListRows::top_showing(rows.top, picked, rows.visible(height), count))
|
||||
})
|
||||
}
|
||||
|
||||
/// Scroll every widget of `f` with its own `/TI` (read before the field's) by its own rows and
|
||||
/// size, so `picked` shows in each.
|
||||
pub(crate) fn scroll_widgets(doc: &mut Document, f: &Field, picked: &[usize], count: usize) -> Result<(), FormError> {
|
||||
for w in f.widgets.iter().filter(|w| w.obj != f.obj) {
|
||||
if doc.get(w.obj).as_dict().is_some_and(|d| d.contains(b"TI"))
|
||||
&& let Some((old, new)) = widget_scroll(w, picked, count)
|
||||
&& old != new
|
||||
{
|
||||
doc.update_dict(w.obj, |d| d.set(b"TI".to_vec(), Object::Int(i64::try_from(new).unwrap_or(0))))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check boxes and radio buttons: `/V` on the field, `/AS` on each widget.
|
||||
fn set_states(doc: &mut Document, f: &Field, on: Option<&str>) -> Result<(), FormError> {
|
||||
let v = on.unwrap_or("Off");
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -326,7 +326,10 @@ struct Piece {
|
||||
/// The interpreter state carried across the operators of a scope.
|
||||
struct Walk {
|
||||
gs: Gs,
|
||||
stack: Vec<Gs>,
|
||||
/// With each saved state, the text and text line matrices: inside a text object `q`/`Q`
|
||||
/// save and restore them too (ISO 32000-2 §9.4.1 as corrected by the PDF Association's
|
||||
/// errata, issue 368), as the renderer does.
|
||||
stack: Vec<(Gs, Matrix, Matrix)>,
|
||||
tm: Matrix,
|
||||
tlm: Matrix,
|
||||
fonts_res: Dict,
|
||||
@@ -437,10 +440,10 @@ impl Walk {
|
||||
|
||||
fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) {
|
||||
match op.op.as_slice() {
|
||||
b"q" => self.stack.push(self.gs.clone()),
|
||||
b"q" => self.stack.push((self.gs.clone(), self.tm, self.tlm)),
|
||||
b"Q" => {
|
||||
if let Some(g) = self.stack.pop() {
|
||||
self.gs = g;
|
||||
if let Some((g, m, lm)) = self.stack.pop() {
|
||||
(self.gs, self.tm, self.tlm) = (g, m, lm);
|
||||
}
|
||||
}
|
||||
b"cm" => {
|
||||
|
||||
@@ -7004,6 +7004,10 @@ mod default_colour_spaces;
|
||||
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
|
||||
mod devicen_inks;
|
||||
|
||||
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
|
||||
// patch 27).
|
||||
mod isolated_cmyk_groups;
|
||||
|
||||
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
|
||||
/// but draws no pixels itself.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2
|
||||
//! §11.3.4, §11.3.5.3).
|
||||
|
||||
use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A print test file's patch of blend modes in isolated groups whose colour space is
|
||||
/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the
|
||||
/// group's colour space, and a subtractive space's components are complemented before the
|
||||
/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes,
|
||||
/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop
|
||||
/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so
|
||||
/// magenta under black with Difference came out magenta instead of green. Vendored
|
||||
/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own
|
||||
/// transparent backdrop) blending in DeviceCMYK is blended in CMYK.
|
||||
///
|
||||
/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated
|
||||
/// group on it whose two elements are tint A and then tint B with the cell's blend mode:
|
||||
/// drawn right, the group is the colour of the square around it.
|
||||
#[test]
|
||||
fn isolated_groups_blend_in_device_cmyk() {
|
||||
// (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand)
|
||||
type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]);
|
||||
let cells: [Cell; 7] = [
|
||||
// |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green.
|
||||
("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
|
||||
("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
|
||||
// Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855);
|
||||
// K is the backdrop's.
|
||||
("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]),
|
||||
// (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's.
|
||||
("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]),
|
||||
];
|
||||
let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]);
|
||||
// `group`: the cell group's /Group entries; `b`: what the second element paints.
|
||||
let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| {
|
||||
let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new());
|
||||
for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() {
|
||||
let x = 10 + i * 50;
|
||||
page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected));
|
||||
let rect = format!("{} 20 20 20 re f", x + 10);
|
||||
let n = 10 + i * 3;
|
||||
for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] {
|
||||
let body = format!("{paint} {rect}");
|
||||
objects += &format!(
|
||||
"{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
x + 40,
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
let body = "/E1 Do /BM gs /E2 Do";
|
||||
objects += &format!(
|
||||
"{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n",
|
||||
n + 2,
|
||||
x + 40,
|
||||
n + 1,
|
||||
body.len()
|
||||
);
|
||||
names += &format!("/G{i} {} 0 R ", n + 2);
|
||||
}
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
{objects}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{group}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec();
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
let p = render("/CS /DeviceCMYK /I true", &tint);
|
||||
for (i, (mode, ..)) in cells.iter().enumerate() {
|
||||
let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30));
|
||||
// The square is a solid CMYK fill; the group lands within rounding of it.
|
||||
assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}");
|
||||
}
|
||||
|
||||
// A group whose contents have no ink tints (here the second element is RGB) is
|
||||
// composited in RGB exactly as before: the same as a group blending in DeviceRGB.
|
||||
let rgb = |_: [f32; 4]| "1 0 0 rg".to_string();
|
||||
let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb);
|
||||
let rgb_group = render("/CS /DeviceRGB /I true", &rgb);
|
||||
assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before");
|
||||
}
|
||||
|
||||
/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost
|
||||
/// its blend mode in the tiles a paint covered completely, which drew black over magenta at
|
||||
/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested
|
||||
/// isolated group that names another blending space (here DeviceRGB) blends in that space
|
||||
/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated
|
||||
/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145).
|
||||
#[test]
|
||||
fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() {
|
||||
let render = |inner_cs: Option<&str>, scale: f32| {
|
||||
// `inner_cs`: the nested group's /CS and /I entries.
|
||||
let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f";
|
||||
let (outer_body, extra) = match inner_cs {
|
||||
None => (group.to_string(), String::new()),
|
||||
Some(cs) => (
|
||||
"/In Do".to_string(),
|
||||
format!(
|
||||
"6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n",
|
||||
group.len()
|
||||
),
|
||||
),
|
||||
};
|
||||
let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream
|
||||
{outer_body}
|
||||
endstream endobj
|
||||
{extra}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
page.len(),
|
||||
outer_body.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| {
|
||||
let (x, y) = ((x * scale) as u32, (y * scale) as u32);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
|
||||
|
||||
// 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely.
|
||||
for scale in [1.0, 4.0] {
|
||||
let p = render(None, scale);
|
||||
let strip = px(&p, 110.0, 245.0, scale);
|
||||
for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] {
|
||||
let group = px(&p, x, y, scale);
|
||||
assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly
|
||||
// as when the enclosing group is not offered to the CMYK path at all.
|
||||
let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0);
|
||||
let green = px(&nested_rgb, 110.0, 245.0, 1.0);
|
||||
assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK");
|
||||
// A nested DeviceCMYK group stays in the CMYK path.
|
||||
let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0);
|
||||
assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3);
|
||||
// A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the
|
||||
// enclosing group's CMYK, even when it names DeviceRGB.
|
||||
let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0);
|
||||
assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK");
|
||||
}
|
||||
|
||||
/// An element of a knockout group is first drawn in erase mode, which clears what the group
|
||||
/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must
|
||||
/// clear too: when the erase pass drew it through the CMYK path, it painted over the red
|
||||
/// beneath instead of clearing it. The page must match the same element with its nested group
|
||||
/// blending in `DeviceRGB`, which never takes the CMYK path.
|
||||
#[test]
|
||||
fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() {
|
||||
let render = |inner_cs: &str| {
|
||||
let knockout = "1 0 0 rg 20 20 160 160 re f /El Do";
|
||||
let element = "/Cm Do";
|
||||
let inner = "/A gs 0 1 0 0 k 60 60 80 80 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length 7 >> stream
|
||||
/Ko Do
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream
|
||||
{knockout}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream
|
||||
{element}
|
||||
endstream endobj
|
||||
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream
|
||||
{inner}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
knockout.len(),
|
||||
element.len(),
|
||||
inner.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error);
|
||||
// The centre of the inner square, (100, 100), is also the centre of the page.
|
||||
p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec()
|
||||
};
|
||||
let rgb = render("/DeviceRGB");
|
||||
let cmyk = render("/DeviceCMYK");
|
||||
assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}");
|
||||
assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}");
|
||||
}
|
||||
|
||||
/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated
|
||||
/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`).
|
||||
fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec<u8> {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{page}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream
|
||||
{group}
|
||||
endstream endobj
|
||||
6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
media[0],
|
||||
media[1],
|
||||
page.len(),
|
||||
bbox[0],
|
||||
bbox[1],
|
||||
bbox[2],
|
||||
bbox[3],
|
||||
group.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
|
||||
}
|
||||
|
||||
/// From the review of #5228: a shading pattern painted before the group left its pattern
|
||||
/// transform on the context, and the finished group was drawn through it, so the cyan half
|
||||
/// of the group showed magenta. The group must look the same whether or not a pattern was
|
||||
/// painted first.
|
||||
#[test]
|
||||
fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() {
|
||||
let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f";
|
||||
let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50));
|
||||
let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do");
|
||||
let alone = render("/G Do");
|
||||
assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}");
|
||||
assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}");
|
||||
}
|
||||
|
||||
/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop
|
||||
/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass
|
||||
/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to
|
||||
/// near black. Every 8-bit K level is checked against a plain fill of the same ink.
|
||||
#[test]
|
||||
fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() {
|
||||
let mut wrong = Vec::new();
|
||||
for level in 0..=255u8 {
|
||||
let k = format!("0 0 0 {} k", f32::from(level) / 255.0);
|
||||
let page = format!("{k} 0 0 20 20 re f /G Do");
|
||||
let group = format!("/B gs {k} 20 0 20 20 re f");
|
||||
let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10));
|
||||
let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10));
|
||||
if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) {
|
||||
wrong.push((level, plain, blended));
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}");
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! #4886: a non-isolated knockout group (`/I false /K true`) starts from the backdrop under it
|
||||
//! (ISO 32000-2 §11.4.7, §11.4.8), so each element blends with the page there, in its own blend
|
||||
//! mode, and replaces the elements before it under its shape. White with `/BM /Difference` over
|
||||
//! magenta is green; drawn into a layer of its own starting transparent, it stayed white.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
const MAGENTA: [u8; 3] = [255, 0, 255];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const WHITE: [u8; 3] = [255, 255, 255];
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
/// Renders a 200 x 200 page: magenta, then the group `/Fk` with group attributes `group` and
|
||||
/// content `content`. Its resources have `/D` (`/BM /Difference`), `/N` (`/BM /Normal`), `/H`
|
||||
/// (`/ca 0.5`), `/W`, a white square from 40 to 160 in a group of its own, `/Nest`, a group
|
||||
/// holding a non-isolated knockout group with a white Difference square from 80 to 120, and
|
||||
/// `/Img`, a white 1 x 1 image, `/Img2`, a blue 2 x 1 image whose soft mask is opaque on the
|
||||
/// left and transparent on the right, `/St`, a 2 x 1 stencil mask painting its left pixel, and
|
||||
/// the shading `/Sh0`, a mesh whose one triangle lies off the page, and `/Sh1`, a red mesh
|
||||
/// triangle from (90, 90) to (110, 90) and (100, 110).
|
||||
/// Returns a lookup of the colour at PDF point (x, y).
|
||||
fn render(group: &str, content: &str) -> impl Fn(u32, u32) -> [u8; 3] {
|
||||
let gs = "/ExtGState << /D << /BM /Difference >> /H << /ca 0.5 >> /N << /BM /Normal >> >>";
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Group << /S /Transparency /CS /DeviceRGB >> /Resources << /XObject << /Fk 5 0 R >> >> >>"
|
||||
.to_string(),
|
||||
stream("", "1 0 1 rg 0 0 200 200 re f /Fk Do"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceRGB {group} >> /Resources << {gs} /XObject << /W 6 0 R /Nest 7 0 R /Img 9 0 R /Img2 10 0 R /St 12 0 R >> /Shading << /Sh0 13 0 R /Sh1 14 0 R >> >>"),
|
||||
content,
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >>", "1 1 1 rg 40 40 120 120 re f"),
|
||||
stream("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /K2 8 0 R >> >>", "/K2 Do"),
|
||||
stream(
|
||||
&format!("/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I false /K true >> /Resources << {gs} >>"),
|
||||
"/D gs 1 1 1 rg 80 80 40 40 re f",
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FFFFFF>"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /SMask 11 0 R /Filter /ASCIIHexDecode",
|
||||
"0000FF0000FF>",
|
||||
),
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode", "FF00>"),
|
||||
stream("/Type /XObject /Subtype /Image /Width 2 /Height 1 /ImageMask true /BitsPerComponent 1 /Filter /ASCIIHexDecode", "40>"),
|
||||
stream(
|
||||
"/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [300 310 300 310 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
"000000FF0000 00FF00FF0000 0000FFFF0000>",
|
||||
),
|
||||
stream(
|
||||
"/ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 /Decode [0 255 0 255 0 1 0 1 0 1] /Filter /ASCIIHexDecode",
|
||||
"005A5AFF0000 006E5AFF0000 00646EFF0000>",
|
||||
),
|
||||
];
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{content}: {:?}", page.error);
|
||||
let (rgba, width) = (page.rgba.to_vec(), page.width);
|
||||
move |x, y| {
|
||||
// Pixel rows run top down.
|
||||
let offset = usize::try_from(((200 - y) * width + x) * 4).unwrap();
|
||||
[rgba[offset], rgba[offset + 1], rgba[offset + 2]]
|
||||
}
|
||||
}
|
||||
|
||||
fn check(at: &impl Fn(u32, u32) -> [u8; 3], x: u32, y: u32, want: [u8; 3], what: &str) {
|
||||
let got = at(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 2), "{what} at ({x}, {y}): {got:?}, expected {want:?}");
|
||||
}
|
||||
|
||||
const NON_ISOLATED: &str = "/I false /K true";
|
||||
const SQUARES: &str = "/D gs 1 1 1 rg 20 20 110 110 re f 70 70 110 110 re f";
|
||||
|
||||
#[test]
|
||||
fn a_non_isolated_knockout_group_blends_each_element_with_the_page() {
|
||||
let squares = render(NON_ISOLATED, SQUARES);
|
||||
check(&squares, 40, 40, GREEN, "first square");
|
||||
check(&squares, 160, 160, GREEN, "second square");
|
||||
// The second square blends with the page, not with the first: green, not magenta again.
|
||||
check(&squares, 100, 100, GREEN, "overlap");
|
||||
check(&squares, 190, 10, MAGENTA, "page outside the group's elements");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_last_element_over_a_point_decides_its_blend_mode() {
|
||||
// Normal blue at 50 % after a Difference square: blue over the page where they overlap.
|
||||
let blue_last = render(NON_ISOLATED, "/D gs 1 1 1 rg 20 20 110 110 re f /N gs 0 0 1 rg q /H gs 70 70 110 110 re f Q");
|
||||
check(&blue_last, 40, 40, GREEN, "Difference alone");
|
||||
check(&blue_last, 100, 100, [128, 0, 255], "Normal blue last");
|
||||
check(&blue_last, 160, 160, [128, 0, 255], "Normal blue alone");
|
||||
// The other way round: the Difference square replaces the blue and blends with the page.
|
||||
let white_last = render(NON_ISOLATED, "q /H gs 0 0 1 rg 20 20 110 110 re f Q /D gs 1 1 1 rg 70 70 110 110 re f");
|
||||
check(&white_last, 40, 40, [128, 0, 255], "Normal blue alone");
|
||||
check(&white_last, 100, 100, GREEN, "Difference last");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_group_in_a_non_isolated_knockout_group_blends_with_the_page() {
|
||||
let nested = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /W Do");
|
||||
check(&nested, 100, 100, GREEN, "nested group drawn with Difference over the knocked-out blue");
|
||||
check(&nested, 30, 30, [0, 0, 255], "blue beside the nested group");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_isolated_knockout_group_still_starts_transparent() {
|
||||
let isolated = render("/I true /K true", SQUARES);
|
||||
check(&isolated, 40, 40, WHITE, "isolated: nothing to blend with");
|
||||
check(&isolated, 100, 100, WHITE, "isolated overlap");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_and_a_partly_transparent_element_blend_with_the_page() {
|
||||
let image = render(NON_ISOLATED, "/D gs q 120 0 0 120 40 40 cm /Img Do Q");
|
||||
check(&image, 100, 100, GREEN, "white image drawn with Difference");
|
||||
check(&image, 20, 20, MAGENTA, "page beside the image");
|
||||
// Difference at 50 %: half the page, half the blend result (green).
|
||||
let half = render(NON_ISOLATED, "/D gs /H gs 1 1 1 rg 40 40 120 120 re f");
|
||||
check(&half, 100, 100, [128, 128, 128], "Difference at 50 %");
|
||||
}
|
||||
|
||||
/// A knockout group nested in another one's element is drawn first in that element's erase
|
||||
/// pass, which must leave the outer group's passes alone (review of #4886's fix).
|
||||
#[test]
|
||||
fn a_knockout_group_inside_an_element_keeps_the_outer_groups_passes() {
|
||||
let nested = render(NON_ISOLATED, "/D gs 1 1 1 rg 10 10 50 50 re f /N gs /Nest Do /D gs 1 1 1 rg 140 140 50 50 re f");
|
||||
check(&nested, 30, 30, GREEN, "Difference square before the nested groups");
|
||||
check(&nested, 165, 165, GREEN, "Difference square after the nested groups");
|
||||
}
|
||||
|
||||
/// A masked image (or, below, a stencil) in another pass than its own replaces what is under its mask
|
||||
/// there too: a blue image over a white Difference square stays blue where its mask is opaque,
|
||||
/// and leaves the square to blend with the page where it is transparent (review of #4886's fix).
|
||||
#[test]
|
||||
fn a_masked_image_replaces_what_is_under_its_mask_in_every_pass() {
|
||||
let masked = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs q 120 0 0 120 40 40 cm /Img2 Do Q");
|
||||
check(&masked, 60, 100, [0, 0, 255], "image over the square, mask opaque");
|
||||
check(&masked, 140, 100, GREEN, "square under the transparent half of the mask");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stencil_replaces_what_is_under_it_in_every_pass() {
|
||||
let stencil = render(NON_ISOLATED, "/D gs 1 1 1 rg 40 40 120 120 re f /N gs 0 0 1 rg q 120 0 0 120 40 40 cm /St Do Q");
|
||||
check(&stencil, 60, 100, [0, 0, 255], "stencil over the square");
|
||||
check(&stencil, 140, 100, GREEN, "square beside the stencil");
|
||||
}
|
||||
|
||||
/// A shading that paints nothing (a mesh off the page) replaces nothing in another pass either.
|
||||
#[test]
|
||||
fn a_shading_that_paints_nothing_clears_nothing_in_another_pass() {
|
||||
let mesh = render(NON_ISOLATED, "0 0 1 rg 20 20 160 160 re f /D gs /Sh0 sh");
|
||||
check(&mesh, 100, 100, [0, 0, 255], "blue under an off-page Difference mesh");
|
||||
}
|
||||
|
||||
/// A shading replaces only what is under the part it paints: a small mesh triangle over a
|
||||
/// square leaves the rest of the square, in its own pass, in another pass and in an isolated
|
||||
/// knockout group.
|
||||
#[test]
|
||||
fn a_small_mesh_replaces_only_what_is_under_its_triangle() {
|
||||
let square = "0 0 1 rg 20 20 160 160 re f";
|
||||
for (group, mode) in [(NON_ISOLATED, ""), (NON_ISOLATED, "/D gs"), ("/K true", "")] {
|
||||
let content = format!("{square} {mode} /Sh1 sh");
|
||||
let mesh = render(group, &content);
|
||||
check(&mesh, 40, 40, [0, 0, 255], &format!("{group} {mode}: square beside the triangle"));
|
||||
if mode.is_empty() {
|
||||
check(&mesh, 100, 97, [255, 0, 0], &format!("{group}: triangle"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@
|
||||
mod cid_fallback_query;
|
||||
mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod knockout_backdrop;
|
||||
mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod nonseparable_gray;
|
||||
mod open_type_cff_simple_font;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod stray_q_in_a_form;
|
||||
mod stroke_degenerate_cubic;
|
||||
mod substitute_widths;
|
||||
mod tiling_cell_placement;
|
||||
mod type3_text_clip;
|
||||
@@ -0,0 +1,95 @@
|
||||
//! The nonseparable blend modes keep grays gray (ISO 32000-2 §11.3.5.3). vello_cpu 0.0.8's
|
||||
//! `clip_color` divided 0 by 0 for grays that rounding left just outside 0..1 and drew them black
|
||||
//! under `Color`. Upstream's report also has black under `Luminosity` over a gray going wrong; that
|
||||
//! case already renders correctly here, so its test guards rather than reproduces.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
fn pdf(objects: &[String]) -> Vec<u8> {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, body) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{body}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn stream(dict: &str, content: &str) -> String {
|
||||
format!("<< {dict} /Length {} >>\nstream\n{content}\nendstream", content.len())
|
||||
}
|
||||
|
||||
/// Renders an isolated group holding a 16 × 16 grid of 6 pt cells, one per gray level, each
|
||||
/// drawn by `cell(level, "x y 6 6 re")` with `/B` (`/BM /{mode} /ca {alpha}`) available, and
|
||||
/// returns each level's centre pixel.
|
||||
fn gray_ramp(mode: &str, alpha: f32, cell: impl Fn(u8, &str) -> String) -> Vec<(u8, [u8; 3])> {
|
||||
let mut content = String::new();
|
||||
for level in 0..=255_u8 {
|
||||
let (x, y) = (2 + u32::from(level % 16) * 6, 2 + u32::from(level / 16) * 6);
|
||||
content.push_str(&cell(level, &format!("{x} {y} 6 6 re")));
|
||||
content.push('\n');
|
||||
}
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Fm0 5 0 R >> >> >>".to_string(),
|
||||
stream("", "/Fm0 Do"),
|
||||
stream(
|
||||
&format!(
|
||||
"/Type /XObject /Subtype /Form /BBox [0 0 100 100] /Group << /S /Transparency /I true >> /Resources << /ExtGState << /B << /BM /{mode} /ca {alpha} >> >> >>"
|
||||
),
|
||||
&content,
|
||||
),
|
||||
];
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(&objects)), RenderConfig::default());
|
||||
let page = renderer.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, preview: false });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
(0..=255_u8)
|
||||
.map(|level| {
|
||||
let (x, y) = (5 + u32::from(level % 16) * 6, 100 - 5 - u32::from(level / 16) * 6);
|
||||
let offset = usize::try_from((y * page.width + x) * 4).unwrap();
|
||||
(level, [page.rgba[offset], page.rgba[offset + 1], page.rgba[offset + 2]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gray(level: u8) -> f32 {
|
||||
f32::from(level) / 255.0
|
||||
}
|
||||
|
||||
fn assert_ramp(what: &str, got: &[(u8, [u8; 3])], expected: impl Fn(u8) -> f32) {
|
||||
let wrong: Vec<_> = got.iter().filter(|(level, rgb)| rgb.iter().any(|&c| (f32::from(c) - expected(*level)).abs() > 2.0)).collect();
|
||||
assert!(wrong.is_empty(), "{what}: {} levels off, (level, pixel): {wrong:?}", wrong.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_gray_under_color_over_nothing_keeps_its_lightness() {
|
||||
// With nothing beneath it the blend result is the source (§11.4.7): each gray at half
|
||||
// opacity, over the white page.
|
||||
let got = gray_ramp("Color", 0.5, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
|
||||
assert_ramp("Color", &got, |level| (f32::from(level) + 255.0) / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn black_under_luminosity_halves_every_opaque_gray() {
|
||||
// B(Cb, Cs) = SetLum(Cb, Lum(Cs)) is black for a black source, so at half opacity the
|
||||
// result is half the backdrop's gray.
|
||||
let got = gray_ramp("Luminosity", 0.5, |level, rect| format!("q {} g {rect} f /B gs 0 g {rect} f Q", gray(level)));
|
||||
assert_ramp("Luminosity", &got, |level| f32::from(level) / 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_into_an_empty_group_keeps_its_lightness_in_every_nonseparable_mode() {
|
||||
for mode in ["Color", "Luminosity", "Hue", "Saturation"] {
|
||||
let got = gray_ramp(mode, 1.0, |level, rect| format!("/B gs {} g {rect} f", gray(level)));
|
||||
assert_ramp(mode, &got, f32::from);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! A `Type1` font whose `FontFile3` has subtype `OpenType` (ISO 32000-2 §9.9) keeps its CFF
|
||||
//! program inside an OpenType table directory; it is drawn with that program, not with a
|
||||
//! standard-font stand-in (hayro-interpret patch (69)).
|
||||
//!
|
||||
//! The program is the renderer's own bundled fixed-pitch CFF face, so no font file is added.
|
||||
//! Drawn right, the bare program and the same program in an OpenType wrapper give the same
|
||||
//! pixels; the stand-in a serif font falls back to is a different face.
|
||||
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
const CFF: &[u8] = include_bytes!("../../../../vendor/hayro-interpret/assets/FoxitFixed.pfb");
|
||||
|
||||
/// `cff` in the smallest OpenType wrapper: an `OTTO` table directory with one `CFF ` table.
|
||||
fn open_type(cff: &[u8]) -> Vec<u8> {
|
||||
let mut out = b"OTTO".to_vec();
|
||||
out.extend_from_slice(&[0, 1, 0, 16, 0, 0, 0, 0]);
|
||||
out.extend_from_slice(b"CFF ");
|
||||
out.extend_from_slice(&0u32.to_be_bytes());
|
||||
out.extend_from_slice(&28u32.to_be_bytes());
|
||||
out.extend_from_slice(&u32::try_from(cff.len()).unwrap().to_be_bytes());
|
||||
out.extend_from_slice(cff);
|
||||
out
|
||||
}
|
||||
|
||||
/// A page showing "AgW" at 60 pt in a serif `Type1` font whose `FontFile3` is `program` of
|
||||
/// `subtype`, or which embeds no program when `None`.
|
||||
fn page(program: Option<(&[u8], &str)>) -> Vec<u8> {
|
||||
let file = if program.is_some() { " /FontFile3 7 0 R" } else { "" };
|
||||
let body = "BT /F1 60 Tf 10 30 Td (AgW) Tj ET";
|
||||
let mut objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 120] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_vec(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes(),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Synthetic-Semibold /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 103 /Widths [600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600 600] /FontDescriptor 6 0 R >>".to_vec(),
|
||||
format!("<< /Type /FontDescriptor /FontName /Synthetic-Semibold /Flags 34 /FontBBox [0 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 120{file} >>").into_bytes(),
|
||||
];
|
||||
if let Some((program, subtype)) = program {
|
||||
let mut stream = format!("<< /Subtype /{subtype} /Length {} >>\nstream\n", program.len()).into_bytes();
|
||||
stream.extend_from_slice(program);
|
||||
stream.extend_from_slice(b"\nendstream");
|
||||
objects.push(stream);
|
||||
}
|
||||
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn pixels(bytes: Vec<u8>) -> Vec<u8> {
|
||||
let mut renderer = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let image = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(image.error.is_none(), "{:?}", image.error);
|
||||
image.rgba.to_vec()
|
||||
}
|
||||
|
||||
fn inked(rgba: &[u8]) -> usize {
|
||||
rgba.chunks(4).filter(|p| p.first().is_some_and(|r| *r < 128)).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_open_type_wrapped_cff_program_draws_like_the_bare_program() {
|
||||
let bare = pixels(page(Some((CFF, "Type1C"))));
|
||||
assert!(inked(&bare) > 200, "the bare CFF program drew no text");
|
||||
let wrapped = pixels(page(Some((&open_type(CFF), "OpenType"))));
|
||||
assert!(wrapped == bare, "the OpenType-wrapped program drew a different face: the font fell back");
|
||||
}
|
||||
|
||||
/// A wrapper with no `CFF ` table still draws, with the stand-in a font without a program
|
||||
/// gets, rather than failing the page or reading the program anyway.
|
||||
#[test]
|
||||
fn a_wrapper_without_a_cff_table_falls_back() {
|
||||
let mut broken = open_type(CFF);
|
||||
broken.get_mut(12..16).unwrap().copy_from_slice(b"glyf");
|
||||
let rgba = pixels(page(Some((&broken, "OpenType"))));
|
||||
assert!(inked(&rgba) > 200, "the stand-in drew no text");
|
||||
assert!(rgba == pixels(page(None)), "the broken wrapper drew another face than the stand-in");
|
||||
assert!(rgba != pixels(page(Some((CFF, "Type1C")))), "the broken wrapper drew the embedded program");
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Contributor-original synthetic pages: a form XObject whose content has more `Q` than `q`.
|
||||
//! A content stream balances its own `q` and `Q` (ISO 32000-2 §8.4.2, §8.10.1), so a `Q` with
|
||||
//! nothing of its own stream to restore is ignored and cannot pop the states of the page that
|
||||
//! drew the form (vendored hayro-interpret patch 68). Each case is paired with the same page
|
||||
//! drawing a balanced form, which must come out the same.
|
||||
use pdfcraft_render::{PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Forms /B (balanced), /S2 (two stray `Q`s) and /S3 (three), each moving its own pen.
|
||||
const FORMS: [(&str, &[u8]); 3] =
|
||||
[("B", b"q BT /F1 12 Tf 200 0 Td ET Q"), ("S2", b"BT /F1 12 Tf 200 0 Td ET Q Q"), ("S3", b"BT /F1 12 Tf 200 0 Td ET Q Q Q")];
|
||||
|
||||
/// A page that shifts and clips inside `q … Q`, draws form `form` there, then text and a fill
|
||||
/// that depend on the shift and the clip, and after its `Q` text that depends on neither.
|
||||
fn page(form: &str, in_text: bool) -> String {
|
||||
let draw = if in_text {
|
||||
format!("BT /F1 12 Tf 72 300 Td (AA) Tj /{form} Do (SECRET) Tj ET")
|
||||
} else {
|
||||
format!("BT /F1 12 Tf 72 300 Td (AA) Tj ET /{form} Do BT /F1 12 Tf 72 280 Td (SECRET) Tj ET")
|
||||
};
|
||||
format!("q 1 0 0 1 20 -100 cm 0 0 200 400 re W n {draw} 0 0 1 rg 0 0 400 50 re f Q BT /F1 12 Tf 72 360 Td (TAIL) Tj ET")
|
||||
}
|
||||
|
||||
/// One 400 × 400 page per entry of `contents`, with Helvetica as /F1 and the forms of `FORMS`.
|
||||
fn pdf(contents: &[String]) -> Arc<Vec<u8>> {
|
||||
let first_page = 4 + FORMS.len();
|
||||
let n = contents.len();
|
||||
let kids: String = (0..n).map(|i| format!("{} 0 R ", first_page + 2 * i)).collect();
|
||||
let xobjects: String = FORMS.iter().enumerate().map(|(i, (name, _))| format!("/{name} {} 0 R ", 4 + i)).collect();
|
||||
let mut objects: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
format!("<< /Type /Pages /Kids [{kids}] /Count {n} >>").into_bytes(),
|
||||
format!("<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> /XObject << {xobjects}>> >>").into_bytes(),
|
||||
];
|
||||
for (_, body) in FORMS {
|
||||
objects.push(stream(b"/Type /XObject /Subtype /Form /BBox [0 0 400 400] /Resources 3 0 R", body));
|
||||
}
|
||||
for (i, content) in contents.iter().enumerate() {
|
||||
objects.push(
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 400 400] /Resources 3 0 R /Contents {} 0 R >>", first_page + 1 + 2 * i).into_bytes(),
|
||||
);
|
||||
objects.push(stream(b"", content.as_bytes()));
|
||||
}
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(object);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
out.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
Arc::new(out)
|
||||
}
|
||||
|
||||
fn stream(dict: &[u8], body: &[u8]) -> Vec<u8> {
|
||||
let mut s = b"<< ".to_vec();
|
||||
s.extend_from_slice(dict);
|
||||
s.extend_from_slice(format!(" /Length {} >>\nstream\n", body.len()).as_bytes());
|
||||
s.extend_from_slice(body);
|
||||
s.extend_from_slice(b"\nendstream");
|
||||
s
|
||||
}
|
||||
|
||||
fn request(page: usize, kind: RequestKind) -> RenderRequest {
|
||||
RenderRequest { page, kind, tile: None, scale: 1.0, tag: 0, preview: false }
|
||||
}
|
||||
|
||||
fn texts(contents: &[String]) -> Vec<Arc<PageText>> {
|
||||
let mut renderer = PageRenderer::new(pdf(contents), RenderConfig::default());
|
||||
(0..contents.len())
|
||||
.map(|page| {
|
||||
let out = renderer.render(request(page, RequestKind::Text));
|
||||
assert!(out.error.is_none(), "page {} text error: {:?}", page + 1, out.error);
|
||||
out.text.expect("page text")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The boxes of the glyphs of `word`'s one match on the page.
|
||||
fn boxes(text: &PageText, word: &str) -> Vec<[f32; 4]> {
|
||||
let hits = text.find(word);
|
||||
assert_eq!(hits.len(), 1, "one {word} in {:?}", text.plain_text());
|
||||
hits[0].clone().map(|i| text.glyphs[i].rect).collect()
|
||||
}
|
||||
|
||||
fn assert_same_boxes(a: &[[f32; 4]], b: &[[f32; 4]]) {
|
||||
assert_eq!(a.len(), b.len());
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert!(x.iter().zip(y).all(|(p, q)| (p - q).abs() < 0.01), "{x:?} != {y:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_text_like_the_balanced_form(in_text: bool) {
|
||||
let pages = texts(&[page("B", in_text), page("S2", in_text), page("S3", in_text)]);
|
||||
for (stray, name) in pages[1..].iter().zip(["S2", "S3"]) {
|
||||
for word in ["SECRET", "TAIL"] {
|
||||
let (got, want) = (boxes(stray, word), boxes(&pages[0], word));
|
||||
assert_eq!(got.len(), want.len(), "{name}: {word}");
|
||||
assert_same_boxes(&got, &want);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_does_not_undo_the_pages_cm() {
|
||||
assert_text_like_the_balanced_form(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_inside_a_text_object_does_not_undo_the_pages_cm() {
|
||||
assert_text_like_the_balanced_form(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forms_stray_q_does_not_undo_the_pages_clip() {
|
||||
let contents = [page("B", false), page("S2", false), page("S3", false)];
|
||||
let mut renderer = PageRenderer::new(pdf(&contents), RenderConfig::default());
|
||||
let balanced = renderer.render(request(0, RequestKind::Pixels));
|
||||
assert!(balanced.error.is_none());
|
||||
for i in 1..contents.len() {
|
||||
let stray = renderer.render(request(i, RequestKind::Pixels));
|
||||
assert!(stray.error.is_none());
|
||||
assert_eq!((stray.width, stray.height), (balanced.width, balanced.height));
|
||||
assert!(stray.rgba == balanced.rgba, "page {} draws differently from the balanced form", i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_balanced_q_inside_a_form_still_restores() {
|
||||
// The balanced form's pen move stays inside its own `q … Q`, so SECRET sits where the page
|
||||
// put it: 72 + 20 from the page's `cm`, and to the right of AA.
|
||||
let pages = texts(&[page("B", false)]);
|
||||
let secret = boxes(&pages[0], "SECRET");
|
||||
assert!((secret[0][0] - 92.0).abs() < 0.5, "SECRET starts at {}", secret[0][0]);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! A contributor-original synthetic page reproducing #4907: an outline that ends in a cubic whose
|
||||
//! control points all sit 0.001 pt from its start was not drawn at all, because the stroker put
|
||||
//! NaN into its outline and the rasterizer dropped the whole path.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A one-page 100 × 20 pt PDF drawing `content`, with a cross-reference table.
|
||||
fn pdf(content: &str) -> Vec<u8> {
|
||||
let objects = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 20] /Contents 4 0 R >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
|
||||
];
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×.
|
||||
fn red_at(content: &str, x: usize, y: usize) -> u8 {
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
let at = (y * out.width as usize + x) * 4;
|
||||
*out.rgba.get(at).expect("the pixel lies on the page")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_ending_in_a_tiny_degenerate_cubic_is_drawn() {
|
||||
// The line runs from (10, 10) to about (84, 9.5) in page space; (50, 10) from the top left is
|
||||
// inside its 2 pt width. The cubic's exact coordinates are what trigger the NaN. The control,
|
||||
// without the cubic, is the baseline: it draws with or without the fix.
|
||||
let path = "0 0 m 73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c S";
|
||||
let control = "0 0 m 73.763 -0.536 l S";
|
||||
for content in [control, path] {
|
||||
let content = format!("0 G 2 w 1 0 0 1 10 10 cm {content}");
|
||||
let red = red_at(&content, 50, 10);
|
||||
assert!(red < 64, "`{content}` left the line's pixel at red {red}, want black");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -2078,7 +2044,6 @@ fn draw_pages(
|
||||
comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image);
|
||||
});
|
||||
*out = PageOut {
|
||||
badge_hover,
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
copy_selected,
|
||||
@@ -3075,7 +3040,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();
|
||||
}
|
||||
@@ -1702,6 +1730,9 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
let form = |app: &PdfCraftApp| app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|doc| doc.form.clone());
|
||||
// A rename also renames the field in the formulas that read it.
|
||||
let before = props.name.as_ref().and_then(|_| form(app));
|
||||
// The widget being edited, by object: a radio button that joins a group by its new name
|
||||
// is appended after the group's own buttons, so its index changes.
|
||||
let widget = before.as_ref().and_then(|all| all.iter().find(|f| f.name == d.field)?.widgets.get(d.widget).map(|w| w.obj));
|
||||
if app.apply_edit(Edit::SetFieldProps { name: d.field.clone(), props: Box::new(props) })
|
||||
&& let Some(i) = app.active
|
||||
{
|
||||
@@ -1710,7 +1741,10 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
let typed = d.name.trim();
|
||||
let prefix = d.field.rsplit_once('.').filter(|_| !typed.contains('.')).map(|(p, _)| format!("{p}.")).unwrap_or_default();
|
||||
let name = format!("{prefix}{typed}");
|
||||
if let (Some(before), Some(after)) = (before, form(app)) {
|
||||
let after = form(app);
|
||||
let index =
|
||||
widget.and_then(|obj| after.as_ref()?.iter().find(|f| f.name == name)?.widgets.iter().position(|w| w.obj == obj)).unwrap_or(d.widget);
|
||||
if let (Some(before), Some(after)) = (before, after) {
|
||||
let n = pdfcraft_engine::form_calculations_changed(&before, &after).iter().filter(|f| **f != name).count();
|
||||
if n > 0 {
|
||||
app.notify(crate::i18n::trn(
|
||||
@@ -1721,7 +1755,7 @@ fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) {
|
||||
));
|
||||
}
|
||||
}
|
||||
app.views[i].prepare.selected = Some((name, d.widget));
|
||||
app.views[i].prepare.selected = Some((name, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1795,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();
|
||||
|
||||
@@ -681,6 +681,7 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
| Edit::ResetForm { .. }
|
||||
| Edit::AddField { .. }
|
||||
| Edit::DeleteField { .. }
|
||||
| Edit::DeleteWidget { .. }
|
||||
| Edit::DuplicateField { .. }
|
||||
| Edit::SetFieldProps { .. }
|
||||
| Edit::SetFieldImage { .. }
|
||||
@@ -743,6 +744,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).
|
||||
|
||||
@@ -155,6 +155,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;
|
||||
|
||||
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user