mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-06 02:21:45 +00:00
Merge main into fix/format-text-fields-fit-pane
Conflicts with main's font menu, size field and accessible names, resolved by keeping both: the font list keeps main's searchable installed-fonts menu inside its outline; the line spacing list and number fields keep their names. The size field now reserves room for "Mixed", so MIN_ROW counts it (62 pt, measured for English), and the spacing columns take their width from the font list so a longer translation of "Mixed" can't widen them.
This commit is contained in:
commit
f711b163c6
221 files changed
+21889
-2203
No files matched your search
Generated
+51
-46
@@ -1178,17 +1178,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1211,7 +1211,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-keymap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"serde",
|
||||
@@ -1221,7 +1221,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-menu"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"craft-keymap",
|
||||
"craft-quit",
|
||||
@@ -1234,7 +1234,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -1248,7 +1248,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-quit"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"egui",
|
||||
@@ -1259,7 +1259,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1280,7 +1280,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-update",
|
||||
@@ -4232,7 +4232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-a11y"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4244,7 +4244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-annot"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4255,7 +4255,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-audit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-automation"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"flate2",
|
||||
@@ -4288,7 +4288,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cli"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"pdfcraft-audit",
|
||||
@@ -4306,11 +4306,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-compare"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-content"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
@@ -4318,7 +4318,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-cos"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"hayro-syntax",
|
||||
@@ -4333,11 +4333,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-create"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"hayro-ccitt",
|
||||
"hayro-jbig2",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"pdfcraft-cos",
|
||||
@@ -4353,7 +4352,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-crypt"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"md-5",
|
||||
@@ -4365,7 +4364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-edit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"kurbo 0.13.1",
|
||||
@@ -4374,6 +4373,8 @@ dependencies = [
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"proptest",
|
||||
"sha2 0.11.1",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
@@ -4381,7 +4382,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-engine"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
@@ -4393,10 +4394,12 @@ dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-audit",
|
||||
"pdfcraft-compare",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-create",
|
||||
"pdfcraft-edit",
|
||||
"pdfcraft-export",
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-geom",
|
||||
@@ -4423,14 +4426,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-export"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-filters"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
@@ -4443,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-fonts"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"harfrust",
|
||||
"pdfcraft-cos",
|
||||
@@ -4453,7 +4456,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-forms"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4466,11 +4469,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-geom"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-js"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"boa_engine",
|
||||
"chrono",
|
||||
@@ -4478,7 +4481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-measure"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4491,14 +4494,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-model"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-cos",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"ocrs",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4509,7 +4512,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"pdfcraft-annot",
|
||||
@@ -4525,11 +4528,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-oracle"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-organize"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-content",
|
||||
@@ -4540,7 +4543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-platform"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"futures-lite",
|
||||
@@ -4550,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-preflight"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"pdfcraft-content",
|
||||
@@ -4561,7 +4564,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-print"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-print",
|
||||
"pdfcraft-content",
|
||||
@@ -4573,7 +4576,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-redact"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-content",
|
||||
@@ -4590,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-render"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"flate2",
|
||||
@@ -4609,7 +4612,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-sign"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aws-lc-rs",
|
||||
@@ -4645,11 +4648,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-testkit"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ui-egui"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"base64 0.23.1",
|
||||
@@ -4673,6 +4676,7 @@ dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"pdfcraft-content",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-engine",
|
||||
"pdfcraft-fonts",
|
||||
@@ -4683,6 +4687,7 @@ dependencies = [
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"pollster 1.0.1",
|
||||
"proptest",
|
||||
"rfd",
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
@@ -4701,7 +4706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-web"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4713,7 +4718,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfa"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"image",
|
||||
@@ -4727,7 +4732,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-xfdf"
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"pdfcraft-annot",
|
||||
"pdfcraft-cos",
|
||||
|
||||
+41
-41
@@ -5,7 +5,7 @@ exclude = ["vendor"]
|
||||
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.1"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -13,52 +13,52 @@ repository = "https://github.com/storytold/pdfcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared frontend components; engines remain independent of egui.
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["serde"] }
|
||||
# Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`.
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# One menu definition for the native macOS menu bar and the in-window bar elsewhere.
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", default-features = false }
|
||||
craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", default-features = false }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff", features = ["egui"] }
|
||||
# internal
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.16.1" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.16.1" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.16.1" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.16.1" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.16.1" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.16.1" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.16.1" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.16.1" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.16.1" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.16.1" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.16.1" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.16.1" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.16.1" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.16.1" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.16.1" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.16.1" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.16.1" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.16.1" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.16.1" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.16.1" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.16.1" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.16.1" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.16.1" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.16.1" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.16.1" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.16.1" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.16.1" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.16.1" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.16.1" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.16.1" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.16.1" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.16.1" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.16.1", default-features = false }
|
||||
pdfcraft-geom = { path = "crates/geom", version = "0.17.0" }
|
||||
pdfcraft-filters = { path = "crates/filters", version = "0.17.0" }
|
||||
pdfcraft-cos = { path = "crates/cos", version = "0.17.0" }
|
||||
pdfcraft-crypt = { path = "crates/crypt", version = "0.17.0" }
|
||||
pdfcraft-organize = { path = "crates/organize", version = "0.17.0" }
|
||||
pdfcraft-measure = { path = "crates/measure", version = "0.17.0" }
|
||||
pdfcraft-annot = { path = "crates/annot", version = "0.17.0" }
|
||||
pdfcraft-audit = { path = "crates/audit", version = "0.17.0" }
|
||||
pdfcraft-fonts = { path = "crates/fonts", version = "0.17.0" }
|
||||
pdfcraft-model = { path = "crates/model", version = "0.17.0" }
|
||||
pdfcraft-content = { path = "crates/content", version = "0.17.0" }
|
||||
pdfcraft-redact = { path = "crates/redact", version = "0.17.0" }
|
||||
pdfcraft-print = { path = "crates/print", version = "0.17.0" }
|
||||
pdfcraft-xfdf = { path = "crates/xfdf", version = "0.17.0" }
|
||||
pdfcraft-sign = { path = "crates/sign", version = "0.17.0" }
|
||||
pdfcraft-optimize = { path = "crates/optimize", version = "0.17.0" }
|
||||
pdfcraft-a11y = { path = "crates/a11y", version = "0.17.0" }
|
||||
pdfcraft-oracle = { path = "crates/oracle", version = "0.17.0" }
|
||||
pdfcraft-testkit = { path = "crates/testkit", version = "0.17.0" }
|
||||
pdfcraft-ocr = { path = "crates/ocr", version = "0.17.0" }
|
||||
pdfcraft-js = { path = "crates/js", version = "0.17.0" }
|
||||
pdfcraft-compare = { path = "crates/compare", version = "0.17.0" }
|
||||
pdfcraft-preflight = { path = "crates/preflight", version = "0.17.0" }
|
||||
pdfcraft-export = { path = "crates/export", version = "0.17.0" }
|
||||
pdfcraft-edit = { path = "crates/edit", version = "0.17.0" }
|
||||
pdfcraft-create = { path = "crates/create", version = "0.17.0" }
|
||||
pdfcraft-forms = { path = "crates/forms", version = "0.17.0" }
|
||||
pdfcraft-xfa = { path = "crates/xfa", version = "0.17.0" }
|
||||
pdfcraft-render = { path = "crates/render", version = "0.17.0" }
|
||||
pdfcraft-engine = { path = "crates/engine", version = "0.17.0" }
|
||||
pdfcraft-platform = { path = "crates/platform", version = "0.17.0" }
|
||||
pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.17.0" }
|
||||
pdfcraft-automation = { path = "crates/automation", version = "0.17.0", default-features = false }
|
||||
|
||||
# pdf
|
||||
hayro = "0.7.1"
|
||||
|
||||
@@ -547,9 +547,18 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | **Reading, organizing and protecting PDFs · you are here** | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+19
@@ -1,5 +1,11 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 further review: inherited drawing/text state, affine image gestures, paste/selection ownership, no-op scripts and menu permissions; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 latest comments: explicit image-paste events, mixed-size field refresh, and documented mixed-script normalization with Undo; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-01 | **Last updated:** 2026-11-01 | **Change:** patch (M7, PR #2155 review follow-up: preserve image crop/tag/stream ownership, resize in page orientation, harden clipboard and text formatting, and reload installed fonts; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: GBK rasters compared with explicit Unicode and independently selected installed faces; wrong-glyph and wrong-family negative controls fail; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-31 | **Last updated:** 2026-10-31 | **Change:** patch (M2, PR #4172: integrate main fa5f9f8c; retain CJK viewer and upstream raster regressions, restore explicit display_config imports; estimates unchanged)
|
||||
> **Last reviewed:** 2026-10-28 · **Last updated:** 2026-10-28 · **Change:** patch (M2, PR #4172: integrate main 5af10276; retain both roadmap histories and viewer-only CJK font policy; estimates unchanged)
|
||||
@@ -213,6 +219,18 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-11-01 (M7, #5657):** Header/footer, watermark and background additions and replacements stage the complete call before committing. Synthetic later-page failures preserve exact incremental bytes, object count, dirty state and allocation cursor; success controls retain other marks and shared/inherited resources. Estimates unchanged.
|
||||
|
||||
- **2026-11-01 (M14, #5572):** Preferences ▸ Documents and view sets how many recent files to keep, from 0 to 50 with 12 by default. At 0 Home has no Recent section and File ▸ Open Recent is disabled. The list is cut when Preferences closes, so the number can pass through 0 on its way to another.
|
||||
|
||||
- **2026-11-01 (M5/M14, customizable hotbar, freehand highlight, and moveable navigation rail):** Added Freehand-Highlight (`/Subtype /Ink`, `/IT /FreehandHighlight`, Multiply blending, 100 pt max line width) inside the standard Highlight flyout and headless `comment_add`; added a customizable floating or side-docked hotbar with a full-side chrome strip mode, settings gear icon anchored at the bar end, and 24–64 px sizing; and added Left/Right docking for the navigation rail (`rail_side`). Focused unit and integration tests pass across `annot`, `automation`, and `ui-egui`. Estimates unchanged.
|
||||
- 2026-11-01: PR #2155 further review preserves inherited Form text state and soft-mask chronology during Arrange, composes crop-group moves in the proper scope, and uses affine/press-relative image resize plus a shared rotation pivot. No-op script buttons preserve hidden source text; paste coordinates, visible selection ownership, and Snapshot/Export permissions have dedicated regressions. Candidate only; estimates unchanged.
|
||||
|
||||
- 2026-11-01: PR #2155 latest follow-up handles explicit image-paste events without release-time guesses, refreshes the font-size field when a selection becomes mixed, and specifies mixed-script normalization with exact Undo restoration. Candidate only; estimates unchanged.
|
||||
|
||||
- 2026-11-01: PR #2155 review follow-up preserves editor crops through group moves and keeps arranged artwork outside added-item streams; unsafe nested-tag extraction is refused. Image handles and flips follow page orientation, added images accept general transforms, and clipboard/text/Home/font-discovery edge cases have focused regressions. Candidate only; estimates unchanged.
|
||||
- 2026-11-01: PR #2155 maintainer edits remove redundant image/text helpers and add regressions for crop ownership, arrange scope/state fidelity, clipboard precedence, crop-drag bounds, and font scanning. Transform and Unicode-script tools complete the headless paths.
|
||||
|
||||
- **2026-10-18 (M3, #1354, local candidate):** Integrated --two-paged with current main, retaining upstream modal/picker ownership and single-tab reuse. Native launch/opening/password regressions, negative checks against the old wrapper, visual inspection, formatting, strict scoped Clippy, layers, assets, parity and scoped WASM compilation pass. Three existing socket tests are unavailable in this workspace; one existing network test remains ignored. No push or GitHub merge; estimates unchanged.
|
||||
- **2026-10-31 (M2, PR #4172 review):** Added same-machine pixel oracles for GBK Song, Hei, Kai and FangSong, including all four subset-prefixed Founder aliases. Reference PDFs use explicit Unicode mappings and independently selected installed faces; font data and rasters remain in memory. All eight cases ran; wrong-glyph and wrong-family production mutations each failed the new assertion and were restored. Validation: 422 renderer unit tests, renderer all-target Clippy with -D warnings, formatting, layers/assets/parity and renderer WASM checks passed; existing optional-patch/WASM warnings and parity notes remain. Estimates unchanged.
|
||||
|
||||
@@ -727,6 +745,7 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
| 2026-10-28 | patch | M2, PR #4172: integrate main 5af10276 and retain both roadmap histories; viewer-only CJK and print/output font policies and estimates unchanged. |
|
||||
| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. |
|
||||
| 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! --theme light|dark|system --language auto|<code> --mode all|read|edit|convert|sign --tool <catalogue id> --left open|closed
|
||||
//! --organize on --fields on --dialog properties|shortcuts|about --palette <query> --home on
|
||||
//! --cover on|off --default-layout single|continuous|two-up|two-page --default-continuous on|off --default-zoom fit-width|fit-page|<percent> --always-default-view on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off
|
||||
//! --page-gaps on|off --default-page-gaps on|off --recent-files 0..50
|
||||
//! --combine-view grid|list --combine-zoom 60..200`
|
||||
//!
|
||||
//! `--new-window` opens a window of its own. Without it, on Windows, a launch that only names files
|
||||
|
||||
@@ -180,3 +180,6 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Combiler01]
|
||||
sync_github_name = true
|
||||
@@ -392,9 +392,13 @@ struct AppearanceDrawing {
|
||||
}
|
||||
|
||||
impl AppearanceDrawing {
|
||||
/// Initialize an appearance drawing buffer and configure its graphics state for opacity and blend mode.
|
||||
fn new(context: &AppearanceContext<'_>) -> Self {
|
||||
let mut drawing = Self::default();
|
||||
if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") {
|
||||
if let Some(gs) = ext_gstate(
|
||||
context.opacity,
|
||||
context.subtype == b"Highlight" || (context.subtype == b"Ink" && context.dict.name(b"IT") == Some(b"FreehandHighlight")),
|
||||
) {
|
||||
drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs));
|
||||
drawing.content.push_str("/GS0 gs\n");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,16 @@ pub(crate) fn grow(r: [f64; 4], by: f64) -> [f64; 4] {
|
||||
[r[0] - by, r[1] - by, r[2] + by, r[3] + by]
|
||||
}
|
||||
|
||||
/// Tight bounding box of ink strokes, including Bezier control points for strokes with three or
|
||||
/// more points (matching [`appearance::smooth_segments`]).
|
||||
pub(crate) fn ink_bounds(strokes: &[Vec<[f64; 2]>]) -> Option<[f64; 4]> {
|
||||
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
|
||||
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
|
||||
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
|
||||
});
|
||||
bounds(strokes.iter().flatten().copied().chain(controls))
|
||||
}
|
||||
|
||||
pub(crate) fn ending_draws(ending: LineEnding) -> bool {
|
||||
ending != LineEnding::None
|
||||
}
|
||||
@@ -87,17 +97,11 @@ pub(crate) fn rect_for(shape: &Shape, style: &Style) -> Result<[f64; 4], AnnotEr
|
||||
let pad = half + if ending_draws(*start) || ending_draws(*end) { appearance::arrow_size(style.width) } else { 0.0 };
|
||||
grow(bounds([*from, *to].into_iter()).unwrap_or_default(), pad + 1.0)
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => {
|
||||
if strokes.iter().all(Vec::is_empty) || !strokes.iter().flatten().all(|p| finite(p)) {
|
||||
return Err(bad("drawing (no points)"));
|
||||
}
|
||||
// Strokes of three or more points are drawn as curves, which stay within their
|
||||
// points and control points.
|
||||
let controls = strokes.iter().filter(|s| s.len() > 2).flat_map(|s| {
|
||||
let pts: Vec<(f64, f64)> = s.iter().map(|p| (p[0], p[1])).collect();
|
||||
appearance::smooth_segments(&pts).into_iter().flat_map(|[a, b, _]| [[a.0, a.1], [b.0, b.1]])
|
||||
});
|
||||
grow(bounds(strokes.iter().flatten().copied().chain(controls)).unwrap_or_default(), half + 1.0)
|
||||
grow(ink_bounds(strokes).unwrap_or_default(), half + 1.0)
|
||||
}
|
||||
Shape::Polygon { vertices, cloud } => {
|
||||
let b = bounds(vertices.iter().copied())
|
||||
@@ -164,6 +168,7 @@ fn subject(shape: &Shape) -> &'static str {
|
||||
Shape::Oval { .. } => "Oval",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Arrow",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::FreehandHighlight { .. } => "Freehand Highlight",
|
||||
Shape::Ink { .. } => "Pencil",
|
||||
Shape::TextBox { .. } => "Text Box",
|
||||
Shape::Typewriter { .. } => "Typewriter",
|
||||
@@ -292,10 +297,13 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Ink { strokes } | Shape::Signature { strokes } => {
|
||||
Shape::Ink { strokes } | Shape::FreehandHighlight { strokes } | Shape::Signature { strokes } => {
|
||||
d.set(b"C".to_vec(), rgb(style.color));
|
||||
let list = strokes.iter().filter(|s| !s.is_empty()).map(|s| num_array(&s.concat())).collect();
|
||||
d.set(b"InkList".to_vec(), Object::Array(list));
|
||||
if matches!(&new.shape, Shape::FreehandHighlight { .. }) {
|
||||
d.set(b"IT".to_vec(), Object::name("FreehandHighlight"));
|
||||
}
|
||||
border(&mut d);
|
||||
}
|
||||
Shape::Polygon { vertices, cloud } => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::create::{
|
||||
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, normalize, num_array, rect_for, rgb,
|
||||
add_annotation, base_dict, bounds, callout_attach, ending_draws, ending_pair, finite, grow, ink_bounds, normalize, num_array, rect_for, rgb,
|
||||
};
|
||||
use super::draw::{draw_appearance, redraw_or_restore, set_appearance};
|
||||
use super::info::{line_endings_of, text_value};
|
||||
@@ -529,7 +529,33 @@ fn apply_text_box(doc: &mut Document, r: ObjRef, rect: [f64; 4], meta: &Meta, ne
|
||||
}
|
||||
|
||||
fn border_width_of(d: &Dict) -> f64 {
|
||||
d.get(b"BS").and_then(|b| b.as_dict()).and_then(|b| b.get(b"W")).and_then(|w| w.as_f64()).unwrap_or(1.0).max(0.0)
|
||||
appearance::border_width(d)
|
||||
}
|
||||
|
||||
/// Recompute `/Rect` for an `Ink` annotation when its stroke width changes from `old_w` to `w`.
|
||||
/// Preserves any baseline margin beyond `old_w / 2.0` (at least `1.0` pt) without accumulating
|
||||
/// padding across repeated width increases and decreases.
|
||||
fn ink_rect_for_width(d: &Dict, old_w: f64, w: f64) -> Option<[f64; 4]> {
|
||||
let list = d.get(b"InkList")?.as_array()?;
|
||||
let mut strokes: Vec<Vec<[f64; 2]>> = Vec::with_capacity(list.len());
|
||||
for s in list {
|
||||
let pts: Vec<f64> = s.as_array()?.iter().map(Object::as_f64).collect::<Option<_>>()?;
|
||||
strokes.push(pts.as_chunks::<2>().0.iter().map(|p| [p[0], p[1]]).collect());
|
||||
}
|
||||
let inner = ink_bounds(&strokes)?;
|
||||
let vals: Vec<f64> = d.get(b"Rect").and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default();
|
||||
let Some(old_rect) = four(&vals).map(normalize) else {
|
||||
return Some(grow(inner, w / 2.0 + 1.0));
|
||||
};
|
||||
let old_half = old_w / 2.0;
|
||||
let new_half = w / 2.0;
|
||||
let base = [
|
||||
(inner[0] - old_rect[0] - old_half).max(1.0),
|
||||
(inner[1] - old_rect[1] - old_half).max(1.0),
|
||||
(old_rect[2] - inner[2] - old_half).max(1.0),
|
||||
(old_rect[3] - inner[3] - old_half).max(1.0),
|
||||
];
|
||||
Some([inner[0] - base[0] - new_half, inner[1] - base[1] - new_half, inner[2] + base[2] + new_half, inner[3] + base[3] + new_half])
|
||||
}
|
||||
|
||||
/// Change a comment's colour, opacity, line width and/or line endings, and redraw it.
|
||||
@@ -576,10 +602,17 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
}
|
||||
}
|
||||
if let Some(w) = width {
|
||||
let old_w = border_width_of(d);
|
||||
let w = w.max(0.0);
|
||||
let mut bs = d.get(b"BS").and_then(|b| b.as_dict()).cloned().unwrap_or_default();
|
||||
bs.set(b"W".to_vec(), Object::Real(w.max(0.0)));
|
||||
bs.set(b"W".to_vec(), Object::Real(w));
|
||||
d.set(b"BS".to_vec(), Object::Dict(bs));
|
||||
d.remove(b"Border");
|
||||
if d.name(b"Subtype") == Some(b"Ink")
|
||||
&& let Some(rect) = ink_rect_for_width(d, old_w, w)
|
||||
{
|
||||
d.set(b"Rect".to_vec(), num_array(&rect));
|
||||
}
|
||||
}
|
||||
if let Some(ends) = endings {
|
||||
if ends.len() == 1 {
|
||||
|
||||
@@ -2194,3 +2194,59 @@ fn degenerate_and_absurd_leader_and_caption_values_do_not_panic() {
|
||||
assert!(long.contains(&format!("({}) Tj", "w".repeat(256))), "cut to 256");
|
||||
assert!(!long.contains(&"w".repeat(257)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freehand_highlight_is_compatible_ink_with_marker_blending() {
|
||||
let shape = Shape::FreehandHighlight { strokes: vec![vec![[20.0, 100.0], [120.0, 100.0]]] };
|
||||
let mut annotation = new(0, shape.clone());
|
||||
annotation.style = Style::default_for(&shape);
|
||||
let mut doc = fixture();
|
||||
let index = add_annotation(&mut doc, &annotation, &meta("marker")).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
let ink = &list(&doc, 0)[index];
|
||||
|
||||
assert_eq!(ink.name(b"Subtype"), Some(&b"Ink"[..]));
|
||||
assert_eq!(ink.name(b"IT"), Some(&b"FreehandHighlight"[..]));
|
||||
assert_eq!(ink.get(b"CA").and_then(Object::as_f64), Some(0.35));
|
||||
let ap = ink.get(b"AP").unwrap().as_dict().unwrap().reference(b"N").unwrap();
|
||||
let resources = doc.get(ap).as_dict().unwrap().get(b"Resources").cloned().unwrap();
|
||||
let blend = resources.as_dict().unwrap().get(b"ExtGState").unwrap().as_dict().unwrap().get(b"GS0").unwrap().as_dict().unwrap();
|
||||
assert_eq!(blend.name(b"BM"), Some(&b"Multiply"[..]));
|
||||
|
||||
let mut doc = doc;
|
||||
let rect_of = |doc: &Document, idx: usize| -> Vec<f64> {
|
||||
list(doc, 0)[idx].get(b"Rect").unwrap().as_array().unwrap().iter().filter_map(Object::as_f64).collect()
|
||||
};
|
||||
let rect_before = rect_of(&doc, index);
|
||||
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider")).unwrap();
|
||||
let rect_after = rect_of(&doc, index);
|
||||
assert!((rect_after[0] - (rect_before[0] - 10.0)).abs() < 1e-4);
|
||||
assert!((rect_after[2] - (rect_before[2] + 10.0)).abs() < 1e-4);
|
||||
|
||||
// Repeated increase/decrease cycles must keep bounds stable and never accumulate padding.
|
||||
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(14.0), endings: None }, &meta("narrower")).unwrap();
|
||||
let rect_restored = rect_of(&doc, index);
|
||||
for (a, b) in rect_restored.iter().zip(rect_before.iter()) {
|
||||
assert!((a - b).abs() < 1e-4, "expected {rect_before:?}, got {rect_restored:?}");
|
||||
}
|
||||
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(34.0), endings: None }, &meta("wider-again")).unwrap();
|
||||
let rect_second_wide = rect_of(&doc, index);
|
||||
for (a, b) in rect_second_wide.iter().zip(rect_after.iter()) {
|
||||
assert!((a - b).abs() < 1e-4, "expected {rect_after:?}, got {rect_second_wide:?}");
|
||||
}
|
||||
|
||||
// Imported ink whose width is specified only by `/Border [0 0 20]` must use 20 pt as its
|
||||
// previous width (shrinking by 5 pt on a decrease to 10 pt rather than expanding as if 1 pt).
|
||||
let (_, r) = lookup::annot_ref(&mut doc, 0, index).unwrap();
|
||||
doc.update_dict(r, |d| {
|
||||
d.remove(b"BS");
|
||||
d.set(b"Border".to_vec(), Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(20.0)]));
|
||||
d.set(b"Rect".to_vec(), create::num_array(&[5.0, 85.0, 135.0, 115.0]));
|
||||
})
|
||||
.unwrap();
|
||||
set_style(&mut doc, 0, index, StyleChange { color: None, opacity: None, width: Some(10.0), endings: None }, &meta("border-shrink")).unwrap();
|
||||
let rect_from_border = rect_of(&doc, index);
|
||||
for (a, b) in rect_from_border.iter().zip([10.0, 90.0, 130.0, 110.0]) {
|
||||
assert!((a - b).abs() < 1e-4, "expected [10, 90, 130, 110], got {rect_from_border:?}");
|
||||
}
|
||||
}
|
||||
@@ -355,6 +355,10 @@ pub enum Shape {
|
||||
start: LineEnding,
|
||||
end: LineEnding,
|
||||
},
|
||||
/// Translucent freehand highlighting, stored as ink with a distinct intent.
|
||||
FreehandHighlight {
|
||||
strokes: Vec<Vec<[f64; 2]>>,
|
||||
},
|
||||
/// Freehand strokes (Draw tool).
|
||||
Ink {
|
||||
strokes: Vec<Vec<[f64; 2]>>,
|
||||
@@ -474,6 +478,7 @@ pub fn rect_quad(r: [f64; 4]) -> [f64; 8] {
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// Return the PDF `/Subtype` name for this annotation shape.
|
||||
pub fn subtype(&self) -> &'static str {
|
||||
match self {
|
||||
Shape::Note { .. } => "Text",
|
||||
@@ -481,7 +486,7 @@ impl Shape {
|
||||
Shape::Rectangle { .. } => "Square",
|
||||
Shape::Oval { .. } => "Circle",
|
||||
Shape::Line { .. } => "Line",
|
||||
Shape::Ink { .. } | Shape::Signature { .. } => "Ink",
|
||||
Shape::Ink { .. } | Shape::FreehandHighlight { .. } | Shape::Signature { .. } => "Ink",
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } | Shape::Callout { .. } => "FreeText",
|
||||
Shape::Mark { .. } | Shape::Stamp { .. } | Shape::CustomStamp { .. } | Shape::TypedSignature { .. } => "Stamp",
|
||||
Shape::Redact { .. } => "Redact",
|
||||
@@ -610,6 +615,7 @@ impl Style {
|
||||
}
|
||||
Shape::Callout { .. } => ([0.0, 0.0, 0.0], 1.0),
|
||||
Shape::Caret { .. } | Shape::Attachment { .. } => ([0.0, 0.47, 0.84], 1.0),
|
||||
Shape::FreehandHighlight { .. } => return Self { color: [1.0, 0.94, 0.0], opacity: 0.35, width: 14.0, fill: None },
|
||||
Shape::Ink { .. } => ([0.0, 0.4, 0.87], 2.0),
|
||||
Shape::TextBox { .. } | Shape::Typewriter { .. } => ([0.0, 0.0, 0.0], 0.0),
|
||||
Shape::Mark { .. } => ([0.0, 0.0, 0.0], 1.5),
|
||||
|
||||
@@ -21,6 +21,9 @@ them with the image's current orientation. Full turns retain its appearance, rec
|
||||
and flips. Angles that are not multiples of 90 are refused without changing the document or
|
||||
its undo/redo history.
|
||||
|
||||
`page_add_text`, `content_update` and `text_edit` take per-line `aligns`. An `"inherit"` entry
|
||||
follows the box-level `align`; legacy `null` entries remain accepted with the same meaning.
|
||||
|
||||
`Content` is `Json(Value)` or `Png { data, width, height }`. Errors are `UnknownTool`, `InvalidArgs` (the call didn't match the schema) or `Failed` (a readable message for the agent).
|
||||
|
||||
## Conventions
|
||||
@@ -199,6 +202,19 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
|
||||
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
|
||||
and [image editing](../../docs/editing-images.md).
|
||||
|
||||
### Superscript and subscript normalization
|
||||
|
||||
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
|
||||
change the font size or baseline. For characters that have such forms, uniformly plain text
|
||||
toggles to the requested script, and uniformly scripted text toggles back to plain text.
|
||||
Characters without a matching form stay unchanged and do not determine the selection's state.
|
||||
|
||||
A selection mixing plain and target-script characters normalizes to the requested script.
|
||||
For example, subscript changes `H₂2` to `H₂₂`, then to `H22` on the next call; superscript
|
||||
changes `Q²2` to `Q²²`, then to `Q22`. A second toggle does not reconstruct the original
|
||||
mixture. Each call is one undoable text edit: `edit_undo` restores the exact prior text,
|
||||
including its original mixture. Save/reopen preserves the resulting normalized characters.
|
||||
|
||||
## Opening local GoToR links
|
||||
|
||||
`link_open {doc, page, index, password?}` follows a local PDF link using `link_list`
|
||||
|
||||
@@ -183,6 +183,7 @@ impl Automation {
|
||||
"page": c.page + 1,
|
||||
"index": c.index + 1,
|
||||
"type": c.subtype,
|
||||
"intent": c.intent,
|
||||
"author": c.author,
|
||||
"contents": c.contents,
|
||||
"modified": c.modified,
|
||||
@@ -328,7 +329,7 @@ impl Automation {
|
||||
let [start, end] = ending_pair(kind, endings.as_deref())?;
|
||||
Shape::Line { from: to_user(&info, f[0], f[1]), to: to_user(&info, t[0], t[1]), start, end }
|
||||
}
|
||||
"ink" => {
|
||||
"ink" | "freehand_highlight" => {
|
||||
let wrong = || ToolError::InvalidArgs("strokes must be an array of arrays of [x, y] points".into());
|
||||
let strokes = a.get("strokes").ok_or_else(|| ToolError::InvalidArgs("ink needs `strokes`".into()))?;
|
||||
let strokes = strokes
|
||||
@@ -346,7 +347,7 @@ impl Automation {
|
||||
.collect::<Result<Vec<_>>>()
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Shape::Ink { strokes }
|
||||
if kind == "freehand_highlight" { Shape::FreehandHighlight { strokes } } else { Shape::Ink { strokes } }
|
||||
}
|
||||
"polygon" | "cloud" | "polyline" => {
|
||||
let wrong = || ToolError::InvalidArgs(format!("{kind} needs `points`: an array of [x, y] points"));
|
||||
|
||||
@@ -83,10 +83,10 @@ pub(crate) fn parse_align(al: &str) -> Result<TextAlign> {
|
||||
/// Most entries `aligns` takes: one per paragraph, and no text has this many.
|
||||
const MAX_ALIGNS: usize = 4096;
|
||||
|
||||
/// `aligns`: each paragraph's (line's) own alignment, or null where it follows `align`.
|
||||
/// `aligns`: each paragraph's (line's) own alignment, or inherit/null where it follows `align`.
|
||||
pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
|
||||
let Some(v) = a.get("aligns") else { return Ok(None) };
|
||||
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify or null"))?;
|
||||
let items = v.as_array().ok_or_else(|| bad("aligns must be an array of left, center, right, justify, inherit or null"))?;
|
||||
if items.len() > MAX_ALIGNS {
|
||||
return Err(bad(format!("aligns takes at most {MAX_ALIGNS} entries")));
|
||||
}
|
||||
@@ -94,8 +94,9 @@ pub(crate) fn opt_aligns(a: &Args) -> Result<Option<Vec<Option<TextAlign>>>> {
|
||||
.iter()
|
||||
.map(|v| match v {
|
||||
Value::Null => Ok(None),
|
||||
Value::String(s) if s == "inherit" => Ok(None),
|
||||
Value::String(s) => parse_align(s).map(Some),
|
||||
_ => Err(bad("each entry of aligns is left, center, right, justify or null")),
|
||||
_ => Err(bad("each entry of aligns is left, center, right, justify, inherit or null")),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Some)
|
||||
|
||||
@@ -12,6 +12,35 @@ pub enum Content {
|
||||
Png { data: Vec<u8>, width: u32, height: u32 },
|
||||
}
|
||||
|
||||
/// `image_edit` `transform`: a matrix `[a b c d e f]` acting on top-left-origin points (a point
|
||||
/// moves to `(a·x + c·y + e, b·x + d·y + f)`) → the same motion in PDF user space, which is
|
||||
/// what `ImageEdit::Transform` applies after the image's placement. Both spaces are related by
|
||||
/// an affine map, so three corners of the crop box pin the result down.
|
||||
fn view_transform_to_user(info: &pdfcraft_render::PageInfo, m: [f64; 6]) -> Result<[f64; 6]> {
|
||||
let [a, b, c, d, e, f] = m;
|
||||
let det = a * d - b * c;
|
||||
if !m.iter().all(|v| v.is_finite()) || !det.is_finite() || det.abs() < 1e-9 {
|
||||
return Err(ToolError::InvalidArgs("transform needs a finite matrix that keeps an area (a·d − b·c must not be 0)".into()));
|
||||
}
|
||||
let moved = |x: f32, y: f32| {
|
||||
let [vx, vy] = info.user_to_view(x, y).map(f64::from);
|
||||
info.view_to_user((a * vx + c * vy + e) as f32, (b * vx + d * vy + f) as f32).map(f64::from)
|
||||
};
|
||||
let [x0, y0, x1, y1] = info.crop;
|
||||
let (w, h) = (f64::from(x1 - x0), f64::from(y1 - y0));
|
||||
if w.abs() < 1e-3 || h.abs() < 1e-3 {
|
||||
return Err(ToolError::InvalidArgs("the page has no area to transform in".into()));
|
||||
}
|
||||
let (o, px, py) = (moved(x0, y0), moved(x1, y0), moved(x0, y1));
|
||||
let (ua, ub, uc, ud) = ((px[0] - o[0]) / w, (px[1] - o[1]) / w, (py[0] - o[0]) / h, (py[1] - o[1]) / h);
|
||||
let (x0, y0) = (f64::from(x0), f64::from(y0));
|
||||
let user = [ua, ub, uc, ud, o[0] - ua * x0 - uc * y0, o[1] - ub * x0 - ud * y0];
|
||||
if !user.iter().all(|v| v.is_finite()) {
|
||||
return Err(ToolError::InvalidArgs("the transform moves the image too far".into()));
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
impl Automation {
|
||||
/// Run the tool `name` with JSON `args` (an object; `null` means no arguments).
|
||||
pub fn call(&mut self, name: &str, args: &Value) -> Result<Vec<Content>> {
|
||||
@@ -357,7 +386,7 @@ impl Automation {
|
||||
let (u, v) = (info.user_to_view(rect[0] as f32, rect[1] as f32), info.user_to_view(rect[2] as f32, rect[3] as f32));
|
||||
[r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))]
|
||||
});
|
||||
json!({ "visible_rect": visible, "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
|
||||
json!({ "visible_rect": visible, "has_editor_crop": im.has_editor_crop(), "image": i + 1, "rect": [r(u[0].min(v[0])), r(u[1].min(v[1])), r(u[0].max(v[0])), r(u[1].max(v[1]))], "pixels": [im.width, im.height], "name": im.name, "kind": if im.is_form { "form" } else { "image" } })
|
||||
})
|
||||
.collect();
|
||||
json!({ "page": page + 1, "count": list.len(), "images": list })
|
||||
@@ -395,6 +424,16 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Move([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"transform" => {
|
||||
let m: Vec<f64> = a
|
||||
.get("matrix")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|x| x.iter().map(Value::as_f64).collect())
|
||||
.ok_or_else(|| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
|
||||
let m = <[f64; 6]>::try_from(m)
|
||||
.map_err(|_| ToolError::InvalidArgs("transform needs matrix: 6 numbers [a, b, c, d, e, f]".into()))?;
|
||||
ImageEdit::Transform(view_transform_to_user(&self.doc(&a)?.info.pages[page], m)?)
|
||||
}
|
||||
"rotate_by" => ImageEdit::RotateBy(
|
||||
a.get("degrees")
|
||||
.and_then(Value::as_f64)
|
||||
@@ -410,9 +449,18 @@ impl Automation {
|
||||
let (u0, u1) = (info.view_to_user(r[0] as f32, r[1] as f32), info.view_to_user(r[2] as f32, r[3] as f32));
|
||||
ImageEdit::Crop([u0[0].min(u1[0]) as f64, u0[1].min(u1[1]) as f64, u0[0].max(u1[0]) as f64, u0[1].max(u1[1]) as f64])
|
||||
}
|
||||
"flip_horizontal" => ImageEdit::Flip { horizontal: true },
|
||||
"flip_vertical" => ImageEdit::Flip { horizontal: false },
|
||||
action @ ("flip_horizontal" | "flip_vertical") => {
|
||||
let horizontal = action == "flip_horizontal";
|
||||
let info = self.doc(&a)?.info.pages.get(page).ok_or_else(|| ToolError::InvalidArgs("the page does not exist".into()))?;
|
||||
// The tool names displayed axes; the engine names PDF user-space axes.
|
||||
ImageEdit::Flip { horizontal: horizontal ^ (info.rotation % 180 == 90) }
|
||||
}
|
||||
"delete" => ImageEdit::Delete,
|
||||
"bring_to_front" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringToFront),
|
||||
"send_to_back" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendToBack),
|
||||
"bring_forward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::BringForward),
|
||||
"send_backward" => ImageEdit::Arrange(pdfcraft_engine::ArrangeOrder::SendBackward),
|
||||
"remove_crop" => ImageEdit::RemoveCrop,
|
||||
"replace" => {
|
||||
let path = self.resolve(a.str("path")?, false)?;
|
||||
let bytes = read_input(&path)?;
|
||||
@@ -624,6 +672,7 @@ impl Automation {
|
||||
}
|
||||
out
|
||||
}
|
||||
"text_script" => self.text_script(&a)?,
|
||||
"doc_audit_space" => {
|
||||
let rows: Vec<Value> = self
|
||||
.doc(&a)?
|
||||
@@ -815,4 +864,52 @@ impl Automation {
|
||||
};
|
||||
Ok(vec![Content::Json(out)])
|
||||
}
|
||||
|
||||
/// `text_script`: Format text ▸ Superscript or Subscript on a paragraph, or on a byte range
|
||||
/// of its editor text, written as `text_edit` writes the paragraph.
|
||||
fn text_script(&mut self, a: &Args) -> Result<Value> {
|
||||
use pdfcraft_engine::text_script::{Script, toggle};
|
||||
let name = a.str("script")?;
|
||||
let script = match name {
|
||||
"superscript" => Script::Super,
|
||||
"subscript" => Script::Sub,
|
||||
other => return Err(ToolError::InvalidArgs(format!("unknown script {other:?} (superscript, subscript)"))),
|
||||
};
|
||||
let page = self.page(a)?;
|
||||
let k = a.int("paragraph")?;
|
||||
let text = {
|
||||
let doc = self.doc(a)?;
|
||||
let blocks = doc.text_blocks(page);
|
||||
let index = self.paragraph_index(doc.id, page, k, &blocks)?;
|
||||
blocks.get(index).map(|b| b.editor_text.clone()).ok_or_else(|| ToolError::InvalidArgs(format!("paragraph {k} is out of range")))?
|
||||
};
|
||||
let range = match a.opt_ints("range")? {
|
||||
None => None,
|
||||
Some(r) => {
|
||||
let bounds = match r.as_slice() {
|
||||
[start, end] => usize::try_from(*start).ok().zip(usize::try_from(*end).ok()),
|
||||
_ => None,
|
||||
};
|
||||
let range = bounds
|
||||
.map(|(start, end)| start..end)
|
||||
.filter(|r| r.start < r.end && r.end <= text.len() && text.is_char_boundary(r.start) && text.is_char_boundary(r.end));
|
||||
let Some(range) = range else {
|
||||
return Err(ToolError::InvalidArgs(format!(
|
||||
"range must be [start, end]: UTF-8 byte offsets of the paragraph's editor_text (from text_paragraphs) with start < end <= {}, on character boundaries",
|
||||
text.len()
|
||||
)));
|
||||
};
|
||||
Some(range)
|
||||
}
|
||||
};
|
||||
let (toggled, _) = toggle(&text, range.as_ref(), script)
|
||||
.ok_or_else(|| ToolError::InvalidArgs(format!("nothing to change: no character of the text has a {name} form")))?;
|
||||
let args = json!({ "doc": a.get("doc"), "page": a.get("page"), "paragraph": k, "text": toggled });
|
||||
let mut out = match self.call("text_edit", &args)?.into_iter().next() {
|
||||
Some(Content::Json(out)) => out,
|
||||
_ => json!({}),
|
||||
};
|
||||
out["script"] = json!(name);
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
}), &["doc", "page"])),
|
||||
t("object_list", "List editable objects", "List existing paragraphs, Image/Form artwork and added content once, with source kind/index references, displayed rectangles and document generation. Indexes change when content changes; use generation in object_move to reject stale references.")
|
||||
.ro().with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Clipping and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
t("object_move", "Move objects together", "Atomically translate 1–1000 mixed references from object_list by [dx, dy] in displayed points (right/down). Preserves glyph codes, fonts, image data and relative spacing; success is one undo step. Text used as a clipping path and vertical text are refused. A bad/stale reference refuses the entire move.")
|
||||
.with(schema(json!({"doc":doc(),"page":{"type":"integer","minimum":1},
|
||||
"objects":{"type":"array","minItems":1,"maxItems":1000,"uniqueItems":true,"items":{
|
||||
"type":"object","properties":{"kind":{"type":"string","enum":["added","text","image"]},"index":{"type":"integer","minimum":1}},"required":["kind","index"],"additionalProperties":false}},
|
||||
@@ -637,7 +637,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
}),
|
||||
&["doc", "page", "text"],
|
||||
@@ -660,7 +660,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"char_spacing": { "type": "number", "minimum": -500, "maximum": 500, "description": "Points after each character." },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.95 }, "minItems": 4, "maxItems": 4, "description": "Fractions trimmed: left, bottom, right, top. Left and right together, and bottom and top together, trim at most 0.95." },
|
||||
@@ -754,7 +754,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] },
|
||||
"type": { "type": "string", "enum": ["note", "highlight", "underline", "strikeout", "squiggly", "replace", "rectangle", "oval", "line", "arrow", "ink", "freehand_highlight", "textbox", "stamp", "polygon", "cloud", "polyline", "callout", "caret", "attachment"] },
|
||||
"path": { "type": "string", "description": "attachment: the file to attach." },
|
||||
"points": { "type": "array", "items": point(), "minItems": 2 },
|
||||
"knee": point(),
|
||||
@@ -1281,7 +1281,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("ocr.recognize")
|
||||
.with(schema(json!({}), &[])),
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, JBIG2 inside other filters, separations) are listed under skipped. Includes unsaved edits.")
|
||||
t("doc_export_all_images", "Export all images", "Write the images that pages use into a folder (`<name>_Page_<n>_Image_<k>.jpg|png`, n zero-padded to the page count's digits), each once: JPEG images unchanged, others as PNG with their soft mask as alpha. min_size skips images with fewer pixels on their shorter side. Images that can't be decoded yet (JPEG 2000, separations) are listed under skipped. Includes unsaved edits.")
|
||||
.cmd("export.all_images")
|
||||
.with(schema(
|
||||
json!({
|
||||
@@ -1378,17 +1378,18 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 } }), &["doc", "page"])),
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), flip_horizontal, flip_vertical, replace (path: an image file, drawn in the same place) or delete. Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
t("image_edit", "Edit an image", "Change one of a page's images (number from page_images): action move (rect: new full placement box in top-left-origin points, based on page_images.rect), crop (rect: the part to keep, top-left-origin points; the pixels stay in place and page_images.visible_rect shows the result; a later move carries the crop along), remove_crop (remove a crop created by PdfCraft while preserving clipping paths from the original document), rotate (quarters clockwise, default 1), rotate_by (degrees: any angle clockwise about the visible centre; the box in page_images then becomes the turned image's bounding box), transform (matrix: 6 numbers [a, b, c, d, e, f] in top-left-origin points, applied after the current placement so a point (x, y) of the image moves to (a·x + c·y + e, b·x + d·y + f); finite, with a·d − b·c not 0; the box in page_images becomes the transformed image's bounding box), flip_horizontal (reflect left/right on the displayed page), flip_vertical (reflect top/bottom on the displayed page; both flip actions follow the page rotation), replace (path: an image file, drawn in the same place), delete, or arrange: bring_to_front, send_to_back, bring_forward, send_backward (reorder stacking order among content on the page; errors when the image is already at the requested limit; arranging an added image converts it into an ordinary page image). Undoable. The selected image crop transforms with its pixels, isolating shared path scopes while retaining neighboring painting. Text-outline clips return a clear error. Rotation/flip use the visible centre. Form artwork supports move/resize/rotate/flip/delete as a group; replace and image_save only support raster images.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(),
|
||||
"page": { "type": "integer", "minimum": 1 },
|
||||
"image": { "type": "integer", "minimum": 1 },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "flip_horizontal", "flip_vertical", "replace", "delete"] },
|
||||
"action": { "type": "string", "enum": ["move", "crop", "rotate", "rotate_by", "transform", "flip_horizontal", "flip_vertical", "replace", "delete", "bring_to_front", "send_to_back", "bring_forward", "send_backward", "remove_crop"] },
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
|
||||
"quarters": { "type": "integer" },
|
||||
"degrees": { "type": "number" },
|
||||
"matrix": { "type": "array", "items": { "type": "number" }, "minItems": 6, "maxItems": 6 },
|
||||
"path": { "type": "string" },
|
||||
}),
|
||||
&["doc", "page", "image", "action"],
|
||||
@@ -1428,7 +1429,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"size": { "type": "number", "exclusiveMinimum": 0 },
|
||||
"color": { "type": "string", "description": "#rrggbb" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right", "justify"] },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "enum": ["left", "center", "right", "justify", null] }, "description": "Each paragraph's (line's) own alignment, by index; null follows align." },
|
||||
"aligns": { "type": "array", "maxItems": 4096, "items": { "type": "string", "enum": ["left", "center", "right", "justify", "inherit"] }, "description": "Each paragraph's (line's) own alignment, by index; inherit (or legacy null) follows align." },
|
||||
"underline": { "type": "boolean" },
|
||||
"line_spacing": { "type": "number", "description": "Multiple of the font size (1.2 is ordinary)." },
|
||||
"dx": { "type": "number", "description": "Paragraph only: move right by this many points (negative: left)." },
|
||||
@@ -1438,11 +1439,19 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
"scale": { "type": "number", "description": "Horizontal scale in percent." } }),
|
||||
&["doc", "page"],
|
||||
)),
|
||||
t("text_script", "Superscript or subscript", "Format text ▸ Superscript or Subscript on one paragraph (paragraph, from text_paragraphs), or on range [start, end): UTF-8 byte offsets of its editor_text. The characters take the Unicode superscript or subscript forms (2 → ², 2 → ₂; the font must have those glyphs, and extracted text contains them); text already in that script goes back to plain characters. Characters Unicode has no such form for (q, a space) stay unchanged and do not determine the selection's script state. Mixed plain and target-script characters normalize to that script, then to plain text on the next call (H₂2 → H₂₂ → H22); a second toggle does not reconstruct the original mixture. Use Undo to restore the exact prior text. The paragraph is then rewritten as text_edit rewrites it, with the same result fields. Refused when no character would change. Undoable. Mathematical minus U+2212 has its own script mapping; ASCII hyphen-minus stays unchanged.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 },
|
||||
"script": { "type": "string", "enum": ["superscript", "subscript"] },
|
||||
"range": { "type": "array", "items": { "type": "integer", "minimum": 0 }, "minItems": 2, "maxItems": 2, "description": "UTF-8 byte offsets [start, end) of the paragraph's editor_text; the whole paragraph when omitted." } }),
|
||||
&["doc", "page", "paragraph", "script"],
|
||||
)),
|
||||
t("doc_revisions", "List revisions", "List the document's saved revisions (oldest first): each incremental update is one. Returns revision number, where it ends in the file and its size, and which signatures sign exactly that revision.")
|
||||
.with(schema(json!({ "doc": doc() }), &["doc"])),
|
||||
t("doc_open_revision", "Open a revision", "Open saved revision `revision` (1 = the oldest) of a document as a new, unsaved document, to see the file as it was then.")
|
||||
.with(schema(json!({ "doc": doc(), "revision": { "type": "integer", "minimum": 1 } }), &["doc", "revision"])),
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
t("doc_optimize", "Optimize PDF", "Write an optimized copy to `path` (Acrobat's PDF Optimizer). preset: standard (default; Reduce File Size's choices) or mobile (images to 96 ppi above 144, JPEG high quality); the options below change it. compatibility: retain (default; keeps the document's version, or 1.5 when object streams are written), 1.4, 1.5, 1.6, 1.7 or 2.0. merge_fragments (default true) first puts a page picture drawn as many stacked image strips (as some PDF printers write it) back together into one image, so it can be resampled (merged_images, fragments_merged). color / gray: { downsample, ppi, above_ppi, compression: jpeg|zip|retain, quality 1–100 } (defaults: downsample to 150 ppi above 225, JPEG 60). mono: { downsample, ppi, above_ppi, compression: ccitt_g4|jbig2|retain } for black-and-white images and image masks (default: averaged down to 300 ppi above 450 ppi, counted in images_resampled; stored losslessly as CCITT Group 4 or JBIG2 (or 1-bit Flate, once resampled or packed from more bits) when that is smaller, Group 3 fax images, JBIG2 images, black-and-white RGB and indexed images using only palette entries 0 and 1 among them, counted in images_recompressed; retain resamples only, a CCITT image staying CCITT, a JBIG2 image JBIG2 and any other becoming Flate). Images are measured where pages draw them and replaced only when smaller. discard_*: thumbnails (default true), alternate_images (true), tags, print_settings; flate_unencoded (true); object_streams (default true; always off for 1.4); lzw_to_flate (true) decodes LZW streams and stores them with Flate instead, leaving any it cannot decode unchanged (lzw_streams counts the streams converted); remove_invalid_links and remove_unreferenced_dests (true); images and forms the pages list but never draw are always dropped (unused_xobjects). discard: Remove Hidden Information categories (metadata, attachments, comments, form-fields, hidden-text, hidden-layers, bookmarks, links-actions-scripts, private-data). Signed documents are refused. The open document is unchanged.")
|
||||
.cmd("optimize.advanced")
|
||||
.with(schema(
|
||||
json!({
|
||||
|
||||
@@ -664,6 +664,44 @@ fn tool_table_is_well_formed() {
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_homogeneous_enums(schema: &Value, path: &str) {
|
||||
fn json_kind(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(values) = schema.get("enum").and_then(Value::as_array) {
|
||||
let first = values.first().map(json_kind);
|
||||
assert!(values.iter().map(json_kind).all(|kind| Some(kind) == first), "mixed enum types at {path}: {values:?}");
|
||||
}
|
||||
match schema {
|
||||
Value::Object(object) => {
|
||||
for (key, child) in object {
|
||||
assert_homogeneous_enums(child, &format!("{path}.{key}"));
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for (index, child) in array.iter().enumerate() {
|
||||
assert_homogeneous_enums(child, &format!("{path}[{index}]"));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_enums_have_one_json_type() {
|
||||
for tool in tools() {
|
||||
assert_homogeneous_enums(&tool.input_schema, tool.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() {
|
||||
let dir = workdir("font-ranges");
|
||||
@@ -2111,6 +2149,27 @@ fn comments_through_tools() {
|
||||
save_and_reopen_comments(&dir, &mut a, doc);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn freehand_highlight_through_tools() {
|
||||
let dir = workdir("freehand-highlight");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(
|
||||
&mut a,
|
||||
"comment_add",
|
||||
json!({ "doc": doc, "page": 1, "type": "freehand_highlight", "width": 100,
|
||||
"strokes": [[[20, 100], [120, 100]]] }),
|
||||
);
|
||||
let list = ok(&mut a, "comment_list", json!({ "doc": doc }));
|
||||
assert_eq!(list["count"], 1);
|
||||
assert_eq!(list["comments"][0]["type"], "Ink");
|
||||
assert_eq!(list["comments"][0]["intent"], "FreehandHighlight");
|
||||
assert_eq!(list["comments"][0]["color"], "#FFF000");
|
||||
let undone = ok(&mut a, "edit_undo", json!({ "doc": doc }));
|
||||
assert_eq!(undone["undone"], "Add freehand highlight");
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 0);
|
||||
}
|
||||
|
||||
fn add_comment_examples(a: &mut Automation, doc: u64) -> String {
|
||||
// "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page.
|
||||
let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" }));
|
||||
@@ -3286,6 +3345,39 @@ fn editing_existing_text_through_tools() {
|
||||
assert_eq!(p["lines"].as_array().map(Vec::len), Some(2), "{p}");
|
||||
}
|
||||
|
||||
/// Format text ▸ Superscript and Subscript without the GUI: a range, then back again over a space
|
||||
/// (which has no superscript form), each one undo step; refusals change nothing.
|
||||
#[test]
|
||||
fn superscript_and_subscript_through_tools() {
|
||||
let dir = workdir("text-script");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
let paragraph =
|
||||
|a: &mut Automation| ok(a, "text_paragraphs", json!({ "doc": doc, "page": 2 }))["paragraphs"][0]["text"].as_str().unwrap().to_string();
|
||||
let script = |script: &str, range: Value| json!({ "doc": doc, "page": 2, "paragraph": 1, "script": script, "range": range });
|
||||
// "Page 2": the 2 is byte 5.
|
||||
let r = ok(&mut a, "text_script", script("superscript", json!([5, 6])));
|
||||
assert_eq!((r["script"].as_str(), r["paragraph"]["text"].as_str()), (Some("superscript"), Some("Page ²")), "{r}");
|
||||
assert_eq!(page_text(&mut a, doc)[1], "Page ²");
|
||||
// " ²": the space has no superscript form, and the range still toggles back.
|
||||
ok(&mut a, "text_script", script("superscript", json!([4, 7])));
|
||||
assert_eq!(paragraph(&mut a), "Page 2");
|
||||
assert_eq!(ok(&mut a, "edit_undo", json!({ "doc": doc }))["undone"], "Edit text");
|
||||
assert_eq!(paragraph(&mut a), "Page ²");
|
||||
// Refused, changing nothing: a range that splits ², one past the end, an empty one, a range
|
||||
// with nothing that has a subscript form, an unknown script.
|
||||
for (name, range) in [
|
||||
("superscript", json!([5, 6])),
|
||||
("superscript", json!([0, 99])),
|
||||
("superscript", json!([3, 3])),
|
||||
("subscript", json!([4, 5])),
|
||||
("italic", json!([0, 1])),
|
||||
] {
|
||||
assert!(matches!(a.call("text_script", &script(name, range.clone())), Err(ToolError::InvalidArgs(_))), "{name} {range}");
|
||||
}
|
||||
assert_eq!(paragraph(&mut a), "Page ²");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn styled_cyrillic_fallback_is_batched_through_tools_and_reopen() {
|
||||
if pdfcraft_fonts::document_japanese_font().is_none() {
|
||||
@@ -3453,6 +3545,13 @@ fn editing_page_images_through_tools() {
|
||||
assert_eq!(cropped["visible_rect"], json!([10.0, 10.0, 25.0, 30.0]));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate" }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "flip_horizontal" }));
|
||||
// A single image is already in front and at the back; no-op arrange is refused without dirtying undo.
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_to_back" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "bring_forward" })).is_err());
|
||||
assert!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "send_backward" })).is_err());
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "remove_crop" }));
|
||||
assert_eq!(r["undo"], "Remove crop");
|
||||
// Any angle: the box grows to the turned picture's bounds, and it undoes like a quarter turn.
|
||||
let before = ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "rotate_by", "degrees": 30.5 }));
|
||||
@@ -3472,6 +3571,80 @@ fn editing_page_images_through_tools() {
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "delete" })), Err(ToolError::InvalidArgs(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arranging_page_images_through_tools() {
|
||||
let dir = workdir("arrange-page-images");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1, 2] }));
|
||||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_image", json!({ "doc": made, "page": 1, "path": "src/a_page_2.png", "rect": [50, 50, 100, 100] }));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({ "doc": made, "page": 1 }))["count"], 2);
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "bring_to_front" }));
|
||||
assert_eq!(r["undo"], "Bring to front");
|
||||
let r = ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 2, "action": "send_to_back" }));
|
||||
assert_eq!(r["undo"], "Send to back");
|
||||
ok(&mut a, "doc_save", json!({ "doc": made, "path": "arranged.pdf" }));
|
||||
let reopened = ok(&mut a, "doc_open", json!({ "path": "arranged.pdf" }))["doc"].as_u64().unwrap();
|
||||
let after_reopen = ok(&mut a, "page_images", json!({ "doc": reopened, "page": 1 }));
|
||||
assert_eq!(after_reopen["count"], 2);
|
||||
assert_eq!(after_reopen["images"][0]["rect"], json!([0.0, 0.0, 50.0, 75.0]));
|
||||
assert_eq!(after_reopen["images"][1]["rect"], json!([50.0, 50.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
/// The transform a rotated-image resize sends (`ImageEdit::Transform`) is reachable headlessly,
|
||||
/// in the same top-left-origin points as the other image tools, also on a turned page.
|
||||
#[test]
|
||||
fn transforming_page_images_through_tools() {
|
||||
let dir = workdir("transform-page-images");
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({ "doc": doc, "folder": "src", "dpi": 18, "pages": [1] }));
|
||||
let made = ok(&mut a, "doc_create", json!({ "from": "images", "paths": ["src/a_page_1.png"] }))["doc"].as_u64().unwrap();
|
||||
let rect = |a: &mut Automation| ok(a, "page_images", json!({ "doc": made, "page": 1 }))["images"][0]["rect"].clone();
|
||||
let transform =
|
||||
|a: &mut Automation, m: Value| ok(a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": m }));
|
||||
ok(&mut a, "image_edit", json!({ "doc": made, "page": 1, "image": 1, "action": "move", "rect": [5, 5, 25, 35] }));
|
||||
|
||||
// Right 10, down 20.
|
||||
assert_eq!(transform(&mut a, json!([1, 0, 0, 1, 10, 20]))["undo"], "Transform image");
|
||||
assert_eq!(rect(&mut a), json!([15.0, 25.0, 35.0, 55.0]));
|
||||
// Twice the size about the top-left corner of the page.
|
||||
transform(&mut a, json!([2, 0, 0, 2, 0, 0]));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
// A quarter turn, (x, y) → (200 − y, x): the box turns with it, and it undoes in one step.
|
||||
transform(&mut a, json!([0, 1, -1, 0, 200, 0]));
|
||||
assert_eq!(rect(&mut a), json!([90.0, 30.0, 150.0, 70.0]));
|
||||
ok(&mut a, "edit_undo", json!({ "doc": made }));
|
||||
assert_eq!(rect(&mut a), json!([30.0, 50.0, 70.0, 110.0]));
|
||||
|
||||
// On a turned page "right" is still right on screen.
|
||||
ok(&mut a, "page_rotate", json!({ "doc": made, "pages": [1], "degrees": 90 }));
|
||||
let numbers = |v: Value| [0, 1, 2, 3].map(|i| v[i].as_f64().unwrap());
|
||||
let [x0, y0, x1, y1] = numbers(rect(&mut a));
|
||||
transform(&mut a, json!([1, 0, 0, 1, 10, 0]));
|
||||
let after = numbers(rect(&mut a));
|
||||
for (got, want) in after.into_iter().zip([x0 + 10.0, y0, x1 + 10.0, y1]) {
|
||||
assert!((got - want).abs() < 0.015, "{after:?} is not ({x0}, {y0}, {x1}, {y1}) moved right by 10");
|
||||
}
|
||||
|
||||
// Flat, non-finite, short and missing matrices are refused before anything changes.
|
||||
let before = rect(&mut a);
|
||||
for bad in [
|
||||
json!([1, 2, 2, 4, 0, 0]),
|
||||
json!([0, 0, 0, 0, 0, 0]),
|
||||
json!([1e300, 0, 0, 1e300, 0, 0]),
|
||||
json!([1, 0, 0, 1]),
|
||||
json!([1, 0, "invalid", 0, 1, 10, 0]),
|
||||
json!([1, 0, null, 0, 1, 10, 0]),
|
||||
] {
|
||||
let r = a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform", "matrix": bad }));
|
||||
assert!(matches!(r, Err(ToolError::InvalidArgs(_))), "{bad}: {r:?}");
|
||||
}
|
||||
assert!(matches!(a.call("image_edit", &json!({ "doc": made, "page": 1, "image": 1, "action": "transform" })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(rect(&mut a), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enhance_camera_image_through_tools() {
|
||||
let dir = workdir("enhance-camera");
|
||||
@@ -9764,7 +9937,11 @@ fn added_text_lines_take_their_own_alignments() {
|
||||
let dir = workdir("paragraph-aligns");
|
||||
let mut a = auto(&dir);
|
||||
let id = ok(&mut a, "doc_create", json!({"from":"blank","width":600,"height":400}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "page_add_text", json!({"doc":id,"page":1,"text":"Left line\nRight line","at":[40,50],"width":500,"size":12,"aligns":[null,"right"]}));
|
||||
ok(
|
||||
&mut a,
|
||||
"page_add_text",
|
||||
json!({"doc":id,"page":1,"text":"Left line\nRight line\nInherited line","at":[40,50],"width":500,"size":12,"aligns":[null,"right","inherit"]}),
|
||||
);
|
||||
let rect = |a: &mut Automation, text: &str| -> Vec<f64> {
|
||||
let lines = ok(a, "text_lines", json!({"doc":id,"page":1}));
|
||||
let line = lines["lines"].as_array().unwrap().iter().find(|l| l["text"] == text).cloned().unwrap();
|
||||
@@ -9772,10 +9949,13 @@ fn added_text_lines_take_their_own_alignments() {
|
||||
};
|
||||
assert!((rect(&mut a, "Left line")[0] - 40.0).abs() < 1.0);
|
||||
assert!((rect(&mut a, "Right line")[2] - 540.0).abs() < 1.0, "{:?}", rect(&mut a, "Right line"));
|
||||
assert!((rect(&mut a, "Inherited line")[0] - 40.0).abs() < 1.0);
|
||||
// The whole box centred: the right-aligned line goes with it.
|
||||
ok(&mut a, "content_update", json!({"doc":id,"page":1,"index":1,"align":"center"}));
|
||||
let right = rect(&mut a, "Right line");
|
||||
assert!(((right[0] + right[2]) / 2.0 - 290.0).abs() < 1.0, "{right:?}");
|
||||
let inherited = rect(&mut a, "Inherited line");
|
||||
assert!(((inherited[0] + inherited[2]) / 2.0 - 290.0).abs() < 1.0, "{inherited:?}");
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":["sideways"]})).is_err());
|
||||
assert!(a.call("content_update", &json!({"doc":id,"page":1,"index":1,"aligns":"right"})).is_err());
|
||||
}
|
||||
@@ -10190,3 +10370,154 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
|
||||
}
|
||||
write_full(&doc, &SaveOptions::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() {
|
||||
let dir = workdir("editor-crop-group-arrange");
|
||||
let mut a = auto(&dir);
|
||||
let source = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "doc_export_images", json!({"doc":source,"folder":"src","dpi":18,"pages":[1]}));
|
||||
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["src/a_page_1.png"]}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"move","rect":[5,5,25,35]}));
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":"crop","rect":[10,10,20,30]}));
|
||||
ok(&mut a, "page_add_text", json!({"doc":doc,"page":1,"text":"Movable label","rect":[2,40,45,55],"size":6}));
|
||||
let inventory = ok(&mut a, "object_list", json!({"doc":doc,"page":1}));
|
||||
let objects: Vec<_> = inventory["objects"].as_array().unwrap().iter().map(|o| json!({"kind":o["kind"],"index":o["index"]})).collect();
|
||||
assert_eq!(objects.len(), 2);
|
||||
let moved = ok(&mut a, "object_move", json!({"doc":doc,"page":1,"objects":objects,"offset":[3,4]}));
|
||||
assert_eq!(moved["moved"], 2);
|
||||
let picture = ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0].clone();
|
||||
assert_eq!(picture["rect"], json!([8.0, 9.0, 28.0, 39.0]));
|
||||
assert_eq!(picture["visible_rect"], json!([13.0, 14.0, 23.0, 34.0]));
|
||||
assert!(page_text(&mut a, doc)[0].contains("Movable label"));
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc":doc,"page":1}))["images"][0]["visible_rect"], json!([10.0, 10.0, 20.0, 30.0]));
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"grouped.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"grouped.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(ok(&mut a, "page_images", json!({"doc":reopened,"page":1}))["images"][0]["visible_rect"], picture["visible_rect"]);
|
||||
ok(&mut a, "image_edit", json!({"doc":reopened,"page":1,"image":1,"action":"bring_to_front"}));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 1);
|
||||
ok(&mut a, "content_delete", json!({"doc":reopened,"page":1,"index":1}));
|
||||
ok(&mut a, "doc_save", json!({"doc":reopened,"path":"independent.pdf"}));
|
||||
let saved = ok(&mut a, "doc_open", json!({"path":"independent.pdf"}))["doc"].as_u64().unwrap();
|
||||
let after = ok(&mut a, "page_images", json!({"doc":saved,"page":1}));
|
||||
assert_eq!(after["count"], 1);
|
||||
assert_eq!(after["images"][0]["rect"], picture["rect"]);
|
||||
assert_eq!(after["images"][0]["visible_rect"], picture["visible_rect"]);
|
||||
let rendered = a.call("page_render", &json!({"doc":saved,"page":1,"dpi":72})).unwrap();
|
||||
assert!(rendered.iter().any(|c| matches!(c, Content::Png {data,width,height} if !data.is_empty() && *width > 0 && *height > 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_flip_tools_follow_displayed_axes_on_every_page_rotation() {
|
||||
fn corners(a: &Automation, id: u64) -> [[f64; 2]; 4] {
|
||||
let doc = a.session().get(pdfcraft_engine::DocId(id)).unwrap();
|
||||
let [aa, b, c, d, e, f] = doc.page_images(0)[0].matrix;
|
||||
[(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)]
|
||||
.map(|(x, y)| doc.info.pages[0].user_to_view((aa * x + c * y + e) as f32, (b * x + d * y + f) as f32).map(f64::from))
|
||||
}
|
||||
fn raster(a: &mut Automation, id: u64) -> (usize, usize, Vec<u8>) {
|
||||
let rendered = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
let Content::Png { data, .. } = &rendered[0] else { panic!("expected PNG") };
|
||||
let mut reader = png::Decoder::new(std::io::Cursor::new(data)).read_info().unwrap();
|
||||
let mut bytes = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let frame = reader.next_frame(&mut bytes).unwrap();
|
||||
bytes.truncate(frame.buffer_size());
|
||||
if frame.color_type == png::ColorType::Rgb {
|
||||
bytes = bytes.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect();
|
||||
} else {
|
||||
assert_eq!(frame.color_type, png::ColorType::Rgba);
|
||||
}
|
||||
(frame.width as usize, frame.height as usize, bytes)
|
||||
}
|
||||
let dir = workdir("displayed-image-flip");
|
||||
let mut png_bytes = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut png_bytes, 8, 4);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
let colors = [[240, 20, 30], [20, 230, 40], [30, 40, 220], [220, 210, 20]];
|
||||
let pixels: Vec<u8> = (0..4).flat_map(|y| (0..8).flat_map(move |x| colors[usize::from(x >= 4) + 2 * usize::from(y >= 2)])).collect();
|
||||
encoder.write_header().unwrap().write_image_data(&pixels).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("quadrants.png"), png_bytes).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let mut failures = Vec::new();
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
let doc = ok(&mut a, "doc_create", json!({"from":"images","paths":["quadrants.png"]}))["doc"].as_u64().unwrap();
|
||||
if rotation != 0 {
|
||||
ok(&mut a, "page_rotate", json!({"doc":doc,"pages":[1],"degrees":rotation}));
|
||||
}
|
||||
let before = corners(&a, doc);
|
||||
let (width, height, pixels) = raster(&mut a, doc);
|
||||
let xmin = before.iter().map(|p| p[0]).fold(f64::INFINITY, f64::min);
|
||||
let xmax = before.iter().map(|p| p[0]).fold(f64::NEG_INFINITY, f64::max);
|
||||
let ymin = before.iter().map(|p| p[1]).fold(f64::INFINITY, f64::min);
|
||||
let ymax = before.iter().map(|p| p[1]).fold(f64::NEG_INFINITY, f64::max);
|
||||
for (action, horizontal) in [("flip_horizontal", true), ("flip_vertical", false)] {
|
||||
ok(&mut a, "image_edit", json!({"doc":doc,"page":1,"image":1,"action":action}));
|
||||
let after = corners(&a, doc);
|
||||
for (old, new) in before.into_iter().zip(after) {
|
||||
let expected = if horizontal { [xmin + xmax - old[0], old[1]] } else { [old[0], ymin + ymax - old[1]] };
|
||||
if new.into_iter().zip(expected).any(|(actual, want)| (actual - want).abs() > 0.001) {
|
||||
failures.push(format!("rotation {rotation} {action}: corner {old:?} became {new:?}, expected {expected:?}"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let (new_width, new_height, actual_pixels) = raster(&mut a, doc);
|
||||
assert_eq!((new_width, new_height), (width, height));
|
||||
let mut expected_pixels = Vec::new();
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let source = if horizontal { (y * width + width - 1 - x) * 4 } else { ((height - 1 - y) * width + x) * 4 };
|
||||
expected_pixels.extend_from_slice(&pixels[source..source + 4]);
|
||||
}
|
||||
}
|
||||
if actual_pixels.iter().zip(&expected_pixels).any(|(actual, want)| actual.abs_diff(*want) > 1) {
|
||||
failures.push(format!("rotation {rotation} {action}: raster flipped the wrong displayed axis"));
|
||||
}
|
||||
assert_ne!(actual_pixels, pixels, "the asymmetric image makes both flip axes observable");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(corners(&a, doc), before, "one undo restores the original placement");
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{}", failures.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_tools_normalize_but_undo_restores_the_exact_mixture() {
|
||||
let dir = workdir("mixed-script-policy");
|
||||
let mut a = auto(&dir);
|
||||
for (script, mixed, uniform, plain) in [("superscript", "Q²2", "Q²²", "Q22"), ("subscript", "H₂2", "H₂₂", "H22")] {
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"a.pdf"}))["doc"].as_u64().unwrap();
|
||||
ok(&mut a, "text_edit", json!({"doc":doc,"page":1,"paragraph":1,"text":mixed,"size":14,"width":160}));
|
||||
let paragraph =
|
||||
|a: &mut Automation| ok(a, "text_paragraphs", json!({"doc":doc,"page":1}))["paragraphs"][0]["editor_text"].as_str().unwrap().to_owned();
|
||||
assert_eq!(paragraph(&mut a), mixed);
|
||||
let before = a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.clone();
|
||||
let args = json!({"doc":doc,"page":1,"paragraph":1,"script":script});
|
||||
let once = ok(&mut a, "text_script", args.clone());
|
||||
assert_eq!(once["paragraph"]["text"], uniform);
|
||||
assert_eq!(paragraph(&mut a), uniform);
|
||||
ok(&mut a, "text_script", args);
|
||||
assert_eq!(paragraph(&mut a), plain);
|
||||
assert_ne!(plain, mixed, "normalization does not promise to recreate the original mixture");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), uniform, "one Undo restores the preceding scripted text");
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), mixed, "Undo restores the exact initial Unicode mixture");
|
||||
assert_eq!(
|
||||
a.session().get(pdfcraft_engine::DocId(doc)).unwrap().bytes.as_ref(),
|
||||
before.as_ref(),
|
||||
"Undo also restores the exact prior document bytes"
|
||||
);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), uniform);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
assert_eq!(paragraph(&mut a), plain);
|
||||
let path = format!("mixed-{script}.pdf");
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":path}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":path}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page_text(&mut a, reopened)[0], plain);
|
||||
}
|
||||
}
|
||||
@@ -730,11 +730,14 @@ impl Matrix {
|
||||
pub fn invert(&self) -> Option<Matrix> {
|
||||
let [a, b, c, d, e, f] = self.0;
|
||||
let det = a * d - b * c;
|
||||
if det.abs() < 1e-12 || !det.is_finite() {
|
||||
if det == 0.0 || !det.is_finite() || det.abs() <= 1e-12 * ((a * d).abs() + (b * c).abs()) {
|
||||
return None;
|
||||
}
|
||||
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
|
||||
Some(Matrix([ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)]))
|
||||
let inverse = [ia, ib, ic, id, -(e * ia + f * ic), -(e * ib + f * id)];
|
||||
// A determinant that underflows toward zero can still pass the relative check above,
|
||||
// and dividing by it overflows: such a matrix has no usable inverse.
|
||||
inverse.iter().all(|v| v.is_finite()).then_some(Matrix(inverse))
|
||||
}
|
||||
|
||||
/// The bounding box of a rectangle `[x0 y0 x1 y1]` after this transform.
|
||||
|
||||
@@ -552,3 +552,17 @@ fn inline_images_end_where_the_renderer_ends_them() {
|
||||
assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert_accepts_small_scale_and_rejects_singular() {
|
||||
let m = Matrix([1e-7, 0., 0., 1e-7, 0., 0.]);
|
||||
let inv = m.invert().expect("a uniform 1e-7 scale is invertible");
|
||||
let id = m.then(&inv).0;
|
||||
for (got, want) in id.iter().zip(Matrix::IDENTITY.0) {
|
||||
assert!((got - want).abs() < 1e-9, "{id:?}");
|
||||
}
|
||||
assert!(Matrix([1., 2., 2., 4., 0., 0.]).invert().is_none());
|
||||
assert!(Matrix([f64::NAN, 0., 0., 1., 0., 0.]).invert().is_none());
|
||||
// A subnormal scale passes the relative check but its inverse overflows (CodeRabbit on #5703).
|
||||
assert!(Matrix([1e-310, 0., 0., 1., 0., 0.]).invert().is_none());
|
||||
}
|
||||
@@ -90,6 +90,8 @@ recognisable in a diff and byte-stable when re-serialized.
|
||||
- Linearized output, Fast Web View (`core.linearization`, M11).
|
||||
- Structural validation against the Arlington model (`core.arlington-validation`), which will live
|
||||
in the reserved `arlington` crate.
|
||||
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax decoding
|
||||
(`Document::decoded_image`); DCT, JPX and JBIG2 are still decoded through the bootstrap renderer
|
||||
(`core.image-filters`, M2.6).
|
||||
- Image codecs. `pdfcraft-filters` carries the non-image filters and CCITT fax and JBIG2 decoding
|
||||
(`Document::decoded_image`, a JBIG2 image's `/JBIG2Globals` within `MAX_JBIG2_GLOBALS`, 32 MiB,
|
||||
their filters undone once a document for the images that share them, up to 64 MiB kept, while
|
||||
their segments are decoded for every image);
|
||||
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
|
||||
@@ -10,7 +10,7 @@ use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
|
||||
use crate::CosError;
|
||||
use crate::object::{Dict, MAX_DECODED, ObjRef, Object, Stream};
|
||||
use crate::object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream};
|
||||
use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
|
||||
|
||||
mod objects;
|
||||
@@ -49,6 +49,9 @@ pub struct Document {
|
||||
repair_log: Arc<Vec<String>>,
|
||||
cache: Arc<Mutex<HashMap<u32, Arc<Object>>>>,
|
||||
objstms: Arc<Mutex<HashMap<u32, Arc<ObjStm>>>>,
|
||||
/// What `/JBIG2Globals` streams of the file decode to, by object number: the images of a scan
|
||||
/// share them, and each would otherwise decode them again (see `Document::jbig2_globals`).
|
||||
jbig2_globals: Arc<Mutex<HashMap<u32, Arc<Vec<u8>>>>>,
|
||||
stream_limit: usize,
|
||||
overlay: BTreeMap<u32, Slot>,
|
||||
next_num: u32,
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
use super::reader::find;
|
||||
use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes};
|
||||
use super::xref::{Revision, XrefEntry};
|
||||
use super::{Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared};
|
||||
use super::{
|
||||
Arc, CosError, Cow, Dict, Document, Lexer, MAX_DECODED, MAX_JBIG2_GLOBALS, ObjRef, Object, Stream, is_whitespace, parse_indirect_shared,
|
||||
};
|
||||
|
||||
/// The most decoded `/JBIG2Globals` a document keeps for its images to share (see
|
||||
/// `Document::jbig2_globals`): two streams at [`MAX_JBIG2_GLOBALS`].
|
||||
const JBIG2_GLOBALS_HELD: usize = 2 * MAX_JBIG2_GLOBALS;
|
||||
|
||||
impl Document {
|
||||
pub fn bytes(&self) -> &Arc<Vec<u8>> {
|
||||
@@ -233,18 +239,7 @@ impl Document {
|
||||
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::CcittFax, _))) {
|
||||
return None;
|
||||
}
|
||||
// Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the
|
||||
// last filter but a /Crypt, which `filters` leaves out wherever it stands.
|
||||
let index = match s.dict.get(b"Filter") {
|
||||
Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
let parms = match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() {
|
||||
Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)),
|
||||
Some(p) if index == 0 => p.as_dict().cloned(),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_default();
|
||||
let parms = self.last_filter_parms(s);
|
||||
let int = |key: &[u8], default: i64| parms.get(key).and_then(|v| self.resolve(v).as_int()).unwrap_or(default);
|
||||
let flag = |key: &[u8], default: bool| match parms.get(key).map(|v| self.resolve(v)).as_deref() {
|
||||
Some(Object::Bool(b)) => *b,
|
||||
@@ -262,23 +257,62 @@ impl Document {
|
||||
})
|
||||
}
|
||||
|
||||
/// The `/DecodeParms` dictionary of stream `s`'s last filter, resolved: that of a lone filter,
|
||||
/// or its entry in an array. Empty when there is none.
|
||||
fn last_filter_parms(&self, s: &Stream) -> Dict {
|
||||
// Its place in /Filter, /Crypt included, pairs it with its /DecodeParms entry (§7.4.1): the
|
||||
// last filter but a /Crypt, which `filters` leaves out wherever it stands.
|
||||
let index = match s.dict.get(b"Filter") {
|
||||
Some(Object::Array(a)) => a.iter().rposition(|f| f.as_name() != Some(b"Crypt".as_slice())).unwrap_or(0),
|
||||
_ => 0,
|
||||
};
|
||||
match s.dict.get(b"DecodeParms").map(|p| self.resolve(p)).as_deref() {
|
||||
Some(Object::Array(a)) => a.get(index).and_then(|p| self.dict(p)),
|
||||
Some(p) if index == 0 => p.as_dict().cloned(),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The samples of image `s`, decoded: through its filters ([`Stream::decoded`]), or, for a
|
||||
/// CCITT fax image (its last filter), through the filters before it and then
|
||||
/// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], as `CCITTFaxDecode`
|
||||
/// gives them (ISO 32000-2 §7.4.6). Parameters given by reference count, for every filter
|
||||
/// ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its `/Width`. Up to
|
||||
/// its `/Height` rows are decoded, whatever `/Rows` says, within [`MAX_DECODED`] bytes; damaged
|
||||
/// data gives fewer, so a caller checks it has the rows it needs.
|
||||
/// CCITT fax or JBIG2 image (its last filter), through the filters before it and then
|
||||
/// [`pdfcraft_filters::decode_ccitt`] with its [`Document::fax_params`], or
|
||||
/// [`pdfcraft_filters::decode_jbig2`] with its `/JBIG2Globals`, as `CCITTFaxDecode` and
|
||||
/// `JBIG2Decode` give them (ISO 32000-2 §7.4.6, §7.4.7). Parameters given by reference count,
|
||||
/// for every filter ([`Document::with_direct_parms`]). A fax image's `/Columns` has to be its
|
||||
/// `/Width`, and a JBIG2 image's page its `/Width` × `/Height`: PdfCraft's renderer draws the
|
||||
/// whole page into the image. Up to its `/Height` rows are decoded, whatever a fax image's `/Rows` says,
|
||||
/// within [`MAX_DECODED`] bytes; damaged fax data gives fewer, so a caller checks it has the
|
||||
/// rows it needs. Damaged JBIG2 data, and globals that can't be read (within
|
||||
/// [`MAX_JBIG2_GLOBALS`]), are an error.
|
||||
pub fn decoded_image(&self, s: &Stream) -> Result<Vec<u8>, CosError> {
|
||||
self.decoded_image_within(s, MAX_DECODED)
|
||||
}
|
||||
|
||||
/// [`Document::decoded_image`] for an image whose decoding has a tighter bound than
|
||||
/// [`MAX_DECODED`]: no stage, the filters before a fax decoder included, may produce more than
|
||||
/// `max` bytes.
|
||||
/// [`MAX_DECODED`]: no stage, the filters before a fax or JBIG2 decoder included, may produce
|
||||
/// more than `max` bytes. A JBIG2 image's `/JBIG2Globals` are another stream, shared between
|
||||
/// images, and are decoded within [`MAX_JBIG2_GLOBALS`] whatever `max` is.
|
||||
pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
|
||||
let max = max.min(MAX_DECODED);
|
||||
let s = &*self.with_direct_parms(s);
|
||||
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
|
||||
let chain = s.readable_filters().map_err(filter)?;
|
||||
// The filters before the image codec, such as ASCII85Decode, are undone first.
|
||||
let undo = |before: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)]| match before {
|
||||
[] => Ok(Cow::Borrowed(&s.raw[..])),
|
||||
_ => pdfcraft_filters::decode_tolerant(before, &s.raw, max).map(|(data, _)| Cow::Owned(data)).map_err(filter),
|
||||
};
|
||||
// `/Width` and `/Height` are read as given directly, as every caller reads them first: one
|
||||
// given by reference has the image refused there, before it gets here.
|
||||
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok());
|
||||
if let Some(((pdfcraft_filters::Filter::Jbig2, _), before)) = chain.split_last() {
|
||||
let (Some(columns), Some(height)) = (s.dict.int(b"Width").and_then(|w| u32::try_from(w).ok()), height) else {
|
||||
return Err(CosError::Filter("a JBIG2 image's /Width or /Height isn't a size".into()));
|
||||
};
|
||||
let globals = self.jbig2_globals(s)?;
|
||||
return pdfcraft_filters::decode_jbig2(&undo(before)?, globals.as_deref().map(Vec::as_slice), columns, height, max).map_err(filter);
|
||||
}
|
||||
let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
|
||||
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
|
||||
// rows past /Height aren't drawn.
|
||||
@@ -286,18 +320,65 @@ impl Document {
|
||||
if s.dict.int(b"Width") != Some(p.columns) {
|
||||
return Err(CosError::Filter("a fax image's /Columns aren't its /Width".into()));
|
||||
}
|
||||
let height = s.dict.int(b"Height").and_then(|h| u32::try_from(h).ok()).unwrap_or(0);
|
||||
let stride = usize::try_from(p.columns).unwrap_or(usize::MAX).div_ceil(8).max(1);
|
||||
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height);
|
||||
// The filters before it, such as ASCII85Decode, are undone first.
|
||||
let chain = s.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?;
|
||||
let data = match chain.split_last() {
|
||||
Some((_, before)) if !before.is_empty() => {
|
||||
Cow::Owned(pdfcraft_filters::decode_tolerant(before, &s.raw, max).map_err(|e| CosError::Filter(e.to_string()))?.0)
|
||||
}
|
||||
_ => Cow::Borrowed(&s.raw[..]),
|
||||
let rows = u32::try_from(max / stride).unwrap_or(u32::MAX).min(height.unwrap_or(0));
|
||||
let data = undo(chain.split_last().map_or(&[][..], |(_, before)| before))?;
|
||||
pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(filter)
|
||||
}
|
||||
|
||||
/// The `/JBIG2Globals` stream a JBIG2 image `s` names by reference in its last filter's
|
||||
/// `/DecodeParms`: `None` for a stream whose last filter isn't `JBIG2Decode`, or one without
|
||||
/// globals. Redaction finds with it the other images that share a redacted image's globals.
|
||||
pub fn jbig2_globals_ref(&self, s: &Stream) -> Option<ObjRef> {
|
||||
let chain = s.readable_filters().ok()?;
|
||||
if !matches!(chain.last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
|
||||
return None;
|
||||
}
|
||||
self.last_filter_parms(s).get(b"JBIG2Globals").and_then(Object::as_ref)
|
||||
}
|
||||
|
||||
/// What the `/JBIG2Globals` stream of JBIG2 image `s` (in its last filter's `/DecodeParms`)
|
||||
/// decodes to, within [`MAX_JBIG2_GLOBALS`]; `None` without one (a reference to no object is
|
||||
/// one to null, ISO 32000-2 §7.3.10, which is an absent entry, §7.3.7). The image is then read
|
||||
/// without them, as the renderer draws it, so an image that needed them mostly fails to decode
|
||||
/// and can decode to other pixels. A stream that is there and can't be read is an error, rather
|
||||
/// than the image decoded without it.
|
||||
///
|
||||
/// The images of a scan share one globals stream, so a stream of the file, not edited since
|
||||
/// it was read (the same in every clone) and with its `/DecodeParms` direct (by reference they
|
||||
/// are another object, which can change while this one doesn't), has its filters undone once a
|
||||
/// document and is kept, up to [`JBIG2_GLOBALS_HELD`] in all; others are read each time. That
|
||||
/// saves the filters only: the segments it holds are decoded again for every image.
|
||||
fn jbig2_globals(&self, s: &Stream) -> Result<Option<Arc<Vec<u8>>>, CosError> {
|
||||
let Some(entry) = self.last_filter_parms(s).get(b"JBIG2Globals").cloned() else { return Ok(None) };
|
||||
let held = match entry {
|
||||
Object::Ref(r) if !self.overlay.contains_key(&r.num) => Some(r.num),
|
||||
_ => None,
|
||||
};
|
||||
pdfcraft_filters::decode_ccitt(&data, &p, rows).map_err(|e| CosError::Filter(e.to_string()))
|
||||
if let Some(hit) = held.and_then(|num| self.jbig2_globals.lock().ok()?.get(&num).cloned()) {
|
||||
return Ok(Some(hit));
|
||||
}
|
||||
let Object::Stream(g) = &*self.resolve(&entry) else { return Ok(None) };
|
||||
let readable = self.with_direct_parms(g);
|
||||
let held = held.filter(|_| matches!(readable, Cow::Borrowed(_)));
|
||||
let mut data = readable.decoded_within(MAX_JBIG2_GLOBALS).map_err(|e| {
|
||||
let detail = match e {
|
||||
CosError::Filter(detail) => detail,
|
||||
e => e.to_string(),
|
||||
};
|
||||
CosError::Filter(format!("the JBIG2 globals can't be read: {detail}"))
|
||||
})?;
|
||||
// What is kept counts by its length, so that's all it may hold on to.
|
||||
data.shrink_to_fit();
|
||||
let data = Arc::new(data);
|
||||
// A poisoned lock only means nothing is kept.
|
||||
if let (Some(num), Ok(mut kept)) = (held, self.jbig2_globals.lock()) {
|
||||
let total = kept.values().map(|g| g.len()).fold(data.len(), usize::saturating_add);
|
||||
if total <= JBIG2_GLOBALS_HELD {
|
||||
kept.insert(num, data.clone());
|
||||
}
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
pub fn try_get(&self, num: u32) -> Result<Arc<Object>, CosError> {
|
||||
|
||||
@@ -19,6 +19,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: crate::object::MAX_DECODED,
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
@@ -102,6 +103,7 @@ impl Document {
|
||||
repair_log: Arc::new(Vec::new()),
|
||||
cache: Arc::default(),
|
||||
objstms: Arc::default(),
|
||||
jbig2_globals: Arc::default(),
|
||||
stream_limit: stream_limit.min(crate::object::MAX_DECODED),
|
||||
overlay: BTreeMap::new(),
|
||||
next_num: 1,
|
||||
|
||||
@@ -173,6 +173,217 @@ fn fax_images_decode_with_their_parameters() {
|
||||
assert!(behind(&[Object::Int(5), fax], &[null, own], coded.clone()).is_err(), "a /Filter entry that isn't a name");
|
||||
}
|
||||
|
||||
/// Images that share a `/JBIG2Globals` stream of the file decode it once a document: it is kept
|
||||
/// for the next, in every clone. A globals stream edited since is read afresh (the kept one is the
|
||||
/// file's), and objects added in memory aren't kept.
|
||||
#[test]
|
||||
fn shared_jbig2_globals_are_read_once() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
|
||||
let image = |doc: &mut Document| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))
|
||||
};
|
||||
let (a, b) = (image(&mut doc), image(&mut doc));
|
||||
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => panic!("not a stream"),
|
||||
};
|
||||
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
|
||||
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "an object added in memory isn't kept");
|
||||
|
||||
// The same objects read from a file, the catalog holding on to the images (a full save may
|
||||
// number them anew).
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(vec![Object::Ref(a), Object::Ref(b)]))).unwrap();
|
||||
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
|
||||
let mut doc = Document::open(Arc::new(file)).unwrap();
|
||||
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
|
||||
let (a, b) = (images[0].as_ref().unwrap(), images[1].as_ref().unwrap());
|
||||
let parms = stream(&doc, a).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().clone();
|
||||
let globals = parms.reference(b"JBIG2Globals").unwrap();
|
||||
let clone = doc.clone();
|
||||
assert_eq!(doc.decoded_image(&stream(&doc, a)).unwrap(), bits);
|
||||
assert_eq!(doc.jbig2_globals.lock().unwrap().len(), 1, "kept");
|
||||
let kept = doc.jbig2_globals.lock().unwrap()[&globals.num].clone();
|
||||
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone's other image");
|
||||
let shared = clone.jbig2_globals.lock().unwrap()[&globals.num].clone();
|
||||
assert!(Arc::ptr_eq(&kept, &shared), "the clone uses the one kept, not a second decode");
|
||||
doc.set(globals, Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())));
|
||||
assert!(doc.decoded_image(&stream(&doc, b)).is_err(), "the edited globals are read afresh");
|
||||
assert_eq!(clone.decoded_image(&stream(&clone, b)).unwrap(), bits, "the clone keeps the file's");
|
||||
}
|
||||
|
||||
/// A JBIG2 image's size is read as given directly, as every caller reads it first, and an image
|
||||
/// with neither is an error, not an image of no rows.
|
||||
#[test]
|
||||
fn a_jbig2_image_needs_its_size_given_directly() {
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF, 0xFF], 8, 2).unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let size = doc.add(Object::Int(2));
|
||||
let image = |width: Object, height: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), width);
|
||||
d.set(b"Height".to_vec(), height);
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
doc.decoded_image(&Stream::from_raw(d, coded.clone()))
|
||||
};
|
||||
assert!(image(Object::Int(8), Object::Int(2)).is_ok());
|
||||
assert!(image(Object::Ref(size), Object::Int(2)).is_err(), "/Width by reference");
|
||||
assert!(image(Object::Int(8), Object::Ref(size)).is_err(), "/Height by reference");
|
||||
}
|
||||
|
||||
/// A document that kept globals of 64 MiB keeps no more, and the images past that still read:
|
||||
/// three streams of 22 MiB, shared by three images, are two kept and one decoded each time.
|
||||
#[test]
|
||||
fn kept_jbig2_globals_are_bounded_in_total() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let length = 22 * 1024 * 1024;
|
||||
let mut skipped = vec![0, 0, 0, 0, 0x3E, 0, 0];
|
||||
skipped.extend_from_slice(&u32::try_from(4 + length).unwrap().to_be_bytes());
|
||||
skipped.extend_from_slice(&[0, 0, 0, 1]);
|
||||
skipped.resize(skipped.len() + length, 0);
|
||||
let mut doc = Document::new_empty();
|
||||
let mut images = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(&skipped))));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
images.push(Object::Ref(doc.add(Object::Stream(Stream::from_raw(d, coded.clone())))));
|
||||
}
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Images".to_vec(), Object::Array(images))).unwrap();
|
||||
let doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let images = doc.get(doc.root().unwrap()).as_dict().unwrap().get(b"Images").unwrap().as_array().unwrap().clone();
|
||||
for image in &images {
|
||||
let Object::Stream(s) = &*doc.get(image.as_ref().unwrap()) else { panic!("not a stream") };
|
||||
assert_eq!(doc.decoded_image(s).unwrap(), bits, "every image reads");
|
||||
}
|
||||
let kept = doc.jbig2_globals.lock().unwrap();
|
||||
assert_eq!(kept.len(), 2, "two fit in 64 MiB, a third doesn't");
|
||||
assert!(kept.values().all(|g| g.capacity() == g.len()), "what is kept holds no more than it counts");
|
||||
}
|
||||
|
||||
/// Globals whose `/DecodeParms` are given by reference aren't kept: that's another object, so
|
||||
/// editing it changes what they decode to while the stream itself, the key kept by, stays the same.
|
||||
#[test]
|
||||
fn jbig2_globals_with_parameters_by_reference_are_read_afresh() {
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let params = pdfcraft_filters::Params { predictor: 12, colors: 1, bits_per_component: 8, columns: 15, early_change: 1 };
|
||||
let mut doc = Document::new_empty();
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(15));
|
||||
let parms = doc.add(Object::Dict(parms));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
let predicted = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, ¶ms, &skipped).unwrap();
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, predicted)));
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(10));
|
||||
d.set(b"Height".to_vec(), Object::Int(3));
|
||||
d.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
let mut image_parms = Dict::new();
|
||||
image_parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
d.set(b"DecodeParms".to_vec(), Object::Dict(image_parms));
|
||||
let image = doc.add(Object::Stream(Stream::from_raw(d, coded)));
|
||||
let root = doc.root().unwrap();
|
||||
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
|
||||
let mut doc = Document::open(Arc::new(crate::write_full(&doc, &crate::SaveOptions::default()).unwrap())).unwrap();
|
||||
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
|
||||
let stream = |doc: &Document| match &*doc.get(image) {
|
||||
Object::Stream(s) => s.clone(),
|
||||
_ => panic!("not a stream"),
|
||||
};
|
||||
assert_eq!(doc.decoded_image(&stream(&doc)).unwrap(), bits);
|
||||
assert!(doc.jbig2_globals.lock().unwrap().is_empty(), "not kept");
|
||||
let globals = stream(&doc).dict.get(b"DecodeParms").unwrap().as_dict().unwrap().reference(b"JBIG2Globals").unwrap();
|
||||
let Object::Stream(g) = &*doc.get(globals) else { panic!("not a stream") };
|
||||
let parms = g.dict.reference(b"DecodeParms").unwrap();
|
||||
doc.set(parms, Object::Dict(Dict::new()));
|
||||
assert!(doc.decoded_image(&stream(&doc)).is_err(), "without the predictor they read otherwise");
|
||||
}
|
||||
|
||||
/// A JBIG2 image reads through its decoder, behind other filters too, with the `/JBIG2Globals` of
|
||||
/// its own `/DecodeParms` (a dictionary or its place in an array, /Crypt counting, by reference
|
||||
/// too): globals that can't be read are an error, and ones at another filter's place aren't its
|
||||
/// own, nor are those of a lone dictionary when other filters come first. Its page has to be its
|
||||
/// /Width × /Height, and fit the bound.
|
||||
#[test]
|
||||
fn jbig2_images_decode_with_their_globals() {
|
||||
// 10 × 3: the first five samples of each row white, the rest black.
|
||||
let bits = [0b1111_1000, 0, 0b1111_1000, 0, 0b1111_1000, 0];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 10, 3).unwrap();
|
||||
let hex = coded.iter().map(|b| format!("{b:02x}")).chain([">".to_string()]).collect::<String>().into_bytes();
|
||||
let mut doc = Document::new_empty();
|
||||
// An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips, Flate-encoded; and data
|
||||
// that isn't JBIG2.
|
||||
let globals = |doc: &mut Document, data: &[u8]| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
let globals = doc.add(Object::Stream(Stream::from_raw(d, pdfcraft_filters::encode_flate(data))));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
|
||||
Object::Dict(parms)
|
||||
};
|
||||
let (good, bad) = (globals(&mut doc, &[0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1]), globals(&mut doc, b"not JBIG2"));
|
||||
let indirect = Object::Ref(doc.add(bad.clone()));
|
||||
let image = |filters: &[&str], parms: Option<Object>, data: &[u8], (width, height): (i64, i64), max: usize| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Width".to_vec(), Object::Int(width));
|
||||
d.set(b"Height".to_vec(), Object::Int(height));
|
||||
d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect()));
|
||||
if let Some(p) = parms {
|
||||
d.set(b"DecodeParms".to_vec(), p);
|
||||
}
|
||||
doc.decoded_image_within(&Stream::from_raw(d, data.to_vec()), max)
|
||||
};
|
||||
let jbig2 = ["JBIG2Decode"];
|
||||
let full = (10, 3);
|
||||
assert_eq!(image(&jbig2, None, &coded, full, MAX_DECODED).unwrap(), bits);
|
||||
assert_eq!(image(&jbig2, Some(good.clone()), &coded, full, MAX_DECODED).unwrap(), bits, "globals it skips");
|
||||
for parms in [bad.clone(), Object::Array(vec![bad.clone()]), indirect.clone(), Object::Array(vec![indirect])] {
|
||||
let e = image(&jbig2, Some(parms.clone()), &coded, full, MAX_DECODED).unwrap_err();
|
||||
assert!(e.to_string().contains("JBIG2"), "{parms:?}: {e}");
|
||||
}
|
||||
let behind = ["ASCIIHexDecode", "JBIG2Decode"];
|
||||
assert_eq!(image(&behind, None, &hex, full, MAX_DECODED).unwrap(), bits, "behind ASCIIHexDecode");
|
||||
assert!(image(&behind, Some(Object::Array(vec![Object::Null, bad.clone()])), &hex, full, MAX_DECODED).is_err(), "its own globals");
|
||||
assert_eq!(image(&behind, Some(Object::Array(vec![bad.clone(), Object::Null])), &hex, full, MAX_DECODED).unwrap(), bits, "another's");
|
||||
assert_eq!(image(&behind, Some(bad.clone()), &hex, full, MAX_DECODED).unwrap(), bits, "a lone dictionary, the first filter's");
|
||||
let after_crypt = ["Crypt", "JBIG2Decode"];
|
||||
assert!(image(&after_crypt, Some(Object::Array(vec![Object::Null, bad.clone()])), &coded, full, MAX_DECODED).is_err(), "after /Crypt");
|
||||
assert_eq!(image(&after_crypt, Some(Object::Array(vec![bad, Object::Null])), &coded, full, MAX_DECODED).unwrap(), bits, "/Crypt's");
|
||||
|
||||
for size in [(11, 3), (9, 3), (10, 2), (10, 4)] {
|
||||
assert!(image(&jbig2, None, &coded, size, MAX_DECODED).is_err(), "a page other than {size:?}");
|
||||
}
|
||||
assert_eq!(image(&jbig2, None, &coded, full, 6).unwrap(), bits, "the page within the bound");
|
||||
assert!(image(&jbig2, None, &coded, full, 5).is_err(), "the page past the bound");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_file_id_is_derived_from_the_encryption_seed() {
|
||||
let mut doc = Document::new_empty();
|
||||
|
||||
@@ -25,7 +25,7 @@ mod writer;
|
||||
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
|
||||
pub use bytes::Bytes;
|
||||
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
|
||||
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use object::{Dict, MAX_DECODED, MAX_JBIG2_GLOBALS, Name, ObjRef, Object, PdfString, Stream};
|
||||
pub use parser::{Lexer, is_whitespace, parse_indirect};
|
||||
pub use pdfcraft_crypt::{
|
||||
Algorithm, Auth, Method as CryptMethod, NewEncryption, Permissions, SecurityHandler, effective_password, pdfdoc_byte, pdfdoc_char,
|
||||
|
||||
@@ -213,6 +213,12 @@ pub struct Stream {
|
||||
/// Upper bound for a single decoded stream (decompression-bomb defence).
|
||||
pub const MAX_DECODED: usize = 1 << 30;
|
||||
|
||||
/// The most an image's `/JBIG2Globals` stream may decode to: the symbol and pattern dictionaries an
|
||||
/// encoder shares between pages, themselves coded, so far less than any stream may decode to
|
||||
/// ([`MAX_DECODED`]). They are decoded before the image's JBIG2 page is read, so the page's size
|
||||
/// can't bound them: they have a limit of their own.
|
||||
pub const MAX_JBIG2_GLOBALS: usize = 32 << 20;
|
||||
|
||||
impl Stream {
|
||||
/// A new stream from already-encoded bytes (`/Length` is set at write time).
|
||||
pub fn from_raw(dict: Dict, raw: Vec<u8>) -> Self {
|
||||
|
||||
@@ -245,7 +245,7 @@ pub fn write_incremental(doc: &Document, opts: &SaveOptions) -> Result<Vec<u8>,
|
||||
if let Some(p) = prev {
|
||||
trailer.set(b"Prev".to_vec(), Object::Int(p as i64));
|
||||
}
|
||||
let size = doc.next_num().max(trailer.int(b"Size").unwrap_or(0) as u32);
|
||||
let size = doc.next_num().max(trailer.int(b"Size").map_or(0, |n| n.clamp(0, i64::from(u32::MAX)) as u32));
|
||||
if as_stream {
|
||||
let rows = offsets.iter().map(|(n, (o, g, used))| (*n, if *used { Row::InFile(*o, *g) } else { Row::Free(*g) })).collect();
|
||||
write_xref_stream(&mut out, base, &mut trailer, rows, size);
|
||||
|
||||
@@ -228,6 +228,16 @@ fn incremental_save_keeps_a_trailer_only_edit() {
|
||||
assert_qpdf_clean(&saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_save_ignores_a_negative_trailer_size() {
|
||||
let mut doc = Document::open(Arc::new(classic_table())).unwrap();
|
||||
doc.trailer_mut().set(b"Size".to_vec(), Object::Int(-1));
|
||||
let next = doc.add(Object::Int(5)).num + 1;
|
||||
let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let back = Document::open(Arc::new(saved)).unwrap();
|
||||
assert_eq!(back.trailer().int(b"Size"), Some(i64::from(next)));
|
||||
}
|
||||
|
||||
/// What one edit added and a later one unlinked (a deleted comment, a cleared redaction mark) is
|
||||
/// not written: the file never held it, and writing it would keep what was removed. Additions the
|
||||
/// update still refers to, from the trailer or a changed object and through other additions, are.
|
||||
|
||||
@@ -15,8 +15,6 @@ pdfcraft-model = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# For `image::bmp_with_pixel_offset`, shared with XFA pictures.
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
# Already in the graph through hayro: decodes JBIG2 images for Export ▸ Images.
|
||||
hayro-jbig2.workspace = true
|
||||
png = "0.18"
|
||||
tiff = "0.11"
|
||||
image = { version = "0.25", default-features = false, features = ["bmp", "gif", "jpeg"] }
|
||||
|
||||
@@ -27,11 +27,12 @@ Layer L4. Create a PDF (Acrobat's Create a PDF tool), execution plan M10.2:
|
||||
Every function returns a `pdfcraft_cos::Document`; the caller writes it.
|
||||
|
||||
Image export and Save Image As apply each lossless image's Decode pair before converting its color components.
|
||||
JBIG2 images and masks (JBIG2 as their only filter) are decoded with `hayro-jbig2`, their `/JBIG2Globals` included,
|
||||
as `JBIG2Decode` gives them: black as 0. An image whose JBIG2 page has another width than the image, or more
|
||||
pixels than an exported image may have, or whose globals decode to more than 32 MiB (they are decoded before the
|
||||
page's size is known, so they have a limit of their own), is skipped; such a mask is left out and the image
|
||||
exported opaque, as JPX and CCITT masks are.
|
||||
JBIG2 images and masks are decoded through `pdfcraft_cos::Document::decoded_image` (`pdfcraft_filters::decode_jbig2`), behind
|
||||
other filters too, with the `/JBIG2Globals` of their own `/DecodeParms` entry, as `JBIG2Decode` gives them: black as 0. An image
|
||||
whose JBIG2 page isn't its size (PdfCraft's renderer draws the whole page into the image; the page is refused before it is decoded),
|
||||
or whose globals can't be read or decode to more than 32 MiB (they are decoded before the page's size is known, so they have a
|
||||
limit of their own), is skipped; such a mask is left out and the image exported opaque, as JPX and CCITT
|
||||
masks are.
|
||||
Indexed Decode values select palette entries after rounding and clipping. Color-key masks compare the original samples.
|
||||
Missing, null or malformed Decode arrays keep the default mapping.
|
||||
Soft-mask Decode intervals map samples to opacity before the mask is resized, including reversed ranges and indirect entries.
|
||||
@@ -43,4 +44,4 @@ CCITT fax images (Group 3 and 4) are decoded for export through `pdfcraft_cos::D
|
||||
`ASCII85Decode` too. Byte-aligned Group 3 rows are read by their end-of-line codes, whether the fill starts each code on a byte or
|
||||
ends it on one, when the data starts with one (T.4 sends one before the first row), and on bytes otherwise, whatever `/EndOfLine`
|
||||
says; data that comes up short is reported. Data with codes but none before the first row is read on bytes, and is reported or,
|
||||
at `/K 0`, may come out wrong. JPEG 2000 and JBIG2 images are still reported, not exported.
|
||||
at `/K 0`, may come out wrong. JPEG 2000 images are still reported, not exported.
|
||||
+30
-110
@@ -100,114 +100,14 @@ fn filters(s: &Stream) -> Vec<Vec<u8>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples of image `s`, decoded: through its filters, with a `/DecodeParms` dictionary given
|
||||
/// by reference, or as an array's entry, or with numbers inside by reference, read too (a predictor
|
||||
/// given so would otherwise be lost), or, for a JBIG2 image
|
||||
/// (its only filter), through a JBIG2 decoder with its `/JBIG2Globals`, as rows of one-bit samples
|
||||
/// each starting on a byte, with black as 0, as `JBIG2Decode` gives them (ISO 32000-2 §7.4.7).
|
||||
/// The samples of image `s`, decoded ([`Document::decoded_image`]): through its filters, with a
|
||||
/// `/DecodeParms` dictionary given by reference, or as an array's entry, or with numbers inside by
|
||||
/// reference, read too (a predictor given so would otherwise be lost); a CCITT fax or JBIG2 image
|
||||
/// through its decoder too, behind other filters as well, a JBIG2 image with its `/JBIG2Globals`.
|
||||
/// A JBIG2 page has to be the image's size, which the caller checked against the export's limit,
|
||||
/// before the page is decoded.
|
||||
fn decoded(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
|
||||
match filters(s).as_slice() {
|
||||
[f] if f == b"JBIG2Decode" => jbig2(doc, s),
|
||||
_ => doc.decoded_image(s).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The `/DecodeParms` of a stream with one filter: a dictionary, or the one in an array (7.4.2).
|
||||
fn decode_parms(doc: &Document, s: &Stream) -> Dict {
|
||||
let Some(p) = s.dict.get(b"DecodeParms").map(|p| doc.resolve(p)) else { return Dict::default() };
|
||||
let p = match &*p {
|
||||
Object::Array(a) => match a.first() {
|
||||
Some(first) => doc.resolve(first),
|
||||
None => return Dict::default(),
|
||||
},
|
||||
_ => p,
|
||||
};
|
||||
p.as_dict().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The most an image's `/JBIG2Globals` decode to: the symbol and pattern dictionaries an encoder
|
||||
/// shares between pages, themselves coded, so far less than any stream may decode to
|
||||
/// ([`pdfcraft_cos::MAX_DECODED`], 1 GiB). Globals past it refuse the image.
|
||||
const MAX_GLOBALS: usize = 32 << 20;
|
||||
|
||||
/// [`decoded`] for a JBIG2 image. Its JBIG2 page has to be the image's width, which the caller
|
||||
/// checked against the export's size limit, and within that limit itself: the decoder decodes the
|
||||
/// whole page, rows past the image's height too, so a small `/Height` over a far taller page
|
||||
/// would cost more than the limit allows. Rows past the image's height are dropped, and too few
|
||||
/// rows are refused by the caller. Globals that can't be read refuse the image, rather than
|
||||
/// decoding it without them. They are decoded before the page's size is known, so the page's
|
||||
/// limit can't bound them; they have [`MAX_GLOBALS`] of their own.
|
||||
fn jbig2(doc: &Document, s: &Stream) -> Result<Vec<u8>, String> {
|
||||
let globals = match decode_parms(doc, s).get(b"JBIG2Globals").map(|g| doc.resolve(g)).as_deref() {
|
||||
Some(Object::Stream(g)) => {
|
||||
Some(doc.with_direct_parms(g).decoded_within(MAX_GLOBALS).map_err(|e| format!("the JBIG2 globals can't be read: {e}"))?)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let image = hayro_jbig2::Image::new_embedded(&s.raw, globals.as_deref()).map_err(|e| format!("the JBIG2 image can't be read: {e}"))?;
|
||||
if s.dict.int(b"Width") != Some(i64::from(image.width())) {
|
||||
return Err("the JBIG2 image's width isn't the image's width".into());
|
||||
}
|
||||
if u64::from(image.width()).saturating_mul(u64::from(image.height())) > MAX_PIXELS {
|
||||
return Err(format!("the JBIG2 page, {} × {}, is too large to export", image.width(), image.height()));
|
||||
}
|
||||
let height = s.dict.int(b"Height").and_then(|h| usize::try_from(h).ok()).unwrap_or(0);
|
||||
let mut rows = Rows::new(image.width() as usize, height);
|
||||
image.decode(&mut rows).map_err(|e| format!("the JBIG2 image can't be decoded: {e}"))?;
|
||||
Ok(rows.out)
|
||||
}
|
||||
|
||||
/// Rows of one-bit samples, each starting on a byte, filled from a decoder's pixels: `columns`
|
||||
/// a row and at most `rows` rows. Pixels past a row's end and rows past the last are dropped, so
|
||||
/// the output is bounded by the size the caller gave.
|
||||
struct Rows {
|
||||
out: Vec<u8>,
|
||||
row: Vec<u8>,
|
||||
x: usize,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
done: usize,
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn new(columns: usize, rows: usize) -> Self {
|
||||
Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0 }
|
||||
}
|
||||
|
||||
/// `count` pixels, as 1 samples when `one`.
|
||||
fn push(&mut self, one: bool, count: usize) {
|
||||
let end = self.x.saturating_add(count);
|
||||
if one {
|
||||
for x in self.x..end.min(self.columns) {
|
||||
if let Some(b) = self.row.get_mut(x / 8) {
|
||||
*b |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.x = end;
|
||||
}
|
||||
|
||||
fn end_row(&mut self) {
|
||||
if self.done < self.rows {
|
||||
self.out.extend_from_slice(&self.row);
|
||||
self.done += 1;
|
||||
}
|
||||
self.row.fill(0);
|
||||
self.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0.
|
||||
impl hayro_jbig2::Decoder for Rows {
|
||||
fn push_pixel(&mut self, black: bool) {
|
||||
self.push(!black, 1);
|
||||
}
|
||||
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
|
||||
self.push(!black, (chunks as usize).saturating_mul(8));
|
||||
}
|
||||
fn next_line(&mut self) {
|
||||
self.end_row();
|
||||
}
|
||||
doc.decoded_image(s).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The file for one image: its JPEG data as is, or a PNG. An image with transparency (a soft
|
||||
@@ -218,7 +118,6 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
let jpeg = matches!(f.last().map(Vec::as_slice), Some(b"DCTDecode" | b"DCT")) && f.len() == 1;
|
||||
match f.last().map(Vec::as_slice) {
|
||||
Some(b"JPXDecode") => return Err("JPEG 2000 images can't be exported yet".into()),
|
||||
Some(b"JBIG2Decode") if f.len() > 1 => return Err("JBIG2 images inside other filters can't be exported yet".into()),
|
||||
Some(b"DCTDecode" | b"DCT") if !jpeg => return Err("JPEG images inside other filters can't be exported yet".into()),
|
||||
_ => {}
|
||||
}
|
||||
@@ -236,11 +135,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
|
||||
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
|
||||
let key = if mask { None } else { colour_key(doc, s) };
|
||||
// Keep the existing JPEG and stencil-mask Decode handling separate from lossless colour images.
|
||||
let endpoint = |value: Option<&Object>| value.map(|v| doc.resolve(v)).and_then(|v| v.as_f64()).filter(|v| v.is_finite());
|
||||
let inverted = (jpeg || mask)
|
||||
&& s.dict
|
||||
.get(b"Decode")
|
||||
.map(|d| doc.resolve(d))
|
||||
.and_then(|d| d.as_array().map(|a| a.first().and_then(Object::as_f64) > a.get(1).and_then(Object::as_f64)))
|
||||
// Both ends have to be finite numbers, as for lossless images, or the default mapping stands.
|
||||
.and_then(|d| {
|
||||
let a = d.as_array()?;
|
||||
Some(endpoint(a.first())? > endpoint(a.get(1))?)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
// In a PDF a CMYK JPEG's samples are ink amounts unless /Decode inverts them, while image
|
||||
// viewers read a CMYK JPEG file as inverted (Adobe's convention): as is, it would show as
|
||||
@@ -612,7 +516,23 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
|
||||
return Some(px);
|
||||
}
|
||||
let img = image::GrayImage::from_raw(mw as u32, mh as u32, px)?;
|
||||
Some(image::imageops::resize(&img, w as u32, h as u32, image::imageops::FilterType::Triangle).into_raw())
|
||||
Some(scaled(&img, w as u32, h as u32).into_raw())
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
|
||||
/// 16 bytes a pixel, which a mask of other proportions than its image can make larger than any
|
||||
/// machine: 100,000 × 1 on an image of 1 × 100,000 asks for 160 GB, and that aborts the process.
|
||||
/// Such a mask is scaled across first, which holds no more pixels than one of the two has. Past a
|
||||
/// quarter over the larger of them, so that rounding leaves a mask of its image's shape as it was.
|
||||
pub(crate) fn scaled(img: &image::GrayImage, w: u32, h: u32) -> image::GrayImage {
|
||||
use image::imageops::{FilterType, resize};
|
||||
let (mw, mh) = (u64::from(img.width()), u64::from(img.height()));
|
||||
let largest = (mw * mh).max(u64::from(w) * u64::from(h));
|
||||
if (mw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
|
||||
resize(&resize(img, w, img.height(), FilterType::Triangle), w, h, FilterType::Triangle)
|
||||
} else {
|
||||
resize(img, w, h, FilterType::Triangle)
|
||||
}
|
||||
}
|
||||
|
||||
fn png(w: u32, h: u32, rgb: &[u8], alpha: Option<&[u8]>) -> Result<Vec<u8>, String> {
|
||||
|
||||
+157
-21
@@ -2054,10 +2054,10 @@ fn symbol_coded_jbig2_images_export_as_their_picture() {
|
||||
|
||||
/// Export ▸ Images reads JBIG2 images (ISO 32000-2 §7.4.7), JBIG2 black as 0: as the image, and as
|
||||
/// a stencil `/Mask` of another. Their `/JBIG2Globals` are read, from a dictionary or the one in
|
||||
/// an array, so globals that can't be read skip the image rather than being ignored. Rows past the
|
||||
/// image's height are left out; a JBIG2 page of another width, or with fewer rows than the image,
|
||||
/// or larger than an exported image may be (however small the image's `/Height`), or data that
|
||||
/// isn't JBIG2, skips the image.
|
||||
/// an array, so globals that can't be read skip the image rather than being ignored. A JBIG2 page
|
||||
/// of another size than the image (a taller one too: PdfCraft's renderer draws the whole page into
|
||||
/// the image), refused before it is decoded however large, or data that isn't JBIG2, skips the
|
||||
/// image.
|
||||
#[test]
|
||||
fn jbig2_images_export_as_their_picture() {
|
||||
let (w, h) = (203i64, 41i64);
|
||||
@@ -2118,33 +2118,24 @@ fn jbig2_images_export_as_their_picture() {
|
||||
for in_array in [false, true] {
|
||||
assert!(skipped(&page(w, h, &jbig2, Some((&b"not JBIG2"[..], in_array)), false)), "unreadable globals, in an array: {in_array}");
|
||||
}
|
||||
assert_eq!(exported_rgba(&page(w, h - 5, &jbig2, None, false)).1, picture(h - 5), "rows past the height");
|
||||
for (width, height, data) in [(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h, &b"not JBIG2"[..])] {
|
||||
for (width, height, data) in
|
||||
[(w + 8, h, &jbig2[..]), (w - 1, h, &jbig2[..]), (w, h + 1, &jbig2[..]), (w, h - 5, &jbig2[..]), (w, h, &b"not JBIG2"[..])]
|
||||
{
|
||||
assert!(skipped(&page(width, height, data, None, false)), "{width} × {height}, {} bytes", data.len());
|
||||
}
|
||||
// The same stream with its page information (the first segment's data, after its 11-byte
|
||||
// header: width, then height) claiming a 20,000 × 10,000 page: 200 million pixels, over the
|
||||
// export's 100 million, behind an image of 20,000 × 4. The decoder would decode the whole page
|
||||
// (it takes pages of up to about 2^28 pixels), so it is refused first.
|
||||
// header: width, then height) claiming a 20,000 × 10,000 page, behind an image of 20,000 × 4:
|
||||
// the decoder would decode the whole page, so it is refused first.
|
||||
let mut large = jbig2.clone();
|
||||
large[11..15].copy_from_slice(&20_000u32.to_be_bytes());
|
||||
large[15..19].copy_from_slice(&10_000u32.to_be_bytes());
|
||||
assert!(skipped(&page(20_000, 4, &large, None, false)), "a page over the export's size");
|
||||
assert!(skipped(&page(20_000, 4, &large, None, false)), "a page far taller than the image");
|
||||
// Refused the same ways as a stencil /Mask, it is left out: the image exports opaque.
|
||||
for (width, height, mask) in [(w + 8, h, &jbig2), (20_000, 4, &large)] {
|
||||
let rgba = exported_rgba(&page(width, height, mask, None, true)).1;
|
||||
assert_eq!(rgba.len() as i64, width * height * 4, "a {width}-wide mask");
|
||||
assert!(alphas(&rgba).iter().all(|a| *a == 255), "a refused {width}-wide mask is left out");
|
||||
}
|
||||
// A page of exactly the budget, 20,000 × 5,000, is decoded; one row more is refused.
|
||||
for (rows, refused) in [(5_000u32, false), (5_001, true)] {
|
||||
large[15..19].copy_from_slice(&rows.to_be_bytes());
|
||||
let doc = page(20_000, 4, &large, None, false);
|
||||
assert_eq!(skipped(&doc), refused, "a page of 20,000 × {rows}");
|
||||
if !refused {
|
||||
assert_eq!(exported_rgba(&doc).1.len(), 20_000 * 4 * 4);
|
||||
}
|
||||
}
|
||||
// Damaged data never panics.
|
||||
for end in [0, 1, 10, 20, 30, jbig2.len() / 2] {
|
||||
let _ = extract_images(&page(w, h, &jbig2[..end], None, false), &[0], 0);
|
||||
@@ -2155,6 +2146,38 @@ fn jbig2_images_export_as_their_picture() {
|
||||
assert_eq!(alphas(&exported_rgba(&page(w, h, &jbig2, None, true)).1), alpha);
|
||||
}
|
||||
|
||||
/// A JBIG2 image behind another filter (ASCIIHexDecode) exports as its picture, as a fax image does,
|
||||
/// with the `/JBIG2Globals` at its own place in a `/DecodeParms` array: globals that can't be read
|
||||
/// there skip the image, and ones at the other filter's place aren't the image's.
|
||||
#[test]
|
||||
fn jbig2_images_behind_another_filter_export_as_their_picture() {
|
||||
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
|
||||
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
|
||||
let hex: Vec<u8> = jbig2.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
|
||||
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect();
|
||||
// With unreadable globals at place `at` of its /DecodeParms.
|
||||
let page = |at: Option<usize>| {
|
||||
let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
|
||||
with_image(&mut doc, |doc| {
|
||||
let mut d = image_dict(16, 2, "DeviceGray", 1);
|
||||
d.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]));
|
||||
if let Some(at) = at {
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"not JBIG2".to_vec())))));
|
||||
let mut entries = vec![Object::Null, Object::Null];
|
||||
entries[at] = Object::Dict(parms);
|
||||
d.set(b"DecodeParms".to_vec(), Object::Array(entries));
|
||||
}
|
||||
Stream::from_raw(d, hex.clone())
|
||||
});
|
||||
doc
|
||||
};
|
||||
assert_eq!(exported_rgba(&page(None)), ("png", picture.clone()));
|
||||
assert_eq!(exported_rgba(&page(Some(0))).1, picture, "globals at ASCIIHexDecode's place");
|
||||
let out = extract_images(&page(Some(1)), &[0], 0);
|
||||
assert!(out.images.is_empty() && out.skipped.len() == 1, "unreadable globals at its own place");
|
||||
}
|
||||
|
||||
/// A JBIG2 image's `/JBIG2Globals` are decoded before its JBIG2 page's size is known, so they have
|
||||
/// a limit of their own, 32 MiB, far below the 1 GiB of any stream: globals that Flate-decode to
|
||||
/// one byte more skip the image, with the limit in the reason, though they take a few kilobytes
|
||||
@@ -2162,7 +2185,8 @@ fn jbig2_images_export_as_their_picture() {
|
||||
/// indirect `/DecodeParms`, in an array or not.
|
||||
#[test]
|
||||
fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
|
||||
const LIMIT: usize = 32 << 20;
|
||||
const LIMIT: usize = pdfcraft_cos::MAX_JBIG2_GLOBALS;
|
||||
assert_eq!(LIMIT, 32 << 20);
|
||||
let bits = [0xF0u8, 0x0F, 0xAA, 0x55];
|
||||
let jbig2 = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
|
||||
let picture: Vec<u8> = (0..32).flat_map(|i| if bits[i / 8] & (0x80 >> (i % 8)) != 0 { [255; 4] } else { [0, 0, 0, 255] }).collect();
|
||||
@@ -2206,6 +2230,118 @@ fn jbig2_globals_are_decoded_within_a_limit_of_their_own() {
|
||||
let out = extract_images(&page(&over, form), &[0], 0);
|
||||
assert!(out.images.is_empty(), "globals over the limit, form {form}");
|
||||
let why = &out.skipped.first().expect("the image is skipped").2;
|
||||
assert!(why.starts_with("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
|
||||
assert!(why.contains("the JBIG2 globals can't be read") && why.contains(&format!("limit of {LIMIT} bytes")), "{why}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `/Decode` says which way round a stencil mask is, and its numbers can be given by reference. Read
|
||||
/// as absent, they gave the wrong answer, so the file came out a negative of what the page draws.
|
||||
#[test]
|
||||
fn a_stencil_masks_decode_array_can_hold_references() {
|
||||
use pdfcraft_cos::Document;
|
||||
let export = |decode: &str| {
|
||||
let pdf = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 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 100 100] /Resources << /XObject << /Im 4 0 R >> >> >> endobj\n\
|
||||
4 0 obj << /Subtype /Image /ImageMask true /Width 8 /Height 1 /BitsPerComponent 1 /Decode {decode} /Filter /ASCIIHexDecode /Length 3 >> stream\nF0>\nendstream endobj\n\
|
||||
5 0 obj 1 endobj\n6 0 obj 0 endobj\n7 0 obj 7 0 R endobj\n8 0 obj 9 0 R endobj\n9 0 obj 8 0 R endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF"
|
||||
);
|
||||
let doc = Document::open(std::sync::Arc::new(pdf.into_bytes())).unwrap();
|
||||
let mut export = crate::extract::extract_images(&doc, &[0], 1);
|
||||
assert_eq!(export.images.len(), 1, "{decode}: {:?}", export.skipped);
|
||||
export.images.remove(0).data
|
||||
};
|
||||
let (inverted, identity) = (export("[1 0]"), export("[0 1]"));
|
||||
assert_ne!(inverted, identity, "the two ways round differ");
|
||||
for decode in ["[5 0 R 0]", "[1 6 0 R]", "[5 0 R 6 0 R]"] {
|
||||
assert_eq!(export(decode), inverted, "{decode}");
|
||||
}
|
||||
for decode in ["[0 5 0 R]", "[6 0 R 1]", "[6 0 R 5 0 R]"] {
|
||||
assert_eq!(export(decode), identity, "{decode}");
|
||||
}
|
||||
// A reference that only leads back to itself holds no number, and an end that is missing or isn't
|
||||
// a number leaves the default mapping, whichever end it is.
|
||||
for decode in ["[7 0 R 0]", "[1 7 0 R]", "[8 0 R 0]", "[1 8 0 R]", "[7 0 R 8 0 R]", "[1]", "[1 /Name]", "[/Name 0]"] {
|
||||
assert_eq!(export(decode), identity, "{decode}");
|
||||
}
|
||||
// Equal ends aren't the two ways round either, and a number too long to hold reads as infinite.
|
||||
assert_eq!(export("[1 1]"), identity);
|
||||
assert_eq!(export(&format!("[{} 0]", "9".repeat(400))), identity);
|
||||
}
|
||||
|
||||
/// A mask of other proportions than its image is scaled to it, however far apart they are: the
|
||||
/// first two shapes asked for 160 GB on the way, in a file of a kilobyte, and that aborted the
|
||||
/// process. The third would ask for 10 GB if it went across first, which is why the way round
|
||||
/// depends on the shapes. The value comes through, and masks of ordinary proportions, of the
|
||||
/// image's size and larger than it, are scaled as they always were.
|
||||
#[test]
|
||||
fn a_mask_of_other_proportions_than_its_image_is_scaled_without_the_memory_of_both() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
let gray = |w: i64, h: i64, data: &[u8]| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(w));
|
||||
d.set(b"Height".to_vec(), Object::Int(h));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
d.set(b"Filter".to_vec(), Object::name("FlateDecode"));
|
||||
Stream::from_raw(d, pdfcraft_filters::encode_flate(data))
|
||||
};
|
||||
let shapes = [
|
||||
(100_000usize, 1usize, 1usize, 100_000usize),
|
||||
(100_000, 64, 64, 100_000),
|
||||
(1, 100_000, 100_000, 1),
|
||||
(64, 1, 1, 64),
|
||||
(3, 2, 6, 4),
|
||||
(3, 2, 3, 2),
|
||||
(6, 4, 3, 2),
|
||||
];
|
||||
for (mask_w, mask_h, w, h) in shapes {
|
||||
let mut doc = Document::new_empty();
|
||||
let mask = doc.add(Object::Stream(gray(mask_w as i64, mask_h as i64, &vec![0x80; mask_w * mask_h])));
|
||||
let mut image = gray(w as i64, h as i64, &vec![0x40; w * h]);
|
||||
image.dict.set(b"SMask".to_vec(), Object::Ref(mask));
|
||||
let image = doc.add(Object::Stream(image));
|
||||
let (name, png) = crate::image_file(&doc, image).unwrap_or_else(|e| panic!("{mask_w} × {mask_h} on {w} × {h}: {e}"));
|
||||
assert_eq!(name, "png");
|
||||
let mut reader = ::png::Decoder::new(std::io::Cursor::new(&png)).read_info().unwrap();
|
||||
let mut rgba = vec![0; reader.output_buffer_size().unwrap()];
|
||||
let info = reader.next_frame(&mut rgba).unwrap();
|
||||
assert_eq!((info.width as usize, info.height as usize, info.color_type), (w, h, ::png::ColorType::Rgba));
|
||||
assert!(rgba.as_chunks::<4>().0.iter().all(|p| p[3].abs_diff(0x80) <= 1), "{mask_w} × {mask_h} on {w} × {h}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The long way round is for proportions far apart: up to a quarter over the larger image the
|
||||
/// buffer is no danger, and such a mask scales as it always did, to the byte. Noise, as a flat mask comes
|
||||
/// out the same either way.
|
||||
#[test]
|
||||
fn proportions_up_to_the_limit_scale_as_they_did() {
|
||||
let noise = |w: u32, h: u32| {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let px = (0..w * h)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect();
|
||||
image::GrayImage::from_raw(w, h, px).unwrap()
|
||||
};
|
||||
// Each exactly a quarter over the larger: the source's size, then the target's.
|
||||
for (mw, mh, w, h) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
|
||||
let mask = noise(mw, mh);
|
||||
let direct = image::imageops::resize(&mask, w, h, image::imageops::FilterType::Triangle);
|
||||
assert_eq!(crate::extract::scaled(&mask, w, h), direct, "{mw} × {mh} to {w} × {h}");
|
||||
}
|
||||
// A step past it goes the long way, which is how to tell.
|
||||
let mask = noise(25, 15);
|
||||
let direct = image::imageops::resize(&mask, 10, 20, image::imageops::FilterType::Triangle);
|
||||
assert_ne!(crate::extract::scaled(&mask, 10, 20), direct);
|
||||
// A mask of its image's size comes through as it is.
|
||||
let mask = noise(20, 10);
|
||||
assert_eq!(crate::extract::scaled(&mask, 20, 10), mask);
|
||||
}
|
||||
@@ -24,3 +24,5 @@ unicode-bidi = "0.3"
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
@@ -7,9 +7,25 @@ use super::place::{
|
||||
};
|
||||
use super::{Document, EditError, Object, added, backdrop, helvetica_width};
|
||||
|
||||
/// Commit one complete mark operation only after every selected page succeeds. The snapshot
|
||||
/// shares original bytes, cross-references, caches and unchanged objects; only the overlay table
|
||||
/// and document metadata are copied. Dropping a failed stage also drops removed marks, changed
|
||||
/// page/resources/backdrop dictionaries and new objects, including the allocation cursor.
|
||||
fn apply_mark_transaction(doc: &mut Document, apply: impl FnOnce(&mut Document) -> Result<(), EditError>) -> Result<(), EditError> {
|
||||
let mut staged = doc.clone();
|
||||
apply(&mut staged)?;
|
||||
*doc = staged;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on
|
||||
/// those pages are removed first (Acrobat's Replace Existing).
|
||||
/// those pages are removed first (Acrobat's Replace Existing). The whole call is atomic:
|
||||
/// on error, the document (including earlier selected pages) is unchanged.
|
||||
pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
|
||||
apply_mark_transaction(doc, |staged| add_header_footer_in_place(staged, pages, hf, replace, cx))
|
||||
}
|
||||
|
||||
fn add_header_footer_in_place(doc: &mut Document, pages: &[usize], hf: &HeaderFooter, replace: bool, cx: &Context) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if hf.text.iter().all(|t| t.trim().is_empty()) {
|
||||
@@ -121,8 +137,13 @@ pub fn add_header_footer(doc: &mut Document, pages: &[usize], hf: &HeaderFooter,
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a text watermark to `pages`.
|
||||
/// Add a text or picture watermark to `pages`. All selected pages, including mark replacement,
|
||||
/// form one transaction; on error, the document is unchanged.
|
||||
pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
|
||||
apply_mark_transaction(doc, |staged| add_watermark_in_place(staged, pages, wm, replace))
|
||||
}
|
||||
|
||||
fn add_watermark_in_place(doc: &mut Document, pages: &[usize], wm: &Watermark, replace: bool) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
let text = wm.text.trim();
|
||||
@@ -201,8 +222,13 @@ pub fn add_watermark(doc: &mut Document, pages: &[usize], wm: &Watermark, replac
|
||||
|
||||
/// Add a solid-colour background behind the content of `pages`. With `bg.replace_backdrop`, a
|
||||
/// white backdrop a page paints itself is replaced first, so the background shows (see
|
||||
/// [`backdrop`]); a picture backdrop (a scan) still hides it.
|
||||
/// [`backdrop`]); a picture backdrop (a scan) still hides it. All selected pages, including
|
||||
/// replacement and backdrop edits, form one transaction; on error, the document is unchanged.
|
||||
pub fn add_background(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
|
||||
apply_mark_transaction(doc, |staged| add_background_in_place(staged, pages, bg, replace))
|
||||
}
|
||||
|
||||
fn add_background_in_place(doc: &mut Document, pages: &[usize], bg: &Background, replace: bool) -> Result<(), EditError> {
|
||||
let all = page_list(doc);
|
||||
check(pages, all.len())?;
|
||||
if replace {
|
||||
|
||||
+1313
-142
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,11 @@
|
||||
//! pdfcraft-edit — page content editing (L4). Today: page marks, Acrobat's Header & footer,
|
||||
//! Watermark, Background and Bates numbering (execution plan M7.6).
|
||||
//!
|
||||
//! **Atomic page-mark operations.** Adding or replacing headers/footers, watermarks and backgrounds
|
||||
//! stages the whole call on a document snapshot and commits only after every selected page succeeds.
|
||||
//! An error preserves existing marks, content, resources, pending edits and allocated objects. The
|
||||
//! snapshot shares original PDF bytes and unchanged objects; it copies the overlay table and metadata.
|
||||
//!
|
||||
//! **How marks are stored.** Each mark is one content stream appended to (or, for content behind
|
||||
//! the page, inserted after the backgrounds at the start of) the page's `/Contents`, drawn in
|
||||
//! display space (after `/Rotate`) and wrapped in a marked-content artifact (§14.8.2.2) so
|
||||
@@ -35,6 +40,7 @@ pub type Rgb = [f64; 3];
|
||||
|
||||
pub mod backdrop;
|
||||
pub use backdrop::{Backdrop, backdrops_left_off, page_backdrop, page_backdrops};
|
||||
pub use text::strip_subset_tag;
|
||||
mod background;
|
||||
pub use background::page_background;
|
||||
pub mod objects;
|
||||
@@ -43,7 +49,7 @@ pub use objects::{EditableObject, MAX_MOVE_OBJECTS, ObjectKind, ObjectTarget, ed
|
||||
mod flatten;
|
||||
pub use flatten::{flatten, flatten_fill_sign};
|
||||
pub mod images;
|
||||
pub use images::{ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
|
||||
pub use images::{ArrangeOrder, ImageChange, PageImage, change_image, page_images, reading_images, rect_to_rect, turn_about_centre, turn_by_degrees};
|
||||
pub mod bidi;
|
||||
pub mod indic;
|
||||
mod linebreak;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The public mark APIs either commit the complete operation or leave the document untouched.
|
||||
use super::{cx, fixture, reopen, streams};
|
||||
use crate::place::MARK_PLACEMENT_FAILURE;
|
||||
use crate::*;
|
||||
use pdfcraft_cos::{ObjRef, SaveOptions, write_incremental};
|
||||
|
||||
const SELECTED: &[usize] = &[0, 1, 2];
|
||||
|
||||
struct PlacementFailure;
|
||||
|
||||
impl PlacementFailure {
|
||||
fn after_two_pages(kind: MarkKind) -> Self {
|
||||
MARK_PLACEMENT_FAILURE.with(|failure| {
|
||||
assert!(failure.get().is_none());
|
||||
failure.set(Some((kind, 2, 0)));
|
||||
});
|
||||
Self
|
||||
}
|
||||
|
||||
fn completed(&self) -> usize {
|
||||
MARK_PLACEMENT_FAILURE.with(|failure| failure.get().unwrap().2)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PlacementFailure {
|
||||
fn drop(&mut self) {
|
||||
MARK_PLACEMENT_FAILURE.with(|failure| failure.set(None));
|
||||
}
|
||||
}
|
||||
|
||||
/// Four pages share an original stream and resources, both inherited and directly referenced.
|
||||
/// All have all three existing marks, including a picture background with a replaced white fill.
|
||||
fn marked_document() -> (Document, MarkSource) {
|
||||
let mut doc = fixture();
|
||||
let parent = ObjRef::new(2, 0);
|
||||
let extra = doc.add(Object::Dict(Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("Page")),
|
||||
(b"Parent".to_vec(), Object::Ref(parent)),
|
||||
(b"Contents".to_vec(), Object::Ref(ObjRef::new(7, 0))),
|
||||
])));
|
||||
doc.update_dict(parent, |d| {
|
||||
d.set(b"Kids".to_vec(), Object::Array([3, 4, 5].map(|n| Object::Ref(ObjRef::new(n, 0))).into_iter().chain([Object::Ref(extra)]).collect()));
|
||||
d.set(b"Count".to_vec(), Object::Int(4));
|
||||
})
|
||||
.unwrap();
|
||||
doc.set(ObjRef::new(7, 0), Object::Stream(Stream::from_raw(Dict::new(), b"1 g 0 0 600 800 re f 0 g BT /F1 9 Tf (Original page) Tj ET".to_vec())));
|
||||
let picture = |doc: &mut Document, gray| {
|
||||
let image = doc.add(Object::Stream(Stream::from_raw(
|
||||
Dict::from_iter([
|
||||
(b"Type".to_vec(), Object::name("XObject")),
|
||||
(b"Subtype".to_vec(), Object::name("Image")),
|
||||
(b"Width".to_vec(), Object::Int(1)),
|
||||
(b"Height".to_vec(), Object::Int(1)),
|
||||
(b"ColorSpace".to_vec(), Object::name("DeviceGray")),
|
||||
(b"BitsPerComponent".to_vec(), Object::Int(8)),
|
||||
]),
|
||||
vec![gray],
|
||||
)));
|
||||
MarkSource { xobject: image, size: (1.0, 1.0), image: true }
|
||||
};
|
||||
let old_picture = picture(&mut doc, 170);
|
||||
let replacement_picture = picture(&mut doc, 80);
|
||||
let all = [0, 1, 2, 3];
|
||||
let hf = HeaderFooter {
|
||||
text: ["Old header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
|
||||
font: added::Family::Times,
|
||||
..Default::default()
|
||||
};
|
||||
add_header_footer(&mut doc, &all, &hf, false, &cx()).unwrap();
|
||||
add_watermark(&mut doc, &all, &Watermark { text: "Old watermark".into(), opacity: 0.45, ..Default::default() }, false).unwrap();
|
||||
add_background(&mut doc, &all, &Background { source: Some(old_picture), opacity: 0.6, replace_backdrop: true, ..Default::default() }, false)
|
||||
.unwrap();
|
||||
let pages = page_list(&doc);
|
||||
let shared = ObjRef::new(6, 0);
|
||||
let resources = (*doc.resolve(pages[0].dict.get(b"Resources").unwrap())).clone();
|
||||
doc.set(shared, resources);
|
||||
for (i, page) in pages.iter().enumerate() {
|
||||
doc.update_dict(page.obj, |d| {
|
||||
if i % 2 == 0 {
|
||||
d.set(b"Resources".to_vec(), Object::Ref(shared));
|
||||
} else {
|
||||
d.remove(b"Resources");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let doc = reopen(&doc);
|
||||
assert!(!doc.is_modified());
|
||||
let pages = page_list(&doc);
|
||||
assert_eq!(pages.len(), 4);
|
||||
assert!(doc.get(pages[1].obj).as_dict().unwrap().get(b"Resources").is_none(), "a selected page inherits its resources");
|
||||
assert_eq!(pages[0].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
|
||||
assert_eq!(pages[2].dict.get(b"Resources").and_then(Object::as_ref), Some(shared));
|
||||
for i in 0..4 {
|
||||
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
|
||||
assert_eq!(mark_streams(&doc, i, kind).len(), 1, "page {i} has existing {kind:?}");
|
||||
}
|
||||
}
|
||||
(doc, replacement_picture)
|
||||
}
|
||||
|
||||
fn mark_streams(doc: &Document, page: usize, kind: MarkKind) -> Vec<String> {
|
||||
streams(doc, page).into_iter().filter(|s| s.contains(&format!("/PCMark /{}", kind.tag()))).collect()
|
||||
}
|
||||
|
||||
fn apply_replacement(doc: &mut Document, kind: MarkKind, source: MarkSource, replace: bool) -> Result<(), EditError> {
|
||||
match kind {
|
||||
MarkKind::HeaderFooter => add_header_footer(
|
||||
doc,
|
||||
SELECTED,
|
||||
&HeaderFooter {
|
||||
text: ["New header".into(), String::new(), String::new(), String::new(), String::new(), String::new()],
|
||||
font: added::Family::Courier,
|
||||
shrink: true,
|
||||
..Default::default()
|
||||
},
|
||||
replace,
|
||||
&cx(),
|
||||
),
|
||||
MarkKind::Watermark => add_watermark(doc, SELECTED, &Watermark { source: Some(source), opacity: 0.25, ..Default::default() }, replace),
|
||||
MarkKind::Background => {
|
||||
add_background(doc, SELECTED, &Background { color: [0.1, 0.3, 0.7], replace_backdrop: true, ..Default::default() }, replace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unchanged_after_later_page_failure(kind: MarkKind) {
|
||||
// Replacement and addition share the same operation boundary; exercise replacement first.
|
||||
for (replace, pending_edit) in [(true, false), (true, true), (false, false), (false, true)] {
|
||||
let (mut doc, source) = marked_document();
|
||||
if pending_edit {
|
||||
let pending = doc.add(Object::String(pdfcraft_cos::PdfString::literal(b"unsaved caller edit".to_vec())));
|
||||
doc.update_dict(page_list(&doc)[0].obj, |d| d.set(b"CallerEdit".to_vec(), Object::Ref(pending))).unwrap();
|
||||
}
|
||||
let modified = doc.is_modified();
|
||||
let before = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let objects = doc.object_numbers().len();
|
||||
let mut allocation_probe = doc.clone();
|
||||
let next_object = allocation_probe.add(Object::Null);
|
||||
let failure = PlacementFailure::after_two_pages(kind);
|
||||
let result = apply_replacement(&mut doc, kind, source, replace);
|
||||
assert_eq!(result, Err(EditError::Invalid("injected mark placement failure".into())));
|
||||
assert_eq!(failure.completed(), 2, "the error followed two real page placements");
|
||||
assert_eq!(doc.object_numbers().len(), objects, "{kind:?}, replace={replace}: no allocated objects remain");
|
||||
assert_eq!(doc.is_modified(), modified, "{kind:?}, replace={replace}: the dirty state is preserved");
|
||||
assert!(write_incremental(&doc, &SaveOptions::default()).unwrap() == before, "{kind:?}, replace={replace}: every byte stays unchanged");
|
||||
assert_eq!(doc.add(Object::Null), next_object, "the allocation cursor is unchanged too");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_changes_are_atomic_after_a_later_page_failure() {
|
||||
unchanged_after_later_page_failure(MarkKind::HeaderFooter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watermark_changes_are_atomic_after_a_later_page_failure() {
|
||||
unchanged_after_later_page_failure(MarkKind::Watermark);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_changes_are_atomic_after_a_later_page_failure() {
|
||||
unchanged_after_later_page_failure(MarkKind::Background);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacements_commit_all_selected_pages_and_preserve_other_marks_and_shared_resources() {
|
||||
for kind in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
|
||||
let (mut doc, source) = marked_document();
|
||||
let before = doc.clone();
|
||||
let expected = match kind {
|
||||
MarkKind::HeaderFooter => "(New header)".to_string(),
|
||||
MarkKind::Watermark => format!("/PCPic{} Do", source.xobject.num),
|
||||
MarkKind::Background => "0.1 0.3 0.7 rg".to_string(),
|
||||
};
|
||||
apply_replacement(&mut doc, kind, source, true).unwrap();
|
||||
let doc = reopen(&doc);
|
||||
assert_eq!(streams(&doc, 3), streams(&before, 3), "the unselected page keeps its content");
|
||||
let unselected = page_list(&before)[3].obj;
|
||||
assert_eq!(doc.get(unselected), before.get(unselected), "the unselected page keeps its resources and entries");
|
||||
for reference in [ObjRef::new(6, 0), ObjRef::new(7, 0)] {
|
||||
assert_eq!(
|
||||
format!("{:?}", doc.get(reference)),
|
||||
format!("{:?}", before.get(reference)),
|
||||
"shared resources and original content stay unchanged"
|
||||
);
|
||||
}
|
||||
for page in SELECTED {
|
||||
let marks = mark_streams(&doc, *page, kind);
|
||||
assert_eq!(marks.len(), 1, "one replacement on selected page {page}");
|
||||
assert!(marks[0].contains(&expected), "the new mark is present: {}", marks[0]);
|
||||
let old_page = &page_list(&before)[*page];
|
||||
let new_page = &page_list(&doc)[*page];
|
||||
let old_resources = before.resolve(old_page.dict.get(b"Resources").unwrap());
|
||||
let new_resources = doc.resolve(new_page.dict.get(b"Resources").unwrap());
|
||||
for category in [&b"Font"[..], &b"ExtGState"[..], &b"XObject"[..]] {
|
||||
let old = before.resolve(old_resources.as_dict().unwrap().get(category).unwrap());
|
||||
let new = doc.resolve(new_resources.as_dict().unwrap().get(category).unwrap());
|
||||
for (name, value) in old.as_dict().unwrap().iter() {
|
||||
assert_eq!(new.as_dict().unwrap().get(name), Some(value), "old resources remain usable on page {page}");
|
||||
assert_eq!(doc.resolve(value), before.resolve(value), "referenced resource contents are unchanged on page {page}");
|
||||
}
|
||||
}
|
||||
for other in [MarkKind::HeaderFooter, MarkKind::Watermark, MarkKind::Background] {
|
||||
if other != kind {
|
||||
assert_eq!(mark_streams(&doc, *page, other), mark_streams(&before, *page, other), "unrelated {other:?} on page {page}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,7 +294,32 @@ pub(crate) fn isolation(doc: &Document, list: &[Object]) -> Isolation {
|
||||
/// Put a mark's content on a page: behind (with the behind-marks at the start) or on top
|
||||
/// (appended, after wrapping the original content in q/Q).
|
||||
pub(crate) fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec<u8>, behind: bool) -> Result<(), EditError> {
|
||||
place_tagged(doc, page, kind.tag(), content, behind).map(drop)
|
||||
place_tagged(doc, page, kind.tag(), content, behind)?;
|
||||
#[cfg(test)]
|
||||
fail_after_mark_placement(kind)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Force an error after N completed placements of one kind, on this test thread only.
|
||||
pub(crate) static MARK_PLACEMENT_FAILURE: std::cell::Cell<Option<(MarkKind, usize, usize)>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn fail_after_mark_placement(kind: MarkKind) -> Result<(), EditError> {
|
||||
MARK_PLACEMENT_FAILURE.with(|failure| {
|
||||
if let Some((target, after, completed)) = failure.get()
|
||||
&& target == kind
|
||||
{
|
||||
let completed = completed.saturating_add(1);
|
||||
failure.set(Some((target, after, completed)));
|
||||
if completed == after {
|
||||
return Err(EditError::Invalid("injected mark placement failure".into()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
|
||||
|
||||
+1731
-1
File diff suppressed because it is too large.
Load diff
@@ -24,6 +24,7 @@ use crate::EditError;
|
||||
mod regions;
|
||||
#[path = "text_session.rs"]
|
||||
mod session;
|
||||
pub use fallback::strip_subset_tag;
|
||||
use regions::{alignment_frame, group_blocks, is_centred, rewrite_styled_block};
|
||||
pub use session::TextSession;
|
||||
|
||||
@@ -47,7 +48,7 @@ pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_l
|
||||
pub(crate) use fallback::SOURCE_SPACE_CALLS;
|
||||
use fallback::{
|
||||
MAX_TYPE3_GLYPHS, Type3Glyph, fallback_face, needs_type3, no_fallback_font, no_glyph_for, same_base_font, same_font, source_encodes,
|
||||
source_family, source_program, source_show, source_space, source_width, strip_subset_tag, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
source_family, source_program, source_show, source_space, source_width, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use fallback::{add_type3_font, is_type3_fallback};
|
||||
|
||||
@@ -163,7 +163,7 @@ pub(crate) fn same_base_font(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
/// `ABCDEF+Arial` → `Arial`. A name without a subset tag is returned unchanged.
|
||||
pub(crate) fn strip_subset_tag(name: &str) -> &str {
|
||||
pub fn strip_subset_tag(name: &str) -> &str {
|
||||
let bytes = name.as_bytes();
|
||||
let is_tag = bytes.get(6) == Some(&b'+') && bytes.get(..6).is_some_and(|tag| tag.iter().all(u8::is_ascii_uppercase));
|
||||
if is_tag { name.get(7..).unwrap_or(name) } else { name }
|
||||
|
||||
@@ -19,6 +19,7 @@ pdfcraft-annot = { workspace = true }
|
||||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-audit = { workspace = true }
|
||||
pdfcraft-redact = { workspace = true }
|
||||
pdfcraft-print = { workspace = true }
|
||||
@@ -58,3 +59,5 @@ getrandom = { version = "0.4", features = ["wasm_js"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
# JBIG2 fixtures (`encode_jbig2_generic`).
|
||||
pdfcraft-filters = { workspace = true }
|
||||
@@ -163,6 +163,8 @@ const HELP: Option<&str> = Some("Help");
|
||||
|
||||
/// Every command, in menu order.
|
||||
pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("view.select_tool", "Select", None, None, Document, "mouse-pointer-2"),
|
||||
c("view.hand_tool", "Hand", None, None, Document, "hand"),
|
||||
c("file.open", "Open…", FILE, Some(Shortcut::cmd("O")), Nothing, "folder-open"),
|
||||
c("file.open_recent", "Open Recent", FILE, None, Nothing, "clock"),
|
||||
// Shown at the foot of File ▸ Open Recent and on Home, not as its own File menu item.
|
||||
@@ -259,6 +261,7 @@ pub const COMMANDS: &[CommandSpec] = &[
|
||||
c("comment.underline", "Underline text", None, None, Annotate, "underline"),
|
||||
c("comment.strikeout", "Strikethrough text", None, None, Annotate, "strikethrough"),
|
||||
c("comment.squiggly", "Squiggly underline text", None, None, Annotate, "spline"),
|
||||
c("comment.freehand_highlight", "Freehand-Highlight", None, None, Annotate, "highlighter"),
|
||||
c("comment.ink", "Draw freehand", None, None, Annotate, "pencil"),
|
||||
c("comment.line", "Draw a line", None, None, Annotate, "minus"),
|
||||
c("comment.arrow", "Draw an arrow", None, None, Annotate, "move-right"),
|
||||
|
||||
@@ -82,6 +82,7 @@ pub fn comment_type_name(subtype: &str) -> &str {
|
||||
/// [`comment_type_name`], telling callouts, clouds and typewriter text apart by `/IT`.
|
||||
pub fn comment_kind(a: &pdfcraft_render::Annotation) -> &str {
|
||||
match a.intent.as_deref() {
|
||||
Some("FreehandHighlight") if a.subtype == "Ink" => "Freehand Highlight",
|
||||
Some("FreeTextCallout") => "Callout",
|
||||
Some("PolygonCloud") => "Cloud",
|
||||
Some("FreeTextTypeWriter") => "Typewriter",
|
||||
|
||||
@@ -18,9 +18,8 @@ impl Document {
|
||||
/// [`Document::page_images`]: a raster image, or Form artwork that holds one.
|
||||
pub fn page_image_at(&self, page: usize, at: [f64; 2]) -> Option<usize> {
|
||||
let editor = self.editor.as_ref()?;
|
||||
let inside = |r: &[f64; 4]| (r[0]..=r[2]).contains(&at[0]) && (r[1]..=r[3]).contains(&at[1]);
|
||||
self.page_images(page).iter().rposition(|i| {
|
||||
inside(&i.rect)
|
||||
i.contains(at)
|
||||
&& match (i.is_form, i.object) {
|
||||
(false, Some(_)) => true,
|
||||
(true, Some(form)) => picture_in_form(&editor.cos, form).is_some(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
use super::{Arc, ArrangeOrder, EditError, Hidden, SignatureInfo, TrustStore, optimize};
|
||||
|
||||
/// A change to an existing page image.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -20,6 +20,12 @@ pub enum ImageEdit {
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
Delete,
|
||||
/// Affine transform [a, b, c, d, e, f] applied to the image's matrix.
|
||||
Transform([f64; 6]),
|
||||
/// Change the stacking order.
|
||||
Arrange(ArrangeOrder),
|
||||
/// Remove any private crop on the image.
|
||||
RemoveCrop,
|
||||
}
|
||||
|
||||
/// Forms ▸ Merge data files into spreadsheet: the field values of each file (FDF, XFDF or a
|
||||
|
||||
@@ -111,10 +111,17 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Move object",
|
||||
"Move objects",
|
||||
"Move image",
|
||||
"Transform image",
|
||||
"Rotate image",
|
||||
"Crop image",
|
||||
"Flip image",
|
||||
"Replace image",
|
||||
"Delete image",
|
||||
"Bring to front",
|
||||
"Bring forward",
|
||||
"Send backward",
|
||||
"Send to back",
|
||||
"Remove crop",
|
||||
"Add header & footer",
|
||||
"Update header & footer",
|
||||
"Add watermark",
|
||||
@@ -254,11 +261,19 @@ impl Edit {
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
ImageEdit::Move(_) => "Move image",
|
||||
ImageEdit::Transform(_) => "Transform image",
|
||||
ImageEdit::Rotate(_) | ImageEdit::RotateBy(_) => "Rotate image",
|
||||
ImageEdit::Crop(_) => "Crop image",
|
||||
ImageEdit::Flip { .. } => "Flip image",
|
||||
ImageEdit::Replace { .. } => "Replace image",
|
||||
ImageEdit::Delete => "Delete image",
|
||||
ImageEdit::Arrange(ord) => match ord {
|
||||
pdfcraft_edit::ArrangeOrder::BringToFront => "Bring to front",
|
||||
pdfcraft_edit::ArrangeOrder::BringForward => "Bring forward",
|
||||
pdfcraft_edit::ArrangeOrder::SendBackward => "Send backward",
|
||||
pdfcraft_edit::ArrangeOrder::SendToBack => "Send to back",
|
||||
},
|
||||
ImageEdit::RemoveCrop => "Remove crop",
|
||||
}),
|
||||
Edit::AddHeaderFooter { replace: false, .. } => Label::of("Add header & footer"),
|
||||
Edit::AddHeaderFooter { .. } => Label::of("Update header & footer"),
|
||||
@@ -312,6 +327,7 @@ fn add_annotation(s: &Shape) -> &'static str {
|
||||
Shape::Oval { .. } => "Add oval",
|
||||
Shape::Line { start: LineEnding::None, end: LineEnding::OpenArrow, .. } => "Add arrow",
|
||||
Shape::Line { .. } => "Add line",
|
||||
Shape::FreehandHighlight { .. } => "Add freehand highlight",
|
||||
Shape::Ink { .. } => "Add drawing",
|
||||
Shape::TextBox { .. } => "Add text box",
|
||||
Shape::Typewriter { .. } => "Add text",
|
||||
|
||||
+165
-34
@@ -4,6 +4,7 @@ use super::{
|
||||
};
|
||||
use crate::SourceProblem;
|
||||
use crate::sources::{comment_source, import_pdf_comments};
|
||||
use pdfcraft_content::Matrix;
|
||||
|
||||
/// 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> {
|
||||
@@ -250,6 +251,169 @@ impl EditCtx {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usize, change: &ImageEdit) -> Result<(), EditError> {
|
||||
match change {
|
||||
ImageEdit::Move(to) if !to.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid position".into())));
|
||||
}
|
||||
ImageEdit::Transform(t) => {
|
||||
let [a, b, c, d, _, _] = *t;
|
||||
let determinant = a * d - b * c;
|
||||
if !t.iter().all(|v| v.is_finite()) || !determinant.is_finite() || determinant.abs() < 1e-9 {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("the image transform must be finite and nonsingular".into())));
|
||||
}
|
||||
}
|
||||
ImageEdit::Crop(keep) if !keep.iter().all(|v| v.is_finite()) => {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid crop".into())));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let img = pdfcraft_edit::page_images(doc, page)?
|
||||
.into_iter()
|
||||
.nth(index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Transform(t) => pdfcraft_edit::ImageChange::Transform(*t),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("grouped Form artwork cannot be replaced with a bitmap".into())));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
ImageEdit::Arrange(order) => pdfcraft_edit::ImageChange::Arrange(*order),
|
||||
ImageEdit::RemoveCrop => pdfcraft_edit::ImageChange::RemoveCrop,
|
||||
};
|
||||
let page_model = pdfcraft_model::pages(doc)
|
||||
.into_iter()
|
||||
.nth(page)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} not found", page + 1))))?;
|
||||
let target_stream_obj = match page_model.dict.get(b"Contents") {
|
||||
None => None,
|
||||
Some(c) => match &*doc.resolve(c) {
|
||||
pdfcraft_cos::Object::Array(a) => a.get(img.stream).and_then(|o| o.as_ref()),
|
||||
_ if img.stream == 0 => c.as_ref(),
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
|
||||
let added_items: Vec<(usize, pdfcraft_edit::Added)> = pdfcraft_edit::list_added(doc).into_iter().filter(|a| a.page == page).enumerate().collect();
|
||||
// Ownership belongs to the drawing stream, not the XObject: original and added images may
|
||||
// show the same resource without sharing an editable item.
|
||||
let shows_image = |a: &pdfcraft_edit::Added| img.object.is_some_and(|t| matches!(&a.content, AddedContent::Image(ai) if ai.image == t));
|
||||
let matched_added = added_items.iter().find(|(_, a)| target_stream_obj == Some(a.obj) && (img.object.is_none() || shows_image(a)));
|
||||
|
||||
let mut ai_to_update = None;
|
||||
if let Some((added_idx, added_item)) = matched_added
|
||||
&& let AddedContent::Image(ai) = added_item.content.clone()
|
||||
{
|
||||
let has_active_crop = ai.crop.iter().any(|v| v.abs() > 1e-6)
|
||||
|| img.visible_rect.is_some_and(|v| {
|
||||
(v[0] - img.rect[0]).abs() > 1e-4
|
||||
|| (v[1] - img.rect[1]).abs() > 1e-4
|
||||
|| (v[2] - img.rect[2]).abs() > 1e-4
|
||||
|| (v[3] - img.rect[3]).abs() > 1e-4
|
||||
});
|
||||
let non_axis_transform = matches!(change, ImageEdit::Transform([a, b, c, d, _, _])
|
||||
if !((b.abs() < 1e-5 && c.abs() < 1e-5) || (a.abs() < 1e-5 && d.abs() < 1e-5)));
|
||||
let should_promote = has_active_crop
|
||||
|| non_axis_transform
|
||||
|| matches!(change, ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete);
|
||||
|
||||
if should_promote {
|
||||
doc.update_dict(added_item.obj, |d| {
|
||||
d.remove(b"PCAdded");
|
||||
d.remove(b"PCMark");
|
||||
})
|
||||
.map_err(|e| EditError::Edit(pdfcraft_edit::EditError::Invalid(e.to_string())))?;
|
||||
} else {
|
||||
ai_to_update = Some((*added_idx, ai));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((added_idx, mut ai)) = ai_to_update {
|
||||
let view = Matrix(page_model.view_matrix(doc));
|
||||
let to_display = view.invert().ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("invalid page transform".into())))?;
|
||||
|
||||
match change {
|
||||
ImageEdit::Move(to) => {
|
||||
ai.rect = to_display.bbox(*to);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Transform(_) | ImageEdit::Flip { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Transform(t) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into())));
|
||||
};
|
||||
let t_display = view.then(&Matrix(t)).then(&to_display);
|
||||
let [ta, tb, tc, td, _, _] = t_display.0;
|
||||
let is_parallel = tb.abs() < 1e-5 && tc.abs() < 1e-5;
|
||||
let is_perpendicular = ta.abs() < 1e-5 && td.abs() < 1e-5;
|
||||
|
||||
let (quarters_ccw, disp_flip_h, disp_flip_v) = if is_parallel {
|
||||
(0, ta < 0.0, td < 0.0)
|
||||
} else if is_perpendicular {
|
||||
match (tb > 0.0, tc > 0.0) {
|
||||
(true, false) => (1, false, false),
|
||||
(false, true) => (3, false, false),
|
||||
(true, true) => (1, false, true),
|
||||
(false, false) => (1, true, false),
|
||||
}
|
||||
} else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"non-axis-aligned transforms are not supported for added images".into(),
|
||||
)));
|
||||
};
|
||||
let (toggle_h, toggle_v) = if ai.rotation % 2 == 1 { (disp_flip_v, disp_flip_h) } else { (disp_flip_h, disp_flip_v) };
|
||||
ai.flip_h ^= toggle_h;
|
||||
ai.flip_v ^= toggle_v;
|
||||
ai.rotation = (ai.rotation + quarters_ccw) % 4;
|
||||
ai.rect = t_display.bbox(ai.rect);
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Rotate(q) => {
|
||||
let k = ((4 - q.rem_euclid(4)) % 4) as u8;
|
||||
ai.rotation = (ai.rotation + k) % 4;
|
||||
let r = ai.rect;
|
||||
let (cx, cy) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
if k % 2 == 1 {
|
||||
let (w, h) = (r[2] - r[0], r[3] - r[1]);
|
||||
ai.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
|
||||
}
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Replace { .. } => {
|
||||
let pdfcraft_edit::ImageChange::Replace(new_image) = c else {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into())));
|
||||
};
|
||||
ai.image = new_image;
|
||||
pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?;
|
||||
}
|
||||
ImageEdit::Crop(_) | ImageEdit::RemoveCrop | ImageEdit::RotateBy(_) | ImageEdit::Arrange(_) | ImageEdit::Delete => {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
pdfcraft_edit::change_image(doc, page, index, &c)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Perform an edit on a working copy (the caller discards it on error).
|
||||
pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -> Result<(), EditError> {
|
||||
match edit {
|
||||
@@ -525,40 +689,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::AddOcrText { page, words } => {
|
||||
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => {
|
||||
let img = pdfcraft_edit::page_images(doc, *page)?
|
||||
.into_iter()
|
||||
.nth(*index)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1))))?;
|
||||
let c = match change {
|
||||
ImageEdit::Move(to) => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::rect_to_rect(img.rect, *to)),
|
||||
ImageEdit::Rotate(q) => {
|
||||
pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(img.visible_rect.unwrap_or(img.rect), *q, false, false))
|
||||
}
|
||||
ImageEdit::RotateBy(degrees) => {
|
||||
let t = pdfcraft_edit::turn_by_degrees(img.visible_rect.unwrap_or(img.rect), *degrees)
|
||||
.ok_or_else(|| EditError::Edit(pdfcraft_edit::EditError::Invalid("the rotation angle must be a finite number".into())))?;
|
||||
pdfcraft_edit::ImageChange::Transform(t)
|
||||
}
|
||||
ImageEdit::Flip { horizontal } => pdfcraft_edit::ImageChange::Transform(pdfcraft_edit::turn_about_centre(
|
||||
img.visible_rect.unwrap_or(img.rect),
|
||||
0,
|
||||
*horizontal,
|
||||
!*horizontal,
|
||||
)),
|
||||
ImageEdit::Replace { name, bytes } => {
|
||||
if img.is_form {
|
||||
return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid(
|
||||
"grouped Form artwork cannot be replaced with a bitmap".into(),
|
||||
)));
|
||||
}
|
||||
pdfcraft_edit::ImageChange::Replace(embed_image(doc, name, bytes)?.0)
|
||||
}
|
||||
ImageEdit::Crop(keep) => pdfcraft_edit::ImageChange::Crop(*keep),
|
||||
ImageEdit::Delete => pdfcraft_edit::ImageChange::Delete,
|
||||
};
|
||||
pdfcraft_edit::change_image(doc, *page, *index, &c)?;
|
||||
}
|
||||
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
|
||||
Edit::MoveObjects { page, objects, offset } => pdfcraft_edit::move_objects(doc, *page, objects, *offset)?,
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
|
||||
@@ -54,6 +54,7 @@ mod stamp_preview;
|
||||
mod test_hooks;
|
||||
pub mod text_fonts;
|
||||
mod text_preview;
|
||||
pub mod text_script;
|
||||
pub mod text_style_bookmarks;
|
||||
pub mod word_count;
|
||||
pub mod xfa;
|
||||
@@ -64,6 +65,7 @@ use std::sync::Arc;
|
||||
pub use links::{
|
||||
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
pub use pdfcraft_edit::strip_subset_tag;
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::{CommentLayer, StampPreview};
|
||||
@@ -137,7 +139,7 @@ pub use history::Label;
|
||||
use layers::{apply_layer_state, set_default_layer_state, use_layer_choices};
|
||||
pub use open::{OpenContext, PreparedDocument};
|
||||
pub use page_filter::{PageOrientation, PageParity, filter_pages};
|
||||
pub use pictures::MarkFile;
|
||||
pub use pictures::{MarkFile, image_dimensions};
|
||||
use pictures::{embed_image, fit_picture, mark_source};
|
||||
use protection::unrestricted;
|
||||
pub use protection::{Changes, Printing, Protection, SecuritySummary};
|
||||
@@ -154,6 +156,8 @@ use xfa_form::{
|
||||
mod clock;
|
||||
mod doc_state;
|
||||
|
||||
pub use pdfcraft_edit::ArrangeOrder;
|
||||
|
||||
use clock::{local_utc_offset, parse_ymd};
|
||||
pub use doc_state::{DocId, ImageEdit, OptimizeReport, RecoverySnapshot, SplitPart, guard, merge_data_files};
|
||||
use doc_state::{LOW_RESOLUTION_PRINT_DPI, render_threads, renderer_password, signatures_of, writable};
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use image::ImageDecoder;
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// A picture for a watermark or background (Acrobat: Source ▸ File): an image, or `page`
|
||||
@@ -57,3 +59,69 @@ pub(crate) fn mark_source(doc: &mut pdfcraft_cos::Document, f: &MarkFile) -> Res
|
||||
Ok(pdfcraft_edit::MarkSource { xobject, size, image: true })
|
||||
}
|
||||
}
|
||||
|
||||
/// The dimensions of an image from its bytes (JPEG, PNG, TIFF), without decoding the full raster.
|
||||
pub fn image_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let format = image::guess_format(bytes).ok()?;
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), format);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_alloc = Some(64_000_000 * 4);
|
||||
reader.limits(limits);
|
||||
let mut decoder = reader.into_decoder().ok()?;
|
||||
let (mut w, mut h) = image::ImageDecoder::dimensions(&decoder);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
if format == image::ImageFormat::Jpeg
|
||||
&& u64::from(w) * u64::from(h) <= (64 << 20)
|
||||
&& let Ok(orientation) = decoder.orientation()
|
||||
&& matches!(
|
||||
orientation,
|
||||
image::metadata::Orientation::Rotate90
|
||||
| image::metadata::Orientation::Rotate270
|
||||
| image::metadata::Orientation::Rotate90FlipH
|
||||
| image::metadata::Orientation::Rotate270FlipH
|
||||
)
|
||||
{
|
||||
std::mem::swap(&mut w, &mut h);
|
||||
}
|
||||
Some((w, h))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn phone_jpeg(w: u32, h: u32, orientation: u8) -> Vec<u8> {
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
let mut jpeg = Vec::new();
|
||||
let mut encoder = JpegEncoder::new(&mut jpeg);
|
||||
encoder.encode_image(&image::RgbImage::from_pixel(w, h, image::Rgb([20, 20, 20]))).expect("valid jpeg");
|
||||
let mut exif = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0".to_vec();
|
||||
exif.extend_from_slice(&[orientation, 0, 0, 0, 0, 0, 0, 0]);
|
||||
let mut out = jpeg[..2].to_vec();
|
||||
out.extend_from_slice(&[0xff, 0xe1]);
|
||||
out.extend_from_slice(&((exif.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(&exif);
|
||||
out.extend_from_slice(&jpeg[2..]);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_exif_orientation_swaps_dimensions_for_quarter_turns() {
|
||||
let turned_6 = phone_jpeg(60, 20, 6);
|
||||
assert_eq!(image_dimensions(&turned_6), Some((20, 60)));
|
||||
|
||||
let turned_8 = phone_jpeg(60, 20, 8);
|
||||
assert_eq!(image_dimensions(&turned_8), Some((20, 60)));
|
||||
|
||||
let upright_1 = phone_jpeg(60, 20, 1);
|
||||
assert_eq!(image_dimensions(&upright_1), Some((60, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_return_none() {
|
||||
assert_eq!(image_dimensions(&[]), None);
|
||||
assert_eq!(image_dimensions(b"not an image"), None);
|
||||
}
|
||||
}
|
||||
@@ -413,6 +413,7 @@ impl Session {
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::PasteContent { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
) || matches!(e, Edit::MoveObjects { objects, .. } if objects.iter().any(|o| o.kind == ObjectKind::Added))
|
||||
});
|
||||
let touches_marks = edit.is_none_or(|e| {
|
||||
|
||||
+657
-15
@@ -33,6 +33,39 @@ pub(crate) fn fixture(n: usize) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
fn fixture_with_rotation_and_user_unit(rotate: i64, user_unit: f64) -> Vec<u8> {
|
||||
let mut objs: Vec<Vec<u8>> = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
|
||||
objs.push(b"<< /Type /Pages /Kids [4 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec());
|
||||
objs.push(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_vec());
|
||||
let mut page_dict = String::from("<< /Type /Page /Parent 2 0 R /Contents 5 0 R /Resources << /Font << /F1 3 0 R >> >>");
|
||||
if rotate != 0 {
|
||||
page_dict.push_str(&format!(" /Rotate {rotate}"));
|
||||
}
|
||||
if (user_unit - 1.0).abs() > 1e-6 {
|
||||
page_dict.push_str(&format!(" /UserUnit {user_unit}"));
|
||||
}
|
||||
page_dict.push_str(" >>");
|
||||
objs.push(page_dict.into_bytes());
|
||||
let body = "BT /F1 24 Tf 20 150 Td (Page 1) Tj ET";
|
||||
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()).into_bytes());
|
||||
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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", objs.len() + 1).as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn session_with(n: usize) -> (Session, DocId) {
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("fixture.pdf", None, Arc::new(fixture(n)), None).expect("opens");
|
||||
@@ -6159,24 +6192,27 @@ fn a_static_xfa_form_takes_many_data_values_as_one_by_one() {
|
||||
/// Opening a static XFA form whose datasets fill thousands of fields took minutes: each value
|
||||
/// read the whole form again (twice), so the time grew with the square of the fields. Eight
|
||||
/// times the fields now takes at most about eight times as long (it took about fifty).
|
||||
///
|
||||
/// Each round opens the small form and then the large one, so a busy spell on a shared machine
|
||||
/// slows both, and the best round's ratio is checked. Timing every small opening before the
|
||||
/// large ones let such a spell fall on the large ones alone.
|
||||
#[test]
|
||||
fn opening_a_static_xfa_form_grows_linearly_with_its_data_values() {
|
||||
let time = |n: usize| {
|
||||
let bytes = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(n));
|
||||
(0..3)
|
||||
.map(|_| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
})
|
||||
.min()
|
||||
.unwrap_or_default()
|
||||
let small = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(100));
|
||||
let large = Arc::new(pdfcraft_xfa::fixtures::static_shell_many(800));
|
||||
let time = |bytes: &Arc<Vec<u8>>| {
|
||||
let mut s = Session::new();
|
||||
let start = std::time::Instant::now();
|
||||
s.open("many.pdf", None, bytes.clone(), None).expect("opens");
|
||||
start.elapsed()
|
||||
};
|
||||
let small = time(100);
|
||||
let large = time(800);
|
||||
let ratio = large.as_secs_f64() / small.as_secs_f64().max(1e-6);
|
||||
assert!(ratio < 16.0, "100 fields: {small:?}, 800 fields: {large:?} ({ratio:.1}x)");
|
||||
// The first openings also pay for what the process sets up once.
|
||||
time(&small);
|
||||
time(&large);
|
||||
let rounds: Vec<_> = (0..5).map(|_| (time(&small), time(&large))).collect();
|
||||
let ratio = |(s, l): &(std::time::Duration, std::time::Duration)| l.as_secs_f64() / s.as_secs_f64().max(1e-6);
|
||||
let best = rounds.iter().map(ratio).fold(f64::INFINITY, f64::min);
|
||||
assert!(best < 16.0, "100 and 800 fields, by round: {rounds:?} (best {best:.1}x)");
|
||||
}
|
||||
|
||||
/// A date goes into the XFA data in the field's data picture (`<bind><picture>`), as other
|
||||
@@ -8102,6 +8138,13 @@ fn copy_image_finds_the_image_under_a_point_and_decodes_it_at_full_size() {
|
||||
assert_eq!((w, h), (3, 2));
|
||||
assert_eq!(rgba, px.into_raw());
|
||||
assert!(d.page_image_rgba(0, 1).is_err());
|
||||
|
||||
// When the image is cropped, page_image_at only hit-tests the visible cropped region:
|
||||
let crop_keep = [r[0] + (r[2] - r[0]) * 0.25, r[1] + (r[3] - r[1]) * 0.25, r[0] + (r[2] - r[0]) * 0.75, r[1] + (r[3] - r[1]) * 0.75];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop(crop_keep) }).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.page_image_at(0, [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0]), Some(0));
|
||||
assert_eq!(d.page_image_at(0, [r[0] + 0.1, r[1] + 0.1]), None, "cropped-away corner must not hit-test positive");
|
||||
}
|
||||
|
||||
/// Copy Image: a JPEG drawn through grouped Form artwork (as Word, InDesign and scanners write
|
||||
@@ -8154,6 +8197,105 @@ fn copy_image_finds_a_picture_inside_form_artwork_under_text() {
|
||||
assert!(d.page_image_copy_file(0, 1).is_err());
|
||||
}
|
||||
|
||||
/// Copy Image reads a JBIG2 image's `/JBIG2Globals`: here they hold the page information, and the
|
||||
/// image's own stream only an extension segment decoders skip, so it has a picture only through
|
||||
/// them. Its default pixel is black.
|
||||
#[test]
|
||||
fn copy_image_reads_the_globals_of_a_jbig2_picture() {
|
||||
let mut globals = vec![0, 0, 0, 0, 48, 0, 1, 0, 0, 0, 19];
|
||||
globals.extend_from_slice(&16u32.to_be_bytes());
|
||||
globals.extend_from_slice(&2u32.to_be_bytes());
|
||||
globals.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0]); // resolution, flags, striping
|
||||
let image = [0, 0, 0, 1, 62, 0, 1, 0, 0, 0, 0];
|
||||
let stream = |dict: &str, data: &[u8]| {
|
||||
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
o.extend_from_slice(data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
o
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
|
||||
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 6 0 R >>",
|
||||
&image,
|
||||
),
|
||||
stream("", &globals),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
|
||||
assert_eq!((w, h), (16, 2));
|
||||
assert!(rgba.chunks(4).all(|p| p == [0, 0, 0, 255]), "the page's default pixel, from the globals");
|
||||
}
|
||||
|
||||
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
|
||||
/// (ASCIIHexDecode) too: Export's decoding, through `Document::decoded_image`.
|
||||
#[test]
|
||||
fn copy_image_copies_a_jbig2_picture_behind_another_filter() {
|
||||
// 16 × 2: the left half of the top row black, the rest white.
|
||||
let bits = [0x00, 0xFF, 0xFF, 0xFF];
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&bits, 16, 2).unwrap();
|
||||
let hex: Vec<u8> = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
|
||||
let stream = |dict: &str, data: &[u8]| {
|
||||
let mut o = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
o.extend_from_slice(data);
|
||||
o.extend_from_slice(b"\nendstream");
|
||||
o
|
||||
};
|
||||
let objs: Vec<Vec<u8>> = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 300] >>".to_vec(),
|
||||
b"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >>".to_vec(),
|
||||
stream("", b"q 160 0 0 20 20 100 cm /Im1 Do Q"),
|
||||
stream(
|
||||
"/Type /XObject /Subtype /Image /Width 16 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 1 /Filter [/ASCIIHexDecode /JBIG2Decode]",
|
||||
&hex,
|
||||
),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
out.extend_from_slice(o);
|
||||
out.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.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", objs.len() + 1).as_bytes());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("scan.pdf", None, Arc::new(out), None).unwrap();
|
||||
let d = s.get(id).unwrap();
|
||||
assert_eq!(d.page_image_at(0, [30.0, 115.0]), Some(0));
|
||||
assert_eq!(d.page_image_copy_file(0, 0).unwrap().0, "png");
|
||||
let (w, h, rgba) = d.page_image_rgba(0, 0).unwrap();
|
||||
assert_eq!((w, h), (16, 2));
|
||||
let grey: Vec<u8> = rgba.chunks(4).map(|px| px[0]).collect();
|
||||
let expected: Vec<u8> = (0..32).map(|i| if i < 8 { 0 } else { 255 }).collect();
|
||||
assert_eq!(grey, expected, "the picture, black as 0");
|
||||
}
|
||||
|
||||
/// `fixture(2)` with the catalog pointing at a page tree object the file does not have (#1656).
|
||||
fn fixture_without_page_tree() -> Vec<u8> {
|
||||
let bytes = fixture(2);
|
||||
@@ -9119,6 +9261,474 @@ fn a_page_truncated_for_forms_and_pictures_is_listed_once() {
|
||||
assert_eq!(truncated, [0]);
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Transforming an added image, then rotating it, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_transform_then_rotate_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
// Translate by (100, 100): matrix [1, 0, 0, 1, 100, 100]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 100.0, 100.0]) }).unwrap();
|
||||
let transformed = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(transformed), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Moving an added image, then rotating by degrees, must keep the moved position.
|
||||
#[test]
|
||||
fn added_image_move_then_rotate_by_keeps_position() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let rect = |s: &Session| s.get(id).unwrap().page_images(0)[0].rect;
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([120.0, 120.0, 160.0, 160.0]) }).unwrap();
|
||||
let moved = rect(&s);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::RotateBy(90.0) }).unwrap();
|
||||
let rotated = rect(&s);
|
||||
let c = |r: [f64; 4]| ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0);
|
||||
let (a, b) = (c(moved), c(rotated));
|
||||
assert!((a.0 - b.0).abs() < 1.0 && (a.1 - b.1).abs() < 1.0, "rotate_by moved the centre from {a:?} to {b:?}");
|
||||
}
|
||||
|
||||
/// Arranging an added image moves it into the document stream and converts it to an ordinary
|
||||
/// page image (dropping the PCAdded marker), so no empty shell remains in list_added.
|
||||
#[test]
|
||||
fn added_image_arrange_converts_to_ordinary_image() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "p1.png".into(), bytes: Arc::new(png.clone()) }).unwrap();
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 200.0]), name: "p2.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 2);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 1, change: ImageEdit::Arrange(ArrangeOrder::SendToBack) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 2);
|
||||
assert_eq!(s.get(id).unwrap().added().len(), 1, "arranged image is converted and no longer an added-item shell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_crop_then_move_keeps_crop() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 100.0, 100.0]), name: "p.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([30.0, 30.0, 70.0, 70.0]) }).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([50.0, 50.0, 130.0, 130.0]) }).unwrap();
|
||||
let img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(img.visible_rect, Some([60.0, 60.0, 100.0, 100.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_image_move_and_transform_on_rotated_and_user_unit_pages() {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[255u8; 12]).unwrap();
|
||||
}
|
||||
let png_bytes = Arc::new(png);
|
||||
|
||||
let cases = [(90, 1.0), (180, 1.0), (270, 1.0), (0, 2.0), (90, 2.0)];
|
||||
|
||||
for (rotate, user_unit) in cases {
|
||||
let pdf = fixture_with_rotation_and_user_unit(rotate, user_unit);
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let id = s.open("test.pdf", None, Arc::new(pdf), None).expect("opens");
|
||||
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([10.0, 10.0, 50.0, 50.0]), name: "img.png".into(), bytes: png_bytes.clone() }).unwrap();
|
||||
|
||||
// Target user-space rectangle R
|
||||
let r = [30.0, 40.0, 80.0, 90.0];
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move(r) }).unwrap();
|
||||
|
||||
let painted = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted[i] - r[i]).abs() < 1e-3,
|
||||
"Move: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted[i],
|
||||
r[i]
|
||||
);
|
||||
}
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let stream_obj = match &*cos.get(added[0].obj) {
|
||||
pdfcraft_cos::Object::Stream(s) => s.clone(),
|
||||
other => panic!("expected stream, got {other:?}"),
|
||||
};
|
||||
let pc_rect = stream_obj
|
||||
.dict
|
||||
.get(b"PCAdded")
|
||||
.and_then(|o| cos.resolve(o).as_dict().cloned())
|
||||
.and_then(|d| d.get(b"Rect").cloned())
|
||||
.and_then(|o| cos.resolve(&o).as_array().cloned())
|
||||
.unwrap();
|
||||
let pc_box: [f64; 4] =
|
||||
[pc_rect[0].as_f64().unwrap(), pc_rect[1].as_f64().unwrap(), pc_rect[2].as_f64().unwrap(), pc_rect[3].as_f64().unwrap()];
|
||||
let content = String::from_utf8(stream_obj.decoded().unwrap()).unwrap();
|
||||
let re_idx = content.find(" re W n").expect("re W n in content stream");
|
||||
let prefix = &content[..re_idx];
|
||||
let nums: Vec<f64> =
|
||||
prefix.split_whitespace().rev().take(4).collect::<Vec<_>>().into_iter().rev().map(|val| val.parse::<f64>().unwrap()).collect();
|
||||
assert_eq!(nums.len(), 4);
|
||||
let (rx, ry, rw, rh) = (nums[0], nums[1], nums[2], nums[3]);
|
||||
assert!((rx - pc_box[0]).abs() < 1e-3, "re x {rx} != pc_box[0] {}", pc_box[0]);
|
||||
assert!((ry - pc_box[1]).abs() < 1e-3, "re y {ry} != pc_box[1] {}", pc_box[1]);
|
||||
assert!((rw - (pc_box[2] - pc_box[0])).abs() < 1e-3, "re w {rw} != {}", pc_box[2] - pc_box[0]);
|
||||
assert!((rh - (pc_box[3] - pc_box[1])).abs() < 1e-3, "re h {rh} != {}", pc_box[3] - pc_box[1]);
|
||||
|
||||
// Transform test: user-space translation by (10, 10)
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, 1.0, 10.0, 10.0]) }).unwrap();
|
||||
|
||||
let expected_transformed = [r[0] + 10.0, r[1] + 10.0, r[2] + 10.0, r[3] + 10.0];
|
||||
let painted_transformed = s.get(id).unwrap().page_images(0)[0].rect;
|
||||
for i in 0..4 {
|
||||
assert!(
|
||||
(painted_transformed[i] - expected_transformed[i]).abs() < 1e-3,
|
||||
"Transform: rotate={rotate}, user_unit={user_unit}: painted[{i}] = {}, expected {}",
|
||||
painted_transformed[i],
|
||||
expected_transformed[i]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_on_added_image_replaces_xobject_and_undo_restores() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
let png2 = make_png([0, 0, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([20.0, 20.0, 60.0, 60.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let old_obj = s.get(id).unwrap().page_images(0)[0].object.expect("initial image object ref");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img2.png".into(), bytes: png2 } }).unwrap();
|
||||
|
||||
let new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("new image object ref");
|
||||
assert_ne!(new_obj, old_obj, "after Replace the page references the new XObject");
|
||||
|
||||
s.undo(id).unwrap();
|
||||
|
||||
let restored_obj = s.get(id).unwrap().page_images(0)[0].object.expect("restored image object ref");
|
||||
assert_eq!(restored_obj, old_obj, "undo restores the old XObject");
|
||||
|
||||
// Also test replacement on an image with active crop (promoted path)
|
||||
let png3 = make_png([0, 255, 0]);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Crop([25.0, 25.0, 55.0, 55.0]) }).unwrap();
|
||||
let crop_obj = s.get(id).unwrap().page_images(0)[0].object.expect("crop image object ref");
|
||||
assert_eq!(crop_obj, old_obj);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Replace { name: "img3.png".into(), bytes: png3 } }).unwrap();
|
||||
let promoted_new_obj = s.get(id).unwrap().page_images(0)[0].object.expect("promoted replace object ref");
|
||||
assert_ne!(promoted_new_obj, crop_obj);
|
||||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0)[0].object.expect("undo promoted replace"), crop_obj);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mirroring_transform_on_added_image_flips_and_non_finite_is_refused() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([255, 0, 0]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([0.0, 0.0, 80.0, 40.0]), name: "img1.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
let initial_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert_eq!(initial_img.rect, [0.0, 0.0, 80.0, 40.0]);
|
||||
assert!(initial_img.matrix[0] > 0.0, "initially unflipped");
|
||||
|
||||
// Mirroring transform: [-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
|
||||
let flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(flipped_img.matrix[0] < 0.0, "horizontal mirror flips matrix x-axis, got matrix: {:?}", flipped_img.matrix);
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1);
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert!(ai.flip_h, "PCAdded record must have flip_h = true");
|
||||
|
||||
// Mirroring horizontally again flips it back
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 80.0, 0.0]) }).unwrap();
|
||||
let unflipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(unflipped_img.matrix[0] > 0.0, "double horizontal mirror restores positive x-axis");
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert!(!ai2.flip_h, "PCAdded record must have flip_h = false");
|
||||
|
||||
// Mirroring vertically: [1.0, 0.0, 0.0, -1.0, 0.0, 40.0]
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, -1.0, 0.0, 40.0]) }).unwrap();
|
||||
let v_flipped_img = s.get(id).unwrap().page_images(0)[0].clone();
|
||||
assert!(v_flipped_img.matrix[3] < 0.0, "vertical mirror flips matrix y-axis, got matrix: {:?}", v_flipped_img.matrix);
|
||||
let cos3 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai3) = &pdfcraft_edit::list_added(&cos3)[0].content else { panic!("expected added image") };
|
||||
assert!(ai3.flip_v, "PCAdded record must have flip_v = true");
|
||||
|
||||
// Non-finite transform must be refused with error
|
||||
let res = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([f64::NAN, 0.0, 0.0, 1.0, 0.0, 0.0]) });
|
||||
assert!(res.is_err(), "NaN transform must be refused");
|
||||
|
||||
let res_inf = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.0, 0.0, f64::INFINITY, 0.0, 0.0]) });
|
||||
assert!(res_inf.is_err(), "infinite transform must be refused");
|
||||
|
||||
let res_move = s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([f64::NAN, 20.0, 100.0, 60.0]) });
|
||||
assert!(res_move.is_err(), "NaN position must be refused");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_on_added_image_folds_quarter_turns_and_promotes_non_axis_aligned() {
|
||||
let make_png = |rgb: [u8; 3]| {
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 2, 2);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]];
|
||||
enc.write_header().unwrap().write_image_data(&data).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
let png1 = make_png([0, 128, 255]);
|
||||
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png1 }).unwrap();
|
||||
|
||||
// 1. A pure 90° clockwise rotation matrix about the image centre:
|
||||
let rot_90_cw = pdfcraft_edit::turn_about_centre([100.0, 100.0, 200.0, 150.0], 1, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_cw) }).unwrap();
|
||||
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let added = pdfcraft_edit::list_added(&cos);
|
||||
assert_eq!(added.len(), 1, "stays as PCAdded image");
|
||||
let AddedContent::Image(ai) = &added[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai.rotation, 3, "quarter turn clockwise folds into ai.rotation = 3 (270° CCW)");
|
||||
// Centre is (150, 125); rotated width=50, height=100 -> [125, 75, 175, 175]
|
||||
assert!((ai.rect[0] - 125.0).abs() < 1e-4);
|
||||
assert!((ai.rect[1] - 75.0).abs() < 1e-4);
|
||||
assert!((ai.rect[2] - 175.0).abs() < 1e-4);
|
||||
assert!((ai.rect[3] - 175.0).abs() < 1e-4);
|
||||
|
||||
// 2. A pure 90° counter-clockwise rotation matrix:
|
||||
let rot_90_ccw = pdfcraft_edit::turn_about_centre(ai.rect, 3, false, false);
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_90_ccw) }).unwrap();
|
||||
|
||||
let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!("expected added image") };
|
||||
assert_eq!(ai2.rotation, 0, "quarter turn counter-clockwise restores ai.rotation = 0");
|
||||
assert!((ai2.rect[0] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[1] - 100.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[2] - 200.0).abs() < 1e-4);
|
||||
assert!((ai2.rect[3] - 150.0).abs() < 1e-4);
|
||||
|
||||
// Arbitrary transforms promote the added image, just like RotateBy.
|
||||
let initial_matrix = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
let rot_45 = pdfcraft_edit::turn_by_degrees([100.0, 100.0, 200.0, 150.0], 45.0).unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(rot_45) }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert!(pdfcraft_edit::list_added(&cos).is_empty(), "arbitrary transform promotes the image");
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
let expected = pdfcraft_content::Matrix(initial_matrix).then(&pdfcraft_content::Matrix(rot_45)).0;
|
||||
let actual = s.get(id).unwrap().page_images(0)[0].matrix;
|
||||
assert!(actual.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-3), "{actual:?} != {expected:?}");
|
||||
s.undo(id).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
assert_eq!(pdfcraft_edit::list_added(&cos).len(), 1, "undo restores added-image editability");
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([1.0, 0.5, 0.0, 1.0, 0.0, 0.0]) }).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().page_images(0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_added_image_transform_reflections_with_rotation() {
|
||||
let make_asymmetric_png = || {
|
||||
let mut px = Vec::with_capacity(20 * 20 * 3);
|
||||
for y in 0..20 {
|
||||
for x in 0..20 {
|
||||
let color = match (x < 10, y < 10) {
|
||||
(true, true) => [255, 0, 0], // TL: Red
|
||||
(false, true) => [0, 255, 0], // TR: Green
|
||||
(true, false) => [0, 0, 255], // BL: Blue
|
||||
(false, false) => [255, 255, 0], // BR: Yellow
|
||||
};
|
||||
px.extend_from_slice(&color);
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 20, 20);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||||
}
|
||||
Arc::new(png)
|
||||
};
|
||||
|
||||
let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
let i = ((y_from_top * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
};
|
||||
|
||||
let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50;
|
||||
let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50;
|
||||
let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200;
|
||||
let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50;
|
||||
|
||||
let check_quadrants =
|
||||
|s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| {
|
||||
let c_tl = colour_at(s, id, 75, 125);
|
||||
let c_tr = colour_at(s, id, 125, 125);
|
||||
let c_bl = colour_at(s, id, 75, 175);
|
||||
let c_br = colour_at(s, id, 125, 175);
|
||||
assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl);
|
||||
assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr);
|
||||
assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl);
|
||||
assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br);
|
||||
};
|
||||
|
||||
// --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green.
|
||||
{
|
||||
// 1a: Flip Horizontal after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 1b: Flip Vertical after 90° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 3);
|
||||
assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical");
|
||||
}
|
||||
|
||||
// --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) ---
|
||||
// Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow.
|
||||
// After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue.
|
||||
{
|
||||
// 2a: Flip Horizontal after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW");
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h");
|
||||
assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v");
|
||||
// Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red
|
||||
check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal");
|
||||
}
|
||||
|
||||
{
|
||||
// 2b: Flip Vertical after 270° rotation
|
||||
let (mut s, id) = session_with(1);
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() })
|
||||
.unwrap();
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap();
|
||||
|
||||
s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap();
|
||||
let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap();
|
||||
let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() };
|
||||
assert_eq!(ai.rotation, 1);
|
||||
assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h");
|
||||
assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v");
|
||||
// Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow
|
||||
check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical");
|
||||
}
|
||||
}
|
||||
|
||||
/// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb
|
||||
/// field the file leaves to the viewer. The page as displayed and printed as an image shows the
|
||||
/// check box's caption symbol in its text colour, the button's grey face and the comb's cells in
|
||||
@@ -9469,3 +10079,35 @@ fn saving_without_deleting_pages_stays_incremental() {
|
||||
let saved = s.save_bytes(id).unwrap();
|
||||
assert!(saved.starts_with(original.as_slice()), "an edit that deletes nothing is still appended");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_image_edit_does_not_match_an_added_sibling_of_the_same_xobject() {
|
||||
let mut document = pdfcraft_cos::Document::open(Arc::new(jpeg_cover_pdf(650))).unwrap();
|
||||
let original = pdfcraft_edit::page_images(&document, 0).unwrap()[0].clone();
|
||||
let added = AddedImage::new([20.0, 30.0, 80.0, 90.0], original.object.unwrap());
|
||||
pdfcraft_edit::add_content(&mut document, 0, &AddedContent::Image(added.clone())).unwrap();
|
||||
let bytes = pdfcraft_cos::write_full(&document, &pdfcraft_cos::SaveOptions::default()).unwrap();
|
||||
let mut session = Session::new();
|
||||
let id = session.open("shared-image.pdf", None, Arc::new(bytes), None).unwrap();
|
||||
let before_added = session.get(id).unwrap().added()[0].content.clone();
|
||||
session.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Move([40.0, 50.0, 140.0, 150.0]) }).unwrap();
|
||||
let doc = session.get(id).unwrap();
|
||||
assert_eq!(doc.added()[0].content, before_added, "matching an original Do must not select the added sibling's metadata");
|
||||
assert_eq!(doc.page_images(0)[0].rect, [40.0, 50.0, 140.0, 150.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_transform_engine_refuses_singular_matrices_without_mutation() {
|
||||
let mut png = Vec::new();
|
||||
let mut enc = png::Encoder::new(&mut png, 1, 1);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&[100, 150, 200]).unwrap();
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_images(&[("pixel.png".into(), png)]).unwrap();
|
||||
let id = s.open("image.pdf", None, bytes, None).unwrap();
|
||||
for matrix in [[0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0 + 1e-12, 0.0, 0.0], [1e308, 0.0, 0.0, 1e308, 0.0, 0.0]] {
|
||||
let before = s.get(id).unwrap().bytes.clone();
|
||||
assert!(s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform(matrix) }).is_err());
|
||||
assert_eq!(s.get(id).unwrap().bytes, before);
|
||||
}
|
||||
}
|
||||
+190
-10
@@ -32,17 +32,29 @@ pub fn installed_directories() -> Vec<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded, symlink-free scan. Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// Bounded scan with resolved roots and no descendant symlinks.
|
||||
/// Only standalone, openly licensed, embeddable faces are listed.
|
||||
/// The caller controls roots (automation resolves confinement before calling this).
|
||||
pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
discover_budgeted(roots, 64u64 << 20)
|
||||
}
|
||||
|
||||
/// Bounded scan with resolved roots and no descendant symlinks. `budget` caps the total
|
||||
/// size of the fonts listed: a rejected file (unreadable, not openly licensed, a duplicate)
|
||||
/// does not spend it. Every file read
|
||||
/// counts against four times the budget, so a folder of unusable fonts cannot stall the scan.
|
||||
pub fn discover_budgeted(roots: &[PathBuf], mut budget: u64) -> Vec<FontFile> {
|
||||
use std::io::Read;
|
||||
let mut pending: Vec<_> = roots.iter().take(16).map(|r| (r.clone(), 0usize)).collect();
|
||||
// User font directories themselves are often links. Resolve only the explicitly
|
||||
// supplied roots; links found below them must not escape an automation read root.
|
||||
let mut pending: Vec<_> = roots.iter().take(16).filter_map(|r| r.canonicalize().ok()).map(|r| (r, 0usize)).collect();
|
||||
let mut files = Vec::new();
|
||||
let mut entries = 0usize;
|
||||
let mut budget = 64u64 << 20;
|
||||
let mut read_allowance = budget.saturating_mul(4);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut seen_face_content = std::collections::HashSet::new();
|
||||
while let Some((path, depth)) = pending.pop() {
|
||||
if entries >= 4096 || budget == 0 {
|
||||
if entries >= 4096 || budget == 0 || read_allowance == 0 {
|
||||
break;
|
||||
}
|
||||
entries += 1;
|
||||
@@ -53,26 +65,194 @@ pub fn discover(roots: &[PathBuf]) -> Vec<FontFile> {
|
||||
if meta.is_dir() && depth < 4 {
|
||||
if let Ok(children) = std::fs::read_dir(&path) {
|
||||
let remaining = 4096usize.saturating_sub(entries.saturating_add(pending.len()));
|
||||
pending.extend(children.take(remaining).filter_map(Result::ok).map(|p| (p.path(), depth + 1)));
|
||||
// Best effort: unreadable entries are skipped and do not count against the entry allowance.
|
||||
let mut sorted_children: Vec<_> = children.filter_map(Result::ok).take(remaining).map(|p| p.path()).collect();
|
||||
sorted_children.sort();
|
||||
pending.extend(sorted_children.into_iter().rev().map(|p| (p, depth + 1)));
|
||||
}
|
||||
} else if meta.is_file() && meta.len() <= (16 << 20) && font_extension(&path) {
|
||||
if meta.len() > budget {
|
||||
if meta.len() > budget || meta.len() > read_allowance {
|
||||
continue;
|
||||
}
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
read_allowance = read_allowance.saturating_sub(meta.len());
|
||||
let mut bytes = Vec::new();
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take((16 << 20) + 1).read_to_end(&mut bytes));
|
||||
if read.is_err() || pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
let read = std::fs::File::open(&path).and_then(|f| f.take(meta.len().saturating_add(1)).read_to_end(&mut bytes));
|
||||
if read.is_err() || bytes.len() != meta.len() as usize {
|
||||
continue;
|
||||
}
|
||||
if pdfcraft_fonts::embedding::validate_document_input(&bytes).is_err() {
|
||||
continue;
|
||||
}
|
||||
if let Ok((family, face)) = pdfcraft_fonts::embedding::names(&bytes) {
|
||||
files.push(FontFile { path, family, face });
|
||||
let key = font_content_key(&bytes);
|
||||
if seen_face_content.insert((face.clone(), key)) {
|
||||
budget = budget.saturating_sub(meta.len());
|
||||
files.push(FontFile { path, family, face });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort_by(|a, b| a.face.cmp(&b.face).then(a.path.cmp(&b.path)));
|
||||
files
|
||||
}
|
||||
|
||||
fn font_content_key(bytes: &[u8]) -> (usize, u64) {
|
||||
use std::hash::{DefaultHasher, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
h.write(bytes);
|
||||
(bytes.len(), h.finish())
|
||||
}
|
||||
fn font_extension(path: &Path) -> bool {
|
||||
path.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("ttf") || e.eq_ignore_ascii_case("otf"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_linked_font_root_is_scanned_but_descendant_links_are_not() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-links-{}", std::process::id()));
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
std::fs::copy(&source, real.join("regular.ttf")).unwrap();
|
||||
let link = root.join("linked");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
std::os::unix::fs::symlink(&real, real.join("cycle")).unwrap();
|
||||
std::os::unix::fs::symlink(source.with_file_name("Inter-Medium.ttf"), real.join("escape.ttf")).unwrap();
|
||||
let found = discover(&[link]);
|
||||
let expected = real.canonicalize().unwrap().join("regular.ttf");
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found.first().unwrap().face, "Inter-Regular");
|
||||
assert_eq!(found.first().unwrap().path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_keeps_all_valid_faces_within_its_scan_budget() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-count-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let bytes = std::fs::read(source).unwrap();
|
||||
for id in 0..501u32 {
|
||||
let mut font = bytes.clone();
|
||||
// Valid standalone fonts with distinct bytes: an unreferenced trailer
|
||||
// does not change their tables or embedding licence.
|
||||
font.extend_from_slice(&id.to_le_bytes());
|
||||
std::fs::write(root.join(format!("font-{id:03}.ttf")), font).unwrap();
|
||||
}
|
||||
let found = discover_budgeted(std::slice::from_ref(&root), (bytes.len() as u64 + 4) * 501);
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(found.len(), 501, "do not silently discard accepted fonts after sorting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_installed_directories() {
|
||||
let dirs = installed_directories();
|
||||
assert!(!dirs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_extension() {
|
||||
assert!(font_extension(Path::new("Arial.ttf")));
|
||||
assert!(font_extension(Path::new("Noto.otf")));
|
||||
assert!(font_extension(Path::new("CUSTOM.TTF")));
|
||||
assert!(font_extension(Path::new("ROBOTO.OTF")));
|
||||
assert!(!font_extension(Path::new("document.pdf")));
|
||||
assert!(!font_extension(Path::new("font.woff")));
|
||||
assert!(!font_extension(Path::new("font.woff2")));
|
||||
assert!(!font_extension(Path::new("no_extension")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_font_content_key() {
|
||||
let empty = font_content_key(&[]);
|
||||
assert_eq!(empty.0, 0);
|
||||
|
||||
let a = font_content_key(b"TrueType font sample data bytes");
|
||||
let b = font_content_key(b"TrueType font sample data bytes");
|
||||
let c = font_content_key(b"Different font sample data bytes");
|
||||
|
||||
assert_eq!(a, b, "identical bytes must produce identical keys");
|
||||
assert_ne!(a, c, "differing bytes must produce distinct keys");
|
||||
assert_eq!(a.0, b"TrueType font sample data bytes".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_empty_and_nonexistent_roots() {
|
||||
let empty = discover(&[]);
|
||||
assert!(empty.is_empty());
|
||||
|
||||
let nonexistent = discover(&[PathBuf::from("Z:\\nonexistent_font_path_xyz_12345")]);
|
||||
assert!(nonexistent.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_discover_deduplicates_non_adjacent_same_content() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-dedup-test-{}", std::process::id()));
|
||||
let dir_a = root.join("a");
|
||||
let dir_b = root.join("b");
|
||||
let dir_c = root.join("c");
|
||||
let _ = std::fs::create_dir_all(&dir_a);
|
||||
let _ = std::fs::create_dir_all(&dir_b);
|
||||
let _ = std::fs::create_dir_all(&dir_c);
|
||||
|
||||
let inter_reg = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Regular.ttf");
|
||||
let inter_med = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts/Inter-Medium.ttf");
|
||||
|
||||
if inter_reg.exists() && inter_med.exists() {
|
||||
let _ = std::fs::copy(&inter_reg, dir_a.join("FontA.ttf"));
|
||||
let _ = std::fs::copy(&inter_med, dir_b.join("FontB.ttf"));
|
||||
let _ = std::fs::copy(&inter_reg, dir_c.join("FontC.ttf"));
|
||||
|
||||
let discovered = discover(std::slice::from_ref(&root));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
|
||||
assert_eq!(discovered.len(), 2, "identical content in non-adjacent paths is deduplicated");
|
||||
let faces: Vec<&str> = discovered.iter().map(|f| f.face.as_str()).collect();
|
||||
assert!(faces.contains(&"Inter-Regular"));
|
||||
assert!(faces.contains(&"Inter-Medium"));
|
||||
} else {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_fonts_do_not_spend_the_budget_and_the_order_is_deterministic() {
|
||||
let root = std::env::temp_dir().join(format!("pdfcraft-font-budget-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
|
||||
let fonts = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../assets/fonts");
|
||||
let reg_len = std::fs::metadata(fonts.join("Inter-Regular.ttf")).unwrap().len();
|
||||
let med_len = std::fs::metadata(fonts.join("Inter-Medium.ttf")).unwrap().len();
|
||||
|
||||
// An unusable candidate, scanned first (sorted before the real fonts).
|
||||
std::fs::write(root.join("00_candidate_invalid.ttf"), vec![0u8; 400_000]).unwrap();
|
||||
std::fs::copy(fonts.join("Inter-Regular.ttf"), root.join("01_Inter-Regular.ttf")).unwrap();
|
||||
|
||||
// The budget fits Inter-Regular alone; the rejected file must not use it up first.
|
||||
let run1 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let run2 = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let faces = |run: &[FontFile]| run.iter().map(|f| (f.face.clone(), f.path.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(run1.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"], "a rejected file spends no budget");
|
||||
assert_eq!(faces(&run1), faces(&run2));
|
||||
|
||||
// Accepted fonts do spend it: Inter-Medium no longer fits after Inter-Regular.
|
||||
std::fs::copy(fonts.join("Inter-Medium.ttf"), root.join("02_Inter-Medium.ttf")).unwrap();
|
||||
let one = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
assert_eq!(one.iter().map(|f| f.face.as_str()).collect::<Vec<_>>(), ["Inter-Regular"]);
|
||||
let both = discover_budgeted(std::slice::from_ref(&root), reg_len + med_len);
|
||||
assert_eq!(both.len(), 2);
|
||||
|
||||
// Reading stops at four times the budget, whatever was rejected.
|
||||
for i in 0..4 {
|
||||
std::fs::write(root.join(format!("00_unusable_{i}.ttf")), vec![0u8; reg_len as usize]).unwrap();
|
||||
}
|
||||
let capped = discover_budgeted(std::slice::from_ref(&root), reg_len + 10);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert!(capped.is_empty(), "the read allowance ends the scan before the real fonts");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Superscript and subscript as Unicode characters (Edit a PDF ▸ Format text): `2` ↔ `²` and
|
||||
//! `2` ↔ `₂`.
|
||||
//!
|
||||
//! The text keeps its font, size and baseline; its characters change to the forms Unicode encodes
|
||||
//! for raised and lowered text, so the font must have those glyphs, and extracted text contains
|
||||
//! them. Characters Unicode has no such form for are left as they are.
|
||||
//!
|
||||
//! A selection mixing plain and target-script characters is normalized: `H₂2` becomes `H₂₂`,
|
||||
//! then `H22` on the next subscript toggle; superscript follows the same policy. Two toggles
|
||||
//! restore uniformly plain or uniformly scripted input, but do not reconstruct an original
|
||||
//! mixture. Restoring that exact prior text requires the caller's edit history (Undo).
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// Raised or lowered text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Script {
|
||||
Super,
|
||||
Sub,
|
||||
}
|
||||
|
||||
/// Each character Unicode raises, and its superscript form.
|
||||
const SUPERSCRIPT: &[(char, char)] = &[
|
||||
('0', '⁰'),
|
||||
('1', '¹'),
|
||||
('2', '²'),
|
||||
('3', '³'),
|
||||
('4', '⁴'),
|
||||
('5', '⁵'),
|
||||
('6', '⁶'),
|
||||
('7', '⁷'),
|
||||
('8', '⁸'),
|
||||
('9', '⁹'),
|
||||
('+', '⁺'),
|
||||
('−', '⁻'),
|
||||
('=', '⁼'),
|
||||
('(', '⁽'),
|
||||
(')', '⁾'),
|
||||
('a', 'ᵃ'),
|
||||
('b', 'ᵇ'),
|
||||
('c', 'ᶜ'),
|
||||
('d', 'ᵈ'),
|
||||
('e', 'ᵉ'),
|
||||
('f', 'ᶠ'),
|
||||
('g', 'ᵍ'),
|
||||
('h', 'ʰ'),
|
||||
('i', 'ⁱ'),
|
||||
('j', 'ʲ'),
|
||||
('k', 'ᵏ'),
|
||||
('l', 'ˡ'),
|
||||
('m', 'ᵐ'),
|
||||
('n', 'ⁿ'),
|
||||
('o', 'ᵒ'),
|
||||
('p', 'ᵖ'),
|
||||
('r', 'ʳ'),
|
||||
('s', 'ˢ'),
|
||||
('t', 'ᵗ'),
|
||||
('u', 'ᵘ'),
|
||||
('v', 'ᵛ'),
|
||||
('w', 'ʷ'),
|
||||
('x', 'ˣ'),
|
||||
('y', 'ʸ'),
|
||||
('z', 'ᶻ'),
|
||||
('A', 'ᴬ'),
|
||||
('B', 'ᴮ'),
|
||||
('D', 'ᴰ'),
|
||||
('E', 'ᴱ'),
|
||||
('G', 'ᴳ'),
|
||||
('H', 'ᴴ'),
|
||||
('I', 'ᴵ'),
|
||||
('J', 'ᴶ'),
|
||||
('K', 'ᴷ'),
|
||||
('L', 'ᴸ'),
|
||||
('M', 'ᴹ'),
|
||||
('N', 'ᴺ'),
|
||||
('O', 'ᴼ'),
|
||||
('P', 'ᴾ'),
|
||||
('R', 'ᴿ'),
|
||||
('T', 'ᵀ'),
|
||||
('U', 'ᵁ'),
|
||||
('V', 'ⱽ'),
|
||||
('W', 'ᵂ'),
|
||||
];
|
||||
|
||||
/// Each character Unicode lowers, and its subscript form.
|
||||
const SUBSCRIPT: &[(char, char)] = &[
|
||||
('0', '₀'),
|
||||
('1', '₁'),
|
||||
('2', '₂'),
|
||||
('3', '₃'),
|
||||
('4', '₄'),
|
||||
('5', '₅'),
|
||||
('6', '₆'),
|
||||
('7', '₇'),
|
||||
('8', '₈'),
|
||||
('9', '₉'),
|
||||
('+', '₊'),
|
||||
('−', '₋'),
|
||||
('=', '₌'),
|
||||
('(', '₍'),
|
||||
(')', '₎'),
|
||||
('a', 'ₐ'),
|
||||
('e', 'ₑ'),
|
||||
('h', 'ₕ'),
|
||||
('i', 'ᵢ'),
|
||||
('j', 'ⱼ'),
|
||||
('k', 'ₖ'),
|
||||
('l', 'ₗ'),
|
||||
('m', 'ₘ'),
|
||||
('n', 'ₙ'),
|
||||
('o', 'ₒ'),
|
||||
('p', 'ₚ'),
|
||||
('r', 'ᵣ'),
|
||||
('s', 'ₛ'),
|
||||
('t', 'ₜ'),
|
||||
('u', 'ᵤ'),
|
||||
('v', 'ᵥ'),
|
||||
('x', 'ₓ'),
|
||||
];
|
||||
|
||||
impl Script {
|
||||
fn pairs(self) -> &'static [(char, char)] {
|
||||
match self {
|
||||
Script::Super => SUPERSCRIPT,
|
||||
Script::Sub => SUBSCRIPT,
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` raised or lowered, or `None` when Unicode has no distinct reversible form for it.
|
||||
/// Mathematical minus U+2212 has a form; ASCII hyphen-minus stays unchanged to avoid a lossy collision.
|
||||
pub fn form(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(plain, _)| *plain == c).map(|&(_, form)| form)
|
||||
}
|
||||
|
||||
/// The character `c` is the raised or lowered form of, or `None` when it is no such form.
|
||||
pub fn plain(self, c: char) -> Option<char> {
|
||||
self.pairs().iter().find(|(_, form)| *form == c).map(|&(plain, _)| plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// `range` (UTF-8 bytes of `text`) clamped to it and widened to whole characters, or all of `text`
|
||||
/// when there is none.
|
||||
fn span(text: &str, range: Option<&Range<usize>>) -> Range<usize> {
|
||||
let Some(range) = range else { return 0..text.len() };
|
||||
let mut start = range.start.min(text.len());
|
||||
while !text.is_char_boundary(start) {
|
||||
start = start.saturating_sub(1);
|
||||
}
|
||||
let mut end = range.end.min(text.len());
|
||||
while !text.is_char_boundary(end) {
|
||||
end = end.saturating_add(1);
|
||||
}
|
||||
start..end
|
||||
}
|
||||
|
||||
/// Whether the characters of `text` in `range` (UTF-8 bytes; all of it when `None`) are already in
|
||||
/// `script`: at least one of them is, and none of the others could be. Characters Unicode has no
|
||||
/// form for (a space, a `q`) don't count toward that state.
|
||||
pub fn is_in(text: &str, range: Option<&Range<usize>>, script: Script) -> bool {
|
||||
let target = text.get(span(text, range)).unwrap_or_default();
|
||||
target.chars().any(|c| script.plain(c).is_some()) && target.chars().all(|c| script.plain(c).is_some() || script.form(c).is_none())
|
||||
}
|
||||
|
||||
/// Superscript or Subscript on the characters of `text` in `range` (UTF-8 bytes; all of it when
|
||||
/// `None`): back to plain characters when they are already in `script` ([`is_in`]), and otherwise
|
||||
/// each character that has a form takes it. Returns the new text and the range the converted
|
||||
/// characters now take, or `None` when nothing changes.
|
||||
///
|
||||
/// Mixed plain/scripted input normalizes to the target script and then to plain text; for
|
||||
/// example, `H₂2` → `H₂₂` → `H22`. This stateless conversion does not remember the original
|
||||
/// mixture. Use edit history to restore it, rather than treating a second toggle as Undo.
|
||||
pub fn toggle(text: &str, range: Option<&Range<usize>>, script: Script) -> Option<(String, Range<usize>)> {
|
||||
let span = span(text, range);
|
||||
let target = text.get(span.clone()).filter(|t| !t.is_empty())?;
|
||||
let converted: String = if is_in(text, Some(&span), script) {
|
||||
target.chars().map(|c| script.plain(c).unwrap_or(c)).collect()
|
||||
} else {
|
||||
target.chars().map(|c| script.form(c).unwrap_or(c)).collect()
|
||||
};
|
||||
if converted == target {
|
||||
return None;
|
||||
}
|
||||
let mut out = String::with_capacity(text.len().saturating_add(converted.len()));
|
||||
out.push_str(text.get(..span.start).unwrap_or_default());
|
||||
out.push_str(&converted);
|
||||
out.push_str(text.get(span.end..).unwrap_or_default());
|
||||
let end = span.start.saturating_add(converted.len());
|
||||
Some((out, span.start..end))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_form_toggles_back_to_its_character() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
for &(plain, form) in script.pairs() {
|
||||
let s = plain.to_string();
|
||||
let (raised, range) = toggle(&s, None, script).unwrap();
|
||||
assert_eq!(raised, form.to_string());
|
||||
assert_eq!(range, 0..raised.len());
|
||||
assert_eq!(toggle(&raised, None, script).unwrap().0, s, "{script:?} {plain}");
|
||||
}
|
||||
}
|
||||
assert_eq!(toggle("-", None, Script::Super), None);
|
||||
assert_eq!(toggle("-", None, Script::Sub), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_script_round_trips_preserve_both_minus_characters() {
|
||||
for script in [Script::Super, Script::Sub] {
|
||||
let text = "−2 -3";
|
||||
let once = toggle(text, None, script).unwrap().0;
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_characters_unicode_cannot_raise_toggles_back() {
|
||||
// q has no superscript form and Q no subscript one; the space neither.
|
||||
for (script, text, toggled) in [(Script::Super, "x2 q", "ˣ² q"), (Script::Sub, "CO2 Q", "CO₂ Q")] {
|
||||
let (once, _) = toggle(text, None, script).unwrap();
|
||||
assert_eq!(once, toggled);
|
||||
assert!(is_in(&once, None, script));
|
||||
assert_eq!(toggle(&once, None, script).unwrap().0, text, "{script:?}");
|
||||
}
|
||||
// Nothing to raise: nothing changes.
|
||||
assert_eq!(toggle("q Q", None, Script::Super), None);
|
||||
assert!(!is_in("q Q", None, Script::Super));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_converts_only_its_characters_and_follows_them() {
|
||||
let (text, range) = toggle("E = mc2", Some(&(6..7)), Script::Super).unwrap();
|
||||
assert_eq!((text.as_str(), range.clone()), ("E = mc²", 6..8));
|
||||
assert_eq!(toggle(&text, Some(&range), Script::Super).unwrap(), ("E = mc2".to_string(), 6..7));
|
||||
// A mixed range takes the form throughout.
|
||||
assert_eq!(toggle("H₂2", None, Script::Sub).unwrap().0, "H₂₂");
|
||||
assert!(!is_in("H₂2", None, Script::Sub));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_plain_and_scripted_text_normalizes_instead_of_round_tripping() {
|
||||
for (script, mixed, uniform, plain) in [(Script::Super, "Q²2", "Q²²", "Q22"), (Script::Sub, "H₂2", "H₂₂", "H22")] {
|
||||
assert!(!is_in(mixed, None, script));
|
||||
let (once, range) = toggle(mixed, None, script).unwrap();
|
||||
assert_eq!(once, uniform);
|
||||
assert_eq!(range, 0..uniform.len());
|
||||
assert!(is_in(&once, None, script));
|
||||
let (twice, range) = toggle(&once, None, script).unwrap();
|
||||
assert_eq!(twice, plain);
|
||||
assert_eq!(range, 0..plain.len());
|
||||
assert_ne!(twice, mixed, "a second toggle normalizes the original mixture");
|
||||
assert_eq!(toggle(&twice, None, script).unwrap().0, uniform);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_ranges_are_clamped_to_whole_characters() {
|
||||
// Byte 1 is inside ²: widened to the whole character on each side.
|
||||
assert_eq!(toggle("²x", Some(&(1..1)), Script::Super).unwrap(), ("2x".to_string(), 0..1));
|
||||
assert_eq!(toggle("²x", Some(&(2..2)), Script::Super), None);
|
||||
assert_eq!(toggle("x2", Some(&(1..usize::MAX)), Script::Super).unwrap(), ("x²".to_string(), 1..3));
|
||||
assert_eq!(toggle("x2", Some(&(5..9)), Script::Super), None);
|
||||
#[allow(clippy::reversed_empty_ranges)]
|
||||
let backwards = 2..1;
|
||||
assert_eq!(toggle("x2", Some(&backwards), Script::Super), None);
|
||||
assert_eq!(toggle("", None, Script::Sub), None);
|
||||
assert!(!is_in("", None, Script::Sub));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdfcraft-filters"
|
||||
description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding"
|
||||
description = "PDF stream filters (ISO 32000-2 §7.4): Flate, LZW, predictors, ASCII85, ASCIIHex, RunLength, CCITT Group 4 encoding and Group 3 and 4 decoding, JBIG2 encoding and decoding"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -12,13 +12,12 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
fax.workspace = true
|
||||
# CCITT fax decoding (`decode_ccitt`); already in the tree through hayro.
|
||||
# CCITT fax and JBIG2 decoding (`decode_ccitt`, `decode_jbig2`); already in the tree through hayro.
|
||||
hayro-ccitt.workspace = true
|
||||
hayro-jbig2.workspace = true
|
||||
flate2.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-ccitt.workspace = true
|
||||
hayro-jbig2.workspace = true
|
||||
proptest.workspace = true
|
||||
weezl.workspace = true
|
||||
@@ -3,7 +3,7 @@
|
||||
PDF stream filters: the decoders and encoders behind `/Filter` and `/DecodeParms`.
|
||||
|
||||
- **Layer:** L0, standalone. No dependency on any other PdfCraft crate. `cos` depends on it (architecture §3 rule 3).
|
||||
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's (MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
|
||||
- **Licence:** MIT OR Apache-2.0. Clean-room: written from ISO 32000-2 §7.4, RFC 1950/1951 (through `flate2` with the pure-Rust `miniz_oxide` backend), TIFF 6.0 §14 (predictor 2) and RFC 2083 §6 (PNG filters). CCITT Group 4 encoding (ITU-T T.6) is the `fax` crate's (MIT, already in the tree through `tiff`), checked here by round trips through an independent decoder; CCITT decoding (T.4 and T.6) is the `hayro-ccitt` crate's, and JBIG2 decoding the `hayro-jbig2` crate's (both MIT OR Apache-2.0, already in the tree through hayro), bounded here. JBIG2 generic region encoding is written from the public ISO/IEC 14492 Final Committee Draft (WG1 N1359, 1999); Annex H.2 checks the arithmetic coder byte for byte.
|
||||
|
||||
- `#![forbid(unsafe_code)]`. Builds for `wasm32-unknown-unknown`.
|
||||
|
||||
@@ -26,6 +26,7 @@ encode_flate(data) -> Vec<u8> // zlib, level 6
|
||||
encode_g4(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as CCITT Group 4; columns <= MAX_G4_COLUMNS
|
||||
pub struct CcittParams { k, columns, rows, end_of_line, encoded_byte_align, end_of_block, black_is_1 } // Table 11; Default = its defaults
|
||||
decode_ccitt(data, ¶ms, max_rows) -> Result<Vec<u8>, FilterError> // CCITT fax as CCITTFaxDecode gives it; columns <= MAX_CCITT_COLUMNS
|
||||
decode_jbig2(data, globals, columns, rows, max) -> Result<Vec<u8>, FilterError> // JBIG2 as JBIG2Decode gives it; a page of columns × rows, within max and MAX_JBIG2_PIXELS
|
||||
encode_jbig2_generic(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode; columns <= MAX_JBIG2_COLUMNS
|
||||
encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit rows as lossless JBIG2 for JBIG2Decode, symbol coded when that is smaller; columns <= MAX_JBIG2_COLUMNS
|
||||
```
|
||||
@@ -47,7 +48,7 @@ encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit row
|
||||
| ASCII85Decode | `<~` prefix, `z`, white space, `~>` (or bare `~`, or none) EOD, final partial group; `z` inside a group, a 1-char final group, overflow or bad characters → Corrupt | `z` for zero groups, newline every 75 characters, `~>` |
|
||||
| RunLengthDecode | 0–127 literal, 129–255 repeat, 128 EOD (optional); truncated run → Corrupt | runs of ≥2 repeat, literals up to 128, 128 |
|
||||
| CCITTFaxDecode | not in a chain (`Unsupported`); `decode_ccitt` decodes Group 3 and Group 4 on its own, as rows of 1-bit samples each on a new byte, black 0 unless `black_is_1`, at most `max_rows` rows (fewer with `/Rows`), the rows before any damage, up to 1,048,576 samples a row; Group 3 rows on bytes are read by their end-of-line codes when the data starts with one and on bytes otherwise, whatever `end_of_line` says, so data with codes but none before the first row (not as T.4 sends them) is read on bytes, as the renderer draws it, and at `k` 0 may come out as other rows (`pdfcraft_cos::Document::decoded_image` calls it for a fax image, after the filters before it) | `encode_g4`: Group 4 (`/K -1`) with an end-of-block mark, 0 bits coded black so `/BlackIs1 false` decodes the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 32,767 samples a row (the encoder counts positions in 16 bits); no rows or columns, or too wide → Unsupported, too few bytes → Corrupt |
|
||||
| JBIG2Decode | not here (`Unsupported`; the renderer and image export decode it with `hayro-jbig2`) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made |
|
||||
| JBIG2Decode | not in a chain (`Unsupported`); `decode_jbig2` decodes it on its own with `hayro-jbig2`, given what its `/JBIG2Globals` decode to, as rows of 1-bit samples each on a new byte, black 0; the page has to be `columns` × `rows` (PdfCraft's renderer draws the whole page into the image), within `max` bytes as such rows and 2^28 pixels (`MAX_JBIG2_PIXELS`, about the most the decoder takes), checked before it is decoded; regions inside it are bounded by the decoder alone (a region may claim more pixels than its page); damaged data, or globals that don't decode, are `Corrupt` (`pdfcraft_cos::Document::decoded_image` calls it for a JBIG2 image, after the filters before it) | `encode_jbig2_generic`: the embedded organisation (D.3) with a page information segment and an immediate lossless generic region (7.4.6), no `/JBIG2Globals`; template 0 with its nominal AT pixels and typical prediction (6.2.5), coded with the MQ arithmetic coder (Annex E, Table E.1) and ended by its 0xFF 0xAC marker. A 0 sample is coded as a JBIG2 1 (black), since `JBIG2Decode` gives black as 0, so the stream decodes to the same bits; rows packed MSB first, each on a new byte, padding bits and trailing bytes ignored; up to 1,048,576 samples a row; no rows or columns, or too wide → Unsupported, too few bytes → Corrupt. `encode_jbig2`: the smaller of that and symbol coding (`jbig2/symbols.rs`): each distinct mark (8-connected black pixels, at most 256 a side) stored once in a symbol dictionary (7.4.2; arithmetic coded, each bitmap a template 0 generic region without typical prediction, the bitmaps sharing their contexts) and placed by immediate lossless text regions referring to it (7.4.3; bottom-left reference corner, strips of 1, 2, 4 or 8 rows, whichever is smallest), each of at most 10,000 instances over a band of rows, as the decoder PdfCraft renders with (`hayro-jbig2`) takes no more in one region, with larger marks, and in a second plan marks that occur once, in a generic region over their box. Only exact copies share a symbol (no refinement), so the stream decodes to the same bits; fewer than two symbols, more than 2^20 black runs, 65,536 distinct marks or 16 MiB of their pixels, or more than 2^24 rows, and the generic coding is used; a plan of more than 32,767 symbols (that decoder refuses more) isn't made |
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -57,12 +58,13 @@ encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit row
|
||||
- `ccitt.rs`: `decode_ccitt` gives back the rows `encode_g4` was given (with `/BlackIs1`, `/Rows` and a row limit), refuses bad widths, and bounds its output for any input; byte-aligned Group 3 in each layout, over 32 seeded pictures, decodes to its picture, and a byte short to fewer rows. Group 4 output decoded by `hayro-ccitt` (an independent decoder) matches the input bits: a text-like page, padding bits, the widest row, and property tests over bitmaps of noise and of runs; any input returns rather than panics.
|
||||
- `mq.rs`: the arithmetic coder reproduces Annex H.2's 30 bytes from its 256 decisions.
|
||||
- `jbig2.rs`: output decoded by `hayro-jbig2` (dev-dependency, an independent decoder) matches the input bits: a text-like page (smaller than Group 4), edges, padding, all-white and all-black regions (typical rows), and property tests over bitmaps of noise and of runs; any input returns rather than panics.
|
||||
- `jbig2/decode.rs`: `decode_jbig2` gives back the rows of both codings, padding bits 0; refuses a page of another width or height, past `max` or past `MAX_JBIG2_PIXELS` before decoding it (2^28 pixels take about 34 MB, a bit each), globals that aren't JBIG2 and damaged data, and returns rather than panics on every prefix of a stream.
|
||||
- `jbig2/symbols.rs`: the integer coders against Annex A's example and Table A.1; symbol-coded output decoded by `hayro-jbig2` matches the input bits for every plan and strip size: text whose letters repeat (under half the generic coding's size, the dictionary retained and referred to), large and lone marks left to a generic region, marks on the edges, touching at corners and inside each other's boxes, and property tests over glyphs scattered over each other and the edges with stray pixels (never larger than the generic coding); one shape, a blank page and noise are coded generically; marks joined at a corner, either way, are one mark, and 256 pixels a side is a symbol while 257 wide or tall is left out; black padding bits are ignored; coordinates past 4,435 use Annex A's 32-bit range and PREV stays below 512; past 2^20 runs, 2^24 rows, 65,536 distinct marks or 2^24 symbol pixels the image is coded generically, and at each cap it is still looked at; a plan codes at most 32,767 symbols, which decode, and past that the second plan still codes the repeated ones; more than 10,000 marks are placed by several text regions, numbered in turn, the dictionary retained for each but the last; any input returns rather than panics.
|
||||
- `fuzz/`: a detached cargo-fuzz crate (`cargo +nightly fuzz run decode`, run from `crates/filters/`).
|
||||
|
||||
## Status
|
||||
|
||||
M1.1/M1.2 for the general-purpose filters are done. Not yet here: the image decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000, JBIG2), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6.
|
||||
M1.1/M1.2 for the general-purpose filters are done. Not yet here: the DCT and JPX decoders (DCT via zune-jpeg, JPX via hayro-jpeg2000), DCT encoding, CCITT Group 3 encoding and Flate encode levels other than 6.
|
||||
|
||||
|
||||
Lossless symbol coding is also implemented: exact-copy dictionaries/text regions, bounded by the encoder; no approximate or lossy symbol substitution.
|
||||
+17
-11
@@ -132,9 +132,10 @@ fn starts_with_end_of_line(data: &[u8]) -> bool {
|
||||
}
|
||||
|
||||
/// Rows of 1-bit samples filled from a decoder's pixels: `columns` a row, each row on a new byte, at
|
||||
/// most `rows` rows. Pixels past a row's end and rows past the last are dropped.
|
||||
struct Rows {
|
||||
out: Vec<u8>,
|
||||
/// most `rows` rows. Pixels past a row's end and rows past the last are dropped. The JBIG2 decoder
|
||||
/// ([`crate::decode_jbig2`]) fills them too.
|
||||
pub(crate) struct Rows {
|
||||
pub(crate) out: Vec<u8>,
|
||||
row: Vec<u8>,
|
||||
x: usize,
|
||||
columns: usize,
|
||||
@@ -144,12 +145,12 @@ struct Rows {
|
||||
}
|
||||
|
||||
impl Rows {
|
||||
fn new(columns: usize, rows: usize, black_is_1: bool) -> Self {
|
||||
pub(crate) fn new(columns: usize, rows: usize, black_is_1: bool) -> Self {
|
||||
Rows { out: Vec::new(), row: vec![0; columns.div_ceil(8)], x: 0, columns, rows, done: 0, black_is_1 }
|
||||
}
|
||||
|
||||
/// `count` pixels, white or black.
|
||||
fn push(&mut self, white: bool, count: usize) {
|
||||
pub(crate) fn push(&mut self, white: bool, count: usize) {
|
||||
let end = self.x.saturating_add(count);
|
||||
if white != self.black_is_1 {
|
||||
for x in self.x..end.min(self.columns) {
|
||||
@@ -160,6 +161,16 @@ impl Rows {
|
||||
}
|
||||
self.x = end;
|
||||
}
|
||||
|
||||
/// The row filled so far kept, if fewer than `rows` are, and the next one begun.
|
||||
pub(crate) fn end_row(&mut self) {
|
||||
if self.done < self.rows {
|
||||
self.out.extend_from_slice(&self.row);
|
||||
self.done += 1;
|
||||
}
|
||||
self.row.fill(0);
|
||||
self.x = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl hayro_ccitt::Decoder for Rows {
|
||||
@@ -170,12 +181,7 @@ impl hayro_ccitt::Decoder for Rows {
|
||||
self.push(white, (chunks as usize).saturating_mul(8));
|
||||
}
|
||||
fn next_line(&mut self) {
|
||||
if self.done < self.rows {
|
||||
self.out.extend_from_slice(&self.row);
|
||||
self.done += 1;
|
||||
}
|
||||
self.row.fill(0);
|
||||
self.x = 0;
|
||||
self.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
//! a generic region, template 0 with its nominal adaptive pixels, typical prediction on, MQ
|
||||
//! arithmetic coding ([`crate::mq`]); or the image's marks as symbols ([`symbols`]). Written from
|
||||
//! the standard's 6.2.5 (coding a generic region), 7.2 (segment headers), 7.4.1, 7.4.6 and 7.4.8
|
||||
//! (the segments) and D.3 (the embedded organisation PDF uses).
|
||||
//! (the segments) and D.3 (the embedded organisation PDF uses). Decoding is in [`decode`].
|
||||
|
||||
mod decode;
|
||||
mod symbols;
|
||||
|
||||
pub use decode::{MAX_JBIG2_PIXELS, decode_jbig2};
|
||||
|
||||
use crate::FilterError;
|
||||
use crate::mq::{Context, MqEncoder};
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! JBIG2 decoding for `JBIG2Decode` (ISO 32000-2 §7.4.7), through `hayro-jbig2`.
|
||||
|
||||
use crate::FilterError;
|
||||
use crate::ccitt::Rows;
|
||||
|
||||
/// The largest JBIG2 page [`decode_jbig2`] decodes, in pixels. The decoder decodes a page whole,
|
||||
/// and takes pages of up to about this many (20,000 × 10,000 decodes).
|
||||
pub const MAX_JBIG2_PIXELS: u64 = 1 << 28;
|
||||
|
||||
/// A `JBIG2Decode` stream's `data`, with what its `/JBIG2Globals` stream decodes to, decoded as the
|
||||
/// filter gives it: `rows` rows of `columns` 1-bit samples, most significant bit first, each
|
||||
/// starting on a new byte, with black as 0 (JBIG2 codes black as 1).
|
||||
///
|
||||
/// The JBIG2 page has to be the image's size, `columns` × `rows`: PdfCraft's renderer draws the
|
||||
/// whole page into the image, so the rows of a page of another height wouldn't be the image's. The
|
||||
/// decoder decodes the page whole, so it is checked before it is decoded, and has to fit in `max`
|
||||
/// bytes as such rows and have at most [`MAX_JBIG2_PIXELS`]. Regions inside it are bounded by the
|
||||
/// decoder alone: a region may claim more pixels than its page, and is decoded before it is cut to
|
||||
/// the page. With no rows asked for, nothing is decoded.
|
||||
///
|
||||
/// Errors: `data` or `globals` that don't decode, or a page of another size
|
||||
/// ([`FilterError::Corrupt`]); a page past `max` ([`FilterError::LimitExceeded`]) or
|
||||
/// [`MAX_JBIG2_PIXELS`] ([`FilterError::Unsupported`], or `Corrupt` from the decoder, which has the
|
||||
/// same cap and can refuse the page first).
|
||||
pub fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result<Vec<u8>, FilterError> {
|
||||
if rows == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let corrupt = |detail: String| FilterError::Corrupt { filter: "JBIG2Decode", detail };
|
||||
let image = hayro_jbig2::Image::new_embedded(data, globals).map_err(|e| corrupt(e.to_string()))?;
|
||||
let (width, height) = (image.width(), image.height());
|
||||
if (width, height) != (columns, rows) {
|
||||
return Err(corrupt(format!("a page of {width} × {height} pixels for an image of {columns} × {rows}")));
|
||||
}
|
||||
if u64::from(width) * u64::from(height) > MAX_JBIG2_PIXELS {
|
||||
return Err(FilterError::Unsupported(format!("JBIG2 pages of {width} × {height} pixels")));
|
||||
}
|
||||
// u32 to usize: wasm32 and up have at least 32-bit usize.
|
||||
let (width, height) = (width as usize, height as usize);
|
||||
if width.div_ceil(8).checked_mul(height).is_none_or(|bytes| bytes > max) {
|
||||
return Err(FilterError::LimitExceeded(max));
|
||||
}
|
||||
let mut out = Rows::new(width, height, false);
|
||||
image.decode(&mut out).map_err(|e| corrupt(e.to_string()))?;
|
||||
Ok(out.out)
|
||||
}
|
||||
|
||||
/// JBIG2 codes black as 1, which `JBIG2Decode` gives as 0.
|
||||
impl hayro_jbig2::Decoder for Rows {
|
||||
fn push_pixel(&mut self, black: bool) {
|
||||
self.push(!black, 1);
|
||||
}
|
||||
fn push_pixel_chunk(&mut self, black: bool, chunks: u32) {
|
||||
self.push(!black, (chunks as usize).saturating_mul(8));
|
||||
}
|
||||
fn next_line(&mut self) {
|
||||
self.end_row();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{encode_jbig2, encode_jbig2_generic};
|
||||
|
||||
/// `rows` rows of `columns` samples of noise, the bits that pad each row to a byte 0.
|
||||
fn noise(columns: u32, rows: u32) -> Vec<u8> {
|
||||
let stride = (columns as usize).div_ceil(8);
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
let mut data: Vec<u8> = (0..stride * rows as usize)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect();
|
||||
let pad = (stride * 8 - columns as usize) as u32;
|
||||
data.chunks_mut(stride).for_each(|row| *row.last_mut().unwrap() &= 0xFFu8.checked_shl(pad).unwrap_or(0));
|
||||
data
|
||||
}
|
||||
|
||||
/// The same stream with its page information (the first segment's data, after its 11-byte
|
||||
/// header: width, then height) claiming a page of `width` × `height`.
|
||||
fn page_of(data: &[u8], width: u32, height: u32) -> Vec<u8> {
|
||||
let mut data = data.to_vec();
|
||||
data[11..15].copy_from_slice(&width.to_be_bytes());
|
||||
data[15..19].copy_from_slice(&height.to_be_bytes());
|
||||
data
|
||||
}
|
||||
|
||||
/// Either coding decodes to its samples, black as 0, the bits that pad each row 0: a generic
|
||||
/// region of noise, and symbols for letters repeated across the page. With no rows asked for,
|
||||
/// nothing is decoded.
|
||||
#[test]
|
||||
fn both_codings_decode_to_their_samples() {
|
||||
let (w, h) = (203u32, 41u32);
|
||||
let bits = noise(w, h);
|
||||
let generic = encode_jbig2_generic(&bits, w, h).unwrap();
|
||||
assert_eq!(decode_jbig2(&generic, None, w, h, usize::MAX).unwrap(), bits);
|
||||
assert_eq!(decode_jbig2(b"not JBIG2", None, w, 0, usize::MAX).unwrap(), b"", "no rows asked for");
|
||||
|
||||
// Four lines of three letters, black on white.
|
||||
const LETTERS: [[&str; 7]; 3] = [
|
||||
["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
|
||||
[".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
|
||||
["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
|
||||
];
|
||||
let (w, h) = (400u32, 40u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut marks = vec![0xFFu8; stride * h as usize];
|
||||
for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
|
||||
for (k, x) in (3..w as usize - 6).step_by(7).enumerate() {
|
||||
for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
|
||||
for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
|
||||
marks[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let symbols = encode_jbig2(&marks, w, h).unwrap();
|
||||
assert!(symbols.len() < encode_jbig2_generic(&marks, w, h).unwrap().len(), "symbol coded");
|
||||
assert_eq!(decode_jbig2(&symbols, None, w, h, usize::MAX).unwrap(), marks);
|
||||
}
|
||||
|
||||
/// The page has to be the image's size, and is bounded before it is decoded, being decoded
|
||||
/// whole: within `max` as rows of one bit, and within [`MAX_JBIG2_PIXELS`]. Globals are given
|
||||
/// to the decoder: ones it skips decode, ones that aren't JBIG2 are an error, as is damaged
|
||||
/// data, which never panics.
|
||||
#[test]
|
||||
fn what_it_cannot_decode_is_an_error() {
|
||||
let (w, h) = (203u32, 41u32);
|
||||
let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap();
|
||||
for (columns, rows) in [(w - 1, h), (w + 1, h), (w, h - 1), (w, h + 1)] {
|
||||
let e = decode_jbig2(&data, None, columns, rows, usize::MAX).unwrap_err();
|
||||
assert!(matches!(e, FilterError::Corrupt { .. }), "{columns} × {rows}: {e}");
|
||||
}
|
||||
let page = 26 * h as usize;
|
||||
assert!(matches!(decode_jbig2(&data, None, w, h, page - 1), Err(FilterError::LimitExceeded(m)) if m == page - 1), "a byte short");
|
||||
assert_eq!(decode_jbig2(&data, None, w, h, page).unwrap().len(), page, "the page within max");
|
||||
// 16,384 × 16,384 is 2^28 pixels, which decodes; a row more is past the cap, here or in the
|
||||
// decoder, which has the same one and can refuse it first.
|
||||
let large = |rows| decode_jbig2(&page_of(&data, 16_384, rows), None, 16_384, rows, usize::MAX);
|
||||
assert!(matches!(large(16_385), Err(FilterError::Unsupported(_) | FilterError::Corrupt { .. })), "{:?}", large(16_385));
|
||||
assert!(large(16_384).is_ok(), "{:?}", large(16_384).map(|page| page.len()));
|
||||
|
||||
// An extension segment (ISO/IEC 14492 7.4.14), which a decoder skips: its 11-byte header
|
||||
// gives the length of the data after it.
|
||||
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
|
||||
let generic = decode_jbig2(&data, None, w, h, usize::MAX).unwrap();
|
||||
assert_eq!(decode_jbig2(&data, Some(&skipped), w, h, usize::MAX).unwrap(), generic);
|
||||
assert!(matches!(decode_jbig2(&data, Some(b"not JBIG2"), w, h, usize::MAX), Err(FilterError::Corrupt { .. })));
|
||||
for end in 0..data.len() {
|
||||
let _ = decode_jbig2(&data[..end], None, w, h, usize::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
//! TIFF/PNG predictors, `ASCIIHexDecode`, `ASCII85Decode`, `RunLengthDecode`).
|
||||
//! Image codecs (`DCTDecode`, `JPXDecode`, `JBIG2Decode`, `CCITTFaxDecode`),
|
||||
//! `Crypt` and unknown filters are reported as [`FilterError::Unsupported`] so
|
||||
//! callers keep such data encoded. 1-bit samples can be encoded as CCITT Group 4
|
||||
//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own
|
||||
//! ([`decode_ccitt`], [`decode_jbig2`]), as `pdfcraft_cos::Document::decoded_image`
|
||||
//! does after the filters before them. 1-bit samples can be encoded as CCITT Group 4
|
||||
//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]).
|
||||
//!
|
||||
//! Every decoder is bounded by a caller-supplied `max_output` (decompression-bomb
|
||||
@@ -27,7 +29,7 @@ mod runlength;
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub use ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt, encode_g4};
|
||||
pub use jbig2::{MAX_JBIG2_COLUMNS, encode_jbig2, encode_jbig2_generic};
|
||||
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2, encode_jbig2, encode_jbig2_generic};
|
||||
|
||||
/// A PDF stream filter, identified by its `/Filter` name.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
|
||||
+31
-17
@@ -31,7 +31,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
stored as JPEG (Acrobat's quality steps) or ZIP; soft masks are resampled with their image.
|
||||
An image is replaced only if the result is smaller. Left alone by these colour and grey
|
||||
settings: CMYK and other colour spaces, image masks, decode arrays, more than 8 bits, JPEG 2000,
|
||||
JBIG2 and CCITT (black-and-white images, CCITT among them, are below). A JPEG is read with
|
||||
JBIG2 and CCITT (black-and-white images, CCITT and JBIG2 among them, are below). A JPEG is read with
|
||||
its colour transform (an Adobe marker's, else `/ColorTransform`, ISO 32000-2 §7.4.8), so one
|
||||
stored untransformed keeps its colours; one whose marker and `/ColorTransform` disagree is left
|
||||
alone, since readers differ on which wins. Samples are read with `/DecodeParms` given by
|
||||
@@ -40,7 +40,7 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
- **Monochrome images** (`Settings::mono`, `MonoSettings::REDUCE` by default: to 300 ppi above
|
||||
450 ppi, CCITT Group 4). Black-and-white images are a 1-bit image with one colour component
|
||||
(grey, indexed, a separation) or an image mask, stored with no filter, with general-purpose ones
|
||||
or with CCITT fax after any of those (decoded by `Document::decoded_image_within`, no further
|
||||
or with CCITT fax or JBIG2 after any of those (decoded by `Document::decoded_image_within`, no further
|
||||
than its rows can need; byte-aligned Group 3 with end-of-line codes but none before the first
|
||||
row, not as T.4 sends them, is read as PdfCraft draws it, which at `/K 0` may be other rows than
|
||||
were coded, and an image stored again keeps them). Packed to one bit first, unless a colour-key `/Mask` is in the image's
|
||||
@@ -59,24 +59,38 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
downsampling it to JPEG.
|
||||
- **Downsampling:** one whose effective resolution is above `above_ppi` is averaged down to
|
||||
`target_ppi`: each new pixel takes the sample covering more of its area, and the dark one on a
|
||||
tie, so a dark line half a new pixel wide stays: black in grey and the painted part of an image
|
||||
mask (a 0, or a 1 with an inverting decode array), full ink in a separation (a 1, or a 0), and
|
||||
entry 0 of an indexed image, whatever colour its palette gives it (the palette isn't read yet).
|
||||
Not one with a soft mask, whose own size would have to follow; not a halftone or a dither,
|
||||
most of whose new pixels would cover both black and white, so that taking the sample covering
|
||||
more would turn its tones solid (a 50% screen black, a 25% one white); and not one wider than
|
||||
a fax row (`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each
|
||||
column. On a 600 ppi text page, Group 4 at 300 ppi is half the size.
|
||||
tie, so a dark line half a new pixel wide stays: black in grey, the painted part of an image
|
||||
mask (a 0, or a 1 with an inverting decode array), and the darker of an indexed image's two
|
||||
palette entries. Only images whose dark sample is known so are averaged: `DeviceGray` (grey and
|
||||
RGB packed to one bit among them), image masks, and indexed images over `DeviceGray` or
|
||||
`DeviceRGB`, whose palette is read (Rec. 601 luma for RGB; a table of at most 4 KiB), with no
|
||||
decode array or one of two different numbers, and not drawn or listed where `/DefaultGray` or
|
||||
`/DefaultRGB` is or may be in force (an indexed image referred to from beyond the page and form
|
||||
resources read, as an annotation's appearance, may be). Not one with a colour key, which can
|
||||
hide the dark sample a tie would go to. Not a separation or a DeviceN, whose tint transform isn't read, calibrated or ICC
|
||||
grey, or an indexed image over another base or whose two entries look alike. Not one with a soft mask, whose own size would have to
|
||||
follow; not a halftone or a dither, most of whose new pixels would cover both black and white,
|
||||
so that taking the sample covering more would turn its tones solid (a 50% screen black, a 25%
|
||||
one white), nor one with a square of four new pixels that each would, as a small screened
|
||||
picture on a white page has: ragged scanned text has such squares along its strokes too, and
|
||||
is then left at its resolution. And not one wider than a fax row
|
||||
(`pdfcraft_filters::MAX_CCITT_COLUMNS`), as the averaging keeps a counter for each column. On a
|
||||
600 ppi page of text that is averaged, Group 4 at 300 ppi is about half the size.
|
||||
- **Compression:** CCITT Group 4 (`pdfcraft_filters::encode_g4`), or 1-bit Flate when that is
|
||||
smaller, which only a resampled image or one packed from more bits may be: a 1-bit image that
|
||||
is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled;
|
||||
is only stored again becomes Group 4 or stays. Group 4 alone is only decoded to be resampled, and so is JBIG2 alone when Group 4 is asked for;
|
||||
Group 3, or Group 4 inside another filter such as `ASCII85Decode`, is stored again as Group 4
|
||||
alone when that is smaller. Damaged Group 3 with end-of-line codes is stored as the decoder
|
||||
recovers it, a bad row picked up again at the next code, as it reports no damage there; without
|
||||
them, damage stops the decoding and the image is left alone. With Retain, a resampled image
|
||||
keeps its kind of compression (CCITT, or Flate) and nothing else changes; an image of more bits
|
||||
is then left to the grey or colour settings. Every change is kept only when it is smaller as
|
||||
stored.
|
||||
them, damage stops the decoding and the image is left alone. JBIG2 inside another filter, or
|
||||
alone when JBIG2 is asked for, is stored again as the compression asks when that is smaller (as JBIG2 symbols where
|
||||
its own coding is a generic region, say); a symbol-coded scan, averaged, loses its exact
|
||||
repeats and often comes out larger, and then stays as it is. A JBIG2 image stored again refers to no
|
||||
`/JBIG2Globals`, and one whose page isn't its size (PdfCraft's
|
||||
renderer draws the whole page into the image) is left alone. With Retain, a resampled image
|
||||
keeps its kind of compression (CCITT, JBIG2, or Flate) and nothing else changes; an image of
|
||||
more bits is then left to the grey or colour settings. Every change is kept only when it is
|
||||
smaller as stored.
|
||||
- Apart from the downsampling it is lossless: the samples keep their meaning, so the colour
|
||||
space, decode array and `/Mask` stay, but for packed RGB, which becomes grey with one decode
|
||||
pair. Group 4 wins on scans, whose ragged edges defeat Flate; on clean computer-drawn 1-bit
|
||||
@@ -85,8 +99,8 @@ let report = optimize_with_progress(&mut doc, &settings, &mut |stage| keep_going
|
||||
samples.
|
||||
|
||||
- Lossless JBIG2 uses bounded generic-region encoding or exact-copy symbol dictionaries when
|
||||
smaller. No lossy symbol substitution is used. Export reads embedded images and bounded
|
||||
shared globals.
|
||||
smaller. No lossy symbol substitution is used. Export, redaction and this step read JBIG2
|
||||
images, with bounded shared globals.
|
||||
- **Discard objects:** thumbnails, alternate images, document tags, print settings.
|
||||
- **Clean up:** Flate for streams with no filter; for streams with an outer ASCII85 or ASCIIHex
|
||||
layer, that layer is removed when the result is smaller (the inner filters keep their encoded
|
||||
|
||||
+270
-33
@@ -1,6 +1,7 @@
|
||||
//! Image resampling and recompression.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_content::{Matrix, Pieces};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
|
||||
@@ -300,6 +301,19 @@ fn device_rgb(doc: &Document, r: ObjRef) -> bool {
|
||||
&& cs.as_deref().and_then(Object::as_name).is_some_and(|n| matches!(n, b"DeviceRGB" | b"RGB"))
|
||||
}
|
||||
|
||||
/// Whether image `r` is indexed: its palette says which of its samples is dark ([`dark_is_one`]),
|
||||
/// which default colour spaces in force where it is drawn may change.
|
||||
fn indexed(doc: &Document, r: ObjRef) -> bool {
|
||||
let Object::Stream(s) = &*doc.get(r) else { return false };
|
||||
let cs = s.dict.get(b"ColorSpace").map(|cs| doc.resolve(cs));
|
||||
matches!(cs.as_deref(), Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")))
|
||||
}
|
||||
|
||||
/// The first entry of colour space array `a`, its family, read through a reference.
|
||||
fn family(doc: &Document, a: &[Object]) -> Option<Arc<Object>> {
|
||||
a.first().map(|f| doc.resolve(f))
|
||||
}
|
||||
|
||||
/// Call `f` with every reference in `o`, a dictionary's or array's included, a stream's dictionary
|
||||
/// too, but not those in objects referred to.
|
||||
fn each_ref(o: &Object, mut f: impl FnMut(ObjRef)) {
|
||||
@@ -449,7 +463,7 @@ fn is_dct(s: &Stream) -> Option<bool> {
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Pixels decoded from JPEG data, for the test that keeps a mismatched JPEG from decoding.
|
||||
/// Pixels decoded, for the tests that keep an image from being decoded for nothing.
|
||||
static DECODED_PIXELS: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
@@ -618,13 +632,29 @@ fn jpeg_samples(data: &[u8], w: usize, h: usize, n: usize, ycc: bool) -> Option<
|
||||
}
|
||||
|
||||
pub(crate) fn resample(px: &[u8], n: usize, w: u32, h: u32, nw: u32, nh: u32) -> Option<Vec<u8>> {
|
||||
use image::imageops::{FilterType, resize};
|
||||
Some(match n {
|
||||
1 => resize(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||||
_ => resize(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh, FilterType::CatmullRom).into_raw(),
|
||||
1 => scaled(&image::GrayImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
|
||||
_ => scaled(&image::RgbImage::from_raw(w, h, px.to_vec())?, nw, nh).into_raw(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `img` scaled to `w` × `h`. Scaling goes down first, through a buffer of `img`'s width by `h` at
|
||||
/// 16 bytes a pixel, which a soft mask of other proportions than its image can make larger than
|
||||
/// any machine: 1,000,000 × 1 resampled to 4 × 100,000 asks for 1.6 TB, and that aborts the
|
||||
/// process. Such a one is scaled across first, which holds no more pixels than one of the two has.
|
||||
/// Past a quarter over the larger of them, so that rounding leaves a picture of the same shape as
|
||||
/// it was.
|
||||
fn scaled<P: image::Pixel<Subpixel = u8> + 'static>(img: &image::ImageBuffer<P, Vec<u8>>, w: u32, h: u32) -> image::ImageBuffer<P, Vec<u8>> {
|
||||
use image::imageops::{FilterType, resize};
|
||||
let (iw, ih) = (u64::from(img.width()), u64::from(img.height()));
|
||||
let largest = (iw * ih).max(u64::from(w) * u64::from(h));
|
||||
if (iw * u64::from(h)).saturating_mul(4) > largest.saturating_mul(5) {
|
||||
resize(&resize(img, w, img.height(), FilterType::CatmullRom), w, h, FilterType::CatmullRom)
|
||||
} else {
|
||||
resize(img, w, h, FilterType::CatmullRom)
|
||||
}
|
||||
}
|
||||
|
||||
fn jpeg(px: &[u8], n: usize, w: u32, h: u32, quality: u8) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100));
|
||||
@@ -754,7 +784,7 @@ fn one_component(doc: &Document, cs: &Object) -> bool {
|
||||
let cs = doc.resolve(cs);
|
||||
match &*cs {
|
||||
Object::Name(n) => matches!(n.as_slice(), b"DeviceGray" | b"G"),
|
||||
Object::Array(a) => match a.first().and_then(Object::as_name) {
|
||||
Object::Array(a) => match family(doc, a).as_deref().and_then(Object::as_name) {
|
||||
Some(b"CalGray" | b"Separation" | b"Indexed" | b"I") => true,
|
||||
Some(b"ICCBased") => a.get(1).is_some_and(|icc| matches!(&*doc.resolve(icc), Object::Stream(s) if s.dict.int(b"N") == Some(1))),
|
||||
Some(b"DeviceN") => a.get(1).is_some_and(|names| doc.resolve(names).as_array().is_some_and(|n| n.len() == 1)),
|
||||
@@ -771,7 +801,7 @@ type Pixel = fn(&[u8]) -> Option<bool>;
|
||||
/// with its width and height, and, when they were packed from more bits, its dictionary as a 1-bit
|
||||
/// image. These count:
|
||||
/// - one bit per pixel in one colour component, or an image mask, stored with no filter, with
|
||||
/// general-purpose ones, or with CCITT fax last ([`Document::decoded_image_within`], no further
|
||||
/// general-purpose ones, or with CCITT fax or JBIG2 last ([`Document::decoded_image_within`], no further
|
||||
/// than its rows can need);
|
||||
/// - with `pack`, and with no colour-key `/Mask` (its ranges are in the image's own values):
|
||||
/// - eight-bit grey (one component, not indexed) whose every sample is 0 or 255. A decode array
|
||||
@@ -802,8 +832,10 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
|
||||
let len = stride.checked_mul(hu)?;
|
||||
let max = match s.filters().last() {
|
||||
Some((pdfcraft_filters::Filter::CcittFax, _)) => wu.checked_mul(hu)?.checked_add(hu.checked_mul(4)?)?.checked_add(4096)?,
|
||||
// A JBIG2 page, which has to be the image's size, is `len` bytes as rows of a bit.
|
||||
_ => allowance(len, h)?,
|
||||
};
|
||||
count_decoded(u64::from(w) * u64::from(h));
|
||||
let mut data = doc.decoded_image_within(s, max).ok()?;
|
||||
if data.len() < len {
|
||||
return None;
|
||||
@@ -827,7 +859,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
|
||||
packed.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||||
// Components a pixel, bits a component, and a pixel's samples as 1 or 0.
|
||||
let (n, bpc, white): (usize, usize, Pixel) = match &*doc.resolve(cs) {
|
||||
Object::Array(a) if matches!(a.first().and_then(Object::as_name), Some(b"Indexed" | b"I")) => {
|
||||
Object::Array(a) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed" | b"I")) => {
|
||||
let bpc = bits.filter(|b| matches!(b, 2 | 4 | 8))?;
|
||||
match decode.as_deref() {
|
||||
None => {}
|
||||
@@ -908,8 +940,12 @@ fn packed_rows(samples: &[u8], row_len: usize, w: usize, h: usize, (n, bpc, whit
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Resolve polarity only for explicitly understood samples. Indexed palettes and custom
|
||||
/// colour spaces stay at their original resolution rather than guessing which entry is dark.
|
||||
/// Whether sample 1 is the dark one of the 1-bit image `d` describes, after its decode array
|
||||
/// (`[0 1]` when it has none; another must be two different numbers): black in grey is a 0, and so
|
||||
/// is the painted part of an image mask. An indexed image's samples pick palette entries (its
|
||||
/// decode array maps them to entries, rounded and clipped to the table, ISO 32000-2 §8.9.5.2), and
|
||||
/// the palette says which is darker ([`lightness`]). `None` for anything else: separations and
|
||||
/// DeviceN, whose tint transforms aren't read, other colour spaces, and two entries alike.
|
||||
fn dark_is_one(doc: &Document, d: &Dict) -> Option<bool> {
|
||||
let number = |v: Option<&Object>| v.and_then(|v| doc.resolve(v).as_f64()).filter(|v| v.is_finite());
|
||||
let (d0, d1) = match d.get(b"Decode").map(|v| doc.resolve(v)).as_deref() {
|
||||
@@ -923,12 +959,62 @@ fn dark_is_one(doc: &Document, d: &Dict) -> Option<bool> {
|
||||
if matches!(d.get(b"ImageMask"), Some(Object::Bool(true))) {
|
||||
return Some(d1 < d0);
|
||||
}
|
||||
// A colour key can hide the dark sample, and a tie going to it would lose a thin rule of the
|
||||
// sample that shows.
|
||||
if matches!(d.get(b"Mask").map(|m| doc.resolve(m)).as_deref(), Some(Object::Array(_))) {
|
||||
return None;
|
||||
}
|
||||
match d.get(b"ColorSpace").map(|cs| doc.resolve(cs)).as_deref() {
|
||||
Some(Object::Name(n)) if matches!(n.as_slice(), b"DeviceGray" | b"G") => Some(d1 < d0),
|
||||
Some(Object::Array(a)) if matches!(family(doc, a).as_deref().and_then(Object::as_name), Some(b"Indexed")) => {
|
||||
let hival = a.get(2).and_then(|v| match &*doc.resolve(v) {
|
||||
Object::Int(h) if (0..=255).contains(h) => Some(*h),
|
||||
_ => None,
|
||||
})?;
|
||||
// `hival` is 0 to 255, and the rounded value is clipped to it: the cast can't wrap.
|
||||
let entry = |v: f64| v.round().clamp(0.0, hival as f64) as usize;
|
||||
let (l0, l1) = (lightness(doc, a, entry(d0))?, lightness(doc, a, entry(d1))?);
|
||||
(l0 != l1).then_some(l1 < l0)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The most bytes a palette is read to (see [`lightness`]).
|
||||
const MAX_PALETTE: usize = 4096;
|
||||
|
||||
/// How light entry `i` of the palette of indexed colour space `a` (`[/Indexed base hival lookup]`)
|
||||
/// is, from 0 (black) to 255,000 (white): its grey, or its luma (Rec. 601) over `DeviceRGB`. Only
|
||||
/// those two bases count, where the palette's values are the colour: under `/DefaultGray` or
|
||||
/// `/DefaultRGB` they aren't, and the caller asks for no polarity there; `None` for another base
|
||||
/// (calibrated, CMYK, an ICC profile, a resource name), and for an entry the table doesn't hold.
|
||||
fn lightness(doc: &Document, a: &[Object], i: usize) -> Option<u32> {
|
||||
// A device space is its name, or that name alone in an array.
|
||||
let base = doc.resolve(a.get(1)?);
|
||||
let base = match &*base {
|
||||
Object::Array(b) if b.len() == 1 => doc.resolve(b.first()?),
|
||||
_ => base,
|
||||
};
|
||||
let n = match base.as_name()? {
|
||||
b"DeviceGray" => 1,
|
||||
b"DeviceRGB" => 3,
|
||||
_ => return None,
|
||||
};
|
||||
// At most 256 entries of three components: more is no palette. Images share a table, and this
|
||||
// is read for each of them, so a large one is refused before it's copied or decoded.
|
||||
let table = match &*doc.resolve(a.get(3)?) {
|
||||
Object::String(s) if s.bytes.len() <= MAX_PALETTE => s.bytes.clone(),
|
||||
Object::Stream(s) if s.raw.len() <= 4 * MAX_PALETTE => doc.with_direct_parms(s).decoded_within(MAX_PALETTE).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
let c: Vec<u32> = table.get(i.checked_mul(n)?..i.checked_mul(n)?.checked_add(n)?)?.iter().map(|v| u32::from(*v)).collect();
|
||||
Some(match c.as_slice() {
|
||||
[v] => v * 1000,
|
||||
[r, g, b] => 299 * r + 587 * g + 114 * b,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `w` × `h` 1-bit rows (each starting on a new byte) averaged down to `nw` × `nh`, no larger:
|
||||
/// each new pixel takes the sample that covers more of its area, and the dark one when the two tie
|
||||
/// (1 if `dark_is_one`, see [`dark_is_one`]), so a dark line half a new pixel wide stays. `None`
|
||||
@@ -1000,15 +1086,20 @@ fn downsample_bits_checked(data: &[u8], w: u32, h: u32, nw: u32, nh: u32, dark_i
|
||||
/// `/DefaultGray` or `/DefaultRGB` is or may be in force. Also says whether it was resampled.
|
||||
///
|
||||
/// - **Resampled** ([`downsample_bits_checked`]) when its resolution is above `above_ppi`, to `target_ppi`,
|
||||
/// unless it has a soft mask, whose own size would then have to follow, or is a halftone or a
|
||||
/// dither: most of its new pixels would cover both black and white, and taking the sample that
|
||||
/// covers more would turn its tones solid (a 50% screen black, a 25% one white).
|
||||
/// - **Stored** as CCITT Group 4 with `CcittG4`, or as 1-bit Flate when that is smaller, which only
|
||||
/// a resampled image or one packed from more bits may be: a 1-bit image that is only stored again
|
||||
/// becomes Group 4 or stays. Group 4 alone is only decoded to be resampled; Group 3 is stored
|
||||
/// again like any other. With `Retain`, a resampled image keeps its kind of compression
|
||||
/// (CCITT, or Flate), and nothing else changes: an image of more bits stays as it is, for the
|
||||
/// grey or colour settings.
|
||||
/// if its dark sample is known ([`dark_is_one`]: grey, an image mask, or an indexed image whose
|
||||
/// palette says which entry is darker, not under `defaults`), unless it has a soft mask, whose
|
||||
/// own size would then have to follow, or is a halftone or a dither: most of its new pixels would
|
||||
/// cover both black and white, and taking the sample that covers more would turn its tones solid
|
||||
/// (a 50% screen black, a 25% one white). So would a square of four new pixels that each cover
|
||||
/// both, as a small screened picture on a white page has (and ragged scanned text, which is then
|
||||
/// left at its resolution too).
|
||||
/// - **Stored** as CCITT Group 4 with `CcittG4`, or JBIG2 with `Jbig2`, or as 1-bit Flate when that
|
||||
/// is smaller, which only a resampled image or one packed from more bits may be: a 1-bit image
|
||||
/// that is only stored again becomes Group 4 or JBIG2, or stays. Group 4 alone is only decoded
|
||||
/// to be resampled, and so is JBIG2 alone when Group 4 is asked for; Group 3, Group 4 inside
|
||||
/// another filter, and JBIG2 otherwise are stored again like any other. With `Retain`, a resampled image keeps its kind of compression (CCITT, JBIG2, or
|
||||
/// Flate), and nothing else changes: an image of more bits stays as it is, for the grey or
|
||||
/// colour settings. A JBIG2 image stored again refers to no `/JBIG2Globals`.
|
||||
///
|
||||
/// The samples keep their meaning, so the colour space, decode array and `/Mask` stay, but for
|
||||
/// black-and-white RGB, which becomes grey with one decode pair, and an indexed image's default
|
||||
@@ -1020,10 +1111,14 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
|
||||
let fax = doc.fax_params(s);
|
||||
// Its kind of compression, which Retain keeps, behind any other filters.
|
||||
let ccitt = fax.is_some();
|
||||
// Group 4 alone is only worth decoding to resample it. Group 3, or Group 4 behind
|
||||
// ASCII85Decode, say, can be smaller stored again as Group 4 alone.
|
||||
let g4_alone = fax.is_some_and(|p| p.k < 0) && s.filters().len() == 1;
|
||||
if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !downsample {
|
||||
let jbig2 = matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _)));
|
||||
// Group 4 alone is only worth decoding to resample it, and so is JBIG2 alone when Group 4 is what
|
||||
// it would be stored as: that output is rarely smaller, and decoding an ordinary scan costs a
|
||||
// quarter of a second a page. Group 3, Group 4 behind ASCII85Decode, say, and JBIG2 stored as
|
||||
// JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone.
|
||||
let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4;
|
||||
let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0)));
|
||||
if only_to_resample && !downsample {
|
||||
return None;
|
||||
}
|
||||
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?;
|
||||
@@ -1038,8 +1133,8 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
|
||||
} else {
|
||||
(w, h)
|
||||
};
|
||||
let polarity = if defaults { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) };
|
||||
let averaged = if (nw, nh) != (w, h) && polarity.is_some() {
|
||||
let polarity = if defaults || (nw, nh) == (w, h) { None } else { dark_is_one(doc, packed.as_ref().unwrap_or(d)) };
|
||||
let averaged = if polarity.is_some() {
|
||||
// The dark sample of the image as it ends up: packed RGB is grey, with grey's decode pair.
|
||||
let (out, mixed, local_tone) = downsample_bits_checked(&data, w, h, nw, nh, polarity?)?;
|
||||
// A halftone or dither: most new pixels cover both samples. Text covers both only along
|
||||
@@ -1055,7 +1150,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
|
||||
Some(out) => (out, nw, nh),
|
||||
None => (data, w, h),
|
||||
};
|
||||
if (retain || (g4_alone && settings.compression == MonoCompression::CcittG4)) && !resampled {
|
||||
if only_to_resample && !resampled {
|
||||
return None;
|
||||
}
|
||||
let mut dict = packed.unwrap_or_else(|| d.clone());
|
||||
@@ -1078,7 +1173,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let jbig = if settings.compression == MonoCompression::Jbig2 {
|
||||
let jbig = if settings.compression == MonoCompression::Jbig2 || (retain && jbig2) {
|
||||
pdfcraft_filters::encode_jbig2(&data, nw, nh).ok().map(|coded| {
|
||||
let mut dict = dict.clone();
|
||||
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
@@ -1087,7 +1182,7 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let flate = ((resampled || packed_from_more) && !(retain && ccitt)).then(|| Stream::flate(dict, &data));
|
||||
let flate = ((resampled || packed_from_more) && !(retain && (ccitt || jbig2))).then(|| Stream::flate(dict, &data));
|
||||
// Smaller as stored, counting the dictionary: a tiny image can lose by Group 4's parameters.
|
||||
let best = [g4, jbig, flate].into_iter().flatten().filter_map(|c| Some((crate::stored_len(&c)?, c))).min_by_key(|(len, _)| *len)?;
|
||||
(best.0 < crate::stored_len(s)?).then_some((best.1, resampled))
|
||||
@@ -1101,11 +1196,13 @@ pub(crate) fn run(
|
||||
progress: &mut dyn FnMut(Stage) -> bool,
|
||||
) -> Result<(), OptimizeError> {
|
||||
let (ppi, mut defaults, read) = walk_pages(doc, pages);
|
||||
// Black-and-white RGB becomes grey, which default colour spaces would draw otherwise: one used
|
||||
// where the walk didn't look may be drawn so.
|
||||
// Black-and-white RGB becomes grey, which default colour spaces would draw otherwise, and an
|
||||
// indexed image's palette says which of its samples a resampled one keeps, which they may
|
||||
// change: one used where the walk didn't look may be drawn so.
|
||||
let packs = settings.mono.compression != MonoCompression::Retain;
|
||||
let rgb: HashSet<u32> = ppi.keys().filter(|r| packs && device_rgb(doc, **r)).map(|r| r.num).collect();
|
||||
defaults.extend(used_elsewhere(doc, &rgb, &read));
|
||||
let checked: HashSet<u32> =
|
||||
ppi.keys().filter(|r| (packs && device_rgb(doc, **r)) || (settings.mono.downsample && indexed(doc, **r))).map(|r| r.num).collect();
|
||||
defaults.extend(used_elsewhere(doc, &checked, &read));
|
||||
// Soft masks are processed with their image, not on their own.
|
||||
let masks: HashSet<ObjRef> = ppi
|
||||
.keys()
|
||||
@@ -1228,6 +1325,36 @@ mod dimension_tests {
|
||||
assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4]));
|
||||
}
|
||||
|
||||
/// JBIG2 alone, with Group 4 asked for, is decoded only to be resampled. A cost guard: dropped,
|
||||
/// no output changes, and an ordinary scan pays a quarter of a second a page for nothing. Behind
|
||||
/// another filter it can still be stored again, as Group 4 can.
|
||||
#[test]
|
||||
fn a_jbig2_image_alone_is_not_decoded_for_group_4_unless_it_is_resampled() {
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
|
||||
let hex: Vec<u8> = coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect();
|
||||
let image = |filter: Object, data: Vec<u8>| {
|
||||
let mut dict = Dict::new();
|
||||
dict.set(b"Width".to_vec(), Object::Int(16));
|
||||
dict.set(b"Height".to_vec(), Object::Int(2));
|
||||
dict.set(b"BitsPerComponent".to_vec(), Object::Int(1));
|
||||
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
dict.set(b"Filter".to_vec(), filter);
|
||||
Stream::from_raw(dict, data)
|
||||
};
|
||||
let settings = MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE };
|
||||
let decoded = |s: &Stream, settings: &MonoSettings| {
|
||||
DECODED_PIXELS.with(|c| c.set(0));
|
||||
mono(&Document::new_empty(), s, 300.0, false, settings);
|
||||
DECODED_PIXELS.with(|c| c.get())
|
||||
};
|
||||
let alone = image(Object::name("JBIG2Decode"), coded.clone());
|
||||
assert_eq!(decoded(&alone, &settings), 0, "alone, for Group 4, below the threshold");
|
||||
let behind = image(Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("JBIG2Decode")]), hex);
|
||||
assert!(decoded(&behind, &settings) > 0, "behind another filter it can be stored again");
|
||||
let jbig2 = MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE };
|
||||
assert!(decoded(&alone, &jbig2) > 0, "stored as JBIG2 its symbols may come out smaller");
|
||||
}
|
||||
|
||||
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
|
||||
#[test]
|
||||
fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() {
|
||||
@@ -1244,6 +1371,66 @@ mod dimension_tests {
|
||||
assert_eq!(DECODED_PIXELS.with(|c| c.get()), 0, "pixels decoded");
|
||||
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1).map(|p| p.len()), Some(1000 * 1000));
|
||||
}
|
||||
|
||||
/// Resampling goes through no more than one of the two sizes holds, whatever their proportions:
|
||||
/// the first three shapes asked for 160 GB and more on the way, and that aborted the process.
|
||||
/// The last two would ask for 10 GB and 30 GB if they went across first, which is why the way
|
||||
/// round depends on the shapes. A flat picture stays flat, and pictures of ordinary proportions
|
||||
/// resample as they always did.
|
||||
#[test]
|
||||
fn resampling_across_proportions_takes_the_memory_of_neither_product() {
|
||||
let shapes = [
|
||||
(100_000u32, 1u32, 1u32, 100_000u32, 1usize),
|
||||
(1_000_000, 1, 4, 100_000, 1),
|
||||
(100_000, 1, 1, 100_000, 3),
|
||||
(1, 100_000, 100_000, 1, 1),
|
||||
(1, 100_000, 100_000, 1, 3),
|
||||
];
|
||||
for (w, h, nw, nh, n) in shapes {
|
||||
let px = vec![0x60; (w * h) as usize * n];
|
||||
let out = resample(&px, n, w, h, nw, nh).unwrap();
|
||||
assert_eq!(out.len(), (nw * nh) as usize * n);
|
||||
assert!(out.iter().all(|v| v.abs_diff(0x60) <= 1), "{w} × {h} to {nw} × {nh}");
|
||||
}
|
||||
let (w, h, nw, nh) = (40u32, 30u32, 20u32, 15u32);
|
||||
let px: Vec<u8> = (0..w * h).map(|i| (i * 7 % 251) as u8).collect();
|
||||
let image = image::GrayImage::from_raw(w, h, px.clone()).unwrap();
|
||||
let direct = image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw();
|
||||
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct, "as it was");
|
||||
}
|
||||
|
||||
/// The long way round is for proportions far apart: up to a quarter over the larger picture the
|
||||
/// buffer is no danger, and that scales as it always did, to the byte. Noise, as a flat picture comes
|
||||
/// out the same either way.
|
||||
#[test]
|
||||
fn proportions_up_to_the_limit_resample_as_they_did() {
|
||||
let noise = |w: u32, h: u32| -> Vec<u8> {
|
||||
let mut seed = 0x2545_F491_4F6C_DD1Du64;
|
||||
(0..w * h)
|
||||
.map(|_| {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed as u8
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let direct = |px: &[u8], w, h, nw, nh| {
|
||||
let image = image::GrayImage::from_raw(w, h, px.to_vec()).unwrap();
|
||||
image::imageops::resize(&image, nw, nh, image::imageops::FilterType::CatmullRom).into_raw()
|
||||
};
|
||||
// Each exactly a quarter over the larger: the source's size, then the target's.
|
||||
for (w, h, nw, nh) in [(25, 16, 10, 20), (20, 5, 16, 25)] {
|
||||
let px = noise(w, h);
|
||||
assert_eq!(resample(&px, 1, w, h, nw, nh).unwrap(), direct(&px, w, h, nw, nh), "{w} × {h} to {nw} × {nh}");
|
||||
}
|
||||
// A step past it goes the long way, which is how to tell.
|
||||
let px = noise(25, 15);
|
||||
assert_ne!(resample(&px, 1, 25, 15, 10, 20).unwrap(), direct(&px, 25, 15, 10, 20));
|
||||
// A picture of the size asked for comes through as it is.
|
||||
let px = noise(20, 10);
|
||||
assert_eq!(resample(&px, 1, 20, 10, 20, 10).unwrap(), px);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1307,8 +1494,9 @@ mod downsample_tests {
|
||||
assert_eq!(downsample_bits(&[0b1100_0000], 3, 1, 2, 1, false).map(|(out, _)| out), Some(vec![0b1000_0000]));
|
||||
}
|
||||
|
||||
/// The dark sample: 0 in grey and of an image mask, 1 in a separation, each the other way round
|
||||
/// with an inverting decode array; 0 for an indexed image, whatever its decode array.
|
||||
/// The dark sample: 0 in grey and of an image mask, the other way round with an inverting decode
|
||||
/// array; none known for a separation, nor for an indexed image without a palette (see the next
|
||||
/// test), nor with a decode array that isn't two numbers.
|
||||
#[test]
|
||||
fn the_dark_sample_follows_the_colour_space_and_decode_array() {
|
||||
let doc = Document::new_empty();
|
||||
@@ -1332,6 +1520,7 @@ mod downsample_tests {
|
||||
(vec![mask, inverted.clone()], Some(true)),
|
||||
(vec![separation.clone()], None),
|
||||
(vec![separation, inverted.clone()], None),
|
||||
// A palette that isn't there says nothing.
|
||||
(vec![indexed.clone()], None),
|
||||
(vec![indexed, inverted], None),
|
||||
(vec![("ColorSpace", Object::name("DeviceGray")), ("Decode", Object::Array(vec![Object::name("x"), 0.into()]))], None),
|
||||
@@ -1340,6 +1529,54 @@ mod downsample_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An indexed image's dark sample is the one picking the darker palette entry, after its decode
|
||||
/// array: over DeviceGray and DeviceRGB (by luma, each weight checked), with the table a string
|
||||
/// or a stream, directly or by reference. Another base (CMYK, calibrated, an ICC profile, a
|
||||
/// resource name), two entries alike, an entry the table doesn't hold, and a `hival` past 255
|
||||
/// say nothing.
|
||||
#[test]
|
||||
fn an_indexed_images_palette_says_which_sample_is_dark() {
|
||||
let mut doc = Document::new_empty();
|
||||
let string = |bytes: &[u8]| Object::String(pdfcraft_cos::PdfString::literal(bytes.to_vec()));
|
||||
let referenced = doc.add(string(&[255, 0]));
|
||||
let stream = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &[0, 0, 0, 255, 255, 255])));
|
||||
let cal = |name: &str| Object::Array(vec![Object::name(name), Object::Dict(Dict::new())]);
|
||||
let indexed = |base: Object, hival: i64, table: Object| {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("Indexed"), base, hival.into(), table]));
|
||||
d
|
||||
};
|
||||
let inverted = |mut d: Dict| {
|
||||
d.set(b"Decode".to_vec(), Object::Array(vec![1.into(), 0.into()]));
|
||||
d
|
||||
};
|
||||
for (d, dark_one, what) in [
|
||||
(indexed(Object::name("DeviceGray"), 1, string(&[0, 255])), Some(false), "grey, black first"),
|
||||
(indexed(Object::name("DeviceGray"), 1, string(&[255, 0])), Some(true), "grey, white first"),
|
||||
(indexed(Object::name("DeviceGray"), 1, string(&[90, 89])), Some(true), "two greys a step apart"),
|
||||
(inverted(indexed(Object::name("DeviceGray"), 1, string(&[255, 0]))), Some(false), "white first, inverted"),
|
||||
(indexed(Object::name("DeviceGray"), 1, Object::Ref(referenced)), Some(true), "the table by reference"),
|
||||
(indexed(Object::name("DeviceRGB"), 1, Object::Ref(stream)), Some(false), "RGB in a stream"),
|
||||
(indexed(Object::name("DeviceRGB"), 1, string(&[255, 255, 0, 0, 0, 255])), Some(true), "yellow, then blue"),
|
||||
// Pure red (76,245) is lighter than pure blue (29,070), and darker than green (149,685).
|
||||
(indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 0, 255])), Some(true), "red, then blue"),
|
||||
(indexed(Object::name("DeviceRGB"), 1, string(&[255, 0, 0, 0, 255, 0])), Some(false), "red, then green"),
|
||||
(indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 0, 255, 0])), Some(false), "blue, then green"),
|
||||
// Blue (29,070) is lighter than grey 20 (20,000): the blue weight decides it.
|
||||
(indexed(Object::name("DeviceRGB"), 1, string(&[0, 0, 255, 20, 20, 20])), Some(true), "blue, then dark grey"),
|
||||
(indexed(Object::name("DeviceCMYK"), 1, string(&[0, 0, 0, 0, 0, 0, 0, 255])), None, "CMYK"),
|
||||
(indexed(cal("CalGray"), 1, string(&[200, 20])), None, "calibrated grey"),
|
||||
(indexed(cal("ICCBased"), 1, string(&[0, 255])), None, "an ICC profile"),
|
||||
(indexed(Object::name("CS0"), 1, string(&[0, 255])), None, "a resource name"),
|
||||
(indexed(Object::name("DeviceGray"), 1, string(&[7, 7])), None, "two entries alike"),
|
||||
(indexed(Object::name("DeviceGray"), 1, string(&[0])), None, "a table of one entry"),
|
||||
(indexed(Object::name("DeviceGray"), 0, string(&[0, 255])), None, "hival 0: both samples pick entry 0"),
|
||||
(indexed(Object::name("DeviceGray"), 256, string(&[0, 255])), None, "hival past 255"),
|
||||
] {
|
||||
assert_eq!(dark_is_one(&doc, &d), dark_one, "{what}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_it_cannot_resample_is_none() {
|
||||
assert_eq!(downsample_bits(&[0; 2], 4, 2, 5, 1, false).map(|(out, _)| out), None, "wider");
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
//! A new image replaces the old one only if it is smaller. The colour and grey settings leave
|
||||
//! alone images they can't decode faithfully (CMYK and other colour spaces, masks, decode arrays
|
||||
//! other than the default, JPEG 2000, JBIG2, CCITT, other than 8 bits, JPEGs whose Adobe marker
|
||||
//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks and CCITT fax,
|
||||
//! and `/ColorTransform` disagree). Black-and-white images (1-bit, image masks, CCITT fax and JBIG2,
|
||||
//! Group 3 among them; and eight-bit grey and RGB of only black and white, and indexed images
|
||||
//! that use only palette entries 0 and 1, packed to one bit) above a threshold are averaged down to a
|
||||
//! target resolution, halftones and dithers aside, and are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
|
||||
//! target resolution when grey, image masks or indexed, halftones and dithers aside (see the README), and
|
||||
//! are stored as CCITT Group 4 or lossless JBIG2, or 1-bit
|
||||
//! Flate once resampled or packed, when that is smaller.
|
||||
//! - **Discard objects:** page thumbnails, alternate images, document tags (structure tree),
|
||||
//! print settings.
|
||||
|
||||
@@ -2045,6 +2045,127 @@ fn rendered_text_becomes_jbig2_symbols() {
|
||||
assert!(render(&doc) == before, "the page looks the same");
|
||||
}
|
||||
|
||||
/// A JBIG2 scan goes through Monochrome Images like any black-and-white image. Above 450 ppi it
|
||||
/// is averaged as its Flate copy is, to the same samples, and stored as each compression asks:
|
||||
/// Group 4 or JBIG2, or Flate when that is smaller, or under Retain JBIG2 again; alone or behind another
|
||||
/// filter, and with `/JBIG2Globals`, which the new image doesn't refer to. Below 450 ppi it is
|
||||
/// stored again only when that is smaller: a generic region of text stays under Group 4, which
|
||||
/// is larger, and under Retain, and becomes symbols with JBIG2 asked for; behind ASCIIHex it is
|
||||
/// stored again alone, as Group 4 where the hex is spaced out. A JBIG2 page taller than the image
|
||||
/// is left alone.
|
||||
#[test]
|
||||
fn jbig2_scans_are_averaged_and_stored_again() {
|
||||
// Black squares 16 samples a side on even rows and columns: at 600 ppi, halving leaves no new
|
||||
// pixel covering both samples, so nothing keeps the picture at its resolution.
|
||||
let (w, h) = (1200u32, 480u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut squares = vec![0xFFu8; stride * h as usize];
|
||||
for (top, left) in (0..h as usize / 32).flat_map(|r| (0..w as usize / 32).map(move |c| (r * 32 + 8, c * 32 + 8))) {
|
||||
for y in top..top + 16 {
|
||||
for x in left..left + 16 {
|
||||
squares[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
let grey = [("BitsPerComponent", 1.into()), ("ColorSpace", Object::name("DeviceGray"))];
|
||||
let hex = |coded: &[u8]| -> Vec<u8> { coded.iter().flat_map(|b| format!("{b:02x}").into_bytes()).chain(*b">").collect() };
|
||||
// An image of `coded` behind `filters`, with globals a decoder skips when `globals`, drawn at
|
||||
// `width` × `height` pt.
|
||||
let jbig2_page = |coded: &[u8], filters: &[&str], globals: bool, (iw, ih): (u32, u32), (width, height): (f64, f64)| {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name("Image"));
|
||||
d.set(b"Width".to_vec(), Object::Int(iw.into()));
|
||||
d.set(b"Height".to_vec(), Object::Int(ih.into()));
|
||||
for (k, v) in &grey {
|
||||
d.set(k.as_bytes().to_vec(), v.clone());
|
||||
}
|
||||
d.set(b"Filter".to_vec(), Object::Array(filters.iter().map(|f| Object::name(f)).collect()));
|
||||
if globals {
|
||||
let g = doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1])));
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(g));
|
||||
let entries = filters.iter().map(|f| if *f == "JBIG2Decode" { Object::Dict(parms.clone()) } else { Object::Null }).collect();
|
||||
d.set(b"DecodeParms".to_vec(), Object::Array(entries));
|
||||
}
|
||||
let data = if filters.len() > 1 { hex(coded) } else { coded.to_vec() };
|
||||
let im = doc.add(Object::Stream(Stream::from_raw(d, data)));
|
||||
page(&mut doc, &[("Im", im)], &format!("q {width} 0 0 {height} 20 600 cm /Im Do Q"));
|
||||
(doc, im)
|
||||
};
|
||||
let coded = pdfcraft_filters::encode_jbig2_generic(&squares, w, h).unwrap();
|
||||
// 144 × 57.6 pt is 600 ppi.
|
||||
let at_600 = (144.0, 57.6);
|
||||
for compression in [MonoCompression::CcittG4, MonoCompression::Jbig2, MonoCompression::Retain] {
|
||||
let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() };
|
||||
let mut doc = Document::new_empty();
|
||||
let im = mono_image(&mut doc, w, h, &squares, None, &grey);
|
||||
page(&mut doc, &[("Im", im)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
|
||||
assert_eq!(optimize(&mut doc, &settings).unwrap().images_resampled, 1, "Flate, {compression:?}");
|
||||
let averaged = doc.decoded_image(&stream(&doc, im)).unwrap();
|
||||
for (filters, globals) in [(&["JBIG2Decode"][..], false), (&["ASCIIHexDecode", "JBIG2Decode"][..], false), (&["JBIG2Decode"][..], true)] {
|
||||
let (mut doc, im) = jbig2_page(&coded, filters, globals, (w, h), at_600);
|
||||
let r = optimize(&mut doc, &settings).unwrap();
|
||||
let s = stream(&doc, im);
|
||||
let what = format!("{filters:?}, globals {globals}, {compression:?}: {r:?}");
|
||||
assert_eq!((r.images_resampled, s.dict.int(b"Width"), s.dict.int(b"Height")), (1, Some(600), Some(240)), "{what}");
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), averaged, "{what}");
|
||||
assert!(!s.dict.contains(b"DecodeParms") || s.dict.name(b"Filter") == Some(&b"CCITTFaxDecode"[..]), "{what}");
|
||||
let filter = s.dict.name(b"Filter").map(<[u8]>::to_vec);
|
||||
// The smallest of what each compression may store; under Retain, JBIG2 again.
|
||||
let allowed: &[&[u8]] = match compression {
|
||||
MonoCompression::CcittG4 => &[b"CCITTFaxDecode", b"FlateDecode"],
|
||||
MonoCompression::Jbig2 => &[b"JBIG2Decode", b"FlateDecode"],
|
||||
MonoCompression::Retain => &[b"JBIG2Decode"],
|
||||
};
|
||||
assert!(filter.as_deref().is_some_and(|f| allowed.contains(&f)), "{what}: {:?}", filter.map(String::from_utf8));
|
||||
}
|
||||
}
|
||||
|
||||
// At 300 ppi, below 450: rendered text, whose letters repeat, coded as one generic region.
|
||||
let (tw, th) = (600u32, 480u32);
|
||||
let text = rendered_text(tw, th);
|
||||
let generic = pdfcraft_filters::encode_jbig2_generic(&text, tw, th).unwrap();
|
||||
let at_300 = (144.0, 115.2);
|
||||
for compression in [MonoCompression::CcittG4, MonoCompression::Retain] {
|
||||
let settings = Settings { mono: MonoSettings { compression, ..MonoSettings::REDUCE }, ..Settings::default() };
|
||||
let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300);
|
||||
let before = stream(&doc, im);
|
||||
optimize(&mut doc, &settings).unwrap();
|
||||
assert_eq!(stream(&doc, im), before, "JBIG2 below 450 ppi, {compression:?}");
|
||||
}
|
||||
let jbig2_asked = Settings { mono: MonoSettings { compression: MonoCompression::Jbig2, ..MonoSettings::REDUCE }, ..Settings::default() };
|
||||
let (mut doc, im) = jbig2_page(&generic, &["JBIG2Decode"], false, (tw, th), at_300);
|
||||
let r = optimize(&mut doc, &jbig2_asked).unwrap();
|
||||
let s = stream(&doc, im);
|
||||
assert_eq!((r.images_resampled, r.images_recompressed, s.dict.name(b"Filter")), (0, 1, Some(&b"JBIG2Decode"[..])), "{r:?}");
|
||||
assert!(s.raw.len() * 2 < generic.len(), "symbols {} bytes, the generic region {}", s.raw.len(), generic.len());
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), text);
|
||||
// Behind ASCIIHex it is stored again alone, being smaller so.
|
||||
let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
|
||||
optimize(&mut doc, &jbig2_asked).unwrap();
|
||||
let s = stream(&doc, im);
|
||||
assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"JBIG2Decode"[..])));
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), text);
|
||||
// So it is as Group 4 when that is smaller: here the hex digits are spaced out, as ASCIIHex
|
||||
// allows, so that the stream is many times the code.
|
||||
let (mut doc, im) = jbig2_page(&generic, &["ASCIIHexDecode", "JBIG2Decode"], false, (tw, th), at_300);
|
||||
let mut padded = stream(&doc, im);
|
||||
padded.raw = generic.iter().flat_map(|b| format!("{b:02x}{}", " ".repeat(30)).into_bytes()).chain(*b">").collect::<Vec<u8>>().into();
|
||||
doc.set(im, Object::Stream(padded));
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
let s = stream(&doc, im);
|
||||
assert_eq!((s.filters().len(), s.dict.name(b"Filter")), (1, Some(&b"CCITTFaxDecode"[..])), "Group 4 alone");
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), text);
|
||||
|
||||
// A JBIG2 page taller than the image: the renderer draws the whole page into it, so it isn't
|
||||
// read, and stays as it is.
|
||||
let (mut doc, im) = jbig2_page(&coded, &["JBIG2Decode"], false, (w, h - 8), at_600);
|
||||
let before = stream(&doc, im);
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, im), before, "a taller page");
|
||||
}
|
||||
|
||||
/// A whole 300 dpi letter page of rendered text, more marks than one JBIG2 text region of the
|
||||
/// decoder PdfCraft renders with takes, still looks the same as JBIG2 symbols.
|
||||
#[test]
|
||||
@@ -3394,10 +3515,12 @@ fn lzw_streams_that_cannot_be_read_or_are_not_asked_for_stay_unchanged() {
|
||||
assert_eq!(stream(&doc, c).decoded_strict().unwrap(), ops);
|
||||
}
|
||||
|
||||
/// Unknown palette polarity is never guessed. Both palette orders and Decode directions
|
||||
/// retain the original thin rule and exact samples through a full save and render.
|
||||
/// An indexed image's palette says which sample is dark, so a thin rule survives averaging as it does
|
||||
/// in grey: with the palette either way round, and the decode array either way, the averaged image
|
||||
/// holds the averaged DeviceGray one's samples (flipped where its samples are), and looks the same
|
||||
/// through a full save and render.
|
||||
#[test]
|
||||
fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
|
||||
fn indexed_thin_rules_survive_averaging_for_both_palette_orders_and_decodes() {
|
||||
let (w, h) = (1200u32, 480u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut gray_data = vec![0xFF; stride * h as usize];
|
||||
@@ -3409,6 +3532,7 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
|
||||
page(&mut gray, &[("Im", gray_image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
|
||||
let gray_render = render(&gray);
|
||||
optimize(&mut gray, &Settings::default()).unwrap();
|
||||
let gray_after = render(&Document::open(Arc::new(write_full(&gray, &SaveOptions::default()).unwrap())).unwrap());
|
||||
let gray_stream = stream(&gray, gray_image);
|
||||
let gray_samples = gray.decoded_image(&gray_stream).unwrap();
|
||||
assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240)));
|
||||
@@ -3440,14 +3564,153 @@ fn indexed_thin_rules_keep_their_samples_for_both_palette_orders_and_decodes() {
|
||||
assert_eq!(before, gray_render, "the palette image is visually identical to DeviceGray");
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
let s = stream(&doc, image);
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(w.into()), Some(h.into())));
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), data);
|
||||
assert_eq!((s.dict.int(b"Width"), s.dict.int(b"Height")), (Some(600), Some(240)), "white-first={white_first}, inverted={inverted}");
|
||||
// The rows are 600 samples, whole bytes: flipping each byte flips each sample.
|
||||
let expected: Vec<u8> = gray_samples.iter().map(|b| if white_first != inverted { !b } else { *b }).collect();
|
||||
assert_eq!(doc.decoded_image(&s).unwrap(), expected, "white-first={white_first}, inverted={inverted}: the rule is dark");
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(render(&saved), before, "palette white-first={white_first}, inverted={inverted}");
|
||||
assert!(render(&saved) == gray_after, "palette white-first={white_first}, inverted={inverted}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An indexed image also drawn where the optimizer doesn't read (an annotation's appearance, where
|
||||
/// other default colour spaces may be in force and change which palette entry is darker) keeps its
|
||||
/// resolution, as black-and-white RGB keeps its colour space; drawn only on the page, it is
|
||||
/// averaged. A grey image is averaged either way: sample 0 stays dark in a grey space, and only an
|
||||
/// indexed image's palette depends on what's in force where it is drawn.
|
||||
#[test]
|
||||
fn an_indexed_image_used_beyond_the_page_content_read_is_not_averaged() {
|
||||
let (w, h) = (1200u32, 480u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut data = vec![0xFF; stride * h as usize];
|
||||
for y in 0..h as usize {
|
||||
data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
|
||||
}
|
||||
let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255]));
|
||||
let indexed = Object::Array(vec![Object::name("Indexed"), Object::name("DeviceRGB"), 1.into(), palette]);
|
||||
let cases = [(&indexed, false, 600), (&indexed, true, 1200), (&Object::name("DeviceGray"), false, 600), (&Object::name("DeviceGray"), true, 600)];
|
||||
for (cs, in_annotation, width) in cases {
|
||||
let mut doc = Document::new_empty();
|
||||
let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs.clone())]);
|
||||
// 144 × 57.6 pt is 600 ppi.
|
||||
let p = page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
|
||||
if in_annotation {
|
||||
let resources = Object::Dict({
|
||||
let mut r = Dict::new();
|
||||
r.set(b"XObject".to_vec(), Object::Dict(xobject_dict(&[("Im", image)])));
|
||||
r
|
||||
});
|
||||
let appearance = drawing(&mut doc, Some(resources), &form_entries());
|
||||
annotate(&mut doc, p, appearance);
|
||||
}
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
assert_eq!(stream(&doc, image).dict.int(b"Width"), Some(width), "{cs:?}, in an annotation: {in_annotation}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Which palette entry is darker comes from the colours, weighted as luma, and blue is darker than
|
||||
/// red whichever comes first. An array shaped like an indexed colour space and named otherwise has
|
||||
/// no palette, so its samples aren't averaged.
|
||||
#[test]
|
||||
fn an_indexed_palette_is_read_by_the_luma_of_its_colours() {
|
||||
let (w, h) = (1200u32, 480u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut data = vec![0xFF; stride * h as usize];
|
||||
for y in 0..h as usize {
|
||||
data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
|
||||
}
|
||||
let palette = Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 255, 255, 0, 0]));
|
||||
let optimized = |space: &str| {
|
||||
let cs = Object::Array(vec![Object::name(space), Object::name("DeviceRGB"), 1.into(), palette.clone()]);
|
||||
let mut doc = Document::new_empty();
|
||||
let image = mono_image(&mut doc, w, h, &data, None, &[("BitsPerComponent", 1.into()), ("ColorSpace", cs)]);
|
||||
page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
let s = stream(&doc, image);
|
||||
(s.dict.int(b"Width"), doc.decoded_image(&s).unwrap())
|
||||
};
|
||||
let (width, samples) = optimized("Indexed");
|
||||
assert_eq!(width, Some(600));
|
||||
assert!(samples.iter().any(|b| *b != 0xFF), "the blue line, half a new pixel wide, stays as the dark sample");
|
||||
assert_eq!(optimized("NotIndexed").0, Some(1200));
|
||||
}
|
||||
|
||||
/// What is averaged, by how the image is spelled. A family or a base by reference, or a device base
|
||||
/// alone in an array, is the same colour space. `/I`, an inline abbreviation, and a real `hival`
|
||||
/// aren't one, and neither is a table too large to be a palette, which images share and which
|
||||
/// would be read for each. A colour key can hide the dark sample a tie goes to, so a thin rule of
|
||||
/// the other would be lost, in grey too; a stencil `/Mask` doesn't.
|
||||
#[test]
|
||||
fn an_indexed_image_is_averaged_only_in_a_spelling_it_can_read() {
|
||||
let (w, h) = (1200u32, 480u32);
|
||||
let stride = (w as usize).div_ceil(8);
|
||||
let mut data = vec![0xFF; stride * h as usize];
|
||||
for y in 0..h as usize {
|
||||
data[y * stride + 101 / 8] &= !(0x80 >> (101 % 8));
|
||||
}
|
||||
let table = || Object::String(pdfcraft_cos::PdfString::literal(vec![0, 0, 0, 255, 255, 255]));
|
||||
let indexed = |family: Object, base: Object, hival: Object, table: Object| Object::Array(vec![family, base, hival, table]);
|
||||
let rgb = || Object::name("DeviceRGB");
|
||||
let plain = || indexed(Object::name("Indexed"), rgb(), 1.into(), table());
|
||||
let width = |make: &dyn Fn(&mut Document) -> Vec<(&'static str, Object)>| {
|
||||
let mut doc = Document::new_empty();
|
||||
let mut entries = vec![("BitsPerComponent", Object::Int(1))];
|
||||
entries.extend(make(&mut doc));
|
||||
let image = mono_image(&mut doc, w, h, &data, None, &entries);
|
||||
page(&mut doc, &[("Im", image)], "q 144 0 0 57.6 20 600 cm /Im Do Q");
|
||||
optimize(&mut doc, &Settings::default()).unwrap();
|
||||
stream(&doc, image).dict.int(b"Width")
|
||||
};
|
||||
let averaged = Some(600);
|
||||
let kept = Some(1200);
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", plain())]), averaged, "direct");
|
||||
assert_eq!(
|
||||
width(&|doc: &mut Document| {
|
||||
let family = doc.add(Object::name("Indexed"));
|
||||
vec![("ColorSpace", indexed(Object::Ref(family), rgb(), 1.into(), table()))]
|
||||
}),
|
||||
averaged,
|
||||
"the family by reference"
|
||||
);
|
||||
let base = Object::Array(vec![rgb()]);
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), base.clone(), 1.into(), table()))]), averaged, "the base in an array");
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("I"), rgb(), 1.into(), table()))]), kept, "an inline abbreviation");
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), Object::Real(1.0), table()))]), kept, "a real hival");
|
||||
let long = Object::String(pdfcraft_cos::PdfString::literal([vec![0, 0, 0, 255, 255, 255], vec![0; 4097]].concat()));
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), long.clone()))]), kept, "a string past a palette");
|
||||
// 16 KiB of raw data is all a table may take, whatever it decodes to.
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Filter".to_vec(), Object::name("ASCIIHexDecode"));
|
||||
let table_of = |raw: usize| {
|
||||
let d = d.clone();
|
||||
let hex: Vec<u8> = b"000000FFFFFF".iter().copied().chain(std::iter::repeat_n(b' ', raw - 13)).chain(*b">").collect();
|
||||
move |doc: &mut Document| doc.add(Object::Stream(pdfcraft_cos::Stream::from_raw(d.clone(), hex.clone())))
|
||||
};
|
||||
for (raw, expected) in [(16384, averaged), (16385, kept)] {
|
||||
let add = table_of(raw);
|
||||
assert_eq!(
|
||||
width(&|doc: &mut Document| {
|
||||
let table = add(doc);
|
||||
vec![("ColorSpace", indexed(Object::name("Indexed"), rgb(), 1.into(), Object::Ref(table)))]
|
||||
}),
|
||||
expected,
|
||||
"a stream of {raw} bytes"
|
||||
);
|
||||
}
|
||||
let key = || Object::Array(vec![0.into(), 0.into()]);
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", plain()), ("Mask", key())]), kept, "a colour key, indexed");
|
||||
assert_eq!(width(&|_| vec![("ColorSpace", Object::name("DeviceGray")), ("Mask", key())]), kept, "a colour key, grey");
|
||||
assert_eq!(
|
||||
width(&|doc: &mut Document| {
|
||||
let key = doc.add(key());
|
||||
vec![("ColorSpace", plain()), ("Mask", Object::Ref(key))]
|
||||
}),
|
||||
kept,
|
||||
"a colour key by reference"
|
||||
);
|
||||
}
|
||||
|
||||
/// White margins must not hide a small screened photograph from the tone guard.
|
||||
#[test]
|
||||
fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margins() {
|
||||
|
||||
@@ -17,8 +17,9 @@ pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
pdfcraft-annot = { workspace = true }
|
||||
pdfcraft-xfa = { workspace = true }
|
||||
# CCITT Group 4 and JBIG2 encoding for cleared one-bit images.
|
||||
pdfcraft-filters = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pdfcraft-filters = { workspace = true }
|
||||
pdfcraft-testkit = { workspace = true }
|
||||
+15
-6
@@ -18,10 +18,19 @@ clear_marks(&mut doc, None)?; // remove marks without app
|
||||
more than a fifth of its size (or a point) both ways; zero-size text goes when its origin is
|
||||
inside;
|
||||
- **images**: fully covered → removed; partly covered → a copy with the covered pixels cleared
|
||||
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax as the last
|
||||
filter, decoded through `Document::decoded_image_within`; `/DecodeParms` given by reference,
|
||||
numbers inside included, count). Codecs redaction doesn't decode (DCT, JPX, JBIG2) are removed
|
||||
whole (fail-closed), and so is Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
|
||||
(8/16-bit and 1/2/4-bit images, image masks; Flate/LZW/RL/A85/AHx, and CCITT fax or JBIG2 as
|
||||
the last filter, decoded through `Document::decoded_image_within`, a JBIG2 image with its
|
||||
`/JBIG2Globals`; `/DecodeParms` given by reference, numbers inside included, count). The copy
|
||||
is Flate, or for a one-bit image of one component (a scan as a rule) CCITT Group 4, or JBIG2
|
||||
when it was JBIG2, whichever is smallest as stored, coded from the cleared samples; it refers to
|
||||
no globals. Globals hold the shapes of every image that shares them (a scan's pages, often),
|
||||
those of marks under the area included, so every other image that uses the globals of a removed
|
||||
or replaced image is decoded and stored again in place without them, as a copy is (Flate,
|
||||
Group 4 or JBIG2, whichever is smallest; `Report::images_restored`), and they leave the file on the full rewrite; one that can't be
|
||||
decoded for certain refuses the redaction (`RedactError::SharedJbig2Globals`). Codecs redaction doesn't decode (DCT, JPX) are
|
||||
removed whole (fail-closed), and so is a JBIG2 image whose page isn't its size (the renderer
|
||||
draws the whole page into the image, so the rows cleared wouldn't be the ones shown) or of more
|
||||
than one bit or component, and Group 3 on bytes (`/K` 0 or more, `/EncodedByteAlign`): PdfCraft's
|
||||
renderer draws some of its layouts (the fill ending each end-of-line code on a byte) as other
|
||||
rows than the decoder reads, so a mark over what is shown needn't cover what is cleared; inline images under an area are removed. Images with separate `/SMask`
|
||||
or `/Mask` data, and images whose data decodes to more than three times their declared raster
|
||||
@@ -103,5 +112,5 @@ Patterns are heuristic: dates, protocol identifiers and longer number runs are r
|
||||
`CodeSet::from_id("foia")?.overlay(&["(b)(6)"])` gives the overlay text for the picked codes.
|
||||
Custom code sets aren't supported yet.
|
||||
|
||||
Not yet: pattern locales, struct-tree/alt-text cleanup, metadata and hidden-information
|
||||
sanitising (M8.4), re-encoding of DCT images (they are removed instead).
|
||||
Not yet: pattern locales, struct-tree/alt-text cleanup, re-encoding of DCT images (they are removed
|
||||
instead).
|
||||
+92
-17
@@ -1,13 +1,14 @@
|
||||
//! Clearing the pixels of an image under redaction regions. The image is decoded, every pixel
|
||||
//! whose footprint intersects a region is set to zero (no ink for image masks), and the result is
|
||||
//! written as a new Flate image; the original object is untouched (other pages may use it).
|
||||
//! CCITT fax images decode through `Document::decoded_image_within`, but for Group 3 on bytes,
|
||||
//! which PdfCraft's renderer may draw as other rows. Images whose codec PdfCraft can't decode
|
||||
//! (DCT, JPX, JBIG2), and Group 3 on bytes, return `None`, and the caller removes the whole image
|
||||
//! instead; that is fail-closed.
|
||||
//! written as a new image, Flate, or for a one-bit image of one component Group 4, or JBIG2 when it
|
||||
//! was JBIG2, whichever is smallest; the original object is untouched (other pages may use it).
|
||||
//! CCITT fax and JBIG2 images decode through `Document::decoded_image_within`, but for Group 3 on
|
||||
//! bytes, which PdfCraft's renderer may draw as other rows, and a JBIG2 page of other rows than the
|
||||
//! image's. Images whose codec PdfCraft can't decode (DCT, JPX), and those, return `None`, and the
|
||||
//! caller removes the whole image instead; that is fail-closed.
|
||||
|
||||
use pdfcraft_content::{Matrix, overlaps};
|
||||
use pdfcraft_cos::{Document, Object, Stream};
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream};
|
||||
|
||||
/// Most pixels an image may have to be cleared in place (larger ones are removed).
|
||||
const MAX_PIXELS: u64 = 64 << 20;
|
||||
@@ -53,18 +54,20 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
b"FlateDecode" | b"Fl" | b"LZWDecode" | b"LZW" | b"ASCII85Decode" | b"A85" | b"ASCIIHexDecode" | b"AHx" | b"RunLengthDecode" | b"RL"
|
||||
)
|
||||
};
|
||||
// A CCITT fax image (its last filter) decodes through `Document::decoded_image_within`, after the
|
||||
// filters before it, so a scan stored as Group 4 is cleared like any other image.
|
||||
// A CCITT fax or JBIG2 image (its last filter) decodes through `Document::decoded_image_within`,
|
||||
// after the filters before it, so a scan stored as Group 4 or JBIG2 is cleared like any other
|
||||
// image.
|
||||
let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF"));
|
||||
let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice());
|
||||
let readable = |f: &Object| f.as_name().is_some_and(supported);
|
||||
let (ok, is_fax) = match s.dict.get(b"Filter") {
|
||||
None => (true, false),
|
||||
Some(f @ Object::Name(_)) => (readable(f) || fax(f), fax(f)),
|
||||
let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") {
|
||||
None => (true, false, false),
|
||||
Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)),
|
||||
Some(Object::Array(a)) => match a.split_last() {
|
||||
Some((last, before)) if fax(last) => (before.iter().all(readable), true),
|
||||
_ => (a.iter().all(readable), false),
|
||||
Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)),
|
||||
_ => (a.iter().all(readable), false, false),
|
||||
},
|
||||
_ => (false, false),
|
||||
_ => (false, false, false),
|
||||
};
|
||||
if !ok {
|
||||
return None;
|
||||
@@ -83,7 +86,8 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
|
||||
let (ncomp, bpc) = if mask { (1, 1) } else { (components(doc, s)?, s.dict.int(b"BitsPerComponent")? as usize) };
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
|
||||
// JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
|
||||
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) || (is_jbig2 && (ncomp, bpc) != (1, 1)) {
|
||||
return None;
|
||||
}
|
||||
// Every size comes from the file: multiply checked, so an overflowing product refuses the image
|
||||
@@ -105,7 +109,9 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?
|
||||
};
|
||||
// Through the document, which reads `/DecodeParms` given by reference (`Stream::decoded*`
|
||||
// alone would skip such a predictor, and the cleared bytes would then miss the covered pixels).
|
||||
// alone would skip such a predictor, and the cleared bytes would then miss the covered pixels),
|
||||
// and refuses a JBIG2 page of another size than the image: the renderer draws the whole page
|
||||
// into the image, so the rows cleared wouldn't be the ones a mark covers on screen.
|
||||
let mut data = doc.decoded_image_within(s, allowance).ok()?;
|
||||
if data.len() < bytes {
|
||||
return None;
|
||||
@@ -160,5 +166,74 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]
|
||||
dict.remove(b"Length");
|
||||
dict.remove(b"DecodeParms");
|
||||
dict.remove(b"Alternates");
|
||||
Some(Some(Stream::flate(dict, &data)))
|
||||
Some(Some(smallest(dict, &data, w, h, ncomp == 1 && bpc == 1, is_jbig2)))
|
||||
}
|
||||
|
||||
/// The cleared samples `data` of a `w` × `h` image as a stream with `dict`: Flate, or, for one bit
|
||||
/// of one component (`bilevel`, a scan as a rule, which Flate stores several times larger), CCITT
|
||||
/// Group 4, or JBIG2 when the image was JBIG2 (`jbig2`; JBIG2 is PDF 1.4, which such a file already
|
||||
/// is), whichever is smallest as stored, dictionary included (a tiny image can lose by Group 4's
|
||||
/// parameters). Both decode to `data`, cleared before it is coded, so nothing of the original's
|
||||
/// code is carried over; Group 4 or JBIG2 that can't code the rows (too wide) is left out.
|
||||
fn smallest(dict: Dict, data: &[u8], w: usize, h: usize, bilevel: bool, jbig2: bool) -> Stream {
|
||||
let flate = Stream::flate(dict.clone(), data);
|
||||
let Some((columns, rows)) = u32::try_from(w).ok().zip(u32::try_from(h).ok()).filter(|_| bilevel) else { return flate };
|
||||
let g4 = pdfcraft_filters::encode_g4(data, columns, rows).ok().map(|coded| {
|
||||
let mut dict = dict.clone();
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"K".to_vec(), Object::Int(-1));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(i64::from(columns)));
|
||||
parms.set(b"Rows".to_vec(), Object::Int(i64::from(rows)));
|
||||
dict.set(b"Filter".to_vec(), Object::name("CCITTFaxDecode"));
|
||||
dict.set(b"DecodeParms".to_vec(), Object::Dict(parms));
|
||||
Stream::from_raw(dict, coded)
|
||||
});
|
||||
let jbig = jbig2.then(|| pdfcraft_filters::encode_jbig2(data, columns, rows).ok()).flatten().map(|coded| {
|
||||
let mut dict = dict.clone();
|
||||
dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
|
||||
Stream::from_raw(dict, coded)
|
||||
});
|
||||
[g4, jbig].into_iter().flatten().fold(flate, |best, s| if stored_len(&s) < stored_len(&best) { s } else { best })
|
||||
}
|
||||
|
||||
/// The bytes stream `s` takes in a file: its data and its dictionary as written.
|
||||
fn stored_len(s: &Stream) -> usize {
|
||||
let mut dict = Vec::new();
|
||||
pdfcraft_cos::serialize(&Object::Dict(s.dict.clone()), &mut dict);
|
||||
dict.len().saturating_add(s.raw.len())
|
||||
}
|
||||
|
||||
/// JBIG2 image `s` stored again without its `/JBIG2Globals`: decoded through them, as it is drawn,
|
||||
/// and written as the same samples, Flate, Group 4 or JBIG2 coded alone, whichever is smallest
|
||||
/// (`smallest`), its other entries kept. `None` when it can't
|
||||
/// be decoded for certain (too large, data that doesn't decode, a JBIG2 page of another size than
|
||||
/// the image, or not a one-bit image of one component). Redaction does this to every image that
|
||||
/// shares a redacted image's globals, since their symbol dictionary holds the shapes of marks drawn
|
||||
/// under the area too.
|
||||
pub(crate) fn without_globals(doc: &Document, s: &Stream) -> Option<Stream> {
|
||||
let (w, h) = (s.dict.int(b"Width")?, s.dict.int(b"Height")?);
|
||||
if w <= 0 || h <= 0 || (w as u64) * (h as u64) > MAX_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (w as usize, h as usize);
|
||||
let mask = matches!(s.dict.get(b"ImageMask"), Some(Object::Bool(true)));
|
||||
// JBIG2 codes one-bit images of one component (ISO 32000-2 §7.4.7).
|
||||
if !mask && (components(doc, s)?, s.dict.int(b"BitsPerComponent")?) != (1, 1) {
|
||||
return None;
|
||||
}
|
||||
let bytes = w.div_ceil(8).checked_mul(h)?;
|
||||
// As for `clear`: the filters before the JBIG2 decoder may give up to three bytes for each one
|
||||
// of the raster and a byte a row.
|
||||
let allowance = bytes.checked_add(h)?.checked_mul(3)?.checked_add(4096)?;
|
||||
let mut data = doc.decoded_image_within(s, allowance).ok()?;
|
||||
if data.len() < bytes {
|
||||
return None;
|
||||
}
|
||||
data.truncate(bytes);
|
||||
let mut dict = s.dict.clone();
|
||||
dict.remove(b"Length");
|
||||
// The globals are named in the parameters; the copy is coded alone, as `clear`'s are.
|
||||
dict.remove(b"DecodeParms");
|
||||
dict.remove(b"Alternates");
|
||||
Some(smallest(dict, &data, w, h, true, true))
|
||||
}
|
||||
@@ -70,6 +70,12 @@ pub enum RedactError {
|
||||
/// could not be shown complete.
|
||||
#[error("the document's objects nest too deeply to remove hidden information completely; the operation was refused")]
|
||||
TooDeep,
|
||||
/// A JBIG2 image shares its `/JBIG2Globals` with a redacted one and can't be stored again
|
||||
/// without them, so the shapes they hold, those of marks under the area included, would stay.
|
||||
#[error(
|
||||
"{0} JBIG2 image(s) share their symbol data with a redacted image and can't be decoded to be stored without it, so that data, which can show what is under the marks, would stay in the file; nothing was redacted"
|
||||
)]
|
||||
SharedJbig2Globals(usize),
|
||||
#[error(transparent)]
|
||||
Edit(#[from] pdfcraft_edit::EditError),
|
||||
#[error(transparent)]
|
||||
@@ -132,6 +138,9 @@ pub struct Report {
|
||||
pub glyphs: usize,
|
||||
pub images_removed: usize,
|
||||
pub images_cleared: usize,
|
||||
/// JBIG2 images, anywhere in the document, stored again (as a cleared copy is) without the
|
||||
/// `/JBIG2Globals` they shared with a redacted image, so those globals leave the file.
|
||||
pub images_restored: usize,
|
||||
pub paths_removed: usize,
|
||||
pub paths_clipped: usize,
|
||||
pub forms_rewritten: usize,
|
||||
@@ -509,6 +518,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
// A removed image or form must not stay reachable through another resource dictionary (an
|
||||
// inherited or shared /Resources): a full save keeps whatever is still referenced.
|
||||
interp::unlink_doomed(doc, &doomed_xobjects, &retired_properties)?;
|
||||
report.images_restored = restore_shared_jbig2(doc, &doomed_xobjects)?;
|
||||
|
||||
// 5. Verify: nothing readable may remain under a region.
|
||||
let mut residue = 0;
|
||||
@@ -533,6 +543,69 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Most XObjects followed down from the removed and replaced ones to find their JBIG2 images.
|
||||
const MAX_JBIG2_WALK: usize = 100_000;
|
||||
|
||||
/// The `/JBIG2Globals` of a redacted JBIG2 image hold the symbol dictionary of every image that
|
||||
/// shares them (`jbig2enc -s` and most scanners give one to all the pages of a scan): the bitmaps
|
||||
/// of marks drawn only under the area too. The image itself is replaced or removed, but the globals
|
||||
/// would stay for as long as another image uses them. So every image that uses the globals of a
|
||||
/// removed or replaced image (`doomed`), or of one drawn in a removed or replaced form, is decoded
|
||||
/// and stored again in place without them, as a cleared copy is, and a full save then leaves them
|
||||
/// out. An image that can't be stored again refuses the redaction (nothing changes, as `apply`
|
||||
/// works on a copy), but for a removed or replaced one, which is either unlinked or still drawn
|
||||
/// whole on another page. Returns how many images that weren't removed or replaced were stored again.
|
||||
fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef]) -> Result<usize, RedactError> {
|
||||
let mut globals = HashSet::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut stack: Vec<ObjRef> = doomed.to_vec();
|
||||
while let Some(r) = stack.pop() {
|
||||
if !seen.insert(r) || seen.len() > MAX_JBIG2_WALK {
|
||||
continue;
|
||||
}
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
if let Some(g) = doc.jbig2_globals_ref(s) {
|
||||
globals.insert(g);
|
||||
}
|
||||
// A form's images, and its forms' (an image's /SMask is a stream of its own, found below).
|
||||
let resources = s.dict.get(b"Resources").map(|r| doc.resolve(r));
|
||||
let xobjects = resources.as_deref().and_then(Object::as_dict).and_then(|d| d.get(b"XObject")).map(|x| doc.resolve(x));
|
||||
if let Some(x) = xobjects.as_deref().and_then(Object::as_dict) {
|
||||
stack.extend(x.iter().filter_map(|(_, v)| v.as_ref()));
|
||||
}
|
||||
if let Some(m) = s.dict.get(b"SMask").and_then(Object::as_ref) {
|
||||
stack.push(m);
|
||||
}
|
||||
}
|
||||
if globals.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let sharing: Vec<ObjRef> = doc
|
||||
.scan_objects()
|
||||
.filter(|(_, o)| matches!(&**o, Object::Stream(s) if doc.jbig2_globals_ref(s).is_some_and(|g| globals.contains(&g))))
|
||||
.map(|(r, _)| r)
|
||||
.collect();
|
||||
let doomed: HashSet<ObjRef> = doomed.iter().copied().collect();
|
||||
let (mut restored, mut stuck) = (0, 0);
|
||||
for r in sharing {
|
||||
let object = doc.get(r);
|
||||
let Object::Stream(s) = &*object else { continue };
|
||||
match image::without_globals(doc, s) {
|
||||
Some(new) => {
|
||||
doc.set(r, Object::Stream(new));
|
||||
restored += usize::from(!doomed.contains(&r));
|
||||
}
|
||||
None if doomed.contains(&r) => {}
|
||||
None => stuck += 1,
|
||||
}
|
||||
}
|
||||
if stuck > 0 {
|
||||
return Err(RedactError::SharedJbig2Globals(stuck));
|
||||
}
|
||||
Ok(restored)
|
||||
}
|
||||
|
||||
/// What applying the marks collects across pages.
|
||||
struct Applying {
|
||||
report: Report,
|
||||
|
||||
+510
-3
@@ -519,13 +519,19 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
|
||||
])
|
||||
}
|
||||
|
||||
/// The samples of the image a page's (possibly inherited) resources name `name`.
|
||||
/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through
|
||||
/// its image codec too (`Document::decoded_image`).
|
||||
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
|
||||
named_stream(doc, page, name).and_then(|s| doc.decoded_image(&s).ok())
|
||||
}
|
||||
|
||||
/// The image a page's (possibly inherited) resources name `name`.
|
||||
fn named_stream(doc: &Document, page: usize, name: &[u8]) -> Option<Stream> {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(page);
|
||||
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
|
||||
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
|
||||
match &*doc.get(xo.get(name)?.as_ref()?) {
|
||||
Object::Stream(s) => s.decoded().ok(),
|
||||
Object::Stream(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -736,6 +742,506 @@ fn a_busy_fax_image_behind_another_filter_is_cleared() {
|
||||
assert_eq!((report.images_cleared, report.images_removed), (1, 0));
|
||||
}
|
||||
|
||||
/// After a save no stream holds the pixels under a mark, read as it would be shown, JBIG2 included,
|
||||
/// on a scan coded with symbols and on one coded as a generic region. `COVERED` and `KEPT` are pixels
|
||||
/// found nowhere else in the image, one lot under the mark and one beside it, so the search is seen
|
||||
/// to find what stays. The one letter over and over, lower down, is what makes symbols worth coding.
|
||||
/// The picture saved is the one with every pixel under the mark cleared and no other changed, so a
|
||||
/// search that one cleared byte would blind isn't all that holds this.
|
||||
#[test]
|
||||
fn no_stream_holds_the_pixels_under_a_mark_on_a_jbig2_scan() {
|
||||
const COVERED: [u8; 8] = [0x0F, 0x3C, 0x5A, 0xC3, 0x96, 0x69, 0xA5, 0x1E];
|
||||
const KEPT: [u8; 8] = [0x87, 0x2D, 0x4B, 0xE1, 0x78, 0xD2, 0xB4, 0x1F];
|
||||
const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"];
|
||||
let (w, h) = (512usize, 64usize);
|
||||
let stride = w / 8;
|
||||
let mut bits = vec![0xFFu8; stride * h];
|
||||
bits[stride * 2..stride * 2 + 8].copy_from_slice(&COVERED);
|
||||
bits[stride * 2 + 16..stride * 2 + 24].copy_from_slice(&KEPT);
|
||||
for top in (16..h - 8).step_by(10) {
|
||||
for x in (4..w - 8).step_by(8) {
|
||||
for (dy, row) in LETTER.iter().enumerate() {
|
||||
for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
|
||||
bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let holds = |doc: &Document, pixels: &[u8]| {
|
||||
doc.scan_objects().any(|(_, object)| match &*object {
|
||||
Object::Stream(s) => doc.decoded_image(s).is_ok_and(|d| d.windows(pixels.len()).any(|w| w == pixels)),
|
||||
_ => false,
|
||||
})
|
||||
};
|
||||
// Columns 0 to 99 of rows 0 to 7.
|
||||
let mut cleared = bits.clone();
|
||||
for row in cleared.chunks_mut(stride).take(8) {
|
||||
row[..12].fill(0);
|
||||
row[12] &= 0x0F;
|
||||
}
|
||||
let symbols = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
|
||||
let generic = pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap();
|
||||
assert!(symbols.len() < generic.len(), "symbol coding has to be what this tests");
|
||||
for coded in [symbols, generic] {
|
||||
let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode");
|
||||
// Half a point a pixel, its top row at y 132.
|
||||
let mut doc = one_page(b"q 256 0 0 32 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, coded)]);
|
||||
assert!(holds(&doc, &COVERED) && holds(&doc, &KEPT), "the pixels are there to be found");
|
||||
mark(&mut doc, 0, &[[0.25, 128.25, 49.75, 132.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1);
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert!(!holds(&saved, &COVERED), "the covered pixels stayed in the file");
|
||||
assert!(holds(&saved, &KEPT), "the pixels beside them stay");
|
||||
assert!(holds(&saved, &cleared), "every pixel under the mark is cleared, and no other changed");
|
||||
}
|
||||
}
|
||||
|
||||
/// `/JBIG2Globals` data a decoder skips: one extension segment (ISO/IEC 14492 7.4.14) of a type it
|
||||
/// needn't read, holding `secret`; its 11-byte header gives the length of the data after it.
|
||||
fn jbig2_globals(secret: &[u8]) -> Vec<u8> {
|
||||
let mut g = vec![0, 0, 0, 0, 0x3E, 0, 0];
|
||||
g.extend_from_slice(&u32::try_from(4 + secret.len()).unwrap().to_be_bytes());
|
||||
g.extend_from_slice(&[0, 0, 0, 1]);
|
||||
g.extend_from_slice(secret);
|
||||
g
|
||||
}
|
||||
|
||||
/// A JBIG2 image (a scan the optimizer stored as JBIG2) has its covered pixels cleared in a copy,
|
||||
/// like a fax image, instead of being removed whole: with `/JBIG2Globals`, its `/DecodeParms` by
|
||||
/// reference, behind another filter with its globals at its own place, or as a mask, its decode
|
||||
/// array inverting it or not.
|
||||
#[test]
|
||||
fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() {
|
||||
// 16 × 2, all 1 bits (white), or all 0 bits (a mask painting everywhere); the left half of
|
||||
// the top row is covered.
|
||||
let white = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
|
||||
let painted = pdfcraft_filters::encode_jbig2_generic(&[0x00; 4], 16, 2).unwrap();
|
||||
let hex: Vec<u8> = white.iter().map(|b| format!("{b:02x}")).collect::<String>().bytes().chain(*b">").collect();
|
||||
let globals = stream("", jbig2_globals(b"shared"));
|
||||
let image = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1";
|
||||
for (dict, data, extra, cleared) in [
|
||||
(format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode"), white.clone(), vec![], [0x00, 0xFF, 0xFF, 0xFF]),
|
||||
(
|
||||
format!("{image} /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms 8 0 R"),
|
||||
white.clone(),
|
||||
vec![b"<< /JBIG2Globals 9 0 R >>".to_vec(), globals.clone()],
|
||||
[0x00, 0xFF, 0xFF, 0xFF],
|
||||
),
|
||||
(
|
||||
format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"),
|
||||
hex,
|
||||
vec![globals],
|
||||
[0x00, 0xFF, 0xFF, 0xFF],
|
||||
),
|
||||
// A mask's 0 bits paint: cleared bits are set to 1, which paints nothing.
|
||||
(format!("{image} /ImageMask true /Filter /JBIG2Decode"), painted, vec![], [0xFF, 0x00, 0x00, 0x00]),
|
||||
// With /Decode [1 0] a mask's 1 bits paint, and cleared bits are 0.
|
||||
(format!("{image} /ImageMask true /Decode [1 0] /Filter /JBIG2Decode"), white, vec![], [0x00, 0xFF, 0xFF, 0xFF]),
|
||||
] {
|
||||
let mut objs = vec![stream(&dict, data)];
|
||||
objs.extend(extra);
|
||||
let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs);
|
||||
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
|
||||
let report = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{dict}");
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_image(&saved, 0, b"PCRedacted1").as_deref(), Some(&cleared[..]), "{dict}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A JBIG2 image is removed whole when its covered pixels can't be cleared for certain: a JBIG2
|
||||
/// page taller than the image (the renderer draws the whole page into the image, so the rows cleared
|
||||
/// wouldn't be the ones shown)
|
||||
/// or shorter, or of another width; globals that can't be read; data cut short; an image of more
|
||||
/// than one bit or component, which JBIG2 doesn't code, even when its rows take as many bytes; and
|
||||
/// a page far past the image's raster, which isn't decoded.
|
||||
#[test]
|
||||
fn a_jbig2_image_that_cannot_be_cleared_for_certain_is_removed_whole() {
|
||||
let white = |rows: u32| pdfcraft_filters::encode_jbig2_generic(&vec![0xFF; 2 * rows as usize], 16, rows).unwrap();
|
||||
// The page information (the first segment's data, after its 11-byte header) claiming 20,000
|
||||
// rows of 16.
|
||||
let mut tall = white(2);
|
||||
tall[15..19].copy_from_slice(&20_000u32.to_be_bytes());
|
||||
let image = |size: &str, more: &str| {
|
||||
format!("/Type /XObject /Subtype /Image {size} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode {more}")
|
||||
};
|
||||
let size = "/Width 16 /Height 2";
|
||||
for (dict, data, extra) in [
|
||||
(image(size, ""), white(3), vec![]),
|
||||
(image("/Width 16 /Height 3", ""), white(2), vec![]),
|
||||
(image("/Width 15 /Height 2", ""), white(2), vec![]),
|
||||
(image(size, "/DecodeParms << /JBIG2Globals 8 0 R >>"), white(2), vec![stream("", b"not JBIG2")]),
|
||||
(image(size, ""), white(2)[..20].to_vec(), vec![]),
|
||||
(image(size, "").replace("/BitsPerComponent 1", "/BitsPerComponent 8"), white(2), vec![]),
|
||||
(image(size, "").replace("/DeviceGray", "/DeviceRGB"), white(2), vec![]),
|
||||
// Two pixels of RGB at a bit take a byte a row, as JBIG2's rows do.
|
||||
(
|
||||
image("/Width 2 /Height 2", "").replace("/DeviceGray", "/DeviceRGB"),
|
||||
pdfcraft_filters::encode_jbig2_generic(&[0xFF; 2], 2, 2).unwrap(),
|
||||
vec![],
|
||||
),
|
||||
(image(size, ""), tall, vec![]),
|
||||
] {
|
||||
let mut objs = vec![stream(&dict, data)];
|
||||
objs.extend(extra);
|
||||
let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", objs);
|
||||
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
|
||||
let report = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((report.images_cleared, report.images_removed), (0, 1), "{dict}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A scan stored as JBIG2 symbols, with globals, keeps every pixel outside the area once it is
|
||||
/// redacted and saved, and the pixels under it are cleared; neither its JBIG2 code nor its globals,
|
||||
/// which no other image uses, stay in the file. The cleared copy is coded afresh as JBIG2, smaller
|
||||
/// than Flate, and refers to no globals.
|
||||
#[test]
|
||||
fn a_redacted_jbig2_scan_keeps_neither_its_code_nor_its_globals() {
|
||||
const LETTERS: [[&str; 7]; 3] = [
|
||||
["#####", "#....", "####.", "#....", "#....", "#....", "#####"],
|
||||
[".###.", "#...#", "#....", "#....", "#....", "#...#", ".###."],
|
||||
["#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"],
|
||||
];
|
||||
let (w, h) = (400usize, 40usize);
|
||||
let stride = w.div_ceil(8);
|
||||
let mut bits = vec![0xFFu8; stride * h];
|
||||
for (line, top) in [2usize, 12, 22, 32].into_iter().enumerate() {
|
||||
for (k, x) in (3..w - 6).step_by(7).enumerate() {
|
||||
for (dy, row) in LETTERS[(k + line) % 3].iter().enumerate() {
|
||||
for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
|
||||
bits[(top + dy) * stride + (x + dx) / 8] &= !(0x80 >> ((x + dx) % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let coded = pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap();
|
||||
let original = coded.clone();
|
||||
assert!(coded.len() < pdfcraft_filters::encode_jbig2_generic(&bits, w as u32, h as u32).unwrap().len(), "symbol coded");
|
||||
let dict = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 8 0 R >>"
|
||||
);
|
||||
// Half a point a pixel, its top row at y 120.
|
||||
let mut doc = one_page(
|
||||
b"q 200 0 0 20 0 100 cm /Im Do Q",
|
||||
"/XObject << /Im 7 0 R >>",
|
||||
vec![stream(&dict, coded), stream("", jbig2_globals(b"LEAK_GLOBALS"))],
|
||||
);
|
||||
// Columns 0 to 99 of rows 0 to 12.
|
||||
mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
|
||||
let report = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((report.images_cleared, report.images_removed), (1, 0));
|
||||
let mut cleared = bits.clone();
|
||||
for y in 0..=12 {
|
||||
for x in 0..=99 {
|
||||
cleared[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_image(&saved, 0, b"PCRedacted1"), Some(cleared));
|
||||
assert_no_secret(&doc, b"LEAK_GLOBALS");
|
||||
let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap();
|
||||
assert_eq!(copy.dict.name(b"Filter"), Some(&b"JBIG2Decode"[..]));
|
||||
assert!(!copy.dict.contains(b"DecodeParms"), "the copy refers to no globals");
|
||||
let flate = pdfcraft_filters::encode_flate(&named_image(&saved, 0, b"PCRedacted1").unwrap());
|
||||
assert!(copy.raw.len() < flate.len(), "JBIG2 {} bytes, Flate {}", copy.raw.len(), flate.len());
|
||||
for (r, object) in saved.scan_objects() {
|
||||
if let Object::Stream(s) = &*object {
|
||||
assert_ne!(&s.raw[..], &original[..], "the original JBIG2 code stays as {r:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `w` × `h` rows, `w` a multiple of 8: lines of word-sized dark blocks 14 rows tall, their edges
|
||||
/// ragged by a sample from row to row, as a scan's are; Group 4 codes such rows from the row above.
|
||||
fn ragged_scan(w: usize, h: usize) -> Vec<u8> {
|
||||
let stride = w / 8;
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 7;
|
||||
seed ^= seed << 17;
|
||||
seed
|
||||
};
|
||||
let mut bits = vec![0xFFu8; stride * h];
|
||||
for top in (3..h - 14).step_by(20) {
|
||||
let mut x = 4;
|
||||
while x + 70 < w {
|
||||
let len = 20 + (next() % 40) as usize;
|
||||
for y in top..top + 14 {
|
||||
let (left, right) = (x + (next() % 2) as usize, x + len - (next() % 2) as usize);
|
||||
for xx in left..right {
|
||||
bits[y * stride + xx / 8] &= !(0x80 >> (xx % 8));
|
||||
}
|
||||
}
|
||||
x += len + 6 + (next() % 6) as usize;
|
||||
}
|
||||
}
|
||||
bits
|
||||
}
|
||||
|
||||
/// A cleared one-bit image of one component, a scan as a rule, is stored as CCITT Group 4 when
|
||||
/// that is smaller than Flate, whatever it was stored as (Flate here), and never as JBIG2 unless it
|
||||
/// was JBIG2; an eight-bit image stays Flate. The copy decodes to the cleared samples.
|
||||
#[test]
|
||||
fn a_cleared_black_and_white_scan_is_stored_as_group_4() {
|
||||
let (w, h) = (800usize, 200usize);
|
||||
let stride = w / 8;
|
||||
let bits = ragged_scan(w, h);
|
||||
let g4 = pdfcraft_filters::encode_g4(&bits, w as u32, h as u32).unwrap();
|
||||
assert!(g4.len() < pdfcraft_filters::encode_flate(&bits).len(), "Group 4 is smaller than Flate here");
|
||||
let grey: Vec<u8> = (0..w * h).map(|i| (i % 251) as u8).collect();
|
||||
for (dict, data, filter) in [
|
||||
(
|
||||
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode"),
|
||||
pdfcraft_filters::encode_flate(&bits),
|
||||
&b"CCITTFaxDecode"[..],
|
||||
),
|
||||
(
|
||||
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode"),
|
||||
pdfcraft_filters::encode_flate(&grey),
|
||||
&b"FlateDecode"[..],
|
||||
),
|
||||
] {
|
||||
let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]);
|
||||
// The left quarter of the top half.
|
||||
mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{dict}");
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
let copy = named_stream(&saved, 0, b"PCRedacted1").unwrap();
|
||||
assert_eq!(copy.dict.name(b"Filter"), Some(filter), "{dict}");
|
||||
// Covered: rows 0 to 99 (y 150 down to 125, a quarter point a row), samples 0 to 199.
|
||||
let mut expected = if filter == b"FlateDecode" { grey.clone() } else { bits.clone() };
|
||||
for y in 0..100 {
|
||||
for x in 0..200 {
|
||||
if filter == b"FlateDecode" {
|
||||
expected[y * w + x] = 0;
|
||||
} else {
|
||||
expected[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap(), expected, "{dict}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A cleared one-bit image is stored as the smallest stream, its dictionary counted: a tiny one,
|
||||
/// whose Group 4 code is shorter than Flate's but whose `/DecodeParms` aren't, stays Flate. Rows
|
||||
/// wider than Group 4 codes (`MAX_G4_COLUMNS`) stay Flate too, and still clear.
|
||||
#[test]
|
||||
fn a_cleared_image_group_4_wouldnt_shrink_stays_flate() {
|
||||
let wide = pdfcraft_filters::MAX_G4_COLUMNS as usize + 1;
|
||||
for (w, h) in [(3usize, 2usize), (wide, 2)] {
|
||||
let stride = w.div_ceil(8);
|
||||
assert_eq!(pdfcraft_filters::encode_g4(&vec![0xFF; stride * h], w as u32, h as u32).is_ok(), w != wide, "{w} × {h}");
|
||||
let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /FlateDecode");
|
||||
let mut doc = one_page(
|
||||
b"q 200 0 0 20 0 100 cm /Im Do Q",
|
||||
"/XObject << /Im 7 0 R >>",
|
||||
vec![stream(&dict, pdfcraft_filters::encode_flate(&vec![0xFF; stride * h]))],
|
||||
);
|
||||
// The top row's left half.
|
||||
mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{w} × {h}");
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]), "{w} × {h}");
|
||||
let samples = named_image(&saved, 0, b"PCRedacted1").unwrap();
|
||||
let sample = |x: usize| samples.get(x / 8).is_some_and(|b| b & (0x80 >> (x % 8)) != 0);
|
||||
assert!(!sample(0) && sample(w - 1), "{w} × {h}: the top row's first sample is cleared and its last kept");
|
||||
assert!(samples.get(stride..).is_some_and(|row| row.iter().all(|b| *b == 0xFF)), "{w} × {h}: the bottom row is kept");
|
||||
}
|
||||
}
|
||||
|
||||
/// Only one bit of one component goes to Group 4 or JBIG2. An 8-bit grey image here starts as white
|
||||
/// as a bilevel scan and goes on as noise Flate can't shrink, so Group 4, which would read only the
|
||||
/// first rows' worth of bytes, would look far smaller while losing the picture.
|
||||
#[test]
|
||||
fn a_cleared_image_of_more_than_one_bit_stays_flate() {
|
||||
let (w, h) = (256usize, 256usize);
|
||||
let mut data = vec![0xFF; w * h];
|
||||
let mut state = 0x1234_5678u32;
|
||||
for b in data.iter_mut().skip(w * h / 8) {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 17;
|
||||
state ^= state << 5;
|
||||
*b = state.to_le_bytes()[0];
|
||||
}
|
||||
let dict = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode");
|
||||
let mut doc = one_page(b"q 200 0 0 20 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, pdfcraft_filters::encode_flate(&data))]);
|
||||
mark(&mut doc, 0, &[[0.0, 112.0, 99.0, 118.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1);
|
||||
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
|
||||
assert_eq!(named_image(&saved, 0, b"PCRedacted1").unwrap().get(h * w / 2..), data.get(h * w / 2..), "the rows below the mark are kept");
|
||||
}
|
||||
|
||||
/// A copy stored as Group 4 or JBIG2 is read like any image: a second redaction, across a save,
|
||||
/// clears it again rather than removing it whole, keeps what the first cleared, and stores it
|
||||
/// smaller than Flate again.
|
||||
#[test]
|
||||
fn a_cleared_copy_stored_as_group_4_or_jbig2_can_be_redacted_again() {
|
||||
let (w, h) = (800usize, 200usize);
|
||||
let stride = w / 8;
|
||||
let bits = ragged_scan(w, h);
|
||||
let image = format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray");
|
||||
for (filter, data, wanted) in [
|
||||
("/FlateDecode", pdfcraft_filters::encode_flate(&bits), &b"CCITTFaxDecode"[..]),
|
||||
("/JBIG2Decode", pdfcraft_filters::encode_jbig2(&bits, w as u32, h as u32).unwrap(), &b"JBIG2Decode"[..]),
|
||||
] {
|
||||
let dict = format!("{image} /Filter {filter}");
|
||||
let mut doc = one_page(b"q 200 0 0 50 0 100 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(&dict, data)]);
|
||||
mark(&mut doc, 0, &[[0.0, 125.0, 50.0, 150.0]], "");
|
||||
assert_eq!(apply(&mut doc, None).unwrap().images_cleared, 1, "{filter}");
|
||||
let mut saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
|
||||
assert_eq!(named_stream(&saved, 0, b"PCRedacted1").unwrap().dict.name(b"Filter"), Some(wanted), "{filter}");
|
||||
mark(&mut saved, 0, &[[150.0, 100.0, 200.0, 125.0]], "");
|
||||
let report = apply(&mut saved, None).unwrap();
|
||||
assert_eq!((report.images_cleared, report.images_removed), (1, 0), "{filter}: cleared again, not removed");
|
||||
let again = Document::open(Arc::new(write_full(&saved, &SaveOptions::default()).unwrap())).unwrap();
|
||||
// Rows 0 to 99 of the left quarter, then 100 to 199 of the right.
|
||||
let mut expected = bits.clone();
|
||||
for (rows, columns) in [(0..100, 0..200), (100..200, 600..800)] {
|
||||
for y in rows {
|
||||
for x in columns.clone() {
|
||||
expected[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
}
|
||||
let names: Vec<Vec<u8>> = (1..4).map(|n| format!("PCRedacted{n}").into_bytes()).collect();
|
||||
let stored = names.iter().find_map(|n| named_image(&again, 0, n));
|
||||
assert_eq!(stored, Some(expected), "{filter}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The segments of a JBIG2 stream `encode_jbig2` wrote, each with its header: a header is the
|
||||
/// segment's number (4 bytes), type, flags whose top three bits count the segments it refers to,
|
||||
/// those (a byte each), its page (a byte) and its data's length (4 bytes; ISO/IEC 14492 7.2).
|
||||
fn jbig2_segments(mut coded: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut segments = Vec::new();
|
||||
while !coded.is_empty() {
|
||||
let refers = usize::from(coded[5] >> 5);
|
||||
let header = 4 + 1 + 1 + refers + 1 + 4;
|
||||
let length = u32::from_be_bytes(coded[header - 4..header].try_into().unwrap()) as usize;
|
||||
segments.push(coded[..header + length].to_vec());
|
||||
coded = &coded[header + length..];
|
||||
}
|
||||
segments
|
||||
}
|
||||
|
||||
/// Two pages of a scan share one `/JBIG2Globals` stream holding the symbol dictionary, as
|
||||
/// `jbig2enc -s` and most scanners write them, and the first has a mark found nowhere else (a
|
||||
/// signature, say) under the area. The dictionary holds that mark's bitmap, so once the first page
|
||||
/// is redacted the second page's image is stored again without the globals, and neither the
|
||||
/// dictionary nor any stream naming globals stays in the saved file; both pages keep every pixel
|
||||
/// outside the area.
|
||||
#[test]
|
||||
fn globals_a_redacted_jbig2_image_shares_with_another_page_leave_the_file() {
|
||||
const LETTER: [&str; 7] = ["#####", "#....", "####.", "#....", "#....", "#....", "#####"];
|
||||
const SIGNATURE: [&str; 7] = ["#..#.##", ".##.#..", "#.#..##", "..##.#.", "##..#.#", ".#.##..", "#..#..#"];
|
||||
let (w, h) = (400usize, 40usize);
|
||||
let stride = w.div_ceil(8);
|
||||
let draw = |bits: &mut [u8], shape: &[&str], top: usize, left: usize| {
|
||||
for (dy, row) in shape.iter().enumerate() {
|
||||
for (dx, _) in row.bytes().enumerate().filter(|(_, c)| *c == b'#') {
|
||||
bits[(top + dy) * stride + (left + dx) / 8] &= !(0x80 >> ((left + dx) % 8));
|
||||
}
|
||||
}
|
||||
};
|
||||
// The letter over and over lower down, and the signature once, top left, under the mark.
|
||||
let mut first = vec![0xFFu8; stride * h];
|
||||
for top in [14usize, 24, 32] {
|
||||
for x in (3..w - 6).step_by(7) {
|
||||
draw(&mut first, &LETTER, top, x);
|
||||
}
|
||||
}
|
||||
draw(&mut first, &SIGNATURE, 2, 10);
|
||||
// The second page is a scan's ragged text, which Group 4 or JBIG2 stores smaller than Flate.
|
||||
let second = ragged_scan(w, h);
|
||||
// The first page's symbol dictionary (its second segment) goes to the globals, on no page; the
|
||||
// second page's segments are numbered past it.
|
||||
let mut segments = jbig2_segments(&pdfcraft_filters::encode_jbig2(&first, w as u32, h as u32).unwrap());
|
||||
assert_eq!(segments[1][4], 0, "the second segment is the symbol dictionary");
|
||||
let mut dictionary = segments.remove(1);
|
||||
dictionary[6] = 0;
|
||||
let page_one: Vec<u8> = segments.concat();
|
||||
let page_two: Vec<u8> = jbig2_segments(&pdfcraft_filters::encode_jbig2_generic(&second, w as u32, h as u32).unwrap())
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.flat_map(|(k, mut s)| {
|
||||
s[..4].copy_from_slice(&(100 + k as u32).to_be_bytes());
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
let image = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {w} /Height {h} /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode /DecodeParms << /JBIG2Globals 9 0 R >>"
|
||||
);
|
||||
// Half a point a pixel, the image's top row at y 120.
|
||||
let page = |content: u32, im: u32| {
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents {content} 0 R /Resources << /XObject << /Im {im} 0 R >> >> >>")
|
||||
.into_bytes()
|
||||
};
|
||||
let build = |second_image: &str| {
|
||||
pdf(vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>".to_vec(),
|
||||
page(5, 7),
|
||||
page(6, 8),
|
||||
stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()),
|
||||
stream("", b"q 200 0 0 20 0 100 cm /Im Do Q".as_slice()),
|
||||
stream(&image, page_one.clone()),
|
||||
stream(second_image, page_two.clone()),
|
||||
stream("", dictionary.clone()),
|
||||
])
|
||||
};
|
||||
let mut doc = build(&image);
|
||||
// A page's picture as it is drawn, JBIG2 read with its globals.
|
||||
let picture = |doc: &Document, page: usize, name: &[u8]| {
|
||||
let p = pdfcraft_model::pages(doc).swap_remove(page);
|
||||
let res = doc.resolve(p.dict.get(b"Resources")?).as_dict().cloned()?;
|
||||
let xo = doc.resolve(res.get(b"XObject")?).as_dict().cloned()?;
|
||||
match &*doc.get(xo.get(name)?.as_ref()?) {
|
||||
Object::Stream(s) => doc.decoded_image(s).ok(),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
assert_eq!(picture(&doc, 0, b"Im"), Some(first.clone()), "the first page reads its symbols from the globals");
|
||||
assert_eq!(picture(&doc, 1, b"Im"), Some(second.clone()));
|
||||
// Columns 0 to 99 of rows 0 to 12.
|
||||
mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
|
||||
let report = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((report.images_cleared, report.images_removed, report.images_restored), (1, 0, 1));
|
||||
let mut cleared = first.clone();
|
||||
for y in 0..=12 {
|
||||
for x in 0..=99 {
|
||||
cleared[y * stride + x / 8] &= !(0x80 >> (x % 8));
|
||||
}
|
||||
}
|
||||
let saved = reopen(&doc);
|
||||
assert_eq!(picture(&saved, 0, b"PCRedacted1"), Some(cleared));
|
||||
assert_eq!(picture(&saved, 1, b"Im"), Some(second.clone()), "the second page keeps its picture");
|
||||
// Stored again as a scan is: Group 4 or JBIG2 when smaller than Flate.
|
||||
let restored = named_stream(&saved, 1, b"Im").unwrap();
|
||||
let flate = pdfcraft_filters::encode_flate(&second);
|
||||
assert_ne!(restored.dict.name(b"Filter"), Some(&b"FlateDecode"[..]));
|
||||
assert!(restored.raw.len() < flate.len(), "{:?} {} bytes, Flate {}", restored.dict.name(b"Filter"), restored.raw.len(), flate.len());
|
||||
// The dictionary's data (after its 11-byte header) codes the signature's bitmap.
|
||||
let coded = &dictionary[11..];
|
||||
for (r, object) in saved.scan_objects() {
|
||||
let mut bytes = Vec::new();
|
||||
pdfcraft_cos::serialize(&object, &mut bytes);
|
||||
assert!(!bytes.windows(b"JBIG2Globals".len()).any(|w| w == b"JBIG2Globals"), "{r:?} still names globals");
|
||||
let data = match &*object {
|
||||
Object::Stream(s) => s.decoded().unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
assert!(!bytes.windows(coded.len()).chain(data.windows(coded.len())).any(|w| w == coded), "the symbol dictionary stays as {r:?}");
|
||||
}
|
||||
// The second page's image can't be read for certain (its JBIG2 page is shorter than the
|
||||
// image), so it can't be stored without the globals: the redaction is refused.
|
||||
let mut doc = build(&image.replace(&format!("/Height {h}"), &format!("/Height {}", h + 1)));
|
||||
mark(&mut doc, 0, &[[0.25, 113.75, 49.75, 120.0]], "");
|
||||
assert!(matches!(apply(&mut doc, None), Err(RedactError::SharedJbig2Globals(1))));
|
||||
}
|
||||
|
||||
/// Apply Redactions gives the page its own pruned /Resources, but the dictionary it drew through
|
||||
/// before (inherited from the /Pages node, or shared with another page) still named the removed
|
||||
/// image and form, so a full-rewrite save kept them.
|
||||
@@ -2377,7 +2883,8 @@ fn assert_no_secret(doc: &Document, secret: &[u8]) {
|
||||
pdfcraft_cos::serialize(&object, &mut bytes);
|
||||
assert!(!bytes.windows(secret.len()).any(|w| w == secret), "secret remains in object {r:?}");
|
||||
if let Object::Stream(s) = &*object {
|
||||
let data = s.decoded().unwrap();
|
||||
// Image codecs, a cleared scan coded as Group 4 or JBIG2, decode through the document.
|
||||
let data = s.decoded().or_else(|_| saved.decoded_image(s)).unwrap();
|
||||
assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ mod luminosity_group_space;
|
||||
mod named_form_properties;
|
||||
mod nonseparable_gray;
|
||||
mod open_type_cff_simple_font;
|
||||
mod rotated_image_snap;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod stray_q_in_a_form;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Contributor-original synthetic pages (#5530): a shrunk image on a page turned by `/Rotate` is
|
||||
//! drawn exactly as on the upright page, turned. The crop box and scale put every page edge on a
|
||||
//! whole device pixel, so a turn is a pure permutation of pixels and the turned render, turned
|
||||
//! back, must match the upright one. The image itself sits at fractional coordinates: an upright
|
||||
//! image there is snapped to whole pixels and shrunk once, and before the fix a turned one was
|
||||
//! not, so it was shrunk to a rounded-up size and resampled a second time at a fractional scale
|
||||
//! and offset (most of its pixels off, by up to 217 levels).
|
||||
//!
|
||||
//! Every kind of image that reaches the snap is drawn: an 8-bit and a 1-bit grey image, a stencil
|
||||
//! mask (a CCITT scan is often one), and images with a soft mask of their own size and of another
|
||||
//! size. The last needs no `/Interpolate`: a shrunk soft mask of another size is filtered, and so
|
||||
//! snapped, whatever the image asks (hayro patch 15). The other kinds without `/Interpolate` are
|
||||
//! not snapped upright or turned, and were already the same both ways; they stay in the test as a
|
||||
//! guard.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A PDF of the given objects (object 1 the catalog), with a cross-reference table.
|
||||
fn pdf(objects: &[Vec<u8>]) -> Vec<u8> {
|
||||
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 stream(dict: &str, data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(b"\nendstream");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Image size in samples.
|
||||
const W: usize = 400;
|
||||
const H: usize = 500;
|
||||
|
||||
/// A busy grey pattern with edges both ways, as a scan has.
|
||||
fn grey_samples() -> Vec<u8> {
|
||||
(0..H).flat_map(|y| (0..W).map(move |x| ((x * 7 + y * 13) % 256) as u8 ^ if (x / 3 + y / 5) % 2 == 0 { 0 } else { 0x80 })).collect()
|
||||
}
|
||||
|
||||
/// One bit a sample, rows padded to whole bytes: a text-like pattern of thin strokes.
|
||||
fn bit_samples() -> Vec<u8> {
|
||||
let row = W.div_ceil(8);
|
||||
let mut out = vec![0u8; row * H];
|
||||
for y in 0..H {
|
||||
for x in 0..W {
|
||||
if (x / 2 + y / 3) % 3 == 0 || (x * 5 + y * 3) % 11 < 3 {
|
||||
out[y * row + x / 8] |= 0x80 >> (x % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn alpha_samples(w: usize, h: usize) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| ((x * 3 + y * 5) % 256) as u8)).collect()
|
||||
}
|
||||
|
||||
/// The kinds of image drawn.
|
||||
const KINDS: [&str; 5] = ["8-bit grey", "1-bit grey", "stencil mask", "soft mask", "smaller soft mask"];
|
||||
|
||||
/// The image of `kind` as objects 5 (and 6, its soft mask).
|
||||
fn image(kind: &str, interpolate: bool) -> Vec<Vec<u8>> {
|
||||
let image = |depth: u8, extra: &str, data: &[u8]| {
|
||||
let dict = format!(
|
||||
"/Type /XObject /Subtype /Image /Width {W} /Height {H} /ColorSpace /DeviceGray /BitsPerComponent {depth} /Interpolate {interpolate}{extra}"
|
||||
);
|
||||
stream(&dict, data)
|
||||
};
|
||||
let mask = |w: usize, h: usize| {
|
||||
let dict =
|
||||
format!("/Type /XObject /Subtype /Image /Width {w} /Height {h} /ColorSpace /DeviceGray /BitsPerComponent 8 /Interpolate {interpolate}");
|
||||
stream(&dict, &alpha_samples(w, h))
|
||||
};
|
||||
match kind {
|
||||
"8-bit grey" => vec![image(8, "", &grey_samples())],
|
||||
"1-bit grey" => vec![image(1, "", &bit_samples())],
|
||||
"stencil mask" => vec![stream(
|
||||
&format!("/Type /XObject /Subtype /Image /Width {W} /Height {H} /ImageMask true /BitsPerComponent 1 /Interpolate {interpolate}"),
|
||||
&bit_samples(),
|
||||
)],
|
||||
"soft mask" => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(W, H)],
|
||||
_ => vec![image(8, " /SMask 6 0 R", &grey_samples()), mask(300, 350)],
|
||||
}
|
||||
}
|
||||
|
||||
/// A page whose crop box sits inside a larger media box, as in the report, with the image drawn
|
||||
/// at fractional coordinates inside it, in a colour (a stencil mask paints it).
|
||||
fn page(rotate: u32, kind: &str, interpolate: bool) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 794 1224] /CropBox [114 510 680 1210] /Rotate {rotate} /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> >>"
|
||||
)
|
||||
.into_bytes(),
|
||||
stream("", b"0.1 0.2 0.6 rg q 500.3 0 0 600.7 150.15 560.45 cm /Im Do Q"),
|
||||
];
|
||||
objects.extend(image(kind, interpolate));
|
||||
pdf(&objects)
|
||||
}
|
||||
|
||||
/// At half a pixel per point every crop-box edge, upright or turned, is a whole device pixel,
|
||||
/// and the 400 × 500 image is shrunk to about 250 × 300 pixels.
|
||||
fn render(rotate: u32, kind: &str, interpolate: bool) -> RenderedPage {
|
||||
let mut renderer = PageRenderer::new(Arc::new(page(rotate, kind, interpolate)), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 0.5, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{kind}: {:?}", out.error);
|
||||
out
|
||||
}
|
||||
|
||||
fn rgb(out: &RenderedPage, x: u32, y: u32) -> [u8; 3] {
|
||||
let i = ((y * out.width + x) * 4) as usize;
|
||||
[out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]]
|
||||
}
|
||||
|
||||
/// The largest channel difference between the turned render and the upright one turned the same
|
||||
/// way.
|
||||
fn turned_difference(rotate: u32, kind: &str, interpolate: bool) -> u8 {
|
||||
let upright = render(0, kind, interpolate);
|
||||
let turned = render(rotate, kind, interpolate);
|
||||
let (w, h) = (upright.width, upright.height);
|
||||
// `/Rotate` turns the page clockwise.
|
||||
let expected = |x: u32, y: u32| match rotate {
|
||||
90 => rgb(&upright, y, h - 1 - x),
|
||||
180 => rgb(&upright, w - 1 - x, h - 1 - y),
|
||||
_ => rgb(&upright, w - 1 - y, x),
|
||||
};
|
||||
let (tw, th) = if rotate == 180 { (w, h) } else { (h, w) };
|
||||
assert_eq!((turned.width, turned.height), (tw, th), "{kind}, /Rotate {rotate}");
|
||||
let mut worst = 0;
|
||||
for y in 0..th {
|
||||
for x in 0..tw {
|
||||
let (got, want) = (rgb(&turned, x, y), expected(x, y));
|
||||
worst = (0..3).map(|c| got[c].abs_diff(want[c])).fold(worst, u8::max);
|
||||
}
|
||||
}
|
||||
worst
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shrunk_image_on_a_turned_page_is_the_upright_render_turned() {
|
||||
for kind in KINDS {
|
||||
for interpolate in [true, false] {
|
||||
for rotate in [90, 180, 270] {
|
||||
let worst = turned_difference(rotate, kind, interpolate);
|
||||
assert!(worst <= 1, "{kind}, /Rotate {rotate}, /Interpolate {interpolate}: off by up to {worst} levels");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-1
@@ -465,7 +465,12 @@ pub fn oid(dotted: &str) -> Vec<u8> {
|
||||
/// Encode a dotted OID supplied by a caller. Unlike [`oid`], this validates every arc and
|
||||
/// never panics on malformed input.
|
||||
pub fn try_oid(dotted: &str) -> Result<Vec<u8>, SignError> {
|
||||
let mut arcs = dotted.split('.').map(|a| a.parse::<u64>().map_err(|_| bad("invalid OID arc")));
|
||||
let mut arcs = dotted.split('.').map(|a| {
|
||||
if a.is_empty() || !a.bytes().all(|c| c.is_ascii_digit()) {
|
||||
return Err(bad("invalid OID arc"));
|
||||
}
|
||||
a.parse::<u64>().map_err(|_| bad("invalid OID arc"))
|
||||
});
|
||||
let first = arcs.next().ok_or_else(|| bad("empty OID"))??;
|
||||
let second = arcs.next().ok_or_else(|| bad("OID needs two arcs"))??;
|
||||
if first > 2 || (first < 2 && second >= 40) {
|
||||
@@ -549,6 +554,14 @@ pub fn algorithm(oid_s: &str, params: Option<&[u8]>) -> Vec<u8> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn try_oid_rejects_signs_and_empty_arcs() {
|
||||
for bad in ["+1.2.840", "1.+2", "1.2.-3", "1..2"] {
|
||||
assert!(try_oid(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
assert_eq!(try_oid("1.2.840.113549").unwrap(), oid("1.2.840.113549"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let o = oid("1.2.840.113549.1.7.2");
|
||||
|
||||
@@ -20,6 +20,7 @@ craft-quit = { workspace = true, features = ["egui"] }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
pdfcraft-engine = { workspace = true }
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-render = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-platform = { workspace = true }
|
||||
@@ -96,6 +97,7 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
# Glyph-coverage test of the interface fonts; the same skrifa pdfcraft-fonts already uses.
|
||||
skrifa = "0.44"
|
||||
proptest = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -73,6 +73,7 @@ impl PdfCraftApp {
|
||||
default_mode: Mode::AllTools,
|
||||
view_defaults: Default::default(),
|
||||
units: Default::default(),
|
||||
blank_paper: crate::paper::Paper::system(),
|
||||
date_format_draft: None,
|
||||
mode_override: None,
|
||||
left: LeftPanel::AllTools,
|
||||
@@ -87,6 +88,7 @@ impl PdfCraftApp {
|
||||
space_hand: None,
|
||||
full_screen_tool: None,
|
||||
comment_prefs: Default::default(),
|
||||
hotbar: Default::default(),
|
||||
theme: ThemeKind::Light,
|
||||
// A fresh install follows the desktop's appearance (#1248); saved settings override.
|
||||
theme_preference: ThemePreference::System,
|
||||
@@ -118,6 +120,7 @@ impl PdfCraftApp {
|
||||
all_tools_query: String::new(),
|
||||
all_tools_tab: (None, false),
|
||||
recent: Vec::new(),
|
||||
recent_limit: crate::RECENT_FILES_DEFAULT,
|
||||
reopen_last_session: false,
|
||||
restore_last_view: true,
|
||||
last_views: Vec::new(),
|
||||
@@ -141,6 +144,9 @@ impl PdfCraftApp {
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
open_prepare_gate: None,
|
||||
pinned: Default::default(),
|
||||
recent_check: Default::default(),
|
||||
pinned_tools: Vec::new(),
|
||||
pin_tool_search: String::new(),
|
||||
toast: None,
|
||||
toast_script: None,
|
||||
script_messages: std::collections::VecDeque::new(),
|
||||
@@ -228,7 +234,6 @@ impl PdfCraftApp {
|
||||
last_copied_file: None,
|
||||
system_clipboard: true,
|
||||
clipboard_override: None,
|
||||
v_pressed: false,
|
||||
attach_override: None,
|
||||
duplicate_draft: None,
|
||||
page_props_draft: None,
|
||||
@@ -358,6 +363,46 @@ impl PdfCraftApp {
|
||||
pub fn render_pending(&self) -> bool {
|
||||
self.views.iter().any(|v| v.render_pending())
|
||||
}
|
||||
|
||||
/// Check whether a tool group is pinned to Home.
|
||||
pub fn is_tool_pinned(&self, id: &str) -> bool {
|
||||
self.pinned_tools.iter().any(|t| t == id)
|
||||
}
|
||||
|
||||
/// Pin a tool group to Home. Returns true if newly pinned.
|
||||
pub fn pin_tool(&mut self, id: &str) -> bool {
|
||||
if pdfcraft_engine::catalog::group(id).is_none() {
|
||||
return false;
|
||||
}
|
||||
if !self.is_tool_pinned(id) {
|
||||
self.pinned_tools.push(id.to_string());
|
||||
if let Some(g) = pdfcraft_engine::catalog::group(id) {
|
||||
self.notify_fmt("Pinned {tool} to Home", &[("tool", tl!(g.label))]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Unpin a tool group from Home.
|
||||
pub fn unpin_tool(&mut self, id: &str) {
|
||||
if let Some(pos) = self.pinned_tools.iter().position(|t| t == id) {
|
||||
self.pinned_tools.remove(pos);
|
||||
if let Some(g) = pdfcraft_engine::catalog::group(id) {
|
||||
self.notify_fmt("Unpinned {tool} from Home", &[("tool", tl!(g.label))]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle a tool group's pinned state on Home. Returns whether it is now pinned.
|
||||
pub fn toggle_pin_tool(&mut self, id: &str) -> bool {
|
||||
if self.is_tool_pinned(id) {
|
||||
self.unpin_tool(id);
|
||||
false
|
||||
} else {
|
||||
self.pin_tool(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for PdfCraftApp {
|
||||
@@ -367,6 +412,10 @@ impl eframe::App for PdfCraftApp {
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// The button's release a window move or resize kept from egui.
|
||||
window_controls::release_after_desktop_grab(ctx, raw);
|
||||
// The wheel after Alt+Tab back, before the mouse has moved (#1518).
|
||||
let known = ctx.input(|i| i.pointer.latest_pos().is_some());
|
||||
let size = ctx.input(|i| i.viewport().inner_rect).map_or(egui::Vec2::ZERO, |r| r.size());
|
||||
drag_pointer::locate_wheel(raw, known, size, || drag_pointer::in_window(ctx));
|
||||
self.prepare_raw_input(ctx, raw);
|
||||
if raw.hovered_files.is_empty() && raw.dropped_files.is_empty() {
|
||||
return;
|
||||
@@ -404,6 +453,7 @@ impl eframe::App for PdfCraftApp {
|
||||
}
|
||||
self.poll_pending_opens();
|
||||
}
|
||||
self.check_restored_recent();
|
||||
// Chinese wants its own faces before the Japanese ones (one baseline per line).
|
||||
let cjk = theme::CjkPreference::for_language(lang.code());
|
||||
if !self.styled {
|
||||
|
||||
@@ -10,6 +10,11 @@ pub(crate) fn print_refused() -> &'static str {
|
||||
tl!("The document's security settings don't allow printing")
|
||||
}
|
||||
|
||||
enum ClipboardPaste {
|
||||
Text,
|
||||
Image(std::sync::Arc<egui::ColorImage>),
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Whether text and images may be copied out of document `id`; says why not when they may
|
||||
/// not. Reading aloud and accessibility tools don't ask (assistive technology may read
|
||||
@@ -22,14 +27,83 @@ impl PdfCraftApp {
|
||||
false
|
||||
}
|
||||
|
||||
/// ⌘C copies the selected text, unless a text field has the keyboard (it copies its own).
|
||||
pub(super) fn copy_key(&mut self, i: usize, ctx: &egui::Context) {
|
||||
// ⌘C arrives as a Copy event on most platforms.
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)));
|
||||
if !copy || ctx.egui_wants_keyboard_input() {
|
||||
/// ⌘C / ⌘X / ⌘V copy, cut and paste text or images on canvas (unless a text field has focus).
|
||||
pub(super) fn clipboard_keys(&mut self, i: usize, ctx: &egui::Context) {
|
||||
use egui::{Key, KeyboardShortcut, Modifiers};
|
||||
let pastes: Vec<_> = ctx.input(|input| {
|
||||
input
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
egui::Event::Paste(_) => Some(ClipboardPaste::Text),
|
||||
egui::Event::PasteImage(image) => Some(ClipboardPaste::Image(image.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let free = self.dialog.is_none() && !self.palette_open && !ctx.egui_wants_keyboard_input() && !self.is_preparing();
|
||||
if !free || self.views.get(i).is_some_and(|v| v.forms.focus.is_some() || v.organize) {
|
||||
self.receive_pastes(i, pastes, false);
|
||||
return;
|
||||
}
|
||||
self.copy_selected_text(i, ctx);
|
||||
let copy = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Copy)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::C)));
|
||||
let cut = ctx.input(|input| input.events.iter().any(|e| matches!(e, egui::Event::Cut)))
|
||||
|| ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::X)));
|
||||
let paste = ctx.input_mut(|input| input.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::V)));
|
||||
|
||||
if copy
|
||||
&& !self.copy_selected_text(i, ctx)
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.copy_page_image(sel.page, sel.index);
|
||||
}
|
||||
if cut
|
||||
&& let Some(sel) = self.views.get(i).and_then(|v| v.image_selection.clone())
|
||||
&& let Some(id) = self.views.get(i).map(|v| v.id)
|
||||
&& self.may_copy(id)
|
||||
{
|
||||
self.cut_page_image(sel.page, sel.index);
|
||||
}
|
||||
if !pastes.is_empty() {
|
||||
self.receive_pastes(i, pastes, true);
|
||||
return;
|
||||
}
|
||||
if paste {
|
||||
let has_override_image = matches!(self.clipboard_override, Some(crate::create_ui::Clip::Image { .. }));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let system_has_text = if !has_override_image && self.system_clipboard { crate::os_clipboard::has_text() } else { false };
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let system_has_text = false;
|
||||
|
||||
if !has_override_image && system_has_text {
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
return;
|
||||
}
|
||||
|
||||
let page = self.views.get(i).map_or(0, |v| v.current);
|
||||
self.paste_image(page, None);
|
||||
}
|
||||
}
|
||||
|
||||
fn receive_pastes(&mut self, view: usize, pastes: Vec<ClipboardPaste>, images_allowed: bool) {
|
||||
let page = self.views.get(view).map_or(0, |view| view.current);
|
||||
for paste in pastes {
|
||||
match paste {
|
||||
ClipboardPaste::Text => {
|
||||
// Text fields may own the paste; the canvas cache still observes its contents.
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = None;
|
||||
}
|
||||
ClipboardPaste::Image(image) if images_allowed => {
|
||||
self.paste_received_image(page, &image);
|
||||
}
|
||||
ClipboardPaste::Image(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared permission gate for copying selected page text from menus and keyboard events.
|
||||
@@ -79,3 +153,222 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
fn delivered() -> egui::Event {
|
||||
egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)))
|
||||
}
|
||||
|
||||
fn key(pressed: bool, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
fn image_app() -> PdfCraftApp {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.clipboard_override = Some(crate::create_ui::Clip::Image { width: 1, height: 1, rgba: vec![255, 0, 0, 255] });
|
||||
app
|
||||
}
|
||||
|
||||
fn frame(app: &mut PdfCraftApp, ctx: &egui::Context, time: f64, events: Vec<egui::Event>) -> usize {
|
||||
ctx.run_ui(egui::RawInput { time: Some(time), events, ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
app.session.get(app.views[0].id).unwrap().page_images(0).len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_pastes_for_either_modifier_release_order() {
|
||||
for command_first in [true, false] {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND)]);
|
||||
let releases = if command_first {
|
||||
vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]
|
||||
} else {
|
||||
vec![key(false, egui::Modifiers::COMMAND), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]
|
||||
};
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, releases), 0, "key release is not a delivered paste");
|
||||
assert_eq!(frame(&mut app, &ctx, 1.2, vec![delivered()]), 1, "asynchronous pixels identify the actual paste");
|
||||
assert_eq!(frame(&mut app, &ctx, 1.25, vec![key(false, egui::Modifiers::NONE)]), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_history_after_a_completed_paste_does_not_paste_on_open() {
|
||||
for explicit_press in [false, true] {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
let mut first = vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), delivered()];
|
||||
if explicit_press {
|
||||
first.push(key(true, egui::Modifiers::COMMAND));
|
||||
}
|
||||
first.push(key(false, egui::Modifiers::COMMAND));
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, first), 1);
|
||||
// The integration drops Windows-key identity; Win+V can leave only this release.
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]),
|
||||
1,
|
||||
"opening history must not paste"
|
||||
);
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.2, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
|
||||
2,
|
||||
"choosing an item produces exactly one intentional paste"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_history_and_plain_v_after_an_unrelated_shortcut_do_nothing() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
let copy = egui::Event::Key { key: egui::Key::C, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
frame(&mut app, &ctx, 1.0, vec![egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), copy]);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.2, vec![key(true, egui::Modifiers::NONE), key(false, egui::Modifiers::NONE)]), 0);
|
||||
assert_eq!(frame(&mut app, &ctx, 10.0, vec![key(false, egui::Modifiers::NONE)]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_is_authoritative_and_not_duplicated_by_keys() {
|
||||
let mut app = image_app();
|
||||
app.system_clipboard = true;
|
||||
app.clipboard_override = Some(crate::create_ui::Clip::Text("newer OS contents".into()));
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]), 1);
|
||||
assert_eq!(app.last_copied_image.as_ref().unwrap().2, vec![0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_then_image_batch_pastes_image_and_keeps_its_cache() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![egui::Event::Paste("text".into()), delivered()]), 1);
|
||||
assert_eq!(app.last_copied_image.unwrap().2, vec![0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_then_text_batch_pastes_image_and_leaves_text_authoritative() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered(), egui::Event::Paste("text".into())]), 1);
|
||||
assert!(app.last_copied_image.is_none());
|
||||
assert!(app.last_copied_file.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_delivered_images_in_one_batch_each_paste_once() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(
|
||||
frame(&mut app, &ctx, 1.0, vec![delivered(), delivered(), key(true, egui::Modifiers::COMMAND), key(false, egui::Modifiers::COMMAND)]),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_control_shortcut_still_pastes_once_without_an_image_event() {
|
||||
let mut app = image_app();
|
||||
let ctx = egui::Context::default();
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![key(true, egui::Modifiers::COMMAND)]), 1);
|
||||
assert_eq!(frame(&mut app, &ctx, 1.1, vec![key(false, egui::Modifiers::COMMAND)]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivered_image_obeys_focus_modal_and_permission_guards() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = image_app();
|
||||
app.palette_open = true;
|
||||
assert_eq!(frame(&mut app, &ctx, 1.0, vec![delivered()]), 0);
|
||||
app.palette_open = false;
|
||||
app.views[0].organize = true;
|
||||
assert_eq!(frame(&mut app, &ctx, 2.0, vec![delivered()]), 0);
|
||||
app.views[0].organize = false;
|
||||
app.apply_edit(pdfcraft_engine::Edit::Protect(pdfcraft_engine::Protection {
|
||||
permissions_password: Some("owner".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
let bytes = app.session.save_bytes(app.views[0].id).unwrap();
|
||||
let mut restricted = PdfCraftApp::new();
|
||||
restricted.open_bytes("restricted.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
assert!(!restricted.session.get(restricted.views[0].id).unwrap().allows_modification());
|
||||
assert_eq!(frame(&mut restricted, &ctx, 3.0, vec![delivered()]), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_an_image_less_release_is_silent_but_explicit_paste_explains() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.toast = None;
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(
|
||||
egui::RawInput {
|
||||
events: vec![egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|ui| app.clipboard_keys(0, ui.ctx()),
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.toast.is_none());
|
||||
assert!(!app.paste_image(0, None));
|
||||
assert!(app.toast.is_some(), "an explicit menu action still explains why there is no image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_text_paste_release_is_silent_and_later_image_paste_still_works() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.last_copied_image = Some((1, 1, vec![255, 0, 0, 255]));
|
||||
app.palette_open = true; // A text field owns the first event.
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Paste("text".into())], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.last_copied_image.is_none());
|
||||
app.palette_open = false;
|
||||
app.toast = None;
|
||||
ctx.run_ui(
|
||||
egui::RawInput {
|
||||
events: vec![egui::Event::Key {
|
||||
key: egui::Key::V,
|
||||
physical_key: None,
|
||||
pressed: false,
|
||||
repeat: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
|ui| app.clipboard_keys(0, ui.ctx()),
|
||||
)
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.toast.is_none(), "the text gesture's key release must be silent");
|
||||
app.clipboard_override = None;
|
||||
let delivered = egui::Event::PasteImage(std::sync::Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::GREEN)));
|
||||
ctx.run_ui(egui::RawInput { events: vec![delivered], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
let id = app.active_ids().unwrap().1;
|
||||
assert_eq!(app.session.get(id).unwrap().page_images(0).len(), 1, "a later real image shortcut still works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_failed_keyboard_cut_keeps_the_image_selection() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.system_clipboard = false;
|
||||
app.create_blank();
|
||||
app.views[0].image_selection = Some(crate::edit_text_ui::ImageSelection::at(0, 0));
|
||||
let ctx = egui::Context::default();
|
||||
ctx.run_ui(egui::RawInput { events: vec![egui::Event::Cut], ..Default::default() }, |ui| app.clipboard_keys(0, ui.ctx()))
|
||||
.drop_without_applying_deltas();
|
||||
assert!(app.views[0].image_selection.is_some());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,18 @@ impl PdfCraftApp {
|
||||
pub fn set_option(&mut self, key: &str, value: &str) -> Result<(), String> {
|
||||
let view = self.active.and_then(|i| self.views.get_mut(i));
|
||||
match (key, view) {
|
||||
("hotbar-size", _) => {
|
||||
let size: f32 = value.parse().map_err(|_| "hotbar-size must be 24–64 pixels")?;
|
||||
if !size.is_finite() || !(24.0..=64.0).contains(&size) {
|
||||
return Err("hotbar-size must be 24–64 pixels".into());
|
||||
}
|
||||
self.hotbar.button_size = size;
|
||||
}
|
||||
("hotbar-full-side", _) => {
|
||||
self.hotbar.full_side = value.parse().map_err(|_| "hotbar-full-side must be true or false")?;
|
||||
}
|
||||
("hotbar-tools", _) => self.hotbar.set_tools(value)?,
|
||||
("hotbar-side", _) => self.hotbar.place(value)?,
|
||||
("language", _) => {
|
||||
let language = i18n::normalize_pref(value).ok_or_else(|| {
|
||||
let codes: Vec<&str> = std::iter::once(i18n::AUTO).chain(i18n::Lang::all().map(i18n::Lang::code)).collect();
|
||||
@@ -170,6 +182,12 @@ impl PdfCraftApp {
|
||||
_ => return Err("default-page-gaps must be on or off".into()),
|
||||
};
|
||||
}
|
||||
("recent-files", _) => {
|
||||
let max = crate::RECENT_FILES_MAX;
|
||||
let limit = value.parse().ok().filter(|n| *n <= max).ok_or_else(|| format!("recent-files must be between 0 and {max}"))?;
|
||||
self.recent_limit = limit;
|
||||
self.recent.truncate(limit);
|
||||
}
|
||||
("cover", Some(v)) => {
|
||||
let on = match value {
|
||||
"on" => true,
|
||||
@@ -211,6 +229,13 @@ impl PdfCraftApp {
|
||||
_ => return Err("tools-side must be left or right".into()),
|
||||
}
|
||||
}
|
||||
("rail-side", _) => {
|
||||
self.side_panels.rail_side = match value {
|
||||
"left" => side_panels::ToolsSide::Left,
|
||||
"right" => side_panels::ToolsSide::Right,
|
||||
_ => return Err("rail-side must be left or right".into()),
|
||||
}
|
||||
}
|
||||
("organize", Some(v)) => {
|
||||
if value == "off" {
|
||||
v.organize = false;
|
||||
|
||||
@@ -332,8 +332,9 @@ impl PdfCraftApp {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_association::repair(std::path::Path::new(&p));
|
||||
self.recent.retain(|r| r.path != p);
|
||||
self.recent_check.opened(&p);
|
||||
self.recent.insert(0, RecentFile { name: name.to_string(), path: p, pages, size });
|
||||
self.recent.truncate(12);
|
||||
self.recent.truncate(self.recent_limit);
|
||||
}
|
||||
// What the form's scripts said while it opened (messages, errors) shows now, not with
|
||||
// the next edit.
|
||||
|
||||
@@ -3,7 +3,6 @@ use super::*;
|
||||
impl PdfCraftApp {
|
||||
pub(super) fn shortcuts(&mut self, ctx: &egui::Context) {
|
||||
use egui::Key;
|
||||
let pasted = self.paste_key_released(ctx);
|
||||
// "Save changes?" is modal: its keys are its own (⌘D is Don't save there, not Document
|
||||
// properties; Escape cancels it rather than clearing a selection), and nothing may run
|
||||
// underneath it. They are read here, before the canvas can consume them.
|
||||
@@ -78,8 +77,7 @@ impl PdfCraftApp {
|
||||
prepare::nudge_keys(ctx, &mut self.views[i], doc);
|
||||
}
|
||||
canvas::shortcuts(&mut self.views[i], ctx, self.scroll_speed);
|
||||
self.copy_key(i, ctx);
|
||||
self.paste_image_key(i, ctx, pasted);
|
||||
self.clipboard_keys(i, ctx);
|
||||
self.image_pick_keys(i, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ impl PdfCraftApp {
|
||||
let mut v = serde_json::json!({
|
||||
"side_panels": self.side_panels.to_json(self.left_open),
|
||||
"docking_right": self.right,
|
||||
"recent": self.recent,
|
||||
"recent": self.recent.iter().take(self.recent_limit).collect::<Vec<_>>(),
|
||||
"reopen_last_session": self.reopen_last_session,
|
||||
"restore_last_view": self.restore_last_view,
|
||||
"last_views": self.last_views_to_save(),
|
||||
@@ -139,11 +139,14 @@ impl PdfCraftApp {
|
||||
});
|
||||
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
|
||||
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
|
||||
v["recent_limit"] = serde_json::json!(self.recent_limit);
|
||||
v["fill_text_color"] = serde_json::json!(self.fill_text_color);
|
||||
v["redact_prefs"] = self.redact_defaults.to_json();
|
||||
v["pinned_tools"] = serde_json::json!(self.pinned_tools);
|
||||
// Set outside json! above, which is at serde_json's macro recursion limit.
|
||||
v["hide_empty_highlight_hover"] = serde_json::Value::Bool(self.comment_prefs.hide_empty_highlight_hover);
|
||||
v["show_page_edge_shadows"] = self.show_page_edge_shadows.into();
|
||||
v["hotbar"] = serde_json::to_value(&self.hotbar).unwrap_or_default();
|
||||
v["comment_tool_pinned"] = serde_json::Value::Bool(self.comment_prefs.pinned);
|
||||
v["show_secured_notice"] = serde_json::Value::Bool(self.show_secured_notice);
|
||||
v["organize_zoom"] = serde_json::json!(self.organize_zoom);
|
||||
@@ -155,6 +158,10 @@ impl PdfCraftApp {
|
||||
/// Restore state written by `persist`. Unknown or malformed data is ignored.
|
||||
pub fn restore(&mut self, json: &str) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else { return };
|
||||
if let Ok(mut bar) = serde_json::from_value::<crate::hotbar::Hotbar>(v["hotbar"].clone()) {
|
||||
bar.normalize();
|
||||
self.hotbar = bar;
|
||||
}
|
||||
let panels = side_panels::restore(&v);
|
||||
self.side_panels = panels.panels;
|
||||
if let Some(open) = panels.tools_open {
|
||||
@@ -175,11 +182,16 @@ impl PdfCraftApp {
|
||||
self.organize_zoom = zoom.clamp(*canvas::GRID_ZOOM_RANGE.start(), *canvas::GRID_ZOOM_RANGE.end());
|
||||
}
|
||||
if let Ok(r) = serde_json::from_value::<Vec<RecentFile>>(v["recent"].clone()) {
|
||||
// Only keep entries whose files still exist.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let r: Vec<RecentFile> = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect();
|
||||
// Entries whose files are gone leave once the window is up: asking here could block
|
||||
// startup on a mount that has stopped answering (#5682).
|
||||
self.recent_check.restored(r.iter().map(|f| f.path.clone()).collect());
|
||||
self.recent = r;
|
||||
}
|
||||
// Nonsense keeps the limit it had; settings from before the limit have none.
|
||||
if let Some(limit) = v["recent_limit"].as_u64() {
|
||||
self.recent_limit = usize::try_from(limit).map_or(crate::RECENT_FILES_MAX, |n| n.min(crate::RECENT_FILES_MAX));
|
||||
}
|
||||
self.recent.truncate(self.recent_limit);
|
||||
self.restore_layout_prefs(&v);
|
||||
self.restore_behavior_prefs(&v);
|
||||
self.restore_view_prefs(&v);
|
||||
@@ -207,6 +219,18 @@ impl PdfCraftApp {
|
||||
self.show_secured_notice = on;
|
||||
}
|
||||
self.pinned.restore(&v["pinned_folders"]);
|
||||
if let Some(items) = v.get("pinned_tools").and_then(serde_json::Value::as_array) {
|
||||
let mut tools = Vec::new();
|
||||
for item in items {
|
||||
if let Some(id) = item.as_str()
|
||||
&& pdfcraft_engine::catalog::group(id).is_some()
|
||||
&& !tools.iter().any(|t| t == id)
|
||||
{
|
||||
tools.push(id.to_string());
|
||||
}
|
||||
}
|
||||
self.pinned_tools = tools;
|
||||
}
|
||||
if let Ok(preference) = serde_json::from_value::<ThemePreference>(v["theme"].clone()) {
|
||||
self.set_theme_preference(preference);
|
||||
}
|
||||
|
||||
+608
-38
@@ -84,6 +84,175 @@ pub enum Fit {
|
||||
None,
|
||||
}
|
||||
|
||||
use crate::edit_text_ui::ImageHandle;
|
||||
|
||||
/// Extension trait on `egui::Painter` to draw polygons.
|
||||
pub trait PainterExt {
|
||||
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx;
|
||||
}
|
||||
|
||||
impl PainterExt for egui::Painter {
|
||||
fn polygon(&self, points: Vec<Pos2>, fill: Color32, stroke: Stroke) -> egui::layers::ShapeIdx {
|
||||
self.add(egui::Shape::convex_polygon(points, fill, stroke))
|
||||
}
|
||||
}
|
||||
|
||||
/// User-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// this is the mathematical intersection of the full CTM-derived rect and the clipping path's rect.
|
||||
pub fn image_selectable_bounds(im: &pdfcraft_engine::PageImage) -> [f64; 4] {
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let r = [im.rect[0].max(clip[0]), im.rect[1].max(clip[1]), im.rect[2].min(clip[2]), im.rect[3].min(clip[3])];
|
||||
if r.iter().all(|v| v.is_finite()) && r[2] > r[0] && r[3] > r[1] {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
im.visible_rect.unwrap_or(im.rect)
|
||||
}
|
||||
|
||||
/// Screen-space selectable bounding box for an image object.
|
||||
/// If the image object has an active clipping path applied to it (the crop box),
|
||||
/// the final selectable `egui::Rect` is the mathematical intersection of the image's
|
||||
/// full CTM-derived rect and the clipping path's rect.
|
||||
pub fn image_screen_rect(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Rect {
|
||||
let full = xf.user_rect_f64(info, page, im.rect);
|
||||
if let Some(clip) = im.clip_rect {
|
||||
let clip_r = xf.user_rect_f64(info, page, clip);
|
||||
let inter = full.intersect(clip_r);
|
||||
if inter.is_positive() {
|
||||
return inter;
|
||||
}
|
||||
}
|
||||
if let Some(vr) = im.visible_rect {
|
||||
let r = xf.user_rect_f64(info, page, vr);
|
||||
if r.is_positive() {
|
||||
return r;
|
||||
}
|
||||
}
|
||||
full
|
||||
}
|
||||
|
||||
/// An Oriented Bounding Box (OBB) representing a rotated rectangle in screen space,
|
||||
/// with 8 resize handles and rotated local axes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ImageObb {
|
||||
pub s00: Pos2,
|
||||
pub s10: Pos2,
|
||||
pub s11: Pos2,
|
||||
pub s01: Pos2,
|
||||
pub s_bc: Pos2,
|
||||
pub s_rc: Pos2,
|
||||
pub s_tc: Pos2,
|
||||
pub s_lc: Pos2,
|
||||
pub s_rot: Pos2,
|
||||
pub screen_center: Pos2,
|
||||
pub u_axis: (f64, f64),
|
||||
pub v_axis: (f64, f64),
|
||||
pub is_rotated: bool,
|
||||
}
|
||||
|
||||
impl ImageObb {
|
||||
pub(crate) fn handle_cursor(&self, handle: ImageHandle) -> egui::CursorIcon {
|
||||
let direction = self.handle_pos(handle) - self.screen_center;
|
||||
match handle {
|
||||
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
|
||||
if direction.x * direction.y > 0.0 {
|
||||
egui::CursorIcon::ResizeNwSe
|
||||
} else {
|
||||
egui::CursorIcon::ResizeNeSw
|
||||
}
|
||||
}
|
||||
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
|
||||
_ => egui::CursorIcon::ResizeVertical,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn contains(&self, visible: Rect, p: Pos2) -> bool {
|
||||
(visible.is_positive() && visible.contains(p)) || crate::edit_text_ui::quad_contains(self.corners(), p)
|
||||
}
|
||||
|
||||
pub fn from_matrix(matrix: [f64; 6], xf: &PageXform, info: &DocInfo, page: usize) -> Self {
|
||||
let m = crate::edit_text_ui::Matrix(matrix);
|
||||
let p00 = m.apply(0.0, 0.0);
|
||||
let p10 = m.apply(1.0, 0.0);
|
||||
let p11 = m.apply(1.0, 1.0);
|
||||
let p01 = m.apply(0.0, 1.0);
|
||||
|
||||
let s00 = crate::edit_text_ui::user_to_screen(xf, info, page, p00.0, p00.1);
|
||||
let s10 = crate::edit_text_ui::user_to_screen(xf, info, page, p10.0, p10.1);
|
||||
let s11 = crate::edit_text_ui::user_to_screen(xf, info, page, p11.0, p11.1);
|
||||
let s01 = crate::edit_text_ui::user_to_screen(xf, info, page, p01.0, p01.1);
|
||||
|
||||
let s_bc = Pos2::new((s00.x + s10.x) * 0.5, (s00.y + s10.y) * 0.5);
|
||||
let s_rc = Pos2::new((s10.x + s11.x) * 0.5, (s10.y + s11.y) * 0.5);
|
||||
let s_tc = Pos2::new((s01.x + s11.x) * 0.5, (s01.y + s11.y) * 0.5);
|
||||
let s_lc = Pos2::new((s00.x + s01.x) * 0.5, (s00.y + s01.y) * 0.5);
|
||||
|
||||
let screen_center = Pos2::new((s00.x + s10.x + s11.x + s01.x) * 0.25, (s00.y + s10.y + s11.y + s01.y) * 0.25);
|
||||
let out_dir = s_tc - screen_center;
|
||||
let out_len = out_dir.length().max(1e-4);
|
||||
let out_norm = out_dir / out_len;
|
||||
let s_rot = s_tc + out_norm * 24.0;
|
||||
|
||||
let [a, b, c, d, ..] = matrix;
|
||||
let w_u = (a * a + b * b).sqrt().max(1e-6);
|
||||
let h_v = (c * c + d * d).sqrt().max(1e-6);
|
||||
let u_axis = (a / w_u, b / w_u);
|
||||
let v_axis = (c / h_v, d / h_v);
|
||||
// The fast rectangle path interprets labels in screen space. Use matrix
|
||||
// resizing whenever the image's user-space axes are turned or reflected
|
||||
// on screen, including page /Rotate and View > Rotate View.
|
||||
let u = s10 - s00;
|
||||
let v = s01 - s00;
|
||||
let is_rotated = u.y.abs() > 1e-4 || v.x.abs() > 1e-4 || u.x < 0.0 || v.y > 0.0;
|
||||
|
||||
Self { s00, s10, s11, s01, s_bc, s_rc, s_tc, s_lc, s_rot, screen_center, u_axis, v_axis, is_rotated }
|
||||
}
|
||||
|
||||
pub fn from_image(im: &pdfcraft_engine::PageImage, xf: &PageXform, info: &DocInfo, page: usize) -> Self {
|
||||
let [a, b, c, d, ..] = im.matrix;
|
||||
let is_rot = b.abs() > 1e-4 || c.abs() > 1e-4 || a < 0.0 || d < 0.0;
|
||||
let bounds = image_selectable_bounds(im);
|
||||
let matrix = if !is_rot && bounds[2] > bounds[0] && bounds[3] > bounds[1] {
|
||||
[bounds[2] - bounds[0], 0.0, 0.0, bounds[3] - bounds[1], bounds[0], bounds[1]]
|
||||
} else {
|
||||
im.matrix
|
||||
};
|
||||
Self::from_matrix(matrix, xf, info, page)
|
||||
}
|
||||
|
||||
pub fn handles(&self) -> [(ImageHandle, Pos2); 8] {
|
||||
[
|
||||
ImageHandle::TopLeft,
|
||||
ImageHandle::TopCenter,
|
||||
ImageHandle::TopRight,
|
||||
ImageHandle::RightCenter,
|
||||
ImageHandle::BottomRight,
|
||||
ImageHandle::BottomCenter,
|
||||
ImageHandle::BottomLeft,
|
||||
ImageHandle::LeftCenter,
|
||||
]
|
||||
.map(|h| (h, self.handle_pos(h)))
|
||||
}
|
||||
|
||||
pub fn handle_pos(&self, handle: ImageHandle) -> Pos2 {
|
||||
match handle {
|
||||
ImageHandle::TopLeft => self.s01,
|
||||
ImageHandle::TopCenter => self.s_tc,
|
||||
ImageHandle::TopRight => self.s11,
|
||||
ImageHandle::RightCenter => self.s_rc,
|
||||
ImageHandle::BottomRight => self.s10,
|
||||
ImageHandle::BottomCenter => self.s_bc,
|
||||
ImageHandle::BottomLeft => self.s00,
|
||||
ImageHandle::LeftCenter => self.s_lc,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn corners(&self) -> [Pos2; 4] {
|
||||
[self.s00, self.s10, self.s11, self.s01]
|
||||
}
|
||||
}
|
||||
|
||||
/// The page display as it was before full screen, put back on leaving it
|
||||
/// ([`DocView::enter_presentation`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -569,6 +738,104 @@ pub enum ViewAction {
|
||||
CameraNext(&'static str),
|
||||
}
|
||||
|
||||
/// Default page right-click menu actions.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum PageMenuAction {
|
||||
RotateCCW,
|
||||
RotateCW,
|
||||
Print,
|
||||
SelectTool,
|
||||
HandTool,
|
||||
MarqueeZoom,
|
||||
Copy,
|
||||
Paste(usize, [f64; 2]),
|
||||
AddComment(usize, [f64; 2]),
|
||||
AddBookmark,
|
||||
TakeSnapshot,
|
||||
EditPdf,
|
||||
AddText,
|
||||
AddImage,
|
||||
DeletePage(usize),
|
||||
ExportPdf,
|
||||
Find,
|
||||
DocProperties,
|
||||
FlattenComments,
|
||||
}
|
||||
|
||||
pub(crate) struct PageMenuPerms {
|
||||
pub can_modify: bool,
|
||||
pub can_assemble: bool,
|
||||
pub can_annotate: bool,
|
||||
pub can_print: bool,
|
||||
pub can_copy: bool,
|
||||
pub can_copy_items: bool,
|
||||
pub can_paste: bool,
|
||||
pub has_annotations: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn default_page_context_menu(ui: &mut egui::Ui, page: usize, at: [f64; 2], perms: PageMenuPerms, action: &mut Option<PageMenuAction>) {
|
||||
use PageMenuAction as A;
|
||||
// Takes `choice` when `entry` was clicked, and closes the menu.
|
||||
let mut pick = |entry: egui::Response, ui: &mut egui::Ui, choice: PageMenuAction| {
|
||||
if entry.clicked() {
|
||||
*action = Some(choice);
|
||||
ui.close();
|
||||
}
|
||||
};
|
||||
let icon = |ui: &mut egui::Ui, name: &str, tip: &str| crate::icons::button(ui, name, 24.0, false, tip);
|
||||
// Top horizontal icon bar: Rotate CCW, Print, Select tool, Hand tool, Marquee Zoom
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
pick(icon(ui, "rotate-ccw", tl!("Rotate view counterclockwise")), ui, A::RotateCCW);
|
||||
ui.add_enabled_ui(perms.can_print, |ui| pick(icon(ui, "printer", tl!("Print")), ui, A::Print));
|
||||
pick(icon(ui, "mouse-pointer-2", tl!("Select Tool")), ui, A::SelectTool);
|
||||
pick(icon(ui, "hand", tl!("Hand Tool")), ui, A::HandTool);
|
||||
pick(icon(ui, "zoom-in", tl!("Marquee Zoom")), ui, A::MarqueeZoom);
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(
|
||||
ui.add_enabled(
|
||||
perms.can_copy_items,
|
||||
egui::Button::new(tl!("Copy")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY)),
|
||||
),
|
||||
ui,
|
||||
A::Copy,
|
||||
);
|
||||
pick(
|
||||
ui.add_enabled(
|
||||
perms.can_paste,
|
||||
egui::Button::new(tl!("Paste")).shortcut_text(crate::commands::shortcut_label(ui.ctx(), pdfcraft_engine::commands::Shortcut::cmd("V"))),
|
||||
),
|
||||
ui,
|
||||
A::Paste(page, at),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.add_enabled(perms.can_annotate, egui::Button::new(tl!("Add comment"))), ui, A::AddComment(page, at));
|
||||
if perms.has_annotations {
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Flatten comments"))), ui, A::FlattenComments);
|
||||
}
|
||||
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Add bookmark"))), ui, A::AddBookmark);
|
||||
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Take a snapshot"))), ui, A::TakeSnapshot);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(egui::RichText::new(tl!("Edit this PDF")).strong())), ui, A::EditPdf);
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add text"))), ui, A::AddText);
|
||||
pick(ui.add_enabled(perms.can_modify, egui::Button::new(tl!("Add image"))), ui, A::AddImage);
|
||||
pick(ui.add_enabled(perms.can_assemble, egui::Button::new(tl!("Delete page"))), ui, A::DeletePage(page));
|
||||
pick(ui.add_enabled(perms.can_copy, egui::Button::new(tl!("Export this PDF"))), ui, A::ExportPdf);
|
||||
|
||||
ui.separator();
|
||||
|
||||
pick(ui.button(tl!("Find text or tools")), ui, A::Find);
|
||||
pick(ui.button(tl!("Rotate view clockwise")), ui, A::RotateCW);
|
||||
pick(ui.button(tl!("Document properties…")), ui, A::DocProperties);
|
||||
}
|
||||
|
||||
impl DocView {
|
||||
/// Whether a middle-button scrolling gesture is active (tests and automation).
|
||||
pub fn auto_scrolling(&self) -> bool {
|
||||
@@ -886,6 +1153,7 @@ struct PageEnv<'a> {
|
||||
fill_text_size: f64,
|
||||
fill_text_color: [f64; 3],
|
||||
today: (i64, u32, u32),
|
||||
can_paste_image: bool,
|
||||
}
|
||||
|
||||
/// What the page-scroll closure produces for the rest of the frame.
|
||||
@@ -903,6 +1171,7 @@ struct PageOut {
|
||||
open_initials: bool,
|
||||
open_signature: bool,
|
||||
open_table: Option<(usize, [f64; 4])>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
primary_down: bool,
|
||||
refused: Option<String>,
|
||||
stamp_placed: bool,
|
||||
@@ -946,6 +1215,70 @@ struct PageSlot {
|
||||
|
||||
/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders
|
||||
/// it wants.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn paint_image_suppressed(
|
||||
xf: &PageXform,
|
||||
painter: &egui::Painter,
|
||||
tex: egui::TextureId,
|
||||
u0: f32,
|
||||
v0: f32,
|
||||
u1: f32,
|
||||
v1: f32,
|
||||
suppress: Option<[f32; 4]>,
|
||||
is_full_page_tex: bool,
|
||||
) {
|
||||
let Some([su0, sv0, su1, sv1]) = suppress else {
|
||||
if is_full_page_tex {
|
||||
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
|
||||
} else {
|
||||
xf.paint_image(painter, tex, u0, v0, u1, v1);
|
||||
}
|
||||
return;
|
||||
};
|
||||
let iu0 = u0.max(su0);
|
||||
let iu1 = u1.min(su1);
|
||||
let iv0 = v0.max(sv0);
|
||||
let iv1 = v1.min(sv1);
|
||||
if iu0 >= iu1 || iv0 >= iv1 {
|
||||
if is_full_page_tex {
|
||||
xf.paint_page_texture_rect(painter, tex, u0, v0, u1, v1);
|
||||
} else {
|
||||
xf.paint_image(painter, tex, u0, v0, u1, v1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let map_uv = |pu: f32, pv: f32| -> (f32, f32) {
|
||||
if is_full_page_tex {
|
||||
(pu, pv)
|
||||
} else {
|
||||
let du = (u1 - u0).max(1e-6);
|
||||
let dv = (v1 - v0).max(1e-6);
|
||||
((pu - u0) / du, (pv - v0) / dv)
|
||||
}
|
||||
};
|
||||
let draw_strip = |pu0: f32, pv0: f32, pu1: f32, pv1: f32| {
|
||||
if pu1 <= pu0 || pv1 <= pv0 {
|
||||
return;
|
||||
}
|
||||
let (tu0, tv0) = map_uv(pu0, pv0);
|
||||
let (tu1, tv1) = map_uv(pu1, pv1);
|
||||
xf.paint_image_region(painter, tex, pu0, pv0, pu1, pv1, tu0, tv0, tu1, tv1);
|
||||
};
|
||||
|
||||
if iv0 > v0 {
|
||||
draw_strip(u0, v0, u1, iv0);
|
||||
}
|
||||
if v1 > iv1 {
|
||||
draw_strip(u0, iv1, u1, v1);
|
||||
}
|
||||
if iu0 > u0 {
|
||||
draw_strip(u0, iv0, iu0, iv1);
|
||||
}
|
||||
if u1 > iu1 {
|
||||
draw_strip(iu1, iv0, u1, iv1);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) {
|
||||
let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
@@ -988,13 +1321,33 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
|
||||
Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)),
|
||||
None => frame.wanted.push((i, want_scale, want_tag, None)),
|
||||
}
|
||||
let doc = frame.env.doc;
|
||||
let active_drag_suppress = if let Some(sel) = view.image_selection.clone()
|
||||
&& sel.page == i
|
||||
&& sel.drag.is_some()
|
||||
{
|
||||
let images = cached_edit_images(view, doc, i);
|
||||
if let Some(img) = images.get(sel.index) {
|
||||
let obb = ImageObb::from_image(img, &xf, info, i);
|
||||
let uvs = obb.corners().map(|c| xf.screen_to_norm(c));
|
||||
let u0 = uvs.iter().map(|(u, _)| *u).fold(1.0f32, f32::min).clamp(0.0, 1.0);
|
||||
let u1 = uvs.iter().map(|(u, _)| *u).fold(0.0f32, f32::max).clamp(0.0, 1.0);
|
||||
let v0 = uvs.iter().map(|(_, v)| *v).fold(1.0f32, f32::min).clamp(0.0, 1.0);
|
||||
let v1 = uvs.iter().map(|(_, v)| *v).fold(0.0f32, f32::max).clamp(0.0, 1.0);
|
||||
if u1 > u0 && v1 > v0 { Some([u0, v0, u1, v1]) } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
match view.pages.get(&i) {
|
||||
Some(p) => {
|
||||
if !tiled && p.tag == want_tag {
|
||||
xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
paint_image_suppressed(&xf.texel_aligned(p.tex.size(), ppp), painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
|
||||
} else {
|
||||
// A backdrop, or a raster at an older scale until the new one arrives.
|
||||
xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0);
|
||||
paint_image_suppressed(&xf, painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0, active_drag_suppress, true);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -1005,14 +1358,21 @@ fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFra
|
||||
}
|
||||
}
|
||||
if tiled && r.intersects(visible) {
|
||||
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side));
|
||||
paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side), active_drag_suppress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched,
|
||||
/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing.
|
||||
fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) {
|
||||
fn paint_page_tiles(
|
||||
ui: &egui::Ui,
|
||||
view: &mut DocView,
|
||||
frame: &mut PageFrame<'_>,
|
||||
slot: &PageSlot,
|
||||
(pw_pt, ph_pt, max_side): (f32, f32, f32),
|
||||
active_drag_suppress: Option<[f32; 4]>,
|
||||
) {
|
||||
let PageEnv { ppp, scale, tag, .. } = frame.env;
|
||||
let PageSlot { i, r, xf } = *slot;
|
||||
let visible = frame.visible;
|
||||
@@ -1041,7 +1401,7 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
|
||||
let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32);
|
||||
let [w, h] = tex.size();
|
||||
let (x, y) = (ox as f32, oy as f32);
|
||||
xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh);
|
||||
paint_image_suppressed(&xf, painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh, active_drag_suppress, false);
|
||||
}
|
||||
// Visible tiles, nearest the viewport centre first: each tile costs a full
|
||||
// page interpret, so the workers sharpen what is looked at before the edges.
|
||||
@@ -1066,13 +1426,16 @@ fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>
|
||||
frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h })));
|
||||
if let Some(tex) = view.tiles.get(&(i, tag, x, y)) {
|
||||
let (fw, fh) = (dw as f32, dh as f32);
|
||||
xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image(
|
||||
paint_image_suppressed(
|
||||
&xf.texel_aligned([dw as usize, dh as usize], ppp),
|
||||
painter,
|
||||
tex.id(),
|
||||
x as f32 / fw,
|
||||
y as f32 / fh,
|
||||
(x + w) as f32 / fw,
|
||||
(y + h) as f32 / fh,
|
||||
active_drag_suppress,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1327,9 +1690,18 @@ fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<
|
||||
info,
|
||||
allow_interaction: !others_own_pointer && (matches!(view.router.owner(i), Some(crate::page_router::Owner::Picture { .. })) || !text_used),
|
||||
};
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action);
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
!others_own_pointer && (text_used || image_used)
|
||||
};
|
||||
if tool == QuickTool::EditText {
|
||||
if others_own_pointer {
|
||||
if ptr.pressed().is_some() || resp.clicked() || resp.secondary_clicked() {
|
||||
view.image_selection = None;
|
||||
}
|
||||
} else {
|
||||
crate::content_ui::sync_image_selection(view, i, &images, added);
|
||||
}
|
||||
}
|
||||
let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(cx, doc_links, view);
|
||||
if tool == QuickTool::Select
|
||||
&& resp.secondary_clicked()
|
||||
@@ -1367,17 +1739,20 @@ fn edit_inventory(
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = match view.edit_images.get(&i) {
|
||||
Some((g, l)) if *g == generation => l.clone(),
|
||||
_ => {
|
||||
let l = doc.page_images(i);
|
||||
view.edit_images.insert(i, (generation, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
let images = cached_edit_images(view, doc, i).to_vec();
|
||||
(lines, images)
|
||||
}
|
||||
|
||||
/// Images parsed once per page and document edit generation, shared by painting and menus.
|
||||
fn cached_edit_images<'a>(view: &'a mut DocView, doc: &pdfcraft_engine::Document, page: usize) -> &'a [pdfcraft_engine::PageImage] {
|
||||
let generation = doc.edit_generation();
|
||||
let entry = view.edit_images.entry(page).or_insert_with(|| (generation, doc.page_images(page)));
|
||||
if entry.0 != generation {
|
||||
*entry = (generation, doc.page_images(page));
|
||||
}
|
||||
&entry.1
|
||||
}
|
||||
|
||||
/// What the page modules have under a point on one page, for the page router.
|
||||
struct PageHits<'a> {
|
||||
page: usize,
|
||||
@@ -1426,6 +1801,19 @@ impl PageHits<'_> {
|
||||
images,
|
||||
clip,
|
||||
} = *self;
|
||||
// Crop mode owns presses on its page, including the controls beside its
|
||||
// frame. Underlying paragraphs and the empty-page marquee must not take
|
||||
// a crop button's press before the image module receives it.
|
||||
if tool == QuickTool::EditText
|
||||
&& can_modify
|
||||
&& let Some(selection) = view.image_selection.as_ref().filter(|s| s.page == page && s.crop.is_some())
|
||||
{
|
||||
return vec![crate::page_router::Hit::new(
|
||||
crate::page_router::Owner::Picture { index: selection.index, part: crate::page_router::PicturePart::Body },
|
||||
crate::page_cursor::Layer::Handle,
|
||||
egui::CursorIcon::Default,
|
||||
)];
|
||||
}
|
||||
let fields = if preparing {
|
||||
crate::prepare::hit(xf, info, page, form, field_tool, prepare_allowed, &view.prepare, p)
|
||||
} else if tool == QuickTool::Select {
|
||||
@@ -1935,6 +2323,7 @@ fn cancel_on_escape(ctx: &egui::Context, view: &mut DocView, ptr: &PagePointer)
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn draw_pages(
|
||||
ui: &mut egui::Ui,
|
||||
viewport: Rect,
|
||||
@@ -1968,6 +2357,8 @@ fn draw_pages(
|
||||
prefs,
|
||||
preparing,
|
||||
selection_wheel,
|
||||
ppp,
|
||||
rects,
|
||||
single_paging,
|
||||
tool,
|
||||
unobstructed,
|
||||
@@ -2034,6 +2425,38 @@ fn draw_pages(
|
||||
if resp.secondary_clicked() {
|
||||
// Set again below when the right-click lands on a link.
|
||||
view.links.context = None;
|
||||
let mut over_image = false;
|
||||
if tool == QuickTool::EditText {
|
||||
let pointer_pos = ptr.pos;
|
||||
if let Some(p) = pointer_pos {
|
||||
for &page_idx in visible_pages {
|
||||
let r = snap_to_pixels(rects[page_idx].translate(origin.to_vec2()), ppp);
|
||||
if !r.intersects(visible) {
|
||||
continue;
|
||||
}
|
||||
let xf =
|
||||
PageXform { rect: r, rot: view.rotation, pw: info.pages[page_idx].width.max(1.0), ph: info.pages[page_idx].height.max(1.0) };
|
||||
let images = cached_edit_images(view, doc, page_idx).to_vec();
|
||||
for (idx, img) in images.iter().enumerate().rev() {
|
||||
let obb = ImageObb::from_image(img, &xf, info, page_idx);
|
||||
let b = image_screen_rect(img, &xf, info, page_idx);
|
||||
if obb.contains(b, p) {
|
||||
over_image = true;
|
||||
if view.image_selection.as_ref().is_none_or(|s| s.page != page_idx || s.index != idx) {
|
||||
view.image_selection = Some(crate::edit_text_ui::ImageSelection::at(page_idx, idx));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if over_image {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !over_image && tool == QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
let mut frame = PageFrame {
|
||||
env: *env,
|
||||
@@ -2063,20 +2486,22 @@ fn draw_pages(
|
||||
let PageOut {
|
||||
mut canvas_action,
|
||||
clicked_link,
|
||||
mut copy_selected,
|
||||
copy_selected,
|
||||
mut field_menu,
|
||||
field_placed,
|
||||
field_props,
|
||||
hover_text: _,
|
||||
image_action,
|
||||
mut image_action,
|
||||
open_boxes,
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action: _,
|
||||
primary_down: _,
|
||||
refused,
|
||||
stamp_placed,
|
||||
} = page_out;
|
||||
let mut page_action: Option<PageMenuAction> = None;
|
||||
let painter = ui.painter();
|
||||
if calc_order {
|
||||
view.calc_order.set_hover_page(calc_hover);
|
||||
@@ -2196,20 +2621,33 @@ fn draw_pages(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_none() {
|
||||
let selected = view.selected_text().is_some_and(|t| !t.is_empty());
|
||||
let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY);
|
||||
if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() {
|
||||
copy_selected = true;
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
let can_paste_image = env.can_paste_image;
|
||||
let is_image_menu = tool == QuickTool::EditText
|
||||
&& (ui.ctx().data_mut(|d| d.get_temp::<bool>(crate::edit_text_ui::image_menu_id())).unwrap_or(false)
|
||||
|| view.image_selection.clone().is_some_and(|sel| {
|
||||
let pointer_pos = ptr.pos;
|
||||
let Some(p) = pointer_pos else { return false };
|
||||
let Some((_, xf)) = view.screen_xforms.iter().find(|(page_idx, _)| *page_idx == sel.page).cloned() else { return false };
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let Some(img) = images.get(sel.index) else { return false };
|
||||
let obb = ImageObb::from_image(img, &xf, info, sel.page);
|
||||
let b = image_screen_rect(img, &xf, info, sel.page);
|
||||
obb.contains(b, p)
|
||||
}));
|
||||
if is_image_menu && let Some(sel) = view.image_selection.clone() {
|
||||
let images = cached_edit_images(view, doc, sel.page).to_vec();
|
||||
let at = view.comments.context_at.map(|(_, at)| at);
|
||||
let rotation = (view.rotation % 360 + info.pages.get(sel.page).map_or(0, |p| p.rotation % 360)) % 360;
|
||||
crate::edit_text_ui::image_context_menu(ui, view, &images, sel.page, sel.index, can_paste_image, &mut image_action, at, rotation);
|
||||
return;
|
||||
}
|
||||
if view.comments.selected.is_some() {
|
||||
canvas_action = comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify);
|
||||
return;
|
||||
}
|
||||
// Over a picture (and no comment): Copy Image, as in Acrobat's page menu.
|
||||
let mut copy_image = None;
|
||||
if view.comments.selected.is_none()
|
||||
&& let Some((page, at)) = view.comments.context_at
|
||||
{
|
||||
if let Some((page, at)) = view.comments.context_at {
|
||||
// The page's content is parsed once per right-click, not on every frame the menu is open.
|
||||
let key = egui::Id::unique("copy-image-under-menu");
|
||||
let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option<usize>)>(key)).filter(|c| c.0 == page && c.1 == at);
|
||||
@@ -2233,9 +2671,28 @@ fn draw_pages(
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
canvas_action =
|
||||
comments::context_menu(ui, view, info, &|page, index| doc.annotation_deletable(page, index), prefs, allowed, can_modify).or(copy_image);
|
||||
let has_copy_image = copy_image.is_some();
|
||||
if copy_image.is_some() {
|
||||
canvas_action = copy_image;
|
||||
}
|
||||
let (page, at) = view.comments.context_at.unwrap_or((view.current, [0.0, 0.0]));
|
||||
let can_copy_items = can_copy && (view.image_selection.is_some() || view.selected_text().is_some() || has_copy_image);
|
||||
let can_paste = can_paste_image && can_modify;
|
||||
let perms = PageMenuPerms {
|
||||
can_modify,
|
||||
can_assemble: doc.allows_assembly(),
|
||||
can_annotate: allowed,
|
||||
can_print: doc.allows_printing(),
|
||||
can_copy,
|
||||
can_copy_items,
|
||||
can_paste,
|
||||
has_annotations: !info.annotations.is_empty(),
|
||||
};
|
||||
default_page_context_menu(ui, page, at, perms, &mut page_action);
|
||||
});
|
||||
if !resp.context_menu_opened() {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(crate::edit_text_ui::image_menu_id(), false));
|
||||
}
|
||||
*out = PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
@@ -2249,6 +2706,7 @@ fn draw_pages(
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
primary_down: ptr.primary_down,
|
||||
refused,
|
||||
stamp_placed,
|
||||
@@ -2359,6 +2817,7 @@ fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging:
|
||||
// The turn leaves egui's eased copy of the notch to decay for a few frames; it must not
|
||||
// scroll the page just shown away from its top (or bottom).
|
||||
view.precise_scroll_until = now + 0.25;
|
||||
view.fling.stop();
|
||||
}
|
||||
// Paging owns the vertical axis in single-page view; leave it to the pager.
|
||||
// The rest of a gesture that turned the page is swallowed too, or it would pan the new page
|
||||
@@ -2992,6 +3451,14 @@ fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry:
|
||||
/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests.
|
||||
pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let can_paste_key = egui::Id::unique("can-paste-image");
|
||||
let can_paste_image = if ui.input(|i| i.pointer.secondary_pressed()) {
|
||||
let can = app.can_paste_image();
|
||||
ui.data_mut(|d| d.insert_temp(can_paste_key, can));
|
||||
can
|
||||
} else {
|
||||
ui.data(|d| d.get_temp(can_paste_key)).unwrap_or(false)
|
||||
};
|
||||
// Keyboard scrolling applies to this frame's page view only, never later.
|
||||
let key_scroll = std::mem::take(&mut app.views[index].key_scroll);
|
||||
if key_scroll != 0.0 {
|
||||
@@ -3229,16 +3696,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
fill_text_size: app.fill_text_size,
|
||||
fill_text_color: app.fill_text_color,
|
||||
today,
|
||||
can_paste_image,
|
||||
};
|
||||
let mut page_out = PageOut::default();
|
||||
let fade_strength = ui.spacing().scroll.fade.strength;
|
||||
if !app.show_page_edge_shadows {
|
||||
ui.spacing_mut().scroll.fade.strength = 0.0;
|
||||
}
|
||||
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| {
|
||||
let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), app.show_page_edge_shadows, |ui, viewport| {
|
||||
draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview)
|
||||
});
|
||||
ui.spacing_mut().scroll.fade.strength = fade_strength;
|
||||
let PageOut {
|
||||
canvas_action,
|
||||
clicked_link,
|
||||
@@ -3252,6 +3715,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
open_initials,
|
||||
open_signature,
|
||||
open_table,
|
||||
page_action,
|
||||
primary_down,
|
||||
mut refused,
|
||||
stamp_placed,
|
||||
@@ -3291,8 +3755,12 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
if preparing {
|
||||
crate::prepare::keys(ui.ctx(), view);
|
||||
}
|
||||
if tool != QuickTool::EditText {
|
||||
view.image_selection = None;
|
||||
crate::content_ui::release_image_selection(view, &added);
|
||||
}
|
||||
if editing_content {
|
||||
crate::content_ui::keys(ui.ctx(), view);
|
||||
crate::content_ui::keys(ui.ctx(), view, tool == QuickTool::EditText);
|
||||
}
|
||||
if tool == QuickTool::Link {
|
||||
crate::link_ui::keys(ui.ctx(), view);
|
||||
@@ -3338,6 +3806,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) {
|
||||
field_props,
|
||||
field_menu,
|
||||
image_action,
|
||||
page_action,
|
||||
refused,
|
||||
open_signature,
|
||||
open_initials,
|
||||
@@ -3359,6 +3828,7 @@ struct FrameRequests {
|
||||
field_props: Option<(String, usize)>,
|
||||
field_menu: Option<FieldMenu>,
|
||||
image_action: Option<crate::edit_text_ui::ImageAction>,
|
||||
page_action: Option<PageMenuAction>,
|
||||
refused: Option<String>,
|
||||
open_signature: bool,
|
||||
open_initials: bool,
|
||||
@@ -3373,12 +3843,14 @@ struct FrameRequests {
|
||||
}
|
||||
|
||||
/// Opens the dialogs and runs the app commands the frame asked for.
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) {
|
||||
let FrameRequests {
|
||||
open_props,
|
||||
field_props,
|
||||
field_menu,
|
||||
image_action,
|
||||
page_action,
|
||||
refused,
|
||||
open_signature,
|
||||
open_initials,
|
||||
@@ -3442,12 +3914,109 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::CopyFile(page, index)) => app.copy_page_image_file(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Cut(page, image)) => {
|
||||
app.views[index].image_selection = None;
|
||||
app.cut_page_image(page, image);
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::Paste(page, at)) => {
|
||||
if let Some(pt) = at {
|
||||
app.paste_image_at(page, pt);
|
||||
} else {
|
||||
app.paste_image(page, None);
|
||||
}
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::DrawFreehand) => {
|
||||
app.quick_tool = crate::QuickTool::Comment(crate::comments::CommentTool::Ink);
|
||||
}
|
||||
Some(crate::edit_text_ui::ImageAction::Redact(page, index)) => app.redact_page_image(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Link(page, index)) => app.link_page_image(page, index),
|
||||
Some(crate::edit_text_ui::ImageAction::Recognize(page)) => app.recognize_text_on_page(page),
|
||||
Some(crate::edit_text_ui::ImageAction::AddComment(page, i)) => {
|
||||
if let Some(d) = app.session.get(app.views[index].id) {
|
||||
let images = d.page_images(page);
|
||||
if let Some(img) = images.get(i) {
|
||||
let r = img.visible_rect.unwrap_or(img.rect);
|
||||
let center = [(r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0];
|
||||
app.views[index].comments.context_at = Some((page, center));
|
||||
app.views[index].comments.composer = Some(crate::comments::Composer {
|
||||
page,
|
||||
at: center,
|
||||
kind: crate::comments::ComposerKind::Note,
|
||||
text: String::new(),
|
||||
focus: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
match page_action {
|
||||
Some(PageMenuAction::Copy) => {
|
||||
if let Some(sel) = app.views[index].image_selection.clone() {
|
||||
app.copy_page_image(sel.page, sel.index);
|
||||
} else if app.views[index].selected_text().is_some() {
|
||||
app.execute("edit.copy");
|
||||
} else if let Some((page, at)) = app.views[index].comments.context_at
|
||||
&& let Some(doc) = app.session.get(app.views[index].id)
|
||||
&& let Some(image) = doc.page_image_at(page, at)
|
||||
{
|
||||
app.copy_page_image(page, image);
|
||||
}
|
||||
}
|
||||
Some(PageMenuAction::Paste(page, at)) => {
|
||||
app.paste_image_at(page, at);
|
||||
}
|
||||
Some(PageMenuAction::RotateCCW) => {
|
||||
app.views[index].rotate_view(false);
|
||||
}
|
||||
Some(PageMenuAction::RotateCW) => {
|
||||
app.views[index].rotate_view(true);
|
||||
}
|
||||
Some(PageMenuAction::Print) => {
|
||||
app.open_print();
|
||||
}
|
||||
Some(PageMenuAction::SelectTool) => {
|
||||
app.quick_tool = QuickTool::Select;
|
||||
}
|
||||
Some(PageMenuAction::HandTool) => {
|
||||
app.quick_tool = QuickTool::Hand;
|
||||
}
|
||||
Some(PageMenuAction::MarqueeZoom) => {
|
||||
app.quick_tool = QuickTool::MarqueeZoom;
|
||||
}
|
||||
Some(PageMenuAction::AddComment(page, at)) => {
|
||||
app.views[index].comments.composer =
|
||||
Some(crate::comments::Composer { page, at, kind: crate::comments::ComposerKind::Note, text: String::new(), focus: true });
|
||||
}
|
||||
Some(PageMenuAction::FlattenComments) => {
|
||||
app.views[index].comments.selected = None;
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::Flatten { comments: true, fields: false });
|
||||
}
|
||||
Some(PageMenuAction::AddBookmark) => {
|
||||
app.bookmark_action(crate::panels::BmAction::New);
|
||||
}
|
||||
Some(PageMenuAction::TakeSnapshot) => {
|
||||
app.quick_tool = QuickTool::Snapshot;
|
||||
}
|
||||
Some(PageMenuAction::EditPdf) => {
|
||||
app.quick_tool = QuickTool::EditText;
|
||||
}
|
||||
Some(PageMenuAction::AddText) => {
|
||||
app.quick_tool = QuickTool::AddText;
|
||||
}
|
||||
Some(PageMenuAction::AddImage) => {
|
||||
app.add_image_dialog();
|
||||
}
|
||||
Some(PageMenuAction::DeletePage(page)) => {
|
||||
app.views[index].pending_edit = Some(pdfcraft_engine::Edit::DeletePages { pages: vec![page] });
|
||||
}
|
||||
Some(PageMenuAction::ExportPdf) => {
|
||||
app.select_tool("export");
|
||||
}
|
||||
Some(PageMenuAction::Find) => {
|
||||
app.views[index].open_find();
|
||||
}
|
||||
Some(PageMenuAction::DocProperties) => {
|
||||
app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Description));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(why) = refused {
|
||||
@@ -3487,6 +4056,7 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
|
||||
// the quick tools come back with the window.
|
||||
if app.mode != crate::Mode::Read && !app.full_screen {
|
||||
quick_bar(app, avail, ui);
|
||||
crate::hotbar::show(app, avail, ui.ctx());
|
||||
}
|
||||
if let Some((source, file, destination)) = remote_open {
|
||||
app.open_remote_link(source, file, destination);
|
||||
|
||||
@@ -32,14 +32,22 @@ pub(super) const SUPPRESS_SECS: f64 = 0.25;
|
||||
/// Short rolling extension while egui still has an unprocessed wheel tail.
|
||||
pub(super) const SUPPRESS_TAIL_SECS: f64 = 0.05;
|
||||
|
||||
// Keep the viewer's scroll identity tied to the document instead of its parent UI.
|
||||
// Keep the viewer's scroll identity tied to the document instead of its parent UI. Without
|
||||
// `fades` (Show Page Edge Shadows off) the viewport-edge gradients are off for this area only.
|
||||
pub(super) fn page_scroll<R>(
|
||||
ui: &mut egui::Ui,
|
||||
scroll: egui::ScrollArea,
|
||||
id: egui::Id,
|
||||
fades: bool,
|
||||
draw: impl FnOnce(&mut egui::Ui, egui::Rect) -> R,
|
||||
) -> egui::scroll_area::ScrollAreaOutput<R> {
|
||||
ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner
|
||||
let strength = ui.spacing().scroll.fade.strength;
|
||||
if !fades {
|
||||
ui.spacing_mut().scroll.fade.strength = 0.0;
|
||||
}
|
||||
let out = ui.scope_builder(egui::UiBuilder::new().scope_id(id), |ui| scroll.show_viewport(ui, draw)).inner;
|
||||
ui.spacing_mut().scroll.fade.strength = strength;
|
||||
out
|
||||
}
|
||||
|
||||
/// Run a push button's action (the ones that need no JavaScript engine).
|
||||
|
||||
@@ -279,7 +279,15 @@ fn notice_bar(ui: &mut egui::Ui, t: &Tokens, icon: &str, icon_color: Color32, te
|
||||
/// The floating quick-action bar at the left edge of the document area.
|
||||
pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let pos = area.left_top() + vec2(14.0, 14.0);
|
||||
let mut offset = vec2(0.0, 0.0);
|
||||
if app.hotbar.full_side {
|
||||
match app.hotbar.dock.as_deref() {
|
||||
Some("left") => offset.x += app.hotbar.button_size + 14.0,
|
||||
Some("top") => offset.y += app.hotbar.button_size + 14.0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let pos = area.left_top() + vec2(14.0, 14.0) + offset;
|
||||
egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.card)
|
||||
@@ -302,6 +310,12 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
|
||||
let current = app.comment_prefs.group_tool[g];
|
||||
let active = matches!(app.quick_tool, QuickTool::Comment(t) if t.group() == g);
|
||||
let resp = icons::button(ui, current.icon(), 32.0, active, tl!(current.label()));
|
||||
if comments::GROUPS[g].len() == 1 {
|
||||
if resp.clicked() {
|
||||
app.execute(current.command());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A small corner triangle marks the flyout.
|
||||
let r = resp.rect;
|
||||
let tri = [r.right_bottom() + vec2(-4.0, -4.0), r.right_bottom() + vec2(-9.0, -4.0), r.right_bottom() + vec2(-4.0, -9.0)];
|
||||
|
||||
@@ -239,6 +239,12 @@ impl DocView {
|
||||
self.texts.get(&page).cloned()
|
||||
}
|
||||
|
||||
/// Whether `page`'s text layer has arrived from the renderer. Selecting text, the I-beam and
|
||||
/// find matches on a page all wait for it (tests and automation).
|
||||
pub fn has_page_text(&self, page: usize) -> bool {
|
||||
self.texts.contains_key(&page)
|
||||
}
|
||||
|
||||
pub fn thumb(&self, page: usize) -> Option<&TextureHandle> {
|
||||
self.thumbs.get(&page).map(|t| &t.tex)
|
||||
}
|
||||
|
||||
@@ -46,8 +46,16 @@ pub(crate) fn page_input(
|
||||
let u = info_page.view_to_user(vx, vy);
|
||||
let at = [f64::from(u[0]), f64::from(u[1])];
|
||||
let generation = doc.edit_generation();
|
||||
view.picked_image =
|
||||
doc.page_image_at(page, at).and_then(|index| doc.page_images(page).get(index).map(|i| PickedImage { page, index, rect: i.rect, generation }));
|
||||
view.picked_image = doc.page_image_at(page, at).and_then(|index| {
|
||||
doc.page_images(page).get(index).and_then(|i| {
|
||||
let screen_rect = crate::canvas::image_screen_rect(i, xf, info, page);
|
||||
if screen_rect.is_positive() && screen_rect.contains(p) {
|
||||
Some(PickedImage { page, index, rect: crate::canvas::image_selectable_bounds(i), generation })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Forget the selected picture once the document has been edited since it was picked: Edit text &
|
||||
@@ -71,7 +79,7 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, info:
|
||||
}
|
||||
|
||||
/// A file name without characters file systems refuse.
|
||||
fn safe_name(s: &str) -> String {
|
||||
pub(crate) fn safe_name(s: &str) -> String {
|
||||
let cleaned: String = s.chars().map(|c| if c.is_control() || r#"/\:*?"<>|"#.contains(c) { '_' } else { c }).take(80).collect();
|
||||
let cleaned = cleaned.trim().trim_matches('.').to_string();
|
||||
if cleaned.is_empty() { "PdfCraft".into() } else { cleaned }
|
||||
@@ -171,7 +179,8 @@ impl PdfCraftApp {
|
||||
return;
|
||||
}
|
||||
// The document may have changed since the click: the same picture must still be there.
|
||||
let current = self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(|im| im.rect));
|
||||
let current =
|
||||
self.session.get(self.views[i].id).and_then(|d| d.page_images(picked.page).get(picked.index).map(crate::canvas::image_selectable_bounds));
|
||||
if current == Some(picked.rect) {
|
||||
self.copy_page_image_file(picked.page, picked.index);
|
||||
} else {
|
||||
@@ -195,6 +204,7 @@ impl PdfCraftApp {
|
||||
let copied = file.and_then(|(ext, bytes)| {
|
||||
let name = format!("{stem} p{} img{}.{ext}", page.saturating_add(1), index.saturating_add(1));
|
||||
self.put_file_on_clipboard(&name, &bytes)?;
|
||||
self.last_copied_image = None;
|
||||
self.last_copied_file = Some((name, bytes));
|
||||
Ok(ext)
|
||||
});
|
||||
@@ -213,7 +223,7 @@ impl PdfCraftApp {
|
||||
}
|
||||
let dir = clipboard_dir().ok_or("there is no folder for the copied file")?;
|
||||
let path = write_clipboard_file(&dir, name, bytes)?;
|
||||
arboard::Clipboard::new().and_then(|mut c| c.set().file_list(&[path])).map_err(|e| e.to_string())
|
||||
crate::os_clipboard::with(|c| c.set().file_list(&[path])).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -56,18 +56,17 @@ impl PagePointer {
|
||||
// pointer is outside the window. With a dialog open only a drag counts, up to the frame
|
||||
// it stops: on the frame one opens egui still gives the pages a press that lands under
|
||||
// it, and its click.
|
||||
let held = resp.dragged() || resp.drag_stopped() || (!modal && resp.interact_pointer_pos().is_some());
|
||||
// `contains_pointer` alone also holds a few points under the edge of an Area over the
|
||||
// pages (egui's interact radius), so the top layer there must be the pages' own. A
|
||||
// dialog covers them from the frame it opens, before egui knows its layer.
|
||||
let layer = ui.layer_id();
|
||||
let on_top =
|
||||
|p: &Pos2| !modal && !dragged_elsewhere(ui, resp) && resp.contains_pointer() && ui.ctx().layer_id_at(*p).is_none_or(|l| l == layer);
|
||||
let press_origin = origin.or_else(|| first_press(&edges));
|
||||
let held =
|
||||
resp.dragged() || resp.drag_stopped() || (!modal && (resp.interact_pointer_pos().is_some() || press_origin.is_some_and(|p| on_top(&p))));
|
||||
let pos = at.filter(|p| held || on_top(p));
|
||||
if !held {
|
||||
return Self { pos, origin: None, edges: Vec::new(), primary_down, any_down };
|
||||
}
|
||||
Self { pos, origin: origin.or_else(|| first_press(&edges)), edges, primary_down, any_down }
|
||||
Self { pos, origin: press_origin, edges, primary_down, any_down }
|
||||
}
|
||||
|
||||
/// Where the pointer is, when it is inside `r`.
|
||||
|
||||
@@ -42,8 +42,7 @@ pub(crate) enum Owner {
|
||||
pub(crate) enum PicturePart {
|
||||
/// The picture: a click selects it, a drag moves the selected one.
|
||||
Body,
|
||||
/// A corner of the selected picture (left top, right top, left bottom, right bottom): a drag
|
||||
/// resizes it.
|
||||
/// A resize grip in `ImageObb::handles` order (four corners and four edge midpoints).
|
||||
Corner(usize),
|
||||
/// The selected picture's rotate knob: a drag turns it.
|
||||
Knob,
|
||||
|
||||
@@ -2002,3 +2002,38 @@ fn a_moved_picture_is_drawn_from_the_fresh_raster_while_a_tile_is_still_stale()
|
||||
h.state_mut().views[0].stale_tiles.clear();
|
||||
h.state_mut().views[0].tiles.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_handles_account_for_document_and_view_rotation() {
|
||||
for page_rotation in [0, 90, 180, 270] {
|
||||
for view_rotation in [0, 90, 180, 270] {
|
||||
let (width, height) = if page_rotation % 180 == 0 { (300.0, 400.0) } else { (400.0, 300.0) };
|
||||
let info = DocInfo {
|
||||
pages: vec![pdfcraft_render::PageInfo {
|
||||
width,
|
||||
height,
|
||||
label: String::new(),
|
||||
crop: [0.0, 0.0, 300.0, 400.0],
|
||||
rotation: page_rotation,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let size = if view_rotation % 180 == 0 { vec2(width, height) } else { vec2(height, width) };
|
||||
let xf = PageXform { rect: Rect::from_min_size(pos2(10.0, 20.0), size), rot: view_rotation, pw: width, ph: height };
|
||||
let obb = ImageObb::from_matrix([100.0, 0.0, 0.0, 50.0, 50.0, 70.0], &xf, &info, 0);
|
||||
let total = (page_rotation + view_rotation) % 360;
|
||||
assert_eq!(obb.is_rotated, total != 0, "page={page_rotation} view={view_rotation}: user-labelled grips need matrix resize when turned");
|
||||
for (handle, point) in obb.handles() {
|
||||
let direction = point - obb.screen_center;
|
||||
let cursor = match handle {
|
||||
ImageHandle::TopLeft | ImageHandle::TopRight | ImageHandle::BottomLeft | ImageHandle::BottomRight => {
|
||||
if direction.x * direction.y > 0.0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }
|
||||
}
|
||||
_ if direction.x.abs() > direction.y.abs() => egui::CursorIcon::ResizeHorizontal,
|
||||
_ => egui::CursorIcon::ResizeVertical,
|
||||
};
|
||||
assert_eq!(obb.handle_cursor(handle), cursor, "page={page_rotation} view={view_rotation} handle={handle:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// Where `page` is drawn this frame, with its coordinate mapping.
|
||||
pub(crate) fn page_xform(&self, page: usize) -> Option<PageXform> {
|
||||
pub fn page_xform(&self, page: usize) -> Option<PageXform> {
|
||||
self.screen_xforms.iter().find(|(p, _)| *p == page).map(|(_, xf)| *xf)
|
||||
}
|
||||
}
|
||||
@@ -128,6 +128,10 @@ impl PageXform {
|
||||
Rect::from_two_pos(self.norm_to_screen(a.0, a.1), self.norm_to_screen(b.0, b.1))
|
||||
}
|
||||
|
||||
pub fn user_rect_f64(&self, info: &DocInfo, page: usize, r: [f64; 4]) -> Rect {
|
||||
self.user_rect(info, page, r.map(|v| v as f32))
|
||||
}
|
||||
|
||||
/// This transform resized to a raster of `px` device pixels (width, height before the view
|
||||
/// rotation), so each of its texels covers one screen pixel. The rectangle's corner must
|
||||
/// already be on a whole pixel ([`snap_to_pixels`]).
|
||||
@@ -148,4 +152,34 @@ impl PageXform {
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Draw a sub-rectangle of a whole-page raster texture with specific texture UVs covering the normalised page region.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn paint_image_region(
|
||||
&self,
|
||||
painter: &egui::Painter,
|
||||
tex: egui::TextureId,
|
||||
pu0: f32,
|
||||
pv0: f32,
|
||||
pu1: f32,
|
||||
pv1: f32,
|
||||
tu0: f32,
|
||||
tv0: f32,
|
||||
tu1: f32,
|
||||
tv1: f32,
|
||||
) {
|
||||
let mut mesh = egui::Mesh::with_texture(tex);
|
||||
let corners = [(pu0, pv0, tu0, tv0), (pu1, pv0, tu1, tv0), (pu1, pv1, tu1, tv1), (pu0, pv1, tu0, tv1)];
|
||||
for (u, v, tu, tv) in corners {
|
||||
mesh.vertices.push(egui::epaint::Vertex { pos: self.norm_to_screen(u, v), uv: pos2(tu, tv), color: Color32::WHITE });
|
||||
}
|
||||
mesh.add_triangle(0, 1, 2);
|
||||
mesh.add_triangle(0, 2, 3);
|
||||
painter.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
|
||||
/// Draw a sub-rectangle of a whole-page raster texture, where UV space matches page normalized space.
|
||||
pub fn paint_page_texture_rect(&self, painter: &egui::Painter, tex: egui::TextureId, u0: f32, v0: f32, u1: f32, v1: f32) {
|
||||
self.paint_image_region(painter, tex, u0, v0, u1, v1, u0, v0, u1, v1);
|
||||
}
|
||||
}
|
||||
@@ -145,7 +145,7 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_clipboard {
|
||||
let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) };
|
||||
arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
crate::os_clipboard::with(|c| c.set_image(img)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
self.last_snapshot = Some((w, h, out.rgba));
|
||||
Ok((w, h))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Window chrome: tab strip (with the integrated macOS title bar), mode bar, right rail.
|
||||
|
||||
use craft_ui::panes::{Edge, Rail, RailItem, RailRequest};
|
||||
use craft_ui::panes::{Rail, RailItem, RailRequest};
|
||||
use egui::{Align, Color32, CornerRadius, Layout, Rect, Sense, Stroke, vec2};
|
||||
|
||||
use crate::canvas::{DocView, Fit, PageLayout};
|
||||
@@ -572,13 +572,18 @@ pub fn right_rail(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let view = &mut app.views[index];
|
||||
let mut page_display = None;
|
||||
let rail = Rail::new(egui::Id::unique("rail"))
|
||||
.edge(Edge::End)
|
||||
.edge(app.side_panels.rail_edge())
|
||||
.frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin::symmetric(6, 8)).stroke(Stroke::new(1.0, t.divider)))
|
||||
.show(ui, &items, app.right.as_ref(), |ui| {
|
||||
// Page navigation cluster at the bottom, a panel of its own so that in a short
|
||||
// window the buttons above scroll instead of running into it (#3282).
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing.y = 2.0;
|
||||
let right = app.side_panels.rail_on_right();
|
||||
let dock_tip = if right { tl!("Dock navigation rail on Left") } else { tl!("Dock navigation rail on Right") };
|
||||
if icons::button(ui, if right { "panel-left" } else { "panel-right" }, 32.0, false, dock_tip).clicked() {
|
||||
app.side_panels.rail_side = if right { crate::side_panels::ToolsSide::Left } else { crate::side_panels::ToolsSide::Right };
|
||||
}
|
||||
let tip = crate::commands::key_tip(ui.ctx(), tl!("Zoom out ({key})"), crate::commands::ZOOM_OUT);
|
||||
if icons::button(ui, "zoom-out", 32.0, false, &tip).clicked() {
|
||||
view.zoom_step(false);
|
||||
|
||||
@@ -259,6 +259,8 @@ impl PdfCraftApp {
|
||||
self.notify(error);
|
||||
}
|
||||
}
|
||||
"view.select_tool" => self.quick_tool = crate::QuickTool::Select,
|
||||
"view.hand_tool" => self.quick_tool = crate::QuickTool::Hand,
|
||||
"view.side_panel" => self.toggle_side_panel(),
|
||||
"view.tools_pane" => self.left_open = !self.left_open,
|
||||
"view.focus_page_input" => {
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
//! `theme=light|dark|system` changes the saved theme preference; `ui.state.theme_preference`
|
||||
//! reports the choice and `ui.state.theme` reports the resolved light/dark colours.
|
||||
//! `comment-tool-pinned=true|false` remembers the annotation quick bar's pin across restarts;
|
||||
//! `hotbar-tools` accepts comma-separated command IDs (empty clears the bar), and
|
||||
//! `hotbar-side=float|left|right|top|bottom` floats or docks the customizable hotbar;
|
||||
//! `hotbar-full-side=true|false` extends a docked bar along the entire side.
|
||||
//! `hotbar-size=24..64` controls its button size in logical pixels; `ui.state.hotbar`
|
||||
//! reports its tools, relative position, orientation and strip preference. These options persist across restarts.
|
||||
//! `ui.state.comment_tool_pinned` reports the same preference.
|
||||
//! - `ui.open {path}`: open a file. A file whose tab is already open focuses it
|
||||
//! (`"focused": true`) instead of adding a tab; native opens report `pending: true` until
|
||||
|
||||
@@ -133,9 +133,11 @@ impl Host for crate::PdfCraftApp {
|
||||
"home": self.active.is_none(),
|
||||
"mode": format!("{:?}", self.mode),
|
||||
"default_mode": self.default_mode,
|
||||
"hotbar": self.hotbar,
|
||||
"comment_tool_pinned": self.comment_prefs.pinned,
|
||||
"left_panel": if self.left_open { json!(format!("{:?}", self.left)) } else { Value::Null },
|
||||
"tools_side": if self.side_panels.tools_on_right() { "right" } else { "left" },
|
||||
"rail_side": if self.side_panels.rail_on_right() { "right" } else { "left" },
|
||||
"right_panel": self.right.map(|r| format!("{r:?}")),
|
||||
"dialog": self.dialog.map(|d| format!("{d:?}")),
|
||||
"palette_open": self.palette_open,
|
||||
|
||||
+143
-22
@@ -14,6 +14,7 @@ pub(super) mod credits;
|
||||
pub(super) mod marks_ui;
|
||||
pub(super) mod pageboxes;
|
||||
pub(super) mod palette;
|
||||
pub(super) mod preferences;
|
||||
mod prompts;
|
||||
pub(super) mod protect;
|
||||
pub(super) mod standards_ui;
|
||||
@@ -127,7 +128,9 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) {
|
||||
Dialog::Properties(_) => 640.0,
|
||||
Dialog::Print => 820.0,
|
||||
Dialog::FieldProps | Dialog::BulkFieldProps => 600.0,
|
||||
Dialog::Preferences => 610.0,
|
||||
Dialog::About => 780.0,
|
||||
Dialog::PinTool => 540.0,
|
||||
_ => 520.0,
|
||||
});
|
||||
if dialog_body(ui, app, dialog, &mut req) {
|
||||
@@ -446,6 +449,11 @@ fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &m
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Dialog::PinTool => {
|
||||
if !dialog_pin_tool(ui, app, req) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -1080,6 +1088,7 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
use pdfcraft_engine::{PageOrientation as O, PageParity as P};
|
||||
let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len());
|
||||
let d = &mut app.rotate_draft;
|
||||
let mut typing_to = false;
|
||||
ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0)));
|
||||
ui.add_space(8.0);
|
||||
egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
@@ -1101,10 +1110,11 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
widgets::radio_value(ui, &mut d.which, 0, tl!("All"));
|
||||
widgets::radio_value(ui, &mut d.which, 1, tl!("Selection"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
let from_label = widgets::radio_value(ui, &mut d.which, 2, tl!("From"));
|
||||
ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)).labelled_by(from_label.id);
|
||||
let to_label = ui.label(tl!("to"));
|
||||
typing_to =
|
||||
widgets::typing_into(&ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)).labelled_by(to_label.id));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
});
|
||||
});
|
||||
@@ -1136,10 +1146,12 @@ fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
// OK while the end is still being typed (a screen reader can press it).
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, false);
|
||||
req.rotate_now = true;
|
||||
req.close = true;
|
||||
}
|
||||
@@ -1161,17 +1173,21 @@ fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pe
|
||||
ui.add_space(8.0);
|
||||
ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)]));
|
||||
widgets::radio_value(ui, &mut d.all, true, tl!("All pages"));
|
||||
ui.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
let typing_to = ui
|
||||
.horizontal(|ui| {
|
||||
widgets::radio_value(ui, &mut d.all, false, tl!("From"));
|
||||
ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
let typing_to = widgets::typing_into(&ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)));
|
||||
ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())]));
|
||||
typing_to
|
||||
})
|
||||
.inner;
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
ui.add_space(12.0);
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, false);
|
||||
let pages: Vec<usize> = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() };
|
||||
req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages });
|
||||
req.close = true;
|
||||
@@ -1342,10 +1358,13 @@ fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending)
|
||||
|
||||
/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted.
|
||||
fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
|
||||
ui.set_width(610.0);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
preferences::preferences_header(ui, &t);
|
||||
ui.add_space(10.0);
|
||||
// The settings outgrow a short window: scroll them rather than run off the screen.
|
||||
// The OK row stays below the scroll, in reach however far the settings are scrolled.
|
||||
let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL);
|
||||
let max = (ui.ctx().content_rect().height() - 120.0 - preferences::PREFERENCES_BUTTON_ROW - 40.0).max(preferences::PREFERENCES_MIN_SCROLL);
|
||||
// A scroll area starts from the room the dialog offers it, and a modal offers more
|
||||
// each frame for the first few, so with OK outside the scroll the dialog would grow
|
||||
// over those frames. Asking for the full height as the least it may scroll in
|
||||
@@ -1354,9 +1373,17 @@ fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendin
|
||||
// cards and other full-width rows (#3557).
|
||||
ui.scope(|ui| {
|
||||
ui.spacing_mut().scroll.floating = false;
|
||||
egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t));
|
||||
ui.spacing_mut().scroll.bar_inner_margin = 14.0;
|
||||
ui.spacing_mut().scroll.bar_outer_margin = 2.0;
|
||||
ui.style_mut().scroll_animation = egui::style::ScrollAnimation::duration(0.25);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("preferences-dialog-scroll")
|
||||
.max_height(max)
|
||||
.min_scrolled_height(max)
|
||||
.animated(true)
|
||||
.show(ui, |ui| preferences::preferences_body(ui, app, &t));
|
||||
});
|
||||
req.close = crate::js_ui::preferences_buttons(ui);
|
||||
req.close = preferences::preferences_buttons(ui);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1370,13 +1397,14 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1);
|
||||
let d = &mut app.number_draft;
|
||||
d.to = d.to.clamp(1, n);
|
||||
d.from = d.from.clamp(1, d.to);
|
||||
d.from = d.from.clamp(1, n);
|
||||
let mut typing_to = false;
|
||||
egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
ui.label(tl!("Pages"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut d.from).range(1..=n));
|
||||
ui.label(tl!("to"));
|
||||
ui.add(egui::DragValue::new(&mut d.to).range(1..=n));
|
||||
typing_to = widgets::typing_into(&ui.add(egui::DragValue::new(&mut d.to).range(1..=n)));
|
||||
ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted));
|
||||
});
|
||||
ui.end_row();
|
||||
@@ -1403,18 +1431,20 @@ fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pendi
|
||||
ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999));
|
||||
ui.end_row();
|
||||
});
|
||||
d.to = d.to.max(d.from);
|
||||
widgets::keep_range_ordered(d.from, &mut d.to, typing_to);
|
||||
let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k));
|
||||
ui.add_space(8.0);
|
||||
let preview = if d.from == d.to {
|
||||
// `to` can be below `from` while it is being typed.
|
||||
let preview = if d.to <= d.from {
|
||||
label(d.start)
|
||||
} else {
|
||||
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32))
|
||||
format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start.saturating_add(u32::try_from(d.to - d.from).unwrap_or(u32::MAX))))
|
||||
};
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted),
|
||||
);
|
||||
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
|
||||
// Ordered here too: the footer's OK can be pressed while the end is still being typed.
|
||||
req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to.max(d.from) - 1, style: d.style, prefix: d.prefix.clone(), first: d.start });
|
||||
true
|
||||
}
|
||||
|
||||
@@ -1553,6 +1583,91 @@ fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn dialog_pin_tool(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Pin tools to Home")).font(theme::semibold(18.0)));
|
||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||
if widgets::ghost_button(ui, "x", tl!("Close")).clicked() {
|
||||
req.close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Select tools to display on your Home page for quick access.")).color(t.text_muted));
|
||||
ui.add_space(10.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let (icon_rect, _) = ui.allocate_exact_size(egui::vec2(18.0, 18.0), egui::Sense::hover());
|
||||
crate::icons::paint(ui, icon_rect, "search", 16.0, t.text_muted);
|
||||
let search_resp =
|
||||
ui.add(egui::TextEdit::singleline(&mut app.pin_tool_search).hint_text(tl!("Search tools…")).desired_width(ui.available_width() - 32.0));
|
||||
search_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), tl!("Search tools…")));
|
||||
if !app.pin_tool_search.is_empty() && widgets::ghost_button(ui, "x", tl!("Clear search")).clicked() {
|
||||
app.pin_tool_search.clear();
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
ui.separator();
|
||||
ui.add_space(6.0);
|
||||
|
||||
let query = app.pin_tool_search.trim().to_lowercase();
|
||||
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let mut toggle = None;
|
||||
for g in pdfcraft_engine::catalog::TOOL_GROUPS {
|
||||
let label = tl!(g.label);
|
||||
let blurb = g.sections.first().map(|s| s.items.iter().take(4).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" • ")).unwrap_or_default();
|
||||
if !query.is_empty()
|
||||
&& !label.to_lowercase().contains(&query)
|
||||
&& !blurb.to_lowercase().contains(&query)
|
||||
&& !g.id.to_lowercase().contains(&query)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let pinned = app.is_tool_pinned(g.id);
|
||||
let (rect, resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 44.0), egui::Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, label));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
press.wash(ui, rect, 6, false);
|
||||
let body = rect.translate(press.offset());
|
||||
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
crate::icons::paint(ui, egui::Rect::from_min_size(body.min + egui::vec2(8.0, 11.0), egui::vec2(22.0, 22.0)), g.icon, 20.0, color);
|
||||
|
||||
let btn_w = 78.0;
|
||||
let btn_rect = egui::Rect::from_center_size(body.right_center() - egui::vec2(btn_w / 2.0 + 8.0, 0.0), egui::vec2(btn_w, 26.0));
|
||||
let btn_resp = ui.interact(btn_rect, ui.scope_id().with(("pin_modal_btn", g.id)), egui::Sense::click());
|
||||
let btn_hovered = btn_resp.hovered();
|
||||
|
||||
let (btn_fill, btn_stroke, text_color, btn_label) = if pinned {
|
||||
(t.accent, egui::Stroke::NONE, egui::Color32::WHITE, tl!("Pinned"))
|
||||
} else if btn_hovered {
|
||||
(t.hover, egui::Stroke::new(1.0, t.border), t.text, tl!("Pin"))
|
||||
} else {
|
||||
(egui::Color32::TRANSPARENT, egui::Stroke::new(1.0, t.border), t.text_muted, tl!("Pin"))
|
||||
};
|
||||
let tip = if pinned { tl!("Unpin from Home") } else { tl!("Pin to Home") };
|
||||
btn_resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, pinned, format!("{tip}: {label}")));
|
||||
|
||||
ui.painter().rect(btn_rect, egui::CornerRadius::same(6), btn_fill, btn_stroke, egui::StrokeKind::Inside);
|
||||
ui.painter().text(btn_rect.center(), egui::Align2::CENTER_CENTER, btn_label, theme::medium(12.0), text_color);
|
||||
|
||||
let max_w = (btn_rect.left() - body.left() - 44.0).max(10.0);
|
||||
widgets::row_text(ui, body.min + egui::vec2(38.0, 6.0), label, theme::semibold(13.5), t.text, max_w);
|
||||
widgets::row_text(ui, body.min + egui::vec2(38.0, 24.0), &blurb, theme::regular(11.0), t.text_muted, max_w);
|
||||
|
||||
if btn_resp.clicked() || resp.clicked() {
|
||||
toggle = Some(g.id);
|
||||
}
|
||||
}
|
||||
if let Some(id) = toggle {
|
||||
app.toggle_pin_tool(id);
|
||||
}
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) {
|
||||
ui.add_space(16.0);
|
||||
let changed = draft_changes(app).is_some_and(|c| !c.is_empty());
|
||||
@@ -1569,6 +1684,10 @@ fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req:
|
||||
req.recover = Some(false);
|
||||
req.close = true;
|
||||
}
|
||||
} else if dialog == Dialog::PinTool {
|
||||
if widgets::pill_button(ui, tl!("Done"), true).clicked() {
|
||||
req.close = true;
|
||||
}
|
||||
} else if dialog == Dialog::NumberPages {
|
||||
if widgets::pill_button(ui, tl!("OK"), true).clicked() {
|
||||
req.apply_number = true;
|
||||
@@ -1836,6 +1955,8 @@ fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req:
|
||||
// Protect / Remove security replace the Properties dialog.
|
||||
let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some();
|
||||
if modal_closed || req.close || replaces {
|
||||
// Only now: the number in Preferences can pass through 0 on its way to the one wanted.
|
||||
app.recent.truncate(app.recent_limit);
|
||||
app.dialog = None;
|
||||
app.props_draft = None;
|
||||
app.view_draft = None;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! Preferences › Appearance: the appearance mode (follow the system, light or dark) above a light
|
||||
//! and a dark theme card, each with a small picture of PdfCraft in that theme.
|
||||
|
||||
use egui::{Rect, RichText, pos2, vec2};
|
||||
use egui::{CornerRadius, Rect, RichText, Stroke, pos2, vec2};
|
||||
|
||||
use crate::PdfCraftApp;
|
||||
use crate::theme::{self, ThemeKind, ThemePreference, Tokens};
|
||||
use crate::widgets;
|
||||
|
||||
/// The themes each card offers. One of each today; a new theme is one more row here.
|
||||
pub const LIGHT_THEMES: &[(ThemeKind, &str)] = &[(ThemeKind::Light, "Light gray")];
|
||||
@@ -82,43 +81,70 @@ fn preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// One theme card: its title, Active when it is what shows now, a preview of the chosen theme
|
||||
/// and a radio row per theme. Returns true when the card or one of its radio rows was clicked.
|
||||
fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)]) -> bool {
|
||||
let inner = egui::Frame::new()
|
||||
.fill(t.card)
|
||||
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.border }))
|
||||
.corner_radius(6.0)
|
||||
.inner_margin(egui::Margin::same(8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(title).font(theme::semibold(12.5)).color(t.text));
|
||||
if active {
|
||||
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind);
|
||||
preview(ui, shown, ui.available_width());
|
||||
ui.add_space(4.0);
|
||||
let mut clicked = false;
|
||||
for (kind, label) in themes {
|
||||
// One theme per card today, so it is always the chosen one.
|
||||
clicked |= ui.add(widgets::radio(*kind == shown, tl!(label))).clicked();
|
||||
}
|
||||
clicked
|
||||
});
|
||||
// The whole card picks its theme, not just the radio row.
|
||||
let whole = inner.response.interact(egui::Sense::click()).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
inner.inner || whole.clicked()
|
||||
/// One theme card: its title, Active when it is what shows now, and a preview of the chosen theme.
|
||||
/// Returns whether the card was clicked and the control's Response.
|
||||
fn card(ui: &mut egui::Ui, t: &Tokens, title: &str, active: bool, themes: &[(ThemeKind, &str)], query: &str) -> (bool, egui::Response) {
|
||||
let mut card_clicked = false;
|
||||
let frame_stroke = if active { Stroke::new(1.5, t.accent) } else { Stroke::new(1.0, t.border) };
|
||||
let scope_res = ui.scope_builder(egui::UiBuilder::new().id_salt(title).sense(egui::Sense::click()), |ui| {
|
||||
egui::Frame::new().fill(t.card).stroke(frame_stroke).corner_radius(CornerRadius::same(6)).inner_margin(egui::Margin::same(8)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
let font = theme::semibold(12.5);
|
||||
let ranges = crate::dialogs::preferences::find_match_ranges(title, query);
|
||||
let galley = if ranges.is_empty() {
|
||||
crate::bidi::layout_ui(ui, title.to_owned(), font, t.text)
|
||||
} else {
|
||||
let (job, _) = crate::dialogs::preferences::create_highlight_job(title, &ranges, font, t.text);
|
||||
ui.painter().layout_job(job)
|
||||
};
|
||||
let (title_rect, _) = ui.allocate_exact_size(galley.size(), egui::Sense::hover());
|
||||
ui.painter().galley(title_rect.min, galley, t.text);
|
||||
if active {
|
||||
ui.label(RichText::new(tl!("Active")).color(t.accent_text).small());
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let shown = themes.first().map_or(ThemeKind::Light, |(kind, _)| *kind);
|
||||
preview(ui, shown, ui.available_width());
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
let resp = scope_res.response;
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, true, active, title));
|
||||
|
||||
if resp.has_focus() {
|
||||
ui.painter().rect_stroke(resp.rect, CornerRadius::same(6), Stroke::new(2.0, t.accent_text), egui::StrokeKind::Outside);
|
||||
}
|
||||
|
||||
let resp = theme::hand(resp);
|
||||
let key_select = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Space) || i.key_pressed(egui::Key::Enter));
|
||||
if resp.clicked() || key_select {
|
||||
card_clicked = true;
|
||||
}
|
||||
(card_clicked, resp)
|
||||
}
|
||||
|
||||
/// The Appearance section of Preferences.
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "called by unit tests"))]
|
||||
pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) {
|
||||
ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0)));
|
||||
let mut ctx = crate::dialogs::preferences::PrefSearchContext::new("", false);
|
||||
preferences_section_with_context(ui, app, t, &mut ctx);
|
||||
}
|
||||
|
||||
/// The Appearance section of Preferences with live search highlighting and scrolling context.
|
||||
pub(crate) fn preferences_section_with_context(
|
||||
ui: &mut egui::Ui,
|
||||
app: &mut PdfCraftApp,
|
||||
t: &Tokens,
|
||||
ctx: &mut crate::dialogs::preferences::PrefSearchContext,
|
||||
) {
|
||||
ctx.label(ui, tl_ctx!("theme", "Appearance"), theme::semibold(13.0), t.text);
|
||||
ui.horizontal(|ui| {
|
||||
let label = ui.label(tl!("Appearance mode"));
|
||||
let label = ctx.label(ui, tl!("Appearance mode"), theme::regular(13.0), t.text);
|
||||
let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| {
|
||||
for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] {
|
||||
if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() {
|
||||
@@ -132,18 +158,29 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens)
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let light_active = app.theme == ThemeKind::Light;
|
||||
let light_title = tl!("Light theme");
|
||||
let dark_title = tl!("Dark theme");
|
||||
let light_matches = crate::dialogs::preferences::find_match_ranges(light_title, ctx.query).len();
|
||||
let dark_matches = crate::dialogs::preferences::find_match_ranges(dark_title, ctx.query).len();
|
||||
ctx.total_matches += light_matches + dark_matches;
|
||||
|
||||
ui.columns(2, |cols| {
|
||||
let [light, dark] = cols else { return };
|
||||
if card(light, t, tl!("Light theme"), light_active, LIGHT_THEMES) {
|
||||
let (light_clicked, light_resp) = card(light, t, light_title, light_active, LIGHT_THEMES, ctx.query);
|
||||
if light_clicked {
|
||||
app.execute(mode_command(ThemePreference::Light));
|
||||
}
|
||||
if card(dark, t, tl!("Dark theme"), !light_active, DARK_THEMES) {
|
||||
ctx.scroll_if_first_match(&light_resp, light_matches);
|
||||
|
||||
let (dark_clicked, dark_resp) = card(dark, t, dark_title, !light_active, DARK_THEMES, ctx.query);
|
||||
if dark_clicked {
|
||||
app.execute(mode_command(ThemePreference::Dark));
|
||||
}
|
||||
ctx.scroll_if_first_match(&dark_resp, dark_matches);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
// Acrobat's "Highlight existing fields": shared with the toggle in the form bar.
|
||||
ui.add(crate::widgets::checkbox(&mut app.view_defaults.highlight_fields, tl!("Highlight fields")));
|
||||
ctx.checkbox(ui, &mut app.view_defaults.highlight_fields, tl!("Highlight fields"), t);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -175,7 +212,6 @@ mod tests {
|
||||
);
|
||||
h.run();
|
||||
assert!(h.query_by_label("Light theme").is_some() && h.query_by_label("Dark theme").is_some());
|
||||
assert!(h.query_by_label("Light gray").is_some() && h.query_by_label("Dark gray").is_some());
|
||||
assert_eq!(h.query_all_by_label("Active").count(), 1);
|
||||
if let Ok(path) = std::env::var("PDFCRAFT_APPEARANCE_SHOT") {
|
||||
h.render().unwrap().save(path).unwrap();
|
||||
@@ -200,7 +236,7 @@ mod tests {
|
||||
assert_eq!(h.state().theme_preference, ThemePreference::System);
|
||||
}
|
||||
|
||||
/// Clicking a theme card, or its radio row, picks that theme, not only the mode combo box.
|
||||
/// Clicking a theme card picks that theme, not only the mode combo box.
|
||||
#[test]
|
||||
fn clicking_a_theme_card_picks_that_theme() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -219,10 +255,9 @@ mod tests {
|
||||
app,
|
||||
);
|
||||
h.run();
|
||||
h.get_by_label("Dark gray").click();
|
||||
h.get_by_label("Dark theme").click();
|
||||
h.run();
|
||||
assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Dark, ThemeKind::Dark));
|
||||
// The card's title is not a widget of its own: the click lands on the card.
|
||||
h.get_by_label("Light theme").click();
|
||||
h.run();
|
||||
assert_eq!((h.state().theme_preference, h.state().theme), (ThemePreference::Light, ThemeKind::Light));
|
||||
|
||||
@@ -240,7 +240,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
let min_width = if e.font_size.is_some() { 0.0 } else { 0.5 };
|
||||
if let Some(w) = e.width.as_mut() {
|
||||
ui.label(tl!("Thickness"));
|
||||
ui.add(egui::Slider::new(w, min_width..=12.0).step_by(0.5).suffix(" pt"));
|
||||
ui.add(egui::Slider::new(w, min_width..=crate::comments::MAX_LINE_WIDTH).step_by(0.5).suffix(" pt"));
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(ends) = e.endings.as_mut() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+103
-11
@@ -31,7 +31,7 @@ fn new_text_typed(v: &crate::DocView) -> bool {
|
||||
/// is a deleted annotation's page's annotations before the delete (empty for other edits).
|
||||
fn select_after_edit(
|
||||
view: &mut crate::DocView,
|
||||
edit: Edit,
|
||||
edit: &Edit,
|
||||
info: &pdfcraft_render::DocInfo,
|
||||
annots_before: &[pdfcraft_render::Annotation],
|
||||
quick_tool: crate::QuickTool,
|
||||
@@ -39,21 +39,24 @@ fn select_after_edit(
|
||||
match edit {
|
||||
Edit::MovePages { pages, to } => {
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let to = to.min(info.pages.len() - n);
|
||||
let to = (*to).min(info.pages.len() - n);
|
||||
view.select_pages(&(to..to + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::DuplicatePages { pages, at: Some(at) } => {
|
||||
let n = pages.iter().collect::<std::collections::BTreeSet<_>>().len();
|
||||
let at = at.min(info.pages.len().saturating_sub(n));
|
||||
let at = (*at).min(info.pages.len().saturating_sub(n));
|
||||
view.select_pages(&(at..at + n).collect::<Vec<_>>());
|
||||
}
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]),
|
||||
Edit::InsertBlankPage { at, .. } => view.select_pages(&[(*at).min(info.pages.len() - 1)]),
|
||||
Edit::DeletePages { .. } => view.select_pages(&[]),
|
||||
// A stroke drawn with the pen stays unselected, so the selection box and
|
||||
// author popup don't sit over the next stroke (#429).
|
||||
Edit::AddAnnotation(a)
|
||||
if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. })
|
||||
&& quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) =>
|
||||
if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. } | pdfcraft_engine::Shape::FreehandHighlight { .. })
|
||||
&& matches!(
|
||||
quick_tool,
|
||||
crate::QuickTool::Comment(crate::comments::CommentTool::Ink | crate::comments::CommentTool::FreehandHighlight)
|
||||
) =>
|
||||
{
|
||||
view.comments.selected = None;
|
||||
}
|
||||
@@ -71,7 +74,7 @@ fn select_after_edit(
|
||||
view.comments.open_editor(a.page, n, String::new());
|
||||
}
|
||||
}
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (page, index)),
|
||||
Edit::DeleteAnnotation { page, index } => view.comments.annotation_deleted(annots_before, &info.annotations, (*page, *index)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +115,25 @@ impl PdfCraftApp {
|
||||
pub fn apply_edit(&mut self, edit: Edit) -> bool {
|
||||
let Some((i, id)) = self.active_ids() else { return false };
|
||||
let label = edit.label();
|
||||
let target_arrange_image = match &edit {
|
||||
Edit::EditPageImage { page, index, change: pdfcraft_engine::ImageEdit::Arrange(_) } => {
|
||||
let doc = self.session.get(id);
|
||||
let target = doc.and_then(|d| d.page_images(*page).get(*index).cloned());
|
||||
let ambiguous = doc.is_some_and(|d| {
|
||||
if let Some(t) = &target {
|
||||
d.page_images(*page)
|
||||
.iter()
|
||||
.filter(|img| img.name == t.name && img.object == t.object && rect_matches(img.rect, t.rect))
|
||||
.count()
|
||||
> 1
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
target.map(|t| (t, ambiguous))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let (form_before, page_count_before, had_xfa) = if let Some(d) = self.session.get(id) {
|
||||
(Some(std::sync::Arc::clone(&d.form)), d.info.pages.len(), d.xfa.is_some())
|
||||
} else {
|
||||
@@ -166,7 +188,11 @@ impl PdfCraftApp {
|
||||
view.comments.comment_added();
|
||||
}
|
||||
// Keep the pages the user acted on selected, where they now are.
|
||||
select_after_edit(view, edit, info, &annots_before, self.quick_tool);
|
||||
select_after_edit(view, &edit, info, &annots_before, self.quick_tool);
|
||||
if let (Some((target, ambiguous_before)), Edit::EditPageImage { page, .. }) = (target_arrange_image, &edit) {
|
||||
let page_images = doc.page_images(*page);
|
||||
re_resolve_image_selection(view, *page, &target, ambiguous_before, &page_images);
|
||||
}
|
||||
if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned {
|
||||
self.quick_tool = crate::QuickTool::Select;
|
||||
}
|
||||
@@ -187,6 +213,9 @@ impl PdfCraftApp {
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
// A drawing whose annotation wasn't added must not stay painted.
|
||||
view.comments.drawings_refused();
|
||||
if matches!(&edit, Edit::EditPageImage { change, .. } if !matches!(change, pdfcraft_engine::ImageEdit::Arrange(_))) {
|
||||
view.image_selection = None;
|
||||
}
|
||||
// A paragraph that couldn't be applied opens again with its typing, instead of
|
||||
// the box the click moved to.
|
||||
if let Edit::EditTextBlock { page, block, .. } = &edit
|
||||
@@ -287,13 +316,34 @@ impl PdfCraftApp {
|
||||
if !self.close_text_editors(i, id) {
|
||||
return;
|
||||
}
|
||||
let target_image = self.views.get(i).and_then(|v| v.image_selection.as_ref()).and_then(|s| {
|
||||
let doc = self.session.get(id)?;
|
||||
let images = doc.page_images(s.page);
|
||||
let target = images.get(s.index).cloned()?;
|
||||
let ambiguous =
|
||||
images.iter().filter(|img| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect)).count() > 1;
|
||||
Some((s.page, target, ambiguous))
|
||||
});
|
||||
let had_selection = self.views.get(i).and_then(|v| v.image_selection.as_ref()).is_some();
|
||||
let result = if undo { self.session.undo(id) } else { self.session.redo(id) };
|
||||
match result {
|
||||
Ok(label) => {
|
||||
if let Some(doc) = self.session.get(id) {
|
||||
self.views[i].document_changed(&doc.info);
|
||||
if let Some(doc) = self.session.get(id)
|
||||
&& let Some(view) = self.views.get_mut(i)
|
||||
{
|
||||
view.document_changed(&doc.info);
|
||||
view.picked_image = None;
|
||||
if let Some((page, target, ambiguous_before)) = target_image {
|
||||
let page_images = doc.page_images(page);
|
||||
re_resolve_image_selection(view, page, &target, ambiguous_before, &page_images);
|
||||
} else if had_selection {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
if let Some(view) = self.views.get_mut(i) {
|
||||
view.comments.selected = None;
|
||||
view.comments.history_stepped();
|
||||
}
|
||||
self.views[i].comments.history_stepped();
|
||||
let label = crate::i18n::edit_label(&label);
|
||||
if undo {
|
||||
self.notify_fmt("Undid {label}", &[("label", &label)]);
|
||||
@@ -783,6 +833,48 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn rect_matches(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
a.iter().zip(b.iter()).all(|(x, y)| (x - y).abs() < 1e-3)
|
||||
}
|
||||
|
||||
fn re_resolve_image_selection(
|
||||
view: &mut crate::DocView,
|
||||
page: usize,
|
||||
target: &pdfcraft_engine::PageImage,
|
||||
ambiguous_before: bool,
|
||||
page_images: &[pdfcraft_engine::PageImage],
|
||||
) {
|
||||
let exact_matches: Vec<usize> = page_images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, img)| img.name == target.name && img.object == target.object && rect_matches(img.rect, target.rect))
|
||||
.map(|(idx, _)| idx)
|
||||
.collect();
|
||||
let new_idx = if !ambiguous_before && exact_matches.len() == 1 {
|
||||
exact_matches.first().copied()
|
||||
} else if !ambiguous_before && exact_matches.is_empty() {
|
||||
let name_obj_matches: Vec<usize> =
|
||||
page_images.iter().enumerate().filter(|(_, img)| img.name == target.name && img.object == target.object).map(|(idx, _)| idx).collect();
|
||||
if name_obj_matches.len() == 1 {
|
||||
name_obj_matches.first().copied()
|
||||
} else if name_obj_matches.is_empty() {
|
||||
let name_matches: Vec<usize> = page_images.iter().enumerate().filter(|(_, img)| img.name == target.name).map(|(idx, _)| idx).collect();
|
||||
if name_matches.len() == 1 { name_matches.first().copied() } else { None }
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = view.image_selection.as_mut().filter(|s| s.page == page) {
|
||||
if let Some(idx) = new_idx {
|
||||
s.index = idx;
|
||||
} else {
|
||||
view.image_selection = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The single page a content edit changes (`None` for other edits). Like comment edits,
|
||||
/// these leave every other page's bytes, geometry and text alone, so their rasters stay valid.
|
||||
fn content_page(edit: &Edit) -> Option<usize> {
|
||||
|
||||
+137
-51
@@ -20,6 +20,57 @@ pub(crate) fn card_lift(ui: &egui::Ui, t: &Tokens, rect: Rect, press: theme::Pre
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a single tool card with the standard lift, styling, icon, title, description, and action.
|
||||
/// If `allow_unpin` is true, an unpin button is always accessible in the top right corner.
|
||||
fn tool_card(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'static catalog::ToolGroup, allow_unpin: bool) -> Option<&'static str> {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
card_lift(ui, t, rect, press);
|
||||
let fill = press.fill(t.card, t.hover, t.pressed);
|
||||
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
let body = rect.translate(press.offset());
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
|
||||
let mut unpin_clicked = false;
|
||||
if allow_unpin {
|
||||
let unpin_rect = Rect::from_center_size(body.right_top() + vec2(-18.0, 18.0), vec2(22.0, 22.0));
|
||||
let unpin_resp = ui.interact(unpin_rect, ui.scope_id().with(("unpin_card_btn", g.id)), Sense::click());
|
||||
unpin_resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Unpin tool")));
|
||||
if unpin_resp.hovered() {
|
||||
ui.painter().rect_filled(unpin_rect, CornerRadius::same(4), t.pressed);
|
||||
}
|
||||
icons::paint(ui, unpin_rect.shrink(4.0), "x", 14.0, t.icon);
|
||||
if unpin_resp.on_hover_text(tl!("Unpin from Home")).clicked() {
|
||||
unpin_clicked = true;
|
||||
}
|
||||
}
|
||||
|
||||
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
|
||||
title_job.wrap = egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let title = ui.fonts_mut(|f| f.layout_job(title_job));
|
||||
let title_elided = title.elided;
|
||||
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
|
||||
let blurb = g.sections.first().map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · ")).unwrap_or_default();
|
||||
// Fixed-height cards must leave room for their action in every language.
|
||||
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
|
||||
job.wrap.max_rows = 2;
|
||||
let galley = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let blurb_elided = galley.elided;
|
||||
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(body.left_bottom() + vec2(14.0, -14.0), Align2::LEFT_CENTER, tl!("Use now"), theme::medium(12.0), t.accent_text);
|
||||
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
|
||||
if unpin_clicked {
|
||||
Some(g.id)
|
||||
} else {
|
||||
if theme::hand(resp).clicked() {
|
||||
app.select_tool(g.id);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the Home page with the welcome text, community card and recent documents.
|
||||
pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -73,44 +124,11 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
for id in RECOMMENDED {
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(190.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!(g.label)));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
card_lift(ui, &t, rect, press);
|
||||
let fill = press.fill(t.card, t.hover, t.pressed);
|
||||
ui.painter().rect(rect, CornerRadius::same(10), fill, Stroke::new(1.0, t.divider), egui::StrokeKind::Inside);
|
||||
let body = rect.translate(press.offset());
|
||||
let color = egui::Color32::from_rgb(g.hue[0], g.hue[1], g.hue[2]);
|
||||
icons::paint(ui, Rect::from_min_size(body.min + vec2(14.0, 14.0), vec2(22.0, 22.0)), g.icon, 21.0, color);
|
||||
let mut title_job = egui::text::LayoutJob::simple_singleline(tl!(g.label).to_owned(), theme::semibold(13.5), t.text);
|
||||
title_job.wrap =
|
||||
egui::text::TextWrapping { max_width: body.width() - 58.0, max_rows: 1, break_anywhere: true, ..Default::default() };
|
||||
let title = ui.fonts_mut(|f| f.layout_job(title_job));
|
||||
let title_elided = title.elided;
|
||||
ui.painter().galley(body.min + vec2(44.0, 25.0 - title.size().y * 0.5), title, t.text);
|
||||
let blurb = g
|
||||
.sections
|
||||
.first()
|
||||
.map(|s| s.items.iter().take(3).map(|i| tl!(i.label)).collect::<Vec<_>>().join(" · "))
|
||||
.unwrap_or_default();
|
||||
// Fixed-height cards must leave room for their action in every language.
|
||||
let mut job = egui::text::LayoutJob::simple(blurb.clone(), theme::regular(11.5), t.text_muted, body.width() - 28.0);
|
||||
job.wrap.max_rows = 2;
|
||||
let galley = ui.fonts_mut(|f| f.layout_job(job));
|
||||
let blurb_elided = galley.elided;
|
||||
ui.painter().galley(body.min + vec2(14.0, 46.0), galley, t.text_muted);
|
||||
ui.painter().text(
|
||||
body.left_bottom() + vec2(14.0, -14.0),
|
||||
Align2::LEFT_CENTER,
|
||||
tl!("Use now"),
|
||||
theme::medium(12.0),
|
||||
t.accent_text,
|
||||
);
|
||||
let resp = if title_elided || blurb_elided { resp.on_hover_text(format!("{}\n{blurb}", tl!(g.label))) } else { resp };
|
||||
if theme::hand(resp).clicked() {
|
||||
app.select_tool(g.id);
|
||||
if app.is_tool_pinned(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
tool_card(app, ui, &t, g, false);
|
||||
}
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(170.0, 104.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Open file")));
|
||||
@@ -132,31 +150,64 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(26.0);
|
||||
let mut unpin_tool = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Pinned Tools")).font(theme::semibold(17.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::ghost_button(ui, "pin", tl!("Pin a tool…")).clicked() {
|
||||
app.dialog = Some(crate::Dialog::PinTool);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if app.pinned_tools.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Pin your favorite tools here for quick access.")).color(t.text_muted));
|
||||
} else {
|
||||
ui.add_space(6.0);
|
||||
let pinned = app.pinned_tools.clone();
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 14.0);
|
||||
for id in &pinned {
|
||||
let Some(g) = catalog::group(id) else { continue };
|
||||
if let Some(to_unpin) = tool_card(app, ui, &t, g, true) {
|
||||
unpin_tool = Some(to_unpin);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(id) = unpin_tool {
|
||||
app.unpin_tool(id);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
ui.add_space(26.0);
|
||||
crate::folders_ui::section(app, ui);
|
||||
}
|
||||
|
||||
ui.add_space(26.0);
|
||||
let mut clear = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
// Preferences can turn the list off (#5572).
|
||||
if app.recent_limit > 0 {
|
||||
ui.add_space(26.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(tl!("Recent")).font(theme::semibold(17.0)));
|
||||
if !app.recent.is_empty() {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let resp = widgets::ghost_button(ui, "trash-2", tl!("Clear"));
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Clear Recent Files")));
|
||||
clear = resp.on_hover_text(tl!("Clear Recent Files")).clicked();
|
||||
});
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if app.recent.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
if app.recent.is_empty() {
|
||||
ui.label(egui::RichText::new(tl!("Files you open in PdfCraft appear here. Drop a PDF anywhere to open it.")).color(t.text_muted));
|
||||
}
|
||||
let mut open = None;
|
||||
let mut remove = None;
|
||||
for r in &app.recent {
|
||||
for r in app.recent.iter().take(app.recent_limit) {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &r.name));
|
||||
let press = theme::Press::track(ui, &resp);
|
||||
@@ -229,6 +280,41 @@ pub fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn review_home_harness() -> egui_kittest::Harness<'static, PdfCraftApp> {
|
||||
crate::i18n::set_current(crate::i18n::Lang::EN);
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.pinned_tools = vec!["edit".into()];
|
||||
let mut fonts = false;
|
||||
egui_kittest::Harness::builder().with_size(vec2(1500.0, 1200.0)).build_ui_state(
|
||||
move |ui, app| {
|
||||
if !fonts {
|
||||
ui.ctx().set_fonts(theme::font_definitions());
|
||||
fonts = true;
|
||||
}
|
||||
show(app, ui);
|
||||
},
|
||||
app,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_pinned_recommended_tools_appear_once() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = review_home_harness();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.get_all_by_label("Edit a PDF").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_a_pinned_tool_can_be_unpinned_without_hover() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut h = review_home_harness();
|
||||
h.run_steps(3);
|
||||
h.get_by_label("Unpin tool").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().pinned_tools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_card_description_leaves_room_for_the_action() {
|
||||
let de = crate::i18n::Lang::from_code("de").unwrap();
|
||||
|
||||
Loaded 100 of 221 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user