Merge storytold/pdfcraft main into fix/redact-metadata-2448

Conflicts:
- crates/redact/src/lib.rs: main added the run's ImageBudget to Applying
  and Scope::new; kept both sides (the removed-text fields and the image
  budget), with main's refused-images check before the destructuring.
- crates/redact/src/tests.rs (auto-merged): this PR's two tests that build
  a Scope or an Applying now pass main's ImageBudget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyG234xeYkJfr41E6iP
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-02 23:38:50 +08:00
commit 47cba60da5
241 files changed
+24918 -2479

No files matched your search

Generated
+51 -46
View File
@@ -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",
@@ -4592,7 +4595,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4611,7 +4614,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4647,11 +4650,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",
@@ -4675,6 +4678,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",
@@ -4685,6 +4689,7 @@ dependencies = [
"plist",
"png 0.18.1",
"pollster 1.0.1",
"proptest",
"rfd",
"rustix 1.1.5",
"serde",
@@ -4703,7 +4708,7 @@ dependencies = [
[[package]]
name = "pdfcraft-web"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4715,7 +4720,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"base64 0.23.1",
"image",
@@ -4729,7 +4734,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
version = "0.16.1"
version = "0.17.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
+41 -41
View File
@@ -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"
+10 -1
View File
@@ -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
View File
@@ -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.
@@ -728,6 +746,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. |
+1 -1
View File
@@ -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
+3
View File
@@ -180,3 +180,6 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.Combiler01]
sync_github_name = true
+5 -1
View File
@@ -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");
}
+17 -9
View File
@@ -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 } => {
+36 -3
View File
@@ -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 {
+56
View File
@@ -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:?}");
}
}
+7 -1
View File
@@ -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),
+16
View File
@@ -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`
+1 -1
View File
@@ -15,7 +15,7 @@ impl Automation {
if !export.truncated_pages.is_empty() {
use pdfcraft_engine::compare::{MAX_FORM_DEPTH, MAX_FORM_VISITS};
out["warnings"] = json!([format!(
"forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
"pictures past what one export may hold or decode, or forms nested more than {MAX_FORM_DEPTH} deep, or past the first {MAX_FORM_VISITS} forms of a page (forms the page draws itself count too), were left out on the pages in truncated_pages: their text and images are missing from the export, although the viewer may still show them"
)]);
}
Ok(out)
+3 -2
View File
@@ -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"));
+4 -3
View File
@@ -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)
+102 -5
View File
@@ -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>> {
@@ -294,13 +323,13 @@ impl Automation {
let id = self.doc(&a)?.id;
let before = self.doc(&a)?.bytes.len();
let path = self.resolve(a.str("path")?, true)?;
let (bytes, merged) = self.session.reduced_bytes(id).map_err(failed)?;
let (bytes, merged, images_refused) = self.session.reduced_bytes(id).map_err(failed)?;
// As in the app, a copy that isn't smaller is not written (#490).
let written = bytes.len() < before;
if written {
write_atomic(&path, &bytes)?;
}
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged })
json!({ "path": path.to_string_lossy(), "written": written, "bytes_before": before, "bytes_after": bytes.len(), "merged_objects": merged, "images_refused": images_refused })
}
"doc_optimize" => self.doc_optimize(&a)?,
"doc_initial_view" => self.doc_initial_view(&a)?,
@@ -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)
}
}
+1
View File
@@ -410,6 +410,7 @@ impl Automation {
"images": o.images,
"images_resampled": o.images_resampled,
"images_recompressed": o.images_recompressed,
"images_refused": o.images_refused,
"image_bytes_before": o.image_bytes_before,
"image_bytes_after": o.image_bytes_after,
"thumbnails": o.thumbnails,
+20 -11
View File
@@ -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(),
@@ -1194,7 +1194,7 @@ pub fn tools() -> Vec<ToolDef> {
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
&["doc", "field", "event"],
)),
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit are listed in truncated_pages, with a warning; the text and images of the forms left out are missing.")
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
.cmd("export.docx")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
@@ -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), or past what one export may decode or hold, are listed under skipped, with the reason. Includes unsaved edits.")
.cmd("export.all_images")
.with(schema(
json!({
@@ -1340,7 +1340,7 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["from"],
)),
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. The open document is unchanged.")
t("doc_reduce", "Reduce file size", "Write a smaller copy of the document to `path` with Acrobat's Reduce File Size choices: colour and grey images above 225 ppi downsampled to 150 ppi and JPEG-compressed (medium quality), black-and-white images averaged down to 300 ppi above 450 ppi and each stored as CCITT Group 4 (or 1-bit Flate, once resampled or packed from more bits) whenever that is smaller, thumbnails dropped, identical fonts and images merged, unused objects dropped, compressed object streams. If the copy wouldn't be smaller, nothing is written and `written` is false. Images past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. The open document is unchanged.")
.cmd("optimize.reduce")
.with(schema(json!({ "doc": doc(), "path": { "type": "string" } }), &["doc", "path"])),
t("doc_initial_view", "Initial view", "Read or change how the document opens (Document Properties ▸ Initial View) and its reading options: navigation (page, bookmarks, pages, attachments, layers), layout (default, single, continuous, two_up, two_up_continuous, two_up_cover, two_up_continuous_cover), magnification (default, actual, fit_page, fit_width, fit_height, fit_visible, or a percentage), page, window options (fit_window, center_window, full_screen, display_title), interface options (hide_menubar, hide_toolbar, hide_window_ui), language and binding (left, right). Only the given ones change; returns the result. Undoable.").with(schema(
@@ -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 past what one run may decode stay as they are, counted in images_refused; streams it only stores with Flate past that stay LZW, uncounted. 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!({
+334 -1
View File
@@ -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");
@@ -6545,10 +6718,12 @@ fn optimizing_through_tools() {
let reduced = ok(&mut a, "doc_reduce", json!({ "doc": doc, "path": "reduced.pdf" }));
assert!(reduced["bytes_after"].as_u64().unwrap() < reduced["bytes_before"].as_u64().unwrap());
assert_eq!(reduced["written"], true);
assert_eq!(reduced["images_refused"], 0);
assert_eq!(std::fs::metadata(dir.join("reduced.pdf")).unwrap().len(), reduced["bytes_after"].as_u64().unwrap());
// Target PDF 1.4 (disables object streams) and PDF 2.0 (with object_streams toggled off).
let v14 = ok(&mut a, "doc_optimize", json!({ "doc": doc, "path": "v14.pdf", "compatibility": "1.4" }));
assert_eq!(v14["images_refused"], 0);
assert_eq!((v14["pdf_version"].as_str(), v14["object_streams"].as_bool()), (Some("1.4"), Some(false)), "{v14}");
let v14_bytes = std::fs::read(dir.join("v14.pdf")).unwrap();
assert!(v14_bytes.starts_with(b"%PDF-1.4\n") && !v14_bytes.windows(7).any(|w| w == b"/ObjStm"));
@@ -9831,7 +10006,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();
@@ -9839,10 +10018,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());
}
@@ -10257,3 +10439,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);
}
}
+5 -2
View File
@@ -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.
+14
View File
@@ -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());
}
+8 -3
View File
@@ -90,6 +90,11 @@ 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, and `ImageBudget` for an operation over many images,
which share one allowance of decoding, filters, decoders and pixels alike, and are refused past
it: `IMAGE_WORK`, 2^34 steps, or 2 to 5 minutes of JBIG2 and about 1,000 pages scanned at
300 dpi, plus `IMAGE_WORK_PER_BYTE`, 2^13 steps for each byte of the file);
DCT and JPX are still decoded through the bootstrap renderer (`core.image-filters`, M2.6).
+612
View File
@@ -6,16 +6,218 @@ use pdfcraft_filters::FilterError;
use crate::{CosError, Document, ObjRef, Object, Stream};
/// The steps of decoding an operation over a document's images may take whatever the file's size.
/// A step of JBIG2 decoding ([`hayro_jbig2::Image::decode_within`]) is about a pixel, and takes 6 to
/// 16 ns, so this is 2 to 5 minutes, and about as long again for what an operation does with the
/// pixels. A page scanned at 300 dpi takes about 17 million, 8.4 for its pixels and that again to
/// decode them, so this alone reads about 1,000 of them. A byte into or out of a filter is a step
/// too, and takes less: 3 ns at most, or up to 19 ns through a predictor on samples of a bit.
const IMAGE_WORK: u64 = 1 << 34;
/// The steps each byte of the file adds to [`IMAGE_WORK`], so that a long scan is read whole: such a
/// page pays for itself with about 2 KB of the file, about the smallest a page of text is stored
/// in, and a stream of any filter this reads can be decoded about 8 times at Flate's best, 1,032 to
/// one. So what a hostile file can ask for grows with its length, by about 14 minutes for 10 MB.
const IMAGE_WORK_PER_BYTE: u64 = 1 << 13;
/// Decoded bytes one page's content walk may produce in all: its `/Contents` streams and every
/// form XObject it enters, each occurrence counted again.
pub const MAX_PAGE_DECODED: usize = 256 << 20;
/// Form XObjects one page's content walk may enter, each entry counted again.
pub const MAX_PAGE_FORMS: usize = 50_000;
/// The steps a sample of a JPEG costs: a few bytes can hold a page of one grey, and decoding,
/// resampling and storing it took about 20 to 45 ns a sample.
const JPEG_WORK: u64 = 2;
/// The steps a byte of a JPEG costs to read: its tables took 24 ns a byte before a size was known,
/// and images can share one, so reading it again for each has to count.
const JPEG_READ_WORK: u64 = 4;
/// A stream decode within a byte bound, with the bytes its filter stages decoded: the data or
/// the error, each with that count.
type Decoder = fn(&Stream, usize) -> Result<(Vec<u8>, usize), (FilterError, usize)>;
/// What one operation over a document's images may still spend decoding them: exporting them,
/// optimizing them, and storing LZW streams with Flate as it does, clearing them for redaction.
///
/// An image has its own limit, which doesn't bound a file of thousands of images made to reach it:
/// a few hundred bytes of JBIG2 can ask for seconds of decoding, 4 KB of Flate inside Flate for a
/// gigabyte, and an image that shares `/JBIG2Globals` decodes them again. So images share this
/// allowance, whatever they do with what they decode and whether or not it works, and past it they
/// aren't decoded. A caller then treats them as it does an image it can't read, and
/// [`ImageBudget::refused`] says how many there were.
///
/// An image also pays for its pixels once decoded, as what reads it goes through them one by one
/// however few bytes coded them: blank rows of fax or Flate take almost none. It pays for no more
/// than its data holds, a bit a pixel at least, so an image declaring a size its data doesn't fill
/// costs what a smaller one would, not the run's allowance. A JPEG pays for its samples when
/// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't:
/// their decoders don't run here.
#[derive(Debug)]
pub struct ImageBudget {
work: u64,
refused: usize,
}
impl ImageBudget {
/// The allowance for an operation on `doc`, which a longer file makes larger. The streams made
/// or changed since it was opened count as the file's bytes do, so a document built in memory
/// (combined, created, or given pages of scans) gets what it would once saved and opened again.
pub fn new(doc: &Document) -> Self {
let length = |n: usize| u64::try_from(n).unwrap_or(u64::MAX);
let edited = doc
.scan_objects_subset(doc.modified_objects())
.filter_map(|(_, object)| match &*object.ok()? {
Object::Stream(s) => Some(length(s.raw.len())),
_ => None,
})
.fold(0u64, u64::saturating_add);
let bytes = length(doc.bytes().len()).saturating_add(edited);
Self::with_work(IMAGE_WORK.saturating_add(bytes.saturating_mul(IMAGE_WORK_PER_BYTE)))
}
/// An allowance of `work` steps of decoding.
pub fn with_work(work: u64) -> Self {
Self { work, refused: 0 }
}
/// The samples of image `s`, decoded and charged to the allowance: the one way to decode an
/// image outside the renderer, so that an operation over many images can't decode them without
/// one.
///
/// They go through its filters ([`Stream::decoded`]), or, for a CCITT fax or JBIG2 image (its
/// last filter), through the filters before it and then [`pdfcraft_filters::decode_ccitt_within`]
/// with its [`Document::fax_params`], or [`pdfcraft_filters::decode_jbig2_within`] 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; 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`](crate::MAX_JBIG2_GLOBALS)), are an
/// error.
///
/// No stage, the filters before a fax or JBIG2 decoder included, may produce more than `max`
/// bytes, nor more than [`MAX_DECODED`](crate::MAX_DECODED). A JBIG2 image's `/JBIG2Globals`
/// are another stream, shared between images, and are decoded within `MAX_JBIG2_GLOBALS`
/// whatever `max` is. Each filter as it runs, the globals when they have to be read, and the fax
/// or JBIG2 decoder spend from the allowance, and the image then pays for its pixels; with
/// nothing left an image is refused before anything is read, its globals included.
///
/// ```
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let samples = pdfcraft_cos::ImageBudget::new(&doc).decode(&doc, &s, 1 << 20);
/// assert_eq!(samples.unwrap(), b"");
/// ```
///
/// `Document` has no way of its own to decode an image:
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image(&s);
/// ```
///
/// ```compile_fail
/// # let doc = pdfcraft_cos::Document::new_empty();
/// # let s = pdfcraft_cos::Stream::from_raw(pdfcraft_cos::Dict::new(), Vec::new());
/// let _ = doc.decoded_image_within(&s, 1 << 20);
/// ```
pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let decoded = doc.decoded_image_spending(s, max, &mut self.work);
self.count_refused(s, &decoded);
self.pay_size(s, decoded?)
}
/// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`]
/// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an
/// image that has no fax or JBIG2 decoder to go through.
pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work);
self.count_refused(s, &decoded);
let decoded = decoded?;
if s.filters().iter().all(|(f, _)| f.is_decodable()) { self.pay_size(s, decoded) } else { Ok(decoded) }
}
/// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]),
/// for a stream a run rewrites rather than an image it reads, charged to the allowance.
pub fn decode_chain(
&mut self,
chain: &[(pdfcraft_filters::Filter, pdfcraft_filters::Params)],
data: &[u8],
max: usize,
) -> Result<Vec<u8>, FilterError> {
pdfcraft_filters::decode_within(chain, data, max, &mut self.work)
}
/// Pays `steps` of other work on decoded data, or spends all there is and says so.
pub fn spend(&mut self, steps: u64) -> bool {
let left = self.work.checked_sub(steps);
self.work = left.unwrap_or(0);
left.is_some()
}
/// Pays for `pixels` a decode goes on to work through, or spends all there is, and the image
/// is refused.
pub fn pay_pixels(&mut self, pixels: u64) -> bool {
let paid = self.spend(pixels);
self.refused += usize::from(!paid);
paid
}
/// Pays for reading JPEG `data` and decoding its `samples`, as [`ImageBudget::pay_pixels`] does.
/// A caller pays before its tables are parsed, which can be most of the file.
pub fn pay_jpeg(&mut self, samples: u64, data: &[u8]) -> bool {
let reading = (data.len() as u64).saturating_mul(JPEG_READ_WORK);
self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading))
}
/// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and
/// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for
/// its page, or isn't decoded here. What can't be paid for refuses the image.
fn pay_size(&mut self, s: &Stream, decoded: Vec<u8>) -> Result<Vec<u8>, CosError> {
if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) {
return Ok(decoded);
}
let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0);
let held = u64::try_from(decoded.len()).unwrap_or(u64::MAX).saturating_mul(8);
if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height")).min(held)) {
Ok(decoded)
} else {
Err(CosError::Filter(FilterError::Spent.to_string()))
}
}
/// An image that failed with nothing left was refused, unless it has a filter that costs
/// nothing here, which fails whatever is left.
fn count_refused(&mut self, s: &Stream, decoded: &Result<Vec<u8>, CosError>) {
if decoded.is_err() && self.work == 0 && charged(s) {
self.refused += 1;
}
}
/// The decodes refused for lack of allowance: an image read twice over is counted twice.
pub fn refused(&self) -> usize {
self.refused
}
/// The steps of decoding still allowed.
pub fn work_left(&self) -> u64 {
self.work
}
}
/// Whether `s` can be decoded here and so charged: general filters, a fax or JBIG2 decoder last at
/// most. Those decoders anywhere else can't run.
fn charged(s: &Stream) -> bool {
use pdfcraft_filters::Filter;
s.readable_filters().is_ok_and(|chain| {
chain.split_last().is_none_or(|((last, _), before)| {
(last.is_decodable() || matches!(last, Filter::Jbig2 | Filter::CcittFax)) && before.iter().all(|(f, _)| f.is_decodable())
})
})
}
/// What one operation may still decode and enter across all the streams it reads.
///
/// [`MAX_DECODED`](crate::MAX_DECODED) bounds one decode. It does not bound a page whose
@@ -143,6 +345,401 @@ impl DecodeBudget {
}
}
#[cfg(test)]
mod image_tests {
use super::*;
use crate::Dict;
fn jbig2(parms: Option<Object>) -> Stream {
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"Filter".to_vec(), Object::name("JBIG2Decode"));
if let Some(parms) = parms {
dict.set(b"DecodeParms".to_vec(), parms);
}
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())
}
/// Each image spends what it took from one allowance, and those past it are refused and
/// counted, whatever their filters. What can't be decoded here costs nothing, and fails
/// without being one of these.
#[test]
fn the_images_of_an_operation_share_its_allowance() {
let doc = Document::new_empty();
let image = jbig2(None);
let mut budget = ImageBudget::with_work(u64::MAX);
let samples = budget.decode(&doc, &image, 1 << 20).unwrap();
let cost = u64::MAX - budget.work_left();
assert!(cost > 0);
let mut budget = ImageBudget::with_work(3 * cost);
for left in [2, 1, 0] {
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), left * cost);
}
assert_eq!(budget.refused(), 0, "the allowance held exactly three");
for refused in [1, 2] {
assert!(budget.decode(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.refused(), refused);
}
assert!(budget.decode(&doc, &Stream::flate(Dict::new(), b"samples"), 1 << 20).is_err());
assert!(budget.decode(&doc, &Stream::from_raw(Dict::new(), b"samples".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "other images share it");
for name in ["NoSuchFilter", "DCTDecode", "JPXDecode"] {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name(name));
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "{name} isn't one of these");
}
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Int(5));
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.refused(), 4, "a /Filter that can't be read isn't either");
}
/// What a file of images each inflating a megabyte asks for: 4 KB of Flate inside Flate can ask
/// for a gigabyte, and these aren't JBIG2, so without the allowance nothing would stop them.
#[test]
fn filter_bombs_stop_at_the_allowance() {
let doc = Document::new_empty();
let samples = vec![0; 1 << 20];
let inner = pdfcraft_filters::encode_flate(&samples);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("FlateDecode")]));
let bomb = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&inner));
let cost = (bomb.raw.len() + inner.len() + samples.len()) as u64;
let mut budget = ImageBudget::with_work(2 * cost + 1);
for left in [cost + 1, 1] {
assert_eq!(budget.decode(&doc, &bomb, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), left);
}
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1), "the first it can't pay for ends it");
}
/// Filters in front of a JBIG2 decoder pay as they run, on top of what the decoder takes, and
/// one that fails has still paid: nothing about the JBIG2 data is known by then, and each image
/// of a file can do this.
#[test]
fn filters_in_front_of_jbig2_pay_whether_or_not_they_work() {
let doc = Document::new_empty();
let alone = jbig2(None);
let mut budget = ImageBudget::with_work(u64::MAX);
let samples = budget.decode(&doc, &alone, 1 << 20).unwrap();
let decoding = u64::MAX - budget.work_left();
let behind = |raw: Vec<u8>| {
let mut dict = alone.dict.clone();
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("JBIG2Decode")]));
Stream::from_raw(dict, raw)
};
let image = behind(pdfcraft_filters::encode_flate(&alone.raw));
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap(), samples);
assert_eq!(u64::MAX - budget.work_left(), decoding + (image.raw.len() + alone.raw.len()) as u64);
// Past this image's limit: an error, with what finding out took paid and the rest left.
let bomb = behind(pdfcraft_filters::encode_flate(&vec![0; 1 << 16]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &bomb, 1000).is_err());
assert_eq!(u64::MAX - budget.work_left(), bomb.raw.len() as u64 + 1000);
assert_eq!(budget.refused(), 0, "that image's limit isn't the run's");
// A small allowance is a limit too: it stops there, and the image is refused.
let mut budget = ImageBudget::with_work(bomb.raw.len() as u64 + 10);
assert!(budget.decode(&doc, &bomb, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// Globals that are read again for every image pay every time, and fail having paid. Kept
/// ones have no filters left to undo, so they pay for being read and nothing more.
#[test]
fn globals_pay_for_their_filters_each_time_they_are_decoded() {
let skipped = [0, 0, 0, 0, 0x3E, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1];
let raw = pdfcraft_filters::encode_flate(&skipped);
let stream = |doc: &Document, r: ObjRef| match &*doc.get(r) {
Object::Stream(s) => s.clone(),
_ => panic!("not a stream"),
};
let spent = |doc: &Document, s: &Stream| {
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(doc, s, 1 << 20).unwrap();
u64::MAX - budget.work_left()
};
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(dict, raw.clone())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = doc.add(Object::Stream(jbig2(Some(Object::Dict(parms)))));
let root = doc.root().unwrap();
doc.update_dict(root, |c| c.set(b"Image".to_vec(), Object::Ref(image))).unwrap();
// Added in memory, so not kept: the filters are undone for each read.
let s = stream(&doc, image);
let first = spent(&doc, &s);
assert_eq!(spent(&doc, &s), first, "not kept, so paid for again");
// Read from a file, so kept: the second read has no filters to undo.
let file = crate::write_full(&doc, &crate::SaveOptions::default()).unwrap();
let doc = Document::open(std::sync::Arc::new(file)).unwrap();
let image = doc.get(doc.root().unwrap()).as_dict().unwrap().reference(b"Image").unwrap();
let s = stream(&doc, image);
let filters = (raw.len() + skipped.len()) as u64;
assert_eq!(spent(&doc, &s), first, "the first read undoes them");
assert_eq!(spent(&doc, &s), first - filters, "kept");
// Globals that fail pay too, with the image not refused: these go past their own limit.
let over = pdfcraft_filters::encode_flate(&vec![0; crate::MAX_JBIG2_GLOBALS + 1]);
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let globals = doc.add(Object::Stream(Stream::from_raw(dict, over.clone())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = jbig2(Some(Object::Dict(parms)));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &image, 1 << 20).unwrap_err().to_string().contains("globals"));
assert_eq!(u64::MAX - budget.work_left(), (over.len() + crate::MAX_JBIG2_GLOBALS) as u64);
assert_eq!(budget.refused(), 0);
}
/// A fax image pays for what comes before its decoder and for the rows it asks for, and past
/// the allowance it is refused like any other.
#[test]
fn fax_images_share_the_allowance() {
let doc = Document::new_empty();
let (w, h) = (4096u32, 64u32);
let coded = pdfcraft_filters::encode_g4(&vec![0xFF; 512 * h as usize], w, h).unwrap();
let mut parms = Dict::new();
parms.set(b"K".to_vec(), Object::Int(-1));
parms.set(b"Columns".to_vec(), Object::Int(i64::from(w)));
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(i64::from(w)));
dict.set(b"Height".to_vec(), Object::Int(i64::from(h)));
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("CCITTFaxDecode")]));
dict.set(b"DecodeParms".to_vec(), Object::Array(vec![Object::Null, Object::Dict(parms)]));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_flate(&coded));
let pixels = u64::from(w) * u64::from(h);
let cost = (image.raw.len() + coded.len() + 4 * coded.len() + 512 * h as usize) as u64 + pixels;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &image, 1 << 20).unwrap().len(), 512 * h as usize);
assert_eq!(budget.work_left(), 0, "exactly that");
assert!(budget.decode(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.refused(), 1);
}
/// An image pays for its pixels however few bytes hold them, or a blank page of fax or Flate
/// would cost a run a second for a few bytes' worth of allowance; a JBIG2 image doesn't pay
/// twice, and one this doesn't decode pays nothing. Past what is left it isn't read.
#[test]
fn an_image_pays_for_its_pixels() {
let doc = Document::new_empty();
let blank = |filter: Option<&str>, data: Vec<u8>| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(4096));
dict.set(b"Height".to_vec(), Object::Int(1024));
if let Some(filter) = filter {
dict.set(b"Filter".to_vec(), Object::name(filter));
}
Stream::from_raw(dict, data)
};
let rows = vec![0xFFu8; 512 * 1024];
let flate = blank(Some("FlateDecode"), pdfcraft_filters::encode_flate(&rows));
let cost = (flate.raw.len() + rows.len()) as u64 + 4096 * 1024;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that");
// Its filters alone pay the same.
let mut budget = ImageBudget::with_work(cost);
assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows);
assert_eq!(budget.work_left(), 0);
// Short of its pixels it is refused, having paid for its filters.
let mut budget = ImageBudget::with_work(4096 * 1024 - 1);
assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than"));
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
let mut budget = ImageBudget::with_work(4096 * 1024 - 1);
assert!(budget.decode_filters(&doc, &flate, 1 << 24).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
// What isn't decoded here pays for nothing, and fails as it would.
let mut budget = ImageBudget::with_work(10);
assert!(budget.decode(&doc, &blank(Some("DCTDecode"), b"data".to_vec()), 1 << 24).is_err());
assert_eq!((budget.work_left(), budget.refused()), (10, 0));
// Unfiltered data pays too, and a size that isn't one is no size.
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &blank(None, rows.clone()), 1 << 24).unwrap();
assert_eq!(u64::MAX - budget.work_left(), rows.len() as u64 + 4096 * 1024);
let mut odd = Dict::new();
odd.set(b"Width".to_vec(), Object::Int(-4));
odd.set(b"Height".to_vec(), Object::Int(9));
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, &Stream::from_raw(odd, vec![1, 2]), 1 << 24).unwrap();
assert_eq!(u64::MAX - budget.work_left(), 2);
// A JPEG pays twice over for each sample, and four times for each byte it is read from.
let mut budget = ImageBudget::with_work(30);
assert!(budget.pay_jpeg(9, b"ab"));
assert_eq!((budget.work_left(), budget.refused()), (4, 0));
assert!(!budget.pay_jpeg(0, b"ab"));
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// An image pays for the pixels its data holds, not those its size declares: one claiming
/// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the
/// images after it are decoded as they would be without it.
#[test]
fn an_image_pays_for_no_more_pixels_than_its_data_holds() {
let doc = Document::new_empty();
let sized = |side: i64, data: &[u8]| {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(side));
dict.set(b"Height".to_vec(), Object::Int(side));
Stream::flate(dict, data)
};
let claims = sized(131_072, &[0; 16]);
let image = sized(64, &[7; 64 * 64]);
let cost = |s: &Stream| {
let mut budget = ImageBudget::with_work(u64::MAX);
budget.decode(&doc, s, 1 << 24).unwrap();
u64::MAX - budget.work_left()
};
assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them");
assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels");
for decode in [ImageBudget::decode, ImageBudget::decode_filters] {
let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image));
assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]);
assert_eq!(decode(&mut budget, &doc, &image, 1 << 24).unwrap(), [7; 64 * 64], "the image after it is decoded");
assert_eq!((budget.work_left(), budget.refused()), (0, 0));
}
}
/// A stream a run rewrites is read strictly and charged, and what the caller does with it after
/// comes out of the same allowance: what can't be paid for spends all of it.
#[test]
fn a_rewritten_stream_shares_the_allowance() {
use pdfcraft_filters::{Filter, FilterError, Params};
let samples = vec![7u8; 1000];
let raw = pdfcraft_filters::encode_flate(&samples);
let chain = [(Filter::Flate, Params::default())];
let cost = (raw.len() + samples.len()) as u64;
let mut budget = ImageBudget::with_work(cost + 10);
assert_eq!(budget.decode_chain(&chain, &raw, 1 << 20).unwrap(), samples);
assert_eq!(budget.work_left(), 10);
assert!(budget.spend(4));
assert_eq!(budget.work_left(), 6);
assert!(budget.spend(6), "exactly what is left");
assert_eq!(budget.work_left(), 0);
assert!(budget.spend(0));
let mut budget = ImageBudget::with_work(5);
assert!(!budget.spend(6));
assert_eq!(budget.work_left(), 0, "spends all of it");
assert!(matches!(budget.decode_chain(&chain, &raw, 1 << 20), Err(FilterError::Spent)));
let cut = raw.get(..raw.len() / 2).unwrap();
assert!(matches!(ImageBudget::with_work(u64::MAX).decode_chain(&chain, cut, 1 << 20), Err(FilterError::Corrupt { .. })), "strictly");
}
/// An image read through its filters alone reads parameters given by reference, is charged, and
/// is refused like the others. It isn't given to a decoder.
#[test]
fn filters_alone_are_charged_too() {
let mut doc = Document::new_empty();
let p = pdfcraft_filters::Params { predictor: 12, columns: 4, ..pdfcraft_filters::Params::default() };
let rows = vec![7u8; 400];
let mut parms = Dict::new();
parms.set(b"Predictor".to_vec(), Object::Int(12));
parms.set(b"Columns".to_vec(), Object::Int(4));
let parms = doc.add(Object::Dict(parms));
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
let raw = pdfcraft_filters::encode(&pdfcraft_filters::Filter::Flate, &p, &rows).unwrap();
let image = Stream::from_raw(dict, raw);
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(budget.decode_filters(&doc, &image, 1 << 20).unwrap(), rows);
let cost = u64::MAX - budget.work_left();
// The tag byte of each of its 100 rows counts, which the predictor takes off.
assert_eq!(cost, (image.raw.len() + rows.len() + 100) as u64);
let mut budget = ImageBudget::with_work(cost - 1);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode_filters(&doc, &jbig2(None), 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX, "no decoder here, so nothing was read");
// Nor for a fax image, nor for a decoder that isn't last, however large they say they are.
for chain in [vec!["CCITTFaxDecode"], vec!["CCITTFaxDecode", "FlateDecode"], vec!["JBIG2Decode", "FlateDecode"]] {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(1000));
dict.set(b"Height".to_vec(), Object::Int(1000));
dict.set(b"Filter".to_vec(), Object::Array(chain.iter().map(|f| Object::name(f)).collect()));
let image = Stream::from_raw(dict, b"data".to_vec());
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode_filters(&doc, &image, 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX, "{chain:?}");
}
// Through `decode` a decoder that isn't last pays nothing either; one that is does.
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(1000));
dict.set(b"Height".to_vec(), Object::Int(1000));
dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("JBIG2Decode"), Object::name("FlateDecode")]));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(budget.decode(&doc, &Stream::from_raw(dict, b"data".to_vec()), 1 << 20).is_err());
assert_eq!(budget.work_left(), u64::MAX);
}
/// The images of a file share its globals, so an image refused doesn't read them: this one's
/// can't be read, which is how to tell.
#[test]
fn an_image_past_the_allowance_does_not_read_its_globals() {
let mut doc = Document::new_empty();
let mut unreadable = Dict::new();
unreadable.set(b"Filter".to_vec(), Object::name("NoSuchFilter"));
let globals = doc.add(Object::Stream(Stream::from_raw(unreadable, b"data".to_vec())));
let mut parms = Dict::new();
parms.set(b"JBIG2Globals".to_vec(), Object::Ref(globals));
let image = jbig2(Some(Object::Dict(parms)));
let read = ImageBudget::with_work(u64::MAX).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(read.contains("globals"), "{read}");
let refused = ImageBudget::with_work(0).decode(&doc, &image, 1 << 20).unwrap_err().to_string();
assert!(refused.contains("more image decoding than"), "{refused}");
}
/// A longer file gets more, by its length, and an empty one the fixed part.
#[test]
fn a_longer_file_allows_more_decoding() {
assert_eq!(ImageBudget::new(&Document::new_empty()).work_left(), IMAGE_WORK);
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let len = pdf.len() as u64;
let doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + len * IMAGE_WORK_PER_BYTE);
}
/// A document made in memory, as Combine, Create and inserted scans make one, gets for the
/// streams it was given what it would get for them once saved: what isn't a stream adds nothing.
#[test]
fn streams_made_in_memory_allow_more_decoding_too() {
let mut doc = Document::new_empty();
doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 3000])));
doc.add(Object::Dict(Dict::new()));
doc.add(Object::Stream(Stream::flate(Dict::new(), &[1; 100])));
let flate = pdfcraft_filters::encode_flate(&[1; 100]).len() as u64;
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (3000 + flate) * IMAGE_WORK_PER_BYTE);
// A stream of an opened file changed in memory counts with the file.
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let len = pdf.len() as u64;
let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
doc.add(Object::Stream(Stream::from_raw(Dict::new(), vec![0; 500])));
assert_eq!(ImageBudget::new(&doc).work_left(), IMAGE_WORK + (len + 500) * IMAGE_WORK_PER_BYTE);
}
// What the constants' comments promise, for a page scanned at 300 dpi: about 1,000 of them
// whatever the file's size, and a page paying for itself with 2 KB of the file.
const PAGE: u64 = 17_000_000;
const _: () = assert!(IMAGE_WORK >= 1_000 * PAGE);
const _: () = assert!(IMAGE_WORK_PER_BYTE * 2_100 >= PAGE);
// And that a stream can be decoded about 8 times at Flate's best, 1,032 to one, and the byte in.
const _: () = assert!(IMAGE_WORK_PER_BYTE >= 7 * 1_033);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -198,6 +795,21 @@ mod tests {
assert_eq!(budget.remaining(), (10 << 20) - partial);
}
/// A stream that fails its reading as zlib, and then as raw deflate after decoding all of it,
/// gives nothing back, and each reference still pays for what it decoded.
#[test]
fn a_decode_that_gives_nothing_back_still_pays() {
let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9];
data.extend(vec![0u8; 0x0601]);
data.push(0x07);
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("FlateDecode"));
let stream = Stream::from_raw(dict, data);
let mut budget = DecodeBudget::new(10 * 0x0601, 1);
assert!(budget.decode(&stream).unwrap().is_empty());
assert_eq!(budget.remaining(), 9 * 0x0601);
}
/// A chain whose later stage shrinks the data pays for every stage's output: a Flate stage
/// that inflates to hex text, which ASCIIHex then halves, charges both.
#[test]
+4 -1
View File
@@ -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,
+118 -36
View File
@@ -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,42 +257,129 @@ impl Document {
})
}
/// 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.
pub fn decoded_image(&self, s: &Stream) -> Result<Vec<u8>, CosError> {
/// 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()
}
/// [`Document::decoded_image_spending`] with no limit on the work, for tests of what an image
/// decodes to: everything else decodes images through an [`ImageBudget`](crate::ImageBudget).
#[cfg(test)]
pub(crate) 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.
pub fn decoded_image_within(&self, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
/// [`Document::decoded_image`] within `max` bytes.
#[cfg(test)]
pub(crate) fn decoded_image_within(&self, s: &Stream, max: usize) -> Result<Vec<u8>, CosError> {
let mut unbounded = u64::MAX;
self.decoded_image_spending(s, max, &mut unbounded)
}
/// The samples of image `s`, decoded within `max` bytes ([`MAX_DECODED`] at most), spending from
/// `work`: see [`ImageBudget::decode`](crate::ImageBudget::decode), the way in from outside this
/// crate, which says what this decodes and how.
pub(crate) fn decoded_image_spending(&self, s: &Stream, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
let max = max.min(MAX_DECODED);
let s = &*self.with_direct_parms(s);
let Some(p) = self.fax_params(s) else { return s.decoded_within(max) };
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)], work: &mut u64| match before {
[] => Ok(Cow::Borrowed(&s.raw[..])),
_ => pdfcraft_filters::decode_tolerant_within(before, &s.raw, max, work).map(Cow::Owned).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()));
};
// Nothing to read for what can't be decoded: the images of a file share its globals.
let globals = if *work == 0 { None } else { self.jbig2_globals(s, work)? };
let data = if *work == 0 { Cow::Borrowed(&[][..]) } else { undo(before, work)? };
return pdfcraft_filters::decode_jbig2_within(&data, globals.as_deref().map(Vec::as_slice), columns, height, max, work).map_err(filter);
}
let Some(p) = self.fax_params(s) else { return s.decoded_spending(max, work) };
// As many rows as the image has: a /Rows below /Height would cut the picture short, and
// rows past /Height aren't drawn.
let p = pdfcraft_filters::CcittParams { rows: 0, ..p };
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), work)?;
pdfcraft_filters::decode_ccitt_within(&data, &p, rows, work).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. Undoing
/// the filters spends from `work`, so globals read for every image pay every time.
fn jbig2_globals(&self, s: &Stream, work: &mut u64) -> 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_spending(MAX_JBIG2_GLOBALS, work).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> {
+2
View File
@@ -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,
+211
View File
@@ -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, &params, &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();
+2 -2
View File
@@ -22,10 +22,10 @@ mod parser;
mod security;
mod writer;
pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use budget::{DecodeBudget, ImageBudget, 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,
+14
View File
@@ -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 {
@@ -300,6 +306,14 @@ impl Stream {
self.decode_chain(max, decode)
}
/// [`Stream::decoded_within`] spending from `work`, what an operation may still do across a
/// document's images ([`pdfcraft_filters::decode_tolerant_within`]).
pub(crate) fn decoded_spending(&self, max: usize, work: &mut u64) -> Result<Vec<u8>, CosError> {
let filter = |e: pdfcraft_filters::FilterError| CosError::Filter(e.to_string());
let chain = self.readable_filters().map_err(filter)?;
pdfcraft_filters::decode_tolerant_within(&chain, &self.raw, max.min(MAX_DECODED), work).map_err(filter)
}
/// The stream decoded through its filters by `decode`, or copied when it has none, within
/// `max` and [`MAX_DECODED`].
fn decode_chain(
+1 -1
View File
@@ -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);
+10
View File
@@ -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.
-2
View File
@@ -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"] }
+9 -6
View File
@@ -27,11 +27,14 @@ 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::ImageBudget` (`pdfcraft_filters::decode_jbig2_within`), 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, and such a mask is left out and the image exported opaque, as JPX and CCITT masks are.
Past what one export may decode, which its images share, filters, decoders and pixels alike (`ImageBudget`; a JPEG when it has to be
decoded, as a mask, to apply one, or to undo a CMYK file's inversion, and not when it is written as it is), an image is skipped, and so is one whose mask is past it, which would otherwise come out showing what the mask hides. An export also stops at 2 GiB of files held, or twice the document if that
is more, since they stay in memory until it ends.
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 +46,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.
+114 -130
View File
@@ -6,7 +6,8 @@
use std::collections::HashSet;
use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_DECODED, ObjRef, Object, Stream};
use pdfcraft_filters::FilterError;
/// One image, ready to write.
#[derive(Clone, Debug, PartialEq)]
@@ -31,12 +32,29 @@ pub struct ImageExport {
/// Images larger than this many pixels are skipped (memory).
const MAX_PIXELS: u64 = 100_000_000;
/// What one export may hold of the files it makes: they are all in memory until it ends, and a file
/// of a few KB can make gigabytes of PNG. Images past it are left out, with the reason.
const MAX_EXPORT_BYTES: usize = 2 << 30;
/// What an export of a file of `file_bytes` may hold: twice the file if that is more than
/// [`MAX_EXPORT_BYTES`], so that a file of large images is exported whole.
pub(crate) fn export_bytes(file_bytes: usize) -> usize {
MAX_EXPORT_BYTES.max(file_bytes.saturating_mul(2))
}
/// The images of `pages` (0-based; each image once, on the first page using it), skipping
/// those with fewer than `min_side` pixels on their shorter side.
pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageExport {
extract_images_within(doc, pages, min_side, &mut ImageBudget::new(doc), export_bytes(doc.bytes().len()))
}
/// [`extract_images`] within `budget`, and holding `max_bytes` of files. An image past either is
/// left out, with the reason.
pub(crate) fn extract_images_within(doc: &Document, pages: &[usize], min_side: u32, budget: &mut ImageBudget, max_bytes: usize) -> ImageExport {
let all = pdfcraft_model::pages(doc);
let mut out = ImageExport::default();
let mut seen = HashSet::new();
let mut held = 0usize;
for &p in pages {
let Some(page) = all.get(p) else { continue };
let mut refs = Vec::new();
@@ -53,8 +71,15 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
if w <= 0 || h <= 0 || (w.min(h) as u64) < min_side as u64 {
continue;
}
match image(doc, s) {
Ok((extension, data)) => out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data }),
if held >= max_bytes {
out.skipped.push((p, r, "more images than one export can hold".into()));
continue;
}
match image(doc, s, budget) {
Ok((extension, data)) => {
held = held.saturating_add(data.len());
out.images.push(ExtractedImage { page: p, object: r, width: w as u32, height: h as u32, extension, data });
}
Err(why) => out.skipped.push((p, r, why)),
}
}
@@ -64,8 +89,13 @@ pub fn extract_images(doc: &Document, pages: &[usize], min_side: u32) -> ImageEx
/// One image XObject as a file: JPEG as is, else PNG ("Save image as").
pub fn image_file(doc: &Document, image: ObjRef) -> Result<(&'static str, Vec<u8>), String> {
image_file_within(doc, image, &mut ImageBudget::new(doc))
}
/// [`image_file`] within `budget`, for a caller making a file of many images.
pub fn image_file_within(doc: &Document, image: ObjRef, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
match &*doc.get(image) {
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s),
Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image") => self::image(doc, s, budget),
_ => Err("not an image".into()),
}
}
@@ -100,125 +130,24 @@ 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).
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();
}
/// The samples of image `s`, decoded ([`ImageBudget::decode`]): 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, budget: &mut ImageBudget) -> Result<Vec<u8>, String> {
budget.decode(doc, s, MAX_DECODED).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
/// mask, a stencil `/Mask` or a colour-key `/Mask`) is always a PNG with that transparency as
/// alpha, JPEG images included, so cut-out images keep their transparent background.
fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String> {
fn image(doc: &Document, s: &Stream, budget: &mut ImageBudget) -> Result<(&'static str, Vec<u8>), String> {
let f = filters(s);
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()),
_ => {}
}
@@ -233,14 +162,24 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
};
let mask = s.dict.get(b"ImageMask").is_some_and(|m| matches!(&*doc.resolve(m), Object::Bool(true)));
// A stencil mask (`ImageMask true`) has no /SMask or /Mask of its own (8.9.6.2).
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h).or_else(|| stencil_mask(doc, s, w, h)) };
let refused = budget.refused();
let mut alpha = if mask { None } else { soft_mask(doc, s, w, h, budget).or_else(|| stencil_mask(doc, s, w, h, budget)) };
// Without its mask the image would come out opaque, showing what the mask hides.
if budget.refused() > refused {
return Err(FilterError::Spent.to_string());
}
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
@@ -251,13 +190,16 @@ fn image(doc: &Document, s: &Stream) -> Result<(&'static str, Vec<u8>), String>
}
let mut space = if mask { Space::Gray } else { space(doc, s.dict.get(b"ColorSpace"))? };
let (samples, bpc) = if jpeg {
if !budget.pay_jpeg((w as u64).saturating_mul(h as u64).saturating_mul(space.components() as u64), &s.raw) {
return Err(FilterError::Spent.to_string());
}
// Grey, RGB or CMYK samples (YCCK is converted); a JPEG that doesn't decode, or an
// indexed one (not valid), is written as is, without its transparency.
let Some((samples, decoded)) = jpeg_samples(&s.raw, &space, w, h) else { return as_is() };
space = decoded;
(samples, 8)
} else {
let data = decoded(doc, s)?;
let data = decoded(doc, s, budget)?;
let bpc = if mask { 1 } else { s.dict.int(b"BitsPerComponent").unwrap_or(8) };
let bpc = match bpc {
1 => 1,
@@ -348,6 +290,32 @@ fn size(w: i64, h: i64) -> Option<(usize, usize)> {
(w > 0 && h > 0 && pixels <= MAX_PIXELS).then_some((w, h))
}
#[cfg(test)]
thread_local! {
/// JPEGs decoded whole, for the tests that keep one from being decoded for nothing.
pub(crate) static JPEGS_DECODED: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
/// The size a JPEG's header gives, which is what decoding it goes by, whatever its dictionary
/// says: an image of one pixel can hold a header of 500 megapixels.
fn jpeg_size(data: &[u8]) -> Option<(usize, usize)> {
use zune_jpeg::zune_core::{bytestream::ZCursor, options::DecoderOptions};
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(usize::from(u16::MAX)).set_max_height(usize::from(u16::MAX));
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(data), options);
dec.decode_headers().ok()?;
dec.dimensions()
}
/// JPEG `data`, decoded, if its header says it is `w` × `h`.
fn jpeg_of_size(data: &[u8], w: usize, h: usize) -> Option<image::DynamicImage> {
if jpeg_size(data)? != (w, h) {
return None;
}
#[cfg(test)]
JPEGS_DECODED.with(|n| n.set(n.get() + 1));
image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()
}
/// A JPEG's samples (8 bits): grey for a grey image, CMYK as stored for a CMYK one, else RGB,
/// with the space they are in.
fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u8>, Space)> {
@@ -356,10 +324,7 @@ fn jpeg_samples(data: &[u8], space: &Space, w: usize, h: usize) -> Option<(Vec<u
Space::Cmyk => return cmyk_jpeg_samples(data, w, h).map(|px| (px, Space::Cmyk)),
_ => {}
}
let img = image::load_from_memory_with_format(data, image::ImageFormat::Jpeg).ok()?;
if img.width() as usize != w || img.height() as usize != h {
return None;
}
let img = jpeg_of_size(data, w, h)?;
Some(match space {
Space::Gray => (img.into_luma8().into_raw(), Space::Gray),
_ => (img.into_rgb8().into_raw(), Space::Rgb),
@@ -560,32 +525,35 @@ fn cmyk(c: u8, m: u8, y: u8, k: u8) -> [u8; 3] {
}
/// The soft mask (`/SMask`) as 8-bit alpha, scaled to the image's size when it has another.
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"SMask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
gray_plane(doc, m, w, h)
gray_plane(doc, m, w, h, budget)
}
/// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with
/// `/Decode [1 0]`) hide the image.
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let r = s.dict.get(b"Mask")?.as_ref()?;
let obj = doc.get(r);
let Object::Stream(m) = &*obj else { return None };
Some(gray_plane(doc, m, w, h)?.into_iter().map(|v| 255 - v).collect())
Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect())
}
/// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to
/// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks).
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>> {
fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?;
let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true)));
let f = filters(m);
let mut px = match f.last().map(Vec::as_slice) {
Some(b"DCTDecode" | b"DCT") if f.len() == 1 => {
let img = image::load_from_memory_with_format(&m.raw, image::ImageFormat::Jpeg).ok()?.into_luma8();
(img.width() as usize == mw && img.height() as usize == mh).then(|| img.into_raw())?
// Past the allowance the image goes too: `image` sees this one refused.
if !budget.pay_jpeg((mw as u64).saturating_mul(mh as u64), &m.raw) {
return None;
}
jpeg_of_size(&m.raw, mw, mh)?.into_luma8().into_raw()
}
Some(b"DCTDecode" | b"DCT" | b"JPXDecode") => return None,
_ => {
@@ -599,7 +567,7 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize) -> Option<Vec<u8>>
_ => return None,
};
let max = (1u32 << bpc.min(8)) - 1;
unpack(&decoded(doc, m).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
unpack(&decoded(doc, m, budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect()
}
};
if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) {
@@ -612,7 +580,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> {
+1 -1
View File
@@ -6,7 +6,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream, serialize}
mod extract;
mod upright_image;
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file};
pub use extract::{ExtractedImage, ImageExport, extract_images, image_file, image_file_within};
use pdfcraft_fonts::{literal, win_ansi, wrap};
#[derive(Debug, thiserror::Error, PartialEq)]
+411 -21
View File
@@ -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,372 @@ 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}");
}
}
/// One allowance for all the images of an export: an image past it is left out, saying so.
#[test]
fn jbig2_images_past_the_exports_allowance_are_left_out_with_the_reason() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
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(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap());
let mut cost = ImageBudget::with_work(u64::MAX);
cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap();
let cost = u64::MAX - cost.work_left();
let pdf = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF".to_vec();
let mut doc = Document::open(std::sync::Arc::new(pdf)).unwrap();
let (a, b) = (doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image)));
let mut xobjects = Dict::new();
xobjects.set(b"A".to_vec(), Object::Ref(a));
xobjects.set(b"B".to_vec(), Object::Ref(b));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let page = pdfcraft_model::pages(&doc).swap_remove(0).obj;
doc.update_dict(page, |p| p.set(b"Resources".to_vec(), Object::Dict(resources))).unwrap();
let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(cost), usize::MAX);
assert_eq!(export.images.len(), 1);
assert_eq!(export.skipped.len(), 1);
assert!(export.skipped[0].2.contains("more image decoding than"), "{:?}", export.skipped);
// What an export holds is bounded too: the files are all in memory until it ends.
let export = crate::extract::extract_images_within(&doc, &[0], 1, &mut ImageBudget::with_work(u64::MAX), 1);
assert_eq!(export.images.len(), 1, "the first goes past it");
assert_eq!(export.skipped.len(), 1);
assert!(export.skipped[0].2.contains("than one export can hold"), "{:?}", export.skipped);
// A file of many images shares one allowance too, image by image.
let mut budget = ImageBudget::with_work(cost);
assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok());
assert!(crate::extract::image_file_within(&doc, b, &mut budget).is_err());
}
/// An image that isn't JBIG2 shares the allowance too: a file of filter bombs would hold an export
/// as long as one of JBIG2. What isn't decoded here, a JPEG, is still exported.
#[test]
fn flate_images_past_the_exports_allowance_are_left_out_with_the_reason() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let image = |filter: &str, raw: Vec<u8>| {
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(2));
dict.set(b"Height".to_vec(), Object::Int(2));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
dict.set(b"Filter".to_vec(), Object::name(filter));
Stream::from_raw(dict, raw)
};
let flate = image("FlateDecode", pdfcraft_filters::encode_flate(&[0x80; 4]));
let mut cost = ImageBudget::with_work(u64::MAX);
cost.decode(&Document::new_empty(), &flate, 1 << 20).unwrap();
let cost = u64::MAX - cost.work_left();
let mut doc = Document::new_empty();
let (a, b) = (doc.add(Object::Stream(flate.clone())), doc.add(Object::Stream(flate)));
let mut budget = ImageBudget::with_work(cost);
assert!(crate::extract::image_file_within(&doc, a, &mut budget).is_ok());
let refused = crate::extract::image_file_within(&doc, b, &mut budget).unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(budget.refused(), 1);
let jpeg = image::RgbImage::from_pixel(2, 2, image::Rgb([9, 9, 9]));
let mut bytes = Vec::new();
jpeg.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Jpeg).unwrap();
let jpeg = doc.add(Object::Stream(image("DCTDecode", bytes)));
assert!(crate::extract::image_file_within(&doc, jpeg, &mut budget).is_ok(), "nothing charged");
assert_eq!(budget.refused(), 1);
}
/// A JPEG is written as it is, at no cost, unless something means decoding it, here a mask: that
/// pays for its samples, and past the allowance the image goes, with the reason, as it would come out without
/// its transparency otherwise.
#[test]
fn a_jpeg_that_has_to_be_decoded_shares_the_allowance() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let image_dict = |extra: Option<(&[u8], Object)>| {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(4));
d.set(b"Height".to_vec(), Object::Int(4));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
if let Some((key, value)) = extra {
d.set(key.to_vec(), value);
}
d
};
let mut doc = Document::new_empty();
let mut mask = Dict::new();
mask.set(b"Subtype".to_vec(), Object::name("Image"));
mask.set(b"Width".to_vec(), Object::Int(4));
mask.set(b"Height".to_vec(), Object::Int(4));
mask.set(b"BitsPerComponent".to_vec(), Object::Int(8));
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let mask = doc.add(Object::Stream(Stream::from_raw(mask, vec![0x80; 16])));
let doc_jpeg_len = jpeg.len();
let masked = doc.add(Object::Stream(Stream::from_raw(image_dict(Some((b"SMask", Object::Ref(mask)))), jpeg.clone())));
let plain = doc.add(Object::Stream(Stream::from_raw(image_dict(None), jpeg)));
let mut budget = ImageBudget::with_work(0);
assert!(crate::extract::image_file_within(&doc, plain, &mut budget).is_ok(), "written as it is");
assert_eq!(budget.refused(), 0);
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(crate::extract::image_file_within(&doc, masked, &mut budget).is_ok());
let cost = u64::MAX - budget.work_left();
let read = 4 * doc_jpeg_len as u64;
assert_eq!(cost, 16 + 16 + 2 * 4 * 4 * 3 + read, "the mask's bytes and pixels, the JPEG's samples twice and its reading");
let mut budget = ImageBudget::with_work(cost - 1);
let refused = crate::extract::image_file_within(&doc, masked, &mut budget).unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!(budget.refused(), 1);
}
/// A JPEG that is itself a mask pays for its samples, each time an image reads it: images can
/// share one, and only the images are ever seen once.
#[test]
fn a_jpeg_mask_pays_for_its_samples() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut jpeg = Vec::new();
image::GrayImage::from_pixel(4, 4, image::Luma([90])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let mut mask = Dict::new();
mask.set(b"Subtype".to_vec(), Object::name("Image"));
mask.set(b"Width".to_vec(), Object::Int(4));
mask.set(b"Height".to_vec(), Object::Int(4));
mask.set(b"BitsPerComponent".to_vec(), Object::Int(8));
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
mask.set(b"Filter".to_vec(), Object::name("DCTDecode"));
let mut doc = Document::new_empty();
let read = 4 * jpeg.len() as u64;
let mask = doc.add(Object::Stream(Stream::from_raw(mask, jpeg)));
let mut image = Dict::new();
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
image.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80])));
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok());
assert_eq!(u64::MAX - budget.work_left(), 2 * 4 * 4 + read + 2, "its samples twice, its reading, and the image's byte and pixel");
// Just short of what the mask costs: it isn't decoded, and takes the image with it.
let mut budget = ImageBudget::with_work(2 * 4 * 4 + read - 1);
let refused = crate::extract::image_file_within(&doc, image, &mut budget).unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// What decoding a JPEG takes goes by its header, so that is what has to agree with the
/// dictionary before it is decoded: one image of a pixel can hold a page of 500 megapixels. The
/// file comes out as it is either way, so what shows is whether it was decoded.
#[test]
fn a_jpeg_larger_than_its_dictionary_says_isnt_decoded() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(4, 4, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
// A colour key means decoding it, to find the pixels it hides.
let keyed = |side: i64| {
let mut d = Dict::new();
d.set(b"Subtype".to_vec(), Object::name("Image"));
d.set(b"Width".to_vec(), Object::Int(side));
d.set(b"Height".to_vec(), Object::Int(side));
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
d.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
d.set(b"Filter".to_vec(), Object::name("DCTDecode"));
d.set(b"Mask".to_vec(), Object::Array(vec![Object::Int(0); 6]));
Stream::from_raw(d, jpeg.clone())
};
let decodes = |doc: &Document, image: pdfcraft_cos::ObjRef| {
crate::extract::JPEGS_DECODED.with(|n| n.set(0));
assert!(crate::extract::image_file_within(doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok());
crate::extract::JPEGS_DECODED.with(std::cell::Cell::get)
};
let mut doc = Document::new_empty();
let (honest, lying) = (doc.add(Object::Stream(keyed(4))), doc.add(Object::Stream(keyed(1))));
assert_eq!(decodes(&doc, honest), 1);
assert_eq!(decodes(&doc, lying), 0);
// The same as a mask: read as none, without being decoded.
let mut mask = keyed(1);
mask.dict.remove(b"Mask");
let mask = doc.add(Object::Stream(mask));
let mut image = Dict::new();
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
image.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(image, vec![0x80])));
assert_eq!(decodes(&doc, image), 0);
// Reading the header to find that out is paid for: a mask can be mostly tables, shared by
// every image of a file, and with nothing left it isn't read.
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_ok());
assert_eq!(u64::MAX - budget.work_left(), 2 + 4 * jpeg.len() as u64 + 2, "its one declared sample twice, its bytes, the image's byte and pixel");
let mut budget = ImageBudget::with_work(0);
assert!(crate::extract::image_file_within(&doc, image, &mut budget).is_err());
assert_eq!(budget.refused(), 1, "the image goes, with its mask unread");
}
/// A mask past the allowance can't be left out as an unreadable one is, or the image comes out
/// opaque, showing what the mask hides: the image goes, with the reason.
#[test]
fn an_image_whose_jbig2_mask_is_past_the_allowance_is_left_out() {
use pdfcraft_cos::{Dict, Document, ImageBudget, Object, Stream};
let mut mask = Dict::new();
mask.set(b"Subtype".to_vec(), Object::name("Image"));
mask.set(b"Width".to_vec(), Object::Int(16));
mask.set(b"Height".to_vec(), Object::Int(2));
mask.set(b"BitsPerComponent".to_vec(), Object::Int(1));
mask.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
mask.set(b"Filter".to_vec(), Object::name("JBIG2Decode"));
let mut doc = Document::new_empty();
let mask = doc.add(Object::Stream(Stream::from_raw(mask, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())));
// A JPEG, read for nothing until its mask is, so only the mask can be what stops it.
let mut jpeg = Vec::new();
image::RgbImage::from_pixel(1, 1, image::Rgb([9, 9, 9])).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap();
let mut image = Dict::new();
image.set(b"Subtype".to_vec(), Object::name("Image"));
image.set(b"Width".to_vec(), Object::Int(1));
image.set(b"Height".to_vec(), Object::Int(1));
image.set(b"BitsPerComponent".to_vec(), Object::Int(8));
image.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
image.set(b"Filter".to_vec(), Object::name("DCTDecode"));
image.set(b"SMask".to_vec(), Object::Ref(mask));
let image = doc.add(Object::Stream(Stream::from_raw(image, jpeg)));
assert!(crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(u64::MAX)).is_ok());
let refused = crate::extract::image_file_within(&doc, image, &mut ImageBudget::with_work(0)).unwrap_err();
assert!(refused.contains("more image decoding than"), "{refused}");
}
/// A large honest file keeps its images: what an export may hold grows with the file past the fixed
/// part, and a file too long to double doesn't wrap round to nothing.
#[test]
fn an_export_may_hold_twice_a_large_file() {
let fixed = crate::extract::export_bytes(0);
assert_eq!(fixed, 2 << 30);
assert_eq!(crate::extract::export_bytes(fixed / 2), fixed);
assert_eq!(crate::extract::export_bytes(fixed / 2 + 1), fixed + 2);
assert_eq!(crate::extract::export_bytes(usize::MAX), usize::MAX);
}
/// `/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);
}
+2
View File
@@ -24,3 +24,5 @@ unicode-bidi = "0.3"
[dev-dependencies]
hayro-syntax = { workspace = true }
pdfcraft-render = { workspace = true }
proptest = { workspace = true }
+29 -3
View File
@@ -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 {
+7 -2
View File
@@ -98,8 +98,13 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, EditE
}
b"sc" | b"scn" => st.fill = colour(st.space, op),
b"gs" => {
if let Some(g) = op.name(0).and_then(|n| gstates.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) {
apply_gstate(doc, &g, &mut st);
let mut held = None;
let gstate = match op.name(0).and_then(|n| gstates.get(n)) {
Some(g) => crate::text::dict_in_place(doc, g, &mut held),
None => None,
};
if let Some(g) = gstate {
apply_gstate(doc, g, &mut st);
}
}
b"re" => match op.nums::<4>() {
+1323 -152
View File
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -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;
+211
View File
@@ -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}");
}
}
}
}
}
+26 -1
View File
@@ -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
+1766 -1
View File
File diff suppressed because it is too large. Load diff
+3 -2
View File
@@ -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,13 +48,13 @@ 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};
use interp::{Origin, Paint, union};
use lines::page_dict;
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, form_call, rewritten_contents, splice};
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice};
pub use lines::{MAX_FORM_DEPTH, MAX_FORM_VISITS, glyph_text_lines, text_lines};
pub use rewrite::{LineEdit, replace_line, rewrite_block};
use rewrite::{coincident_ops, rewrite_block_from};
+1 -1
View File
@@ -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 }
+9 -4
View File
@@ -773,8 +773,9 @@ pub(super) fn interpret(
// `Some(properties)`: read `/ActualText`, looking named property lists up in `properties`.
logical_text: Option<&Dict>,
) -> Result<Vec<Shown>, EditError> {
let fonts_res = resources.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let states = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
let (mut held_fonts, mut held_states, none) = (None, None, Dict::new());
let fonts_res = super::resource_dict(doc, resources, b"Font", &mut held_fonts).unwrap_or(&none);
let states = super::resource_dict(doc, resources, b"ExtGState", &mut held_states).unwrap_or(&none);
let mut out: Vec<Shown> = Vec::new();
let mut ts = carry.ts.clone();
let mut at_bt = (0usize, ts.clone());
@@ -881,8 +882,12 @@ pub(super) fn interpret(
if let Some(name) = op.name(0) {
select_gs(&mut ts.ext_gstate, name);
}
let params = op.name(0).and_then(|name| states.get(name)).map(|p| doc.resolve(p));
if let Some(params) = params.as_deref().and_then(Object::as_dict) {
let mut held = None;
let params = match op.name(0).and_then(|name| states.get(name)) {
Some(p) => super::dict_in_place(doc, p, &mut held),
None => None,
};
if let Some(params) = params {
ts.gs.apply(doc, params);
// `/LW` sets the line width as `w` does (ISO 32000-2 §8.4.5).
let lw = params.get(b"LW").and_then(|w| doc.resolve(w).as_f64()).filter(|w| w.is_finite());
+46 -11
View File
@@ -1,3 +1,5 @@
use std::sync::Arc;
use super::fallback::{same_font, same_paint};
use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union};
use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops};
@@ -452,10 +454,41 @@ impl FormBudget {
pub(crate) struct FormCall {
pub(crate) obj: Option<pdfcraft_cos::ObjRef>,
pub(crate) data: Vec<u8>,
pub(crate) resources: Dict,
/// The form, and its `/Resources` when they are an object of their own, which is what
/// [`FormCall::resources`] reads.
form: Arc<Object>,
own: Option<Arc<Object>>,
pub(crate) ctm: Matrix,
}
impl FormCall {
/// The resources its content is read with: its own, or if it has none, `drawn_with`, the ones
/// it was drawn with (PDF 1.1 files). Read in place, as a page can draw a form thousands of times:
/// a copy for each took time in the square of them.
pub(crate) fn resources<'a>(&'a self, drawn_with: &'a Dict) -> &'a Dict {
let direct = || match &*self.form {
Object::Stream(s) => s.dict.get(b"Resources").and_then(Object::as_dict),
_ => None,
};
self.own.as_deref().and_then(Object::as_dict).or_else(direct).unwrap_or(drawn_with)
}
}
/// `o` as a dictionary, read in place for the same reason as [`FormCall::resources`]. `held` keeps it
/// when it is an object of its own. Nothing else is one, and [`Document::resolve`] would copy it.
pub(crate) fn dict_in_place<'a>(doc: &Document, o: &'a Object, held: &'a mut Option<Arc<Object>>) -> Option<&'a Dict> {
match o {
Object::Dict(dict) => Some(dict),
Object::Ref(_) => held.insert(doc.resolve(o)).as_dict(),
_ => None,
}
}
/// The dictionary at `key` of `resources`, `/XObject` or `/Font`, say, as each form reads these.
pub(crate) fn resource_dict<'a>(doc: &Document, resources: &'a Dict, key: &[u8], held: &'a mut Option<Arc<Object>>) -> Option<&'a Dict> {
dict_in_place(doc, resources.get(key)?, held)
}
/// The form XObject `name` in `resources`, drawn with `ctm`. `None` for images, missing names
/// and forms already being read (`path`), and for forms nested too deep or past the page's
/// budget, which `budget` records as truncated; each form entered counts one visit.
@@ -472,13 +505,15 @@ pub(crate) fn form_call(
if limited && budget.truncated {
return None;
}
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned())?;
let o = xobjects.get(name)?;
let obj = o.as_ref();
let mut held = None;
let o = resource_dict(doc, resources, b"XObject", &mut held)?.get(name)?;
// Only a reference can name a stream, so nothing else needs reading, or copying.
let obj = Some(o.as_ref()?);
if obj.is_some_and(|r| path.contains(&r)) {
return None;
}
let Object::Stream(s) = &*doc.resolve(o) else { return None };
let form = doc.resolve(o);
let Object::Stream(s) = &*form else { return None };
if s.dict.name(b"Subtype") != Some(b"Form") {
return None;
}
@@ -492,8 +527,7 @@ pub(crate) fn form_call(
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
<[f64; 6]>::try_from(v).ok().filter(|v| v.iter().all(|x| x.is_finite()))
});
// A form without its own resources uses the ones it was drawn with (PDF 1.1 files).
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
let own = s.dict.get(b"Resources").filter(|r| matches!(r, Object::Ref(_))).map(|r| doc.resolve(r));
let data = match budget.decode.decode(s) {
Ok(data) => data,
Err(_) if budget.decode.remaining() == 0 => {
@@ -503,7 +537,7 @@ pub(crate) fn form_call(
Err(_) => Vec::new(),
};
budget.visits = budget.visits.saturating_add(1);
Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) })
Some(FormCall { obj, data, form, own, ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)) })
}
/// The lines a page shows as it reads, including text drawn by form XObjects (which
@@ -522,8 +556,9 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
fn walk(w: &mut Walk, ops: &[Op], resources: &Dict, carry: &mut Carry, stream: &dyn Fn(usize) -> usize) -> Result<(), EditError> {
let mut cache = HashMap::new();
let mut invoked = Vec::new();
let properties = resources.get(b"Properties").and_then(|o| w.doc.dict(o)).unwrap_or_default();
let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(&properties))?;
let (mut held, none) = (None, Dict::new());
let properties = resource_dict(w.doc, resources, b"Properties", &mut held).unwrap_or(&none);
let shown = interpret(w.doc, ops, resources, &mut cache, carry, Some(&mut invoked), Some(properties))?;
let mut last = None;
let mut last_stream = None;
let mut forms = invoked.into_iter().peekable();
@@ -556,7 +591,7 @@ pub(super) fn reading_lines(doc: &Document, page: usize) -> Result<(Vec<TextLine
if let Some(r) = form.obj {
w.path.push(r);
}
walk(w, &ops, &form.resources, &mut carry, &|_| stream)?;
walk(w, &ops, form.resources(resources), &mut carry, &|_| stream)?;
*text_left = carry.text_left;
if form.obj.is_some() {
w.path.pop();
+3
View File
@@ -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 }
+20 -1
View File
@@ -146,6 +146,11 @@ pub struct FileResult {
pub log: Vec<String>,
}
/// What an action's log says of the images a Reduce File Size step left, if there are any.
fn images_refused_note(images: usize) -> Option<String> {
(images > 0).then(|| format!("Reduce file size: {images} image(s) stayed as they were, past what one run may decode"))
}
fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) -> Result<Option<DocId>, EditError> {
let pages = s.get(id).map_or(0, |d| d.info.pages.len());
let all: Vec<usize> = (0..pages).collect();
@@ -156,9 +161,10 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec<String>) ->
return Ok(None);
}
Step::ReduceFileSize => {
let (bytes, _) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?;
let (bytes, report) = s.optimized_bytes(id, &crate::optimize::Settings::default(), &[])?;
let before = s.get(id).map_or(0, |d| d.bytes.len());
log.push(format!("Reduce file size: {} → {} bytes", before, bytes.len()));
log.extend(images_refused_note(report.optimize.images_refused));
let name = s.get(id).map(|d| d.name.clone()).unwrap_or_default();
s.close(id);
let new = s.open(name, None, bytes, None).map_err(|e| EditError::Reopen(e.to_string()))?;
@@ -222,3 +228,16 @@ pub fn run_on(action: &Action, name: &str, bytes: Arc<Vec<u8>>, mut progress: im
let bytes = if d.is_signed() { s.save_bytes(id) } else { s.save_full_bytes(id) }.map_err(|e| e.to_string())?;
Ok(FileResult { name: name.to_string(), bytes, log })
}
#[cfg(test)]
mod tests {
use super::images_refused_note;
/// A log line only for images that stayed, saying how many.
#[test]
fn the_log_says_when_images_stayed_as_they_were() {
assert_eq!(images_refused_note(0), None);
assert!(images_refused_note(1).is_some_and(|note| note.contains("1 image(s)")));
assert!(images_refused_note(3).is_some_and(|note| note.contains("3 image(s)")));
}
}
+3
View File
@@ -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"),
+1
View File
@@ -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",
+19 -4
View File
@@ -173,14 +173,15 @@ pub struct OfficeExport {
/// The exported file.
pub bytes: Vec<u8>,
/// Pages (0-based) that draw forms nested deeper than [`MAX_FORM_DEPTH`] or more of them
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`]: export stops
/// reading there to stay bounded on hostile files, so the text and images of those forms, or
/// than [`MAX_FORM_VISITS`], or pictures past [`MAX_EXPORT_IMAGE_BYTES`] or past what one export
/// may decode: export stops reading there to stay bounded on hostile files, so the text and images of those forms, or
/// those pictures, are missing from `bytes`.
pub truncated_pages: Vec<usize>,
}
/// An image object prepared for Office export: its file extension and encoded bytes, or why it is
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`]; `Err(false)`: it can't be encoded).
/// left out (`Err(true)`: past [`MAX_EXPORT_IMAGE_BYTES`], or what the export may decode;
/// `Err(false)`: it can't be encoded).
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
impl crate::Document {
@@ -194,6 +195,18 @@ impl crate::Document {
/// pictures went past `image_budget` bytes ([`OfficeExport::truncated_pages`]). Without
/// `images` no picture is prepared.
pub(crate) fn export_pages_checked(&self, images: bool, image_budget: usize) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), Vec::new()) };
self.export_pages_within(images, image_budget, &mut pdfcraft_cos::ImageBudget::new(&editor.cos))
}
/// [`Self::export_pages_checked`], its pictures decoded within `image_work`, which is shared
/// by the whole export: a picture past it is left out like one past `image_budget`.
pub(crate) fn export_pages_within(
&self,
images: bool,
image_budget: usize,
image_work: &mut pdfcraft_cos::ImageBudget,
) -> (Vec<pdfcraft_export::Page>, Vec<usize>) {
let Some(cos) = self.editor.as_ref().map(|e| &e.cos) else { return (Vec::new(), Vec::new()) };
let mut truncated = Vec::new();
// Each image object is encoded once and shared by every place that draws it.
@@ -244,7 +257,9 @@ impl crate::Document {
.filter_map(|im| {
let object = im.object?;
let prepared = encoded.entry(object).or_insert_with(|| {
let (ext, bytes) = pdfcraft_create::image_file(cos, object).map_err(|_| false)?;
let refused = image_work.refused();
let (ext, bytes) =
pdfcraft_create::image_file_within(cos, object, &mut *image_work).map_err(|_| image_work.refused() > refused)?;
image_bytes = image_bytes.checked_sub(bytes.len()).ok_or(true)?;
Ok((if ext == "jpg" { "jpg" } else { "png" }, Arc::from(bytes)))
});
+1 -2
View File
@@ -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(),
+7 -1
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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> {
@@ -257,6 +258,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 {
@@ -532,40 +696,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)?;
+5 -1
View File
@@ -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};
@@ -139,7 +141,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};
@@ -156,6 +158,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};
+68
View File
@@ -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);
}
}
+1
View File
@@ -419,6 +419,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| {
+5 -5
View File
@@ -71,12 +71,12 @@ impl Session {
/// Reduce File Size: Acrobat's defaults (colour and grey images above 225 ppi to 150 ppi,
/// JPEG medium quality; black-and-white averaged down to 300 ppi above 450 ppi, and stored as
/// CCITT Group 4 whenever smaller; thumbnails dropped), identical resources merged, unused
/// objects dropped, objects packed into compressed object streams. Returns the bytes and how
/// many objects were merged. The open document is not changed (Acrobat saves the reduced copy
/// as a new file).
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize), EditError> {
/// objects dropped, objects packed into compressed object streams. Returns the bytes, how
/// many objects were merged and how many images stayed as they were, past what one run may
/// decode. The open document is not changed (Acrobat saves the reduced copy as a new file).
pub fn reduced_bytes(&self, id: DocId) -> Result<(Arc<Vec<u8>>, usize, usize), EditError> {
let (bytes, report) = self.optimized_bytes(id, &optimize::Settings::default(), &[])?;
Ok((bytes, report.merged))
Ok((bytes, report.merged, report.optimize.images_refused))
}
/// Optimize PDF ▸ Advanced optimization: `settings` for images and objects, plus Remove
+738 -16
View File
@@ -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");
@@ -3624,7 +3657,7 @@ fn create_and_reduce() {
)
.unwrap();
let full = s2.save_full_bytes(id2).unwrap();
let (reduced, _) = s2.reduced_bytes(id2).unwrap();
let (reduced, ..) = s2.reduced_bytes(id2).unwrap();
assert!(reduced.len() <= full.len(), "{} > {}", reduced.len(), full.len());
let mut s3 = Session::new();
let r = s3.open("r.pdf", None, reduced, None).unwrap();
@@ -6213,24 +6246,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
@@ -8156,6 +8192,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
@@ -8208,6 +8251,130 @@ 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");
// An Office export shares one allowance across its pictures. One past it is left out, and the
// export says the page is missing something, as for a picture past its bytes.
let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX));
assert_eq!((pages[0].images.len(), truncated.len()), (1, 0));
let (pages, truncated) = d.export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(0));
assert!(pages[0].images.is_empty());
assert_eq!(truncated, [0]);
}
/// An Office export leaves out a picture it can't encode, as it did. That isn't a page cut short:
/// only a picture past what the export may do is reported as one.
#[test]
fn an_office_export_doesnt_call_an_unreadable_picture_truncated() {
let pdf = b"%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 /MediaBox [0 0 200 300] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << /Im1 5 0 R >> >> >> endobj\n\
4 0 obj << /Length 31 >> stream\nq 160 0 0 20 20 100 cm /Im1 Do Q\nendstream endobj\n\
5 0 obj << /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /JPXDecode /Length 4 >> stream\nnone\nendstream endobj\n\
trailer << /Root 1 0 R >>\n%%EOF"
.to_vec();
let mut s = Session::new();
let id = s.open("picture.pdf", None, Arc::new(pdf), None).unwrap();
let (pages, truncated) = s.get(id).unwrap().export_pages_within(true, 1 << 20, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX));
assert!(pages[0].images.is_empty());
assert_eq!(truncated, Vec::<usize>::new());
}
/// Copy Image takes a JBIG2 picture as its pixels and as a PNG file, behind another filter
/// (ASCIIHexDecode) too: Export's decoding, through `ImageBudget::decode`.
#[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);
@@ -9127,6 +9294,61 @@ fn a_fields_own_script_may_not_save_as_or_show_a_panel() {
assert_eq!(o.requests, [js::Request::SaveAs]);
}
/// A page of `count` pictures of one flat grey, `side` pixels square, each an object of its own.
fn page_of_pictures(count: usize, side: usize) -> Vec<u8> {
let data = pdfcraft_filters::encode_flate(&vec![0x80; side * side]);
let names: String = (0..count).map(|i| format!("/Im{i} {} 0 R ", 5 + i)).collect();
let content: String = (0..count).map(|i| format!("q 10 0 0 10 0 0 cm /Im{i} Do Q\n")).collect();
let mut 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 /MediaBox [0 0 200 300] >> endobj\n\
3 0 obj << /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj\n\
4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n",
content.len()
)
.into_bytes();
for i in 0..count {
let dict = format!(
"{} 0 obj << /Type /XObject /Subtype /Image /Width {side} /Height {side} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length {} >> stream\n",
5 + i,
data.len()
);
pdf.extend_from_slice(dict.as_bytes());
pdf.extend_from_slice(&data);
pdf.extend_from_slice(b"\nendstream endobj\n");
}
pdf.extend_from_slice(b"trailer << /Root 1 0 R >>\n%%EOF");
pdf
}
/// The allowance is the export's, not each picture's: a page of many small pictures stops where it
/// runs out, the rest are left out and counted, and the page is listed, which is what the export's
/// warning goes by. A few large pictures are well within it, and all come through.
#[test]
fn a_page_of_many_pictures_stops_at_the_exports_allowance() {
let export = |pdf: Vec<u8>, work: &mut pdfcraft_cos::ImageBudget| {
let mut s = Session::new();
let id = s.open("pictures.pdf", None, Arc::new(pdf), None).unwrap();
s.get(id).unwrap().export_pages_within(true, 1 << 30, work)
};
let mut one = pdfcraft_cos::ImageBudget::with_work(u64::MAX);
export(page_of_pictures(1, 2), &mut one);
let cost = u64::MAX - one.work_left();
assert!(cost > 0);
let mut work = pdfcraft_cos::ImageBudget::with_work(100 * cost);
let (pages, truncated) = export(page_of_pictures(10_000, 2), &mut work);
assert_eq!(pages[0].images.len(), 100);
assert_eq!(truncated, vec![0]);
assert_eq!(work.refused(), 9_900);
let mut s = Session::new();
let id = s.open("large.pdf", None, Arc::new(page_of_pictures(3, 1_000)), None).unwrap();
let (pages, truncated) = s.get(id).unwrap().export_pages_checked(true, 1 << 30);
assert_eq!(pages[0].images.len(), 3);
assert_eq!(truncated, Vec::<usize>::new());
}
/// A page whose forms go past the reading limit and whose picture goes past the image budget is
/// listed once among the truncated pages.
#[test]
@@ -9173,6 +9395,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
@@ -9523,3 +10213,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
View File
@@ -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");
}
}
+272
View File
@@ -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));
}
}
+3 -4
View File
@@ -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
+9 -4
View File
@@ -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`.
@@ -15,17 +15,20 @@ Filter::from_name(b"FlateDecode" | b"Fl" | ...) -> Filter // full names and in
Filter::name(&self) -> &str; Filter::is_image_codec(&self) -> bool
pub struct Params { predictor, colors, bits_per_component, columns, early_change } // i64s; Default = 1, 1, 8, 1, 1
pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) }
pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String), Spent }
decode(chain, data, max_output) -> Result<Vec<u8>, FilterError>
decode_counted(chain, data, max_output) -> Result<(Vec<u8>, usize /*decoded*/), (FilterError, usize /*decoded*/)>
decode_tolerant(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/), FilterError>
decode_tolerant_counted(chain, data, max_output) -> Result<(Vec<u8>, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)>
decode_tolerant_within(chain, data, max_output, work: &mut u64) -> Result<Vec<u8>, FilterError>
decode_within(chain, data, max_output, work: &mut u64) -> Result<Vec<u8>, FilterError>
encode(filter, params, data) -> Result<Vec<u8>, FilterError>
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, &params, 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
```
@@ -34,6 +37,7 @@ encode_jbig2(data, columns, rows) -> Result<Vec<u8>, FilterError> // 1-bit row
- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. So does a chain longer than `MAX_CHAIN` (8) filters, before any stage runs. Decryption belongs to `crypt`.
- `max_output` bounds every intermediate buffer (including the pre-predictor buffer) and gives `LimitExceeded(max_output)`. This also holds when Flate retries raw deflate after a misleading zlib header, in both strict and tolerant decoding.
- `decode_counted` and `decode_tolerant_counted` are for a caller charging a budget (`pdfcraft_cos::DecodeBudget`). `max_output` bounds all stages together (each stage gets what the earlier ones left), and they give the bytes every stage that ran decoded, on success and on failure. A stage's output counts as its decoder produced it, before a predictor or a later stage shrinks it, and a failed decode pays for what it decoded first.
- `decode_tolerant_within` is for a run of images (`pdfcraft_cos::ImageBudget`), as `decode_within` (its strict form), `decode_jbig2_within` and `decode_ccitt_within` are, all spending from one `work`, in the JBIG2 decoder's steps. `max_output` bounds each stage, as in `decode_tolerant`, and no stage decodes past what `work` has left. It pays a step a byte of `data` first and a step a byte each stage decodes, so a chain that fails has paid for what it did: 0.2 to 3 ns a byte in or out, up to 19 ns through a predictor on samples of a bit, against the 6 to 16 ns of a JBIG2 step. What `work` can't pay for spends all of it, and gives `Spent`; with nothing left nothing is read. A chain that can't be decoded at all costs nothing and gives its usual error.
- `decode_tolerant` turns `Corrupt` into "what was decoded so far" plus `partial = true`, then runs the rest of the chain on it (predictors are applied to partial output too). `LimitExceeded` and `Unsupported` are still errors.
## Behaviour
@@ -47,7 +51,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 +61,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.
+89 -28
View File
@@ -50,7 +50,7 @@ pub fn encode_g4(data: &[u8], columns: u32, rows: u32) -> Result<Vec<u8>, Filter
}
}
/// The widest row [`decode_ccitt`] decodes: its row buffer stays within 128 KiB.
/// The widest row [`decode_ccitt_within`] decodes: its row buffer stays within 128 KiB.
pub const MAX_CCITT_COLUMNS: u32 = 1 << 20;
/// A `CCITTFaxDecode` stream's `/DecodeParms` (ISO 32000-2 Table 11). [`Default`] gives the
@@ -74,22 +74,8 @@ impl Default for CcittParams {
}
}
/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most
/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`).
///
/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by
/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a
/// caller that needs a number of rows checks it got them.
///
/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes,
/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4
/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever
/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the
/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each
/// code on a byte, that may be other rows than were coded.
///
/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]).
pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u8>, FilterError> {
/// What [`decode_ccitt_within`] decodes once it is paid for.
fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u8>, FilterError> {
let columns = u32::try_from(p.columns)
.ok()
.filter(|c| (1..=MAX_CCITT_COLUMNS).contains(c))
@@ -123,6 +109,39 @@ pub fn decode_ccitt(data: &[u8], p: &CcittParams, max_rows: u32) -> Result<Vec<u
Ok(out.out)
}
/// The steps of reading a byte of fax code ([`decode_ccitt_within`]): noise takes 114 ns a byte of
/// rows and about two bytes of code to a byte of rows, so that and a step a byte of rows is about
/// what a step of JBIG2 decoding takes.
const CODE_WORK: u64 = 4;
/// CCITT fax `data` decoded as `CCITTFaxDecode` gives it: rows of `columns` 1-bit samples, most
/// significant bit first, each starting on a new byte, with black as 0 (as 1 with `black_is_1`).
///
/// At most `max_rows` rows are decoded (fewer when `rows` says so), so the output is bounded by
/// the caller. Damaged data gives the rows decoded before the damage, as viewers show them: a
/// caller that needs a number of rows checks it got them.
///
/// Group 3 rows on bytes (`k` from 0, `encoded_byte_align`) are read by their end-of-line codes,
/// whether the fill starts each code on a byte or ends it on one, when `data` starts with one (T.4
/// sends one before the first row when it sends them at all), and on bytes otherwise, whatever
/// `end_of_line` says. Group 4 is read as its parameters say. Data with codes but none before the
/// first row is read on bytes, as PdfCraft's renderer draws it; with `k` 0 and the fill ending each
/// code on a byte, that may be other rows than were coded.
///
/// Errors: `columns` that aren't from 1 to [`MAX_CCITT_COLUMNS`] ([`FilterError::Unsupported`]).
///
/// Decoding spends from `work`, what decoding may still cost across a run of images, and there is
/// no way in without it: a
/// byte of rows a step, whether or not the data holds them, and [`CODE_WORK`] a byte of `data`.
/// A blank row takes a bit of code, so the rows are what a few bytes can ask for. It is all paid
/// before anything is decoded, and what `work` can't pay for spends all of it.
pub fn decode_ccitt_within(data: &[u8], p: &CcittParams, max_rows: u32, work: &mut u64) -> Result<Vec<u8>, FilterError> {
let rows = u32::try_from(p.rows).ok().filter(|r| *r > 0).map_or(max_rows, |r| r.min(max_rows));
let stride = u64::try_from(p.columns).unwrap_or(0).div_ceil(8);
crate::pay(work, stride.saturating_mul(u64::from(rows)).saturating_add((data.len() as u64).saturating_mul(CODE_WORK)))?;
decode_ccitt(data, p, max_rows)
}
/// Whether CCITT fax `data` starts with an end-of-line code: a 1 after 11 zeros or more, fill
/// included. T.4 sends one before the first row when it sends them at all, and no other code starts
/// with more than eight zeros.
@@ -132,9 +151,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_within`]) fills them too.
pub(crate) struct Rows {
pub(crate) out: Vec<u8>,
row: Vec<u8>,
x: usize,
columns: usize,
@@ -144,12 +164,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 +180,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 +200,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();
}
}
@@ -183,6 +208,42 @@ impl hayro_ccitt::Decoder for Rows {
mod tests {
use super::*;
/// A run pays for the rows an image asks for and the code it reads, before any of it is
/// decoded: rows the data doesn't hold are paid for too, as a few bytes can ask for any number.
#[test]
fn a_run_of_images_pays_for_the_rows_it_asks_for() {
let (w, h) = (4096u32, 64u32);
let stride = (w as usize).div_ceil(8);
let data = encode_g4(&vec![0xFF; stride * h as usize], w, h).unwrap();
let p = CcittParams { k: -1, columns: i64::from(w), ..CcittParams::default() };
let whole = decode_ccitt(&data, &p, h).unwrap();
let cost = (whole.len() + 4 * data.len()) as u64;
let mut work = cost;
assert_eq!(decode_ccitt_within(&data, &p, h, &mut work).unwrap(), whole, "exactly enough");
assert_eq!(work, 0);
let mut work = cost + 7;
decode_ccitt_within(&data, &p, h, &mut work).unwrap();
assert_eq!(work, 7, "only what it took");
let mut work = cost - 1;
let refused = decode_ccitt_within(&data, &p, h, &mut work).unwrap_err();
assert!(matches!(refused, FilterError::Spent), "a step short: {refused}");
assert_eq!(work, 0, "spends all that was left");
// Nothing left: not read at all, so bad columns aren't reported as bad columns.
let bad = CcittParams { columns: 0, ..p };
assert!(decode_ccitt_within(&data, &bad, h, &mut 0).is_err_and(|e| matches!(e, FilterError::Spent)));
// No code at all, for the same rows.
let mut work = u64::MAX;
decode_ccitt_within(&[], &p, h, &mut work).unwrap();
assert_eq!(u64::MAX - work, whole.len() as u64);
// `/Rows` below what is asked for, and no rows.
let mut work = u64::MAX;
decode_ccitt_within(&data, &CcittParams { rows: 3, ..p }, h, &mut work).unwrap();
assert_eq!(u64::MAX - work, (3 * stride + 4 * data.len()) as u64);
let mut work = u64::MAX;
decode_ccitt_within(&data, &p, 0, &mut work).unwrap();
assert_eq!(u64::MAX - work, 4 * data.len() as u64);
}
/// The rows decoded by an independent decoder (`hayro-ccitt`), as 1 for white and 0 for black.
pub(crate) fn decode(data: &[u8], columns: u32, rows: u32) -> Vec<Vec<bool>> {
struct Rows(Vec<Vec<bool>>, Vec<bool>, usize);
+3 -2
View File
@@ -28,8 +28,9 @@ pub(crate) fn decode(data: &[u8], max: usize) -> Step {
// A header that only looked valid: retry the whole input as raw deflate.
Err(f) if f.partial.is_empty() && matches!(f.error, crate::FilterError::Corrupt { .. }) => match inflate_raw(data, max) {
// Keep the original corruption diagnostic only if the retry is corrupt
// too. A limit error must remain fatal even for tolerant decoding.
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(f),
// too. A limit error must remain fatal even for tolerant decoding. What the
// retry decoded isn't kept, but a caller charging for work has to hear of it.
Err(retry) if matches!(retry.error, crate::FilterError::Corrupt { .. }) => Err(Failure { discarded: retry.partial.len(), ..f }),
retry => retry,
},
r => r,
+4 -1
View File
@@ -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_within};
use crate::FilterError;
use crate::mq::{Context, MqEncoder};
+216
View File
@@ -0,0 +1,216 @@
//! 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_within`] 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).
///
/// Decoding spends from `work`: what decoding may still cost across a run of images, in the
/// decoder's count of steps. There is no way in without it, so a run of images can't decode them
/// without sharing an allowance. Reading `data` and `globals` is charged first
/// ([`hayro_jbig2::charge_reading`]), so an image that turns out not to decode has paid for that,
/// then what decoding takes ([`hayro_jbig2::Image::decode_within`]). An image takes at most what
/// one decode may on top of its reading and its page, whatever `work` allows, and one that `work`
/// can't pay for spends all of it. With nothing left an image isn't read at all, so the images after
/// that one cost nothing but this check, however large their globals.
pub fn decode_jbig2_within(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
if rows == 0 {
return Ok(Vec::new());
}
if *work == 0 {
return Err(FilterError::Spent);
}
hayro_jbig2::charge_reading(work, data.len().saturating_add(globals.map_or(0, <[u8]>::len))).map_err(|_| FilterError::Spent)?;
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);
// Stopped by what was left, it isn't a damaged image, whatever the decoder calls it.
image.decode_within(&mut out, work).map_err(|e| if *work == 0 { FilterError::Spent } else { 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};
/// [`decode_jbig2_within`] with no limit on the work.
fn decode_jbig2(data: &[u8], globals: Option<&[u8]>, columns: u32, rows: u32, max: usize) -> Result<Vec<u8>, FilterError> {
let mut unbounded = u64::MAX;
decode_jbig2_within(data, globals, columns, rows, max, &mut unbounded)
}
/// `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);
}
/// What a page costs is the same however much a run has left. Given exactly that it decodes,
/// and less stops it: spending all that's left, and what comes after isn't read. A page that
/// fails still spends what it took.
#[test]
fn a_run_of_images_spends_its_allowance_down_to_the_step() {
let (w, h) = (203u32, 41u32);
let data = encode_jbig2_generic(&noise(w, h), w, h).unwrap();
let whole = decode_jbig2(&data, None, w, h, usize::MAX).unwrap();
let mut work = u64::MAX;
assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole);
let cost = u64::MAX - work;
assert!(cost >= u64::from(w) * u64::from(h), "a pixel at a time: {cost}");
let mut work = cost;
assert_eq!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap(), whole, "exactly enough");
assert_eq!(work, 0);
let mut work = cost + 7;
decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).unwrap();
assert_eq!(work, 7, "only what it took");
let mut work = cost - 1;
assert!(matches!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work), Err(FilterError::Spent)), "a step short");
assert_eq!(work, 0, "spends all that was left");
// Nothing left: not read at all, so this garbage isn't reported as garbage.
assert!(matches!(decode_jbig2_within(b"garbage", None, w, h, usize::MAX, &mut work), Err(FilterError::Spent)));
// Too little even for its page.
let mut work = 3;
assert!(decode_jbig2_within(&data, None, w, h, usize::MAX, &mut work).is_err());
assert_eq!(work, 0);
// A region that can't be read after the one that can: the page fails having spent the
// first, and the segment it fails on, and that isn't being out of allowance.
let bad_region = [0, 0, 0, 9, 38, 0, 1, 0, 0, 0, 1, 0xFF];
let mut work = u64::MAX;
assert!(decode_jbig2_within(&[&data[..], &bad_region].concat(), None, w, h, usize::MAX, &mut work).is_err());
assert_eq!(u64::MAX - work, cost + 1 + 4 * bad_region.len() as u64, "the first region, a segment more, and reading it");
// Not a stream at all: it pays for being read, and globals count with it.
let mut work = u64::MAX;
assert!(matches!(decode_jbig2_within(b"garbage", Some(b"globals"), w, h, usize::MAX, &mut work), Err(FilterError::Corrupt { .. })));
assert_eq!(u64::MAX - work, 4 * 14);
}
/// 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);
}
}
}
+273 -25
View File
@@ -6,8 +6,28 @@
//! 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
//! ([`encode_g4`]) or lossless JBIG2 ([`encode_jbig2`]).
//! callers keep such data encoded. CCITT fax and JBIG2 images decode on their own
//! ([`decode_ccitt_within`], [`decode_jbig2_within`]), each spending from an allowance its caller
//! shares across a run of images, as `pdfcraft_cos::ImageBudget::decode` does after the filters
//! before them. Neither decodes without one:
//!
//! ```compile_fail
//! let _ = pdfcraft_filters::decode_jbig2(&[], None, 1, 1, 1 << 20);
//! ```
//!
//! ```compile_fail
//! let _ = pdfcraft_filters::decode_ccitt(&[], &pdfcraft_filters::CcittParams::default(), 1);
//! ```
//!
//! ```
//! let mut work = 1 << 20;
//! assert!(pdfcraft_filters::decode_jbig2_within(&[], None, 1, 1, 1 << 20, &mut work).is_err());
//! let p = pdfcraft_filters::CcittParams { columns: 8, ..Default::default() };
//! assert!(pdfcraft_filters::decode_ccitt_within(&[], &p, 1, &mut work).is_ok());
//! ```
//!
//! 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
//! defence) and never panics on malformed input.
@@ -26,8 +46,8 @@ 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 ccitt::{CcittParams, MAX_CCITT_COLUMNS, MAX_G4_COLUMNS, decode_ccitt_within, encode_g4};
pub use jbig2::{MAX_JBIG2_COLUMNS, MAX_JBIG2_PIXELS, decode_jbig2_within, encode_jbig2, encode_jbig2_generic};
/// A PDF stream filter, identified by its `/Filter` name.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -89,7 +109,8 @@ impl Filter {
matches!(self, Filter::Dct | Filter::Jpx | Filter::Jbig2 | Filter::CcittFax)
}
fn is_decodable(&self) -> bool {
/// True for the general-purpose filters, which a chain can hold ([`decode`]).
pub fn is_decodable(&self) -> bool {
matches!(self, Filter::Flate | Filter::Lzw | Filter::AsciiHex | Filter::Ascii85 | Filter::RunLength)
}
}
@@ -121,33 +142,53 @@ pub enum FilterError {
LimitExceeded(usize),
#[error("unsupported filter: {0}")]
Unsupported(String),
/// A run of images has nothing left to spend ([`decode_tolerant_within`]).
#[error("more image decoding than one operation may do")]
Spent,
}
/// A decoder failure carrying whatever was decoded before the error.
pub(crate) struct Failure {
pub(crate) error: FilterError,
pub(crate) partial: Vec<u8>,
/// Bytes decoded on the way that `partial` doesn't hold: they took as long.
pub(crate) discarded: usize,
}
impl Failure {
pub(crate) fn corrupt(filter: &'static str, detail: impl Into<String>, partial: Vec<u8>) -> Self {
Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial }
Failure { error: FilterError::Corrupt { filter, detail: detail.into() }, partial, discarded: 0 }
}
pub(crate) fn limit(max: usize) -> Self {
Failure { error: FilterError::LimitExceeded(max), partial: Vec::new() }
Failure { error: FilterError::LimitExceeded(max), partial: Vec::new(), discarded: 0 }
}
}
pub(crate) type Step = Result<Vec<u8>, Failure>;
/// Takes `steps` from `work`, what a run of images may still cost. What it can't pay for spends
/// all of it.
pub(crate) fn pay(work: &mut u64, steps: u64) -> Result<(), FilterError> {
match work.checked_sub(steps) {
Some(left) => {
*work = left;
Ok(())
}
None => {
*work = 0;
Err(FilterError::Spent)
}
}
}
/// Decodes `data` through `chain` (first element applied first).
///
/// Image codecs, `Crypt` and unknown filters anywhere in the chain give
/// [`FilterError::Unsupported`] (callers keep such data encoded), as does a chain longer than
/// [`MAX_CHAIN`]. `max_output` bounds every intermediate buffer.
pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<Vec<u8>, FilterError> {
run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
run(chain, data, max_output, None, true).map(|(v, _, _)| v).map_err(|(e, _)| e)
}
/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
@@ -155,47 +196,98 @@ pub fn decode(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Res
/// bytes, on success and on failure. A stage's output counts even when a later stage or its
/// predictor shrinks it.
pub fn decode_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, usize), (FilterError, usize)> {
run(chain, data, max_output, true, true).map(|(v, _, spent)| (v, spent))
run(chain, data, max_output, Some(max_output), true).map(|(v, _, spent)| (v, spent))
}
/// Like [`decode`], but on corrupt data returns what was decoded before the error and
/// reports `partial = true` (the behaviour viewers expect). The remaining filters of the
/// chain still run on the partial data. Limit and unsupported errors are still errors.
pub fn decode_tolerant(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool), FilterError> {
run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
run(chain, data, max_output, None, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
}
/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
run(chain, data, max_output, false, true)
run(chain, data, max_output, Some(max_output), false)
}
/// [`decode_tolerant`], spending from `work`, what decoding may still cost across a run of images:
/// a step for each byte of `data`, paid before anything is decoded, and one for each byte a stage
/// decodes, so a chain that fails, or a stage that goes past `max_output`, has paid for what it did.
/// No stage decodes past what is left: a chain that `work` can't pay for spends all of it and stops
/// there, so with nothing left nothing is read. A chain that can't be decoded at all costs nothing,
/// whatever is left, so what an image's filters are is known from the error.
///
/// Unlike [`decode_tolerant`], no filters at all gives `data` itself, within `max_output`.
pub fn decode_tolerant_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
spending(chain, data, max_output, work, false)
}
/// [`decode`], spending from `work` as [`decode_tolerant_within`] does: corrupt data is an error,
/// having paid for what was decoded before it.
pub fn decode_within(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64) -> Result<Vec<u8>, FilterError> {
spending(chain, data, max_output, work, true)
}
fn spending(chain: &[(Filter, Params)], data: &[u8], max_output: usize, work: &mut u64, strict: bool) -> Result<Vec<u8>, FilterError> {
decodable(chain)?;
pay(work, data.len() as u64)?;
if chain.is_empty() {
return if data.len() > max_output { Err(FilterError::LimitExceeded(max_output)) } else { Ok(data.to_vec()) };
}
let left = usize::try_from(*work).unwrap_or(usize::MAX);
match run(chain, data, max_output, Some(left), strict) {
Ok((out, _, decoded)) => pay(work, decoded as u64).map(|()| out),
// The stage stopped at the lower of the two, which took as much to find out.
Err((FilterError::LimitExceeded(_), decoded)) if left.saturating_sub(decoded) > max_output => {
pay(work, (decoded as u64).saturating_add(max_output as u64))?;
Err(FilterError::LimitExceeded(max_output))
}
Err((FilterError::LimitExceeded(_), _)) => {
*work = 0;
Err(FilterError::Spent)
}
Err((e, decoded)) => pay(work, decoded as u64).and(Err(e)),
}
}
/// Longest filter chain decoded. Real streams use one to three filters; every stage may produce
/// up to the output limit, so the chain length multiplies the work.
pub const MAX_CHAIN: usize = 8;
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
/// the bytes decoded before it. `max` bounds each stage, or with `cumulative` all of them
/// together.
fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool, cumulative: bool) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
/// Whether a chain is one [`run`] takes, and if not, the error.
fn decodable(chain: &[(Filter, Params)]) -> Result<(), FilterError> {
if chain.len() > MAX_CHAIN {
return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0));
return Err(FilterError::Unsupported(format!("a chain of {} filters", chain.len())));
}
if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) {
return Err((FilterError::Unsupported(f.name().to_owned()), 0));
match chain.iter().find(|(f, _)| !f.is_decodable()) {
Some((f, _)) => Err(FilterError::Unsupported(f.name().to_owned())),
None => Ok(()),
}
}
/// The decoded data, whether it is partial and the bytes all stages decoded, or the error and
/// the bytes decoded before it. `max` bounds each stage, and `total` all of them together.
fn run(
chain: &[(Filter, Params)],
data: &[u8],
max: usize,
total: Option<usize>,
strict: bool,
) -> Result<(Vec<u8>, bool, usize), (FilterError, usize)> {
decodable(chain).map_err(|e| (e, 0))?;
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
let mut partial = false;
let mut spent = 0usize;
for (filter, params) in chain {
#[cfg(test)]
STAGE_BYTES.with(|c| c.set(c.get() + cur.len()));
let limit = if cumulative { max.saturating_sub(spent) } else { max };
let limit = total.map_or(max, |total| max.min(total.saturating_sub(spent)));
let (step, work) = decode_one(filter, params, &cur, limit);
spent = spent.saturating_add(work);
cur = Cow::Owned(match step {
Ok(v) => v,
Err(Failure { error: FilterError::Corrupt { .. }, partial: out }) if !strict => {
Err(Failure { error: FilterError::Corrupt { .. }, partial: out, .. }) if !strict => {
partial = true;
out
}
@@ -214,15 +306,15 @@ fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Ste
Filter::AsciiHex => counted(asciihex::decode(data, max)),
Filter::Ascii85 => counted(ascii85::decode(data, max)),
Filter::RunLength => counted(runlength::decode(data, max)),
other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }), 0),
other => (Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new(), discarded: 0 }), 0),
}
}
/// A step with the bytes it produced, its output or its partial output.
/// A step with the bytes it produced: its output, or its partial output and what it threw away.
fn counted(step: Step) -> (Step, usize) {
let n = match &step {
Ok(v) => v.len(),
Err(f) => f.partial.len(),
Err(f) => f.partial.len().saturating_add(f.discarded),
};
(step, n)
}
@@ -234,12 +326,12 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usi
let (step, n) = counted(step);
let step = match step {
Ok(v) => predictor::decode(params, v, name),
Err(Failure { error, partial }) => {
Err(Failure { error, partial, discarded }) => {
let partial = match predictor::decode(params, partial, name) {
Ok(v) => v,
Err(f) => f.partial,
};
Err(Failure { error, partial })
Err(Failure { error, partial, discarded })
}
};
(step, n)
@@ -426,6 +518,9 @@ mod tests {
assert_eq!(decode(&chain, &data, max).map(|d| d.len()).ok(), Some(1 << 19));
assert!(matches!(decode_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max));
assert_eq!(decode_counted(&chain, &data, 2 << 20).map(|(_, spent)| spent).ok(), Some((1 << 20) + (1 << 19)));
let tolerant = decode_tolerant_counted(&chain, &data, 2 << 20).map(|(out, partial, spent)| (out.len(), partial, spent));
assert_eq!(tolerant.ok(), Some((1 << 19, false, (1 << 20) + (1 << 19))));
assert!(matches!(decode_tolerant_counted(&chain, &data, max), Err((FilterError::LimitExceeded(m), _)) if m == max));
}
/// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it,
@@ -444,4 +539,157 @@ mod tests {
assert!(matches!(e, FilterError::Corrupt { .. }), "{e}");
assert_eq!(spent, bad.len());
}
fn refused(e: &FilterError) -> bool {
matches!(e, FilterError::Spent)
}
/// A chain pays for what it reads and what each stage decodes, down to the byte: Flate inflates
/// hex that ASCIIHex halves, so a stage counts when a later one shrinks it.
#[test]
fn a_chain_spends_down_to_the_byte() {
let hex = b"ab".repeat(1 << 10);
let data = encode_flate(&hex);
let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
let whole = decode(&chain, &data, 1 << 20).unwrap();
let cost = (data.len() + hex.len() + whole.len()) as u64;
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole);
assert_eq!(u64::MAX - work, cost);
let mut work = cost;
assert_eq!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap(), whole, "exactly enough");
assert_eq!(work, 0);
let mut work = cost + 7;
decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap();
assert_eq!(work, 7, "only what it took");
let mut work = cost - 1;
assert!(refused(&decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap_err()), "a step short");
assert_eq!(work, 0, "spends all that was left");
// Each stage has `max_output` to itself, as in a plain decode: together they are past it.
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &data, hex.len(), &mut work).unwrap(), whole);
}
/// No stage runs past what is left: the first stage of this bomb stops there, so the second
/// isn't given anything to read, and with too little to read it nothing runs at all.
#[test]
fn a_chain_stops_at_what_is_left() {
let inner = encode_flate(&vec![0; 1 << 20]);
let bomb = encode_flate(&inner);
let chain = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())];
let read = |work: u64| {
super::STAGE_BYTES.with(|c| c.set(0));
let mut work = work;
assert!(refused(&decode_tolerant_within(&chain, &bomb, usize::MAX, &mut work).unwrap_err()));
assert_eq!(work, 0);
super::STAGE_BYTES.with(|c| c.get())
};
assert_eq!(read((bomb.len() + inner.len()) as u64 + 10), bomb.len() + inner.len(), "the second stage stops");
assert_eq!(read(bomb.len() as u64 + 10), bomb.len(), "the first stops, and the second never runs");
assert_eq!(read(bomb.len() as u64 - 1), 0, "it can't pay to be read");
}
/// Past `max_output` the chain has failed, and hasn't run out of anything: it pays what it took
/// to find out, and what is left stays. Whichever limit a stage stops at is the one reported.
#[test]
fn a_stage_past_its_limit_pays_for_finding_out() {
let data = encode_flate(&vec![0; 1 << 16]);
let chain = [(Filter::Flate, Params::default())];
let mut work = u64::MAX;
assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000))));
assert_eq!(u64::MAX - work, data.len() as u64 + 1000);
// The later stage of two, which pays for the first as well.
let bomb = encode_flate(&data);
let two = [(Filter::Flate, Params::default()), (Filter::Flate, Params::default())];
let mut work = u64::MAX;
assert!(matches!(decode_tolerant_within(&two, &bomb, data.len(), &mut work), Err(FilterError::LimitExceeded(_))));
assert_eq!(u64::MAX - work, (bomb.len() + 2 * data.len()) as u64);
// Both limits at once: a run that can't go on is what a caller needs to hear.
let mut work = data.len() as u64 + 1000;
assert!(refused(&decode_tolerant_within(&chain, &data, 1000, &mut work).unwrap_err()));
assert_eq!(work, 0);
let mut work = data.len() as u64 + 1001;
assert!(matches!(decode_tolerant_within(&chain, &data, 1000, &mut work), Err(FilterError::LimitExceeded(1000))));
assert_eq!(work, 1, "the image's limit, with a step to spare");
}
/// What a damaged stream decoded is paid for, however the decode ends.
#[test]
fn a_chain_that_fails_has_paid_for_what_it_did() {
let data: Vec<u8> = (0..20_000u32).map(|i| (i % 97) as u8 ^ (i / 300) as u8).collect();
let mut cut = encode_flate(&data);
cut.truncate(cut.len() / 2);
let chain = [(Filter::Flate, Params::default())];
let (partial, _) = decode_tolerant(&chain, &cut, 1 << 20).unwrap();
assert!(!partial.is_empty());
let mut work = u64::MAX;
assert_eq!(decode_tolerant_within(&chain, &cut, 1 << 20, &mut work).unwrap(), partial);
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64);
// Read strictly it is an error, for the same work.
let mut work = u64::MAX;
assert!(matches!(decode_within(&chain, &cut, 1 << 20, &mut work), Err(FilterError::Corrupt { .. })));
assert_eq!(u64::MAX - work, (cut.len() + partial.len()) as u64);
let whole = encode_flate(&data);
let mut work = u64::MAX;
assert_eq!(decode_within(&chain, &whole, 1 << 20, &mut work).unwrap(), data);
assert_eq!(u64::MAX - work, (whole.len() + data.len()) as u64);
// A predictor that comes to nothing, after all of this was inflated for it.
let p = Params { predictor: 99, ..Params::default() };
let data = encode_flate(&vec![0; 1 << 16]);
let mut work = u64::MAX;
assert!(decode_tolerant_within(&[(Filter::Flate, p)], &data, 1 << 20, &mut work).unwrap().is_empty());
assert_eq!(u64::MAX - work, data.len() as u64 + (1 << 16));
}
/// A stream that reads as zlib only until it fails, and as raw deflate until that fails too,
/// gives back no data, and has still decoded everything the retry did.
#[test]
fn a_retry_that_fails_pays_for_what_it_decoded() {
// Stored as raw deflate: this many bytes, then a block of a reserved type. As zlib: the
// same reserved type at once, so the first reading fails with nothing.
let mut data = vec![0x78, 0x01, 0x06, 0xFE, 0xF9];
data.extend(vec![0u8; 0x0601]);
data.push(0x07);
let chain = [(Filter::Flate, Params::default())];
let (out, partial) = decode_tolerant(&chain, &data, 1 << 20).unwrap();
assert!(out.is_empty() && partial, "what is read stays as it was");
let (_, _, spent) = decode_tolerant_counted(&chain, &data, 1 << 20).unwrap();
assert_eq!(spent, 0x0601);
let (e, spent) = decode_counted(&chain, &data, 1 << 20).unwrap_err();
assert!(matches!(e, FilterError::Corrupt { .. }), "{e}");
assert_eq!(spent, 0x0601);
let mut work = u64::MAX;
assert!(decode_tolerant_within(&chain, &data, 1 << 20, &mut work).unwrap().is_empty());
assert_eq!(u64::MAX - work, (data.len() + 0x0601) as u64);
// Through a predictor, which would otherwise lose the count.
let p = Params { predictor: 12, columns: 4, ..Params::default() };
let (_, _, spent) = decode_tolerant_counted(&[(Filter::Flate, p)], &data, 1 << 20).unwrap();
assert_eq!(spent, 0x0601);
}
/// With nothing left nothing is read. What can't be decoded at all never costs anything, and
/// says so whatever is left. Without filters, what is read is the data.
#[test]
fn what_a_chain_is_shows_whatever_is_left() {
let flate = [(Filter::Flate, Params::default())];
let mut work = 0;
assert!(refused(&decode_tolerant_within(&flate, b"garbage", 1 << 20, &mut work).unwrap_err()));
let long = vec![(Filter::Flate, Params::default()); MAX_CHAIN + 1];
for chain in [vec![(Filter::Dct, Params::default())], vec![(Filter::Jbig2, Params::default())], long] {
for left in [0, 100] {
let mut work = left;
let e = decode_tolerant_within(&chain, b"data", 1 << 20, &mut work).unwrap_err();
assert!(matches!(e, FilterError::Unsupported(_)) && !refused(&e), "{e}");
assert_eq!(work, left);
}
}
let mut work = 100;
assert_eq!(decode_tolerant_within(&[], b"data", 4, &mut work).unwrap(), b"data");
assert_eq!(work, 96);
assert!(matches!(decode_tolerant_within(&[], b"data", 3, &mut work), Err(FilterError::LimitExceeded(3))));
assert_eq!(work, 92);
let mut work = 3;
assert!(refused(&decode_tolerant_within(&[], b"data", 4, &mut work).unwrap_err()));
assert_eq!(work, 0);
}
}
+38 -18
View File
@@ -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,14 +99,20 @@ 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. All of a run's images share one allowance of decoding
(`pdfcraft_cos::ImageBudget`), filters, decoders and pixels alike, JPEG samples and merged
pictures too: about
1,000 scanned pages' worth, plus more for each byte of the file, enough to decode a stream
about 8 times at Flate's best. Images past it stay as they are, and `Report::images_refused`
counts those the image step read. Streams it only stores with Flate (below) aren't counted.
- **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
bytes; streams with `/DecodeParms`, images and object streams keep their ASCII layer. LZW-compressed
streams, content and images alike, are decoded and stored with Flate instead (Acrobat's "In
streams that use LZW encoding, use Flate instead"): a predictor carries over to Flate without
streams that use LZW encoding, use Flate instead"), from the run's allowance, past which they
stay LZW: a predictor carries over to Flate without
`EarlyChange`, filters applied before the LZW one go with it and those after it keep their
bytes. A stream whose LZW data doesn't decode cleanly is left as it is. Not yet: inline images.
+69 -10
View File
@@ -18,7 +18,7 @@
//! drops them.
use pdfcraft_content::{Matrix, Op, parse, serialize_ops};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream};
use crate::images::{components, content_bytes, decodable, page_resources, pixels, resample};
use crate::{OptimizeError, Report, Stage};
@@ -152,7 +152,7 @@ fn run_end(ops: &[Op], i: usize, count: usize) -> usize {
}
/// The run's pixels as one image, top row first: `(pixels, width, height)`.
fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec<u8>, u32, u32)> {
fn merge(doc: &Document, run: &[Fragment], n: usize, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32)> {
let (top, bottom) = (run.first()?.rect[3], run.last()?.rect[1]);
let width = run.iter().map(|f| f.width).max()?;
// Pixel rows per unit of height: the finest fragment's.
@@ -167,7 +167,12 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec<u8>, u32, u3
return None;
}
let row = (width as usize).checked_mul(n)?;
let mut out = vec![255u8; row.checked_mul(height as usize)?];
// What this builds isn't what its strips cost to read: four small ones can ask for all of it.
let bytes = row.checked_mul(height as usize)?;
if !budget.pay_pixels(u64::try_from(bytes).ok()?) {
return None;
}
let mut out = vec![255u8; bytes];
for f in run {
let r0 = (((top - f.rect[3]) * density).round().max(0.0) as u32).min(height);
let r1 = (((top - f.rect[1]) * density).round().max(0.0) as u32).min(height);
@@ -176,7 +181,7 @@ fn merge(doc: &Document, run: &[Fragment], n: usize) -> Option<(Vec<u8>, u32, u3
}
let obj = doc.get(f.image);
let Object::Stream(s) = &*obj else { return None };
let px = pixels(doc, s, n)?;
let px = pixels(doc, s, n, budget)?;
let px = if (f.width, f.height) == (width, r1 - r0) { px } else { resample(&px, n, f.width, f.height, width, r1 - r0)? };
out.get_mut(r0 as usize * row..r1 as usize * row)?.copy_from_slice(px.get(..(r1 - r0) as usize * row)?);
}
@@ -230,7 +235,13 @@ fn add_xobject(doc: &mut Document, page: ObjRef, name: &[u8], image: ObjRef) ->
}
/// Merge the fragment runs of one page; returns how many fragments were merged.
fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<usize, OptimizeError> {
fn merge_page(
doc: &mut Document,
page: ObjRef,
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
images: &mut ImageBudget,
) -> Result<usize, OptimizeError> {
let Some(contents) = doc.get(page).as_dict().and_then(|d| d.get(b"Contents").cloned()) else { return Ok(0) };
// A page whose content isn't all known is left alone: rewriting it would drop the rest.
let Some(data) = content_bytes(doc, &contents, &mut DecodeBudget::page()) else { return Ok(0) };
@@ -286,7 +297,7 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
if !progress(Stage::Fragments) {
return Err(OptimizeError::Cancelled);
}
let Some((px, w, h)) = merge(doc, &run, n) else {
let Some((px, w, h)) = merge(doc, &run, n, images) else {
out.extend(ops.get(i..j).unwrap_or_default().iter().cloned());
i = j;
continue;
@@ -320,9 +331,15 @@ fn merge_page(doc: &mut Document, page: ObjRef, report: &mut Report, progress: &
Ok(merged)
}
pub(crate) fn run(doc: &mut Document, pages: &[ObjRef], report: &mut Report, progress: &mut dyn FnMut(Stage) -> bool) -> Result<(), OptimizeError> {
pub(crate) fn run(
doc: &mut Document,
pages: &[ObjRef],
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
budget: &mut ImageBudget,
) -> Result<(), OptimizeError> {
for p in pages {
report.fragments_merged += merge_page(doc, *p, report, progress)?;
report.fragments_merged += merge_page(doc, *p, report, progress, budget)?;
}
Ok(())
}
@@ -343,7 +360,49 @@ mod tests {
Fragment { image, rect: [0.0, edge, 1.0, 1.0], width: 1 << 16, height: 1 },
Fragment { image, rect: [0.0, 0.0, 1.0, edge], width: 1 << 16, height: 1 },
];
assert!(merge(&Document::new_empty(), &run, 3).is_none());
assert!(merge(&Document::new_empty(), &run, 3, &mut ImageBudget::with_work(u64::MAX)).is_none());
}
/// Merging reads the images the pass after it reads, from the same allowance: with nothing
/// left the strips stay as they are, and the budget shows the read it refused.
#[test]
fn merging_spends_the_runs_allowance() {
let mut doc = Document::new_empty();
let mut image = Dict::new();
for (k, v) in
[("Subtype", Object::name("Image")), ("Width", Object::Int(8)), ("Height", Object::Int(1)), ("BitsPerComponent", Object::Int(8))]
{
image.set(k.as_bytes().to_vec(), v);
}
image.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let (mut xobjects, mut drawn, mut raw) = (Dict::new(), String::new(), 0);
for row in 0..8 {
let strip = Stream::flate(image.clone(), &[row * 30; 8]);
raw += strip.raw.len() as u64;
let r = doc.add(Object::Stream(strip));
xobjects.set(format!("I{row}").into_bytes(), Object::Ref(r));
drawn.push_str(&format!("q 80 0 0 10 0 {} cm /I{row} Do Q\n", 70 - 10 * i32::from(row)));
}
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes())));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
let mut budget = ImageBudget::with_work(0);
assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0);
assert_eq!(budget.refused(), 1);
// Reading each strip, and building the picture of 8 rows of 8 they make.
let cost = raw + 8 * (8 + 8) + 8 * 8;
let mut budget = ImageBudget::with_work(u64::MAX);
assert_eq!(merge_page(&mut doc.clone(), page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 8);
assert_eq!(u64::MAX - budget.work_left(), cost);
// Enough for the strips and not for the picture: nothing is built.
let mut budget = ImageBudget::with_work(cost - 1);
assert_eq!(merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut budget).unwrap(), 0);
assert_eq!(budget.refused(), 1);
}
/// Many images drawn on top of each other are not one picture; looking for runs among them reads
@@ -370,7 +429,7 @@ mod tests {
page.set(b"Resources".to_vec(), Object::Dict(resources));
let page = doc.add(Object::Dict(page));
GROUP_READS.with(|c| c.set(0));
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true);
let merged = merge_page(&mut doc, page, &mut Report::default(), &mut |_| true, &mut ImageBudget::with_work(u64::MAX));
assert_eq!(merged.ok(), Some(0));
let reads = GROUP_READS.with(|c| c.get());
assert!(reads <= 40 * 4 * groups, "{reads} group reads");
+587 -51
View File
@@ -1,9 +1,10 @@
//! 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};
use pdfcraft_cos::{DecodeBudget, Dict, Document, ImageBudget, ObjRef, Object, Stream};
use crate::{Compression, ImageSettings, MonoCompression, MonoSettings, OptimizeError, Report, Settings, Stage};
@@ -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) };
}
@@ -465,7 +479,7 @@ fn count_decoded(_n: u64) {
/// every row, and the image would be written back wrong. `None` for a JPEG whose colour transform
/// readers may disagree about ([`ycc_encoded`]), and for data that decodes past what the samples
/// can need ([`allowance`]).
pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option<Vec<u8>> {
pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize, budget: &mut ImageBudget) -> Option<Vec<u8>> {
let (w, h) = (u32::try_from(s.dict.int(b"Width")?).ok()?, u32::try_from(s.dict.int(b"Height")?).ok()?);
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PIXELS {
return None;
@@ -476,11 +490,14 @@ pub(crate) fn pixels(doc: &Document, s: &Stream, n: usize) -> Option<Vec<u8>> {
// never get here: the colour space check excludes them. The JPEG's own size must be the
// dictionary's: `jpeg_samples` refuses another from its header, before any pixels.
let ycc = n == 3 && ycc_encoded(doc, s)?;
if !budget.pay_jpeg(u64::try_from(bytes).ok()?, &s.raw) {
return None;
}
let v = jpeg_samples(&s.raw, usize::try_from(w).ok()?, usize::try_from(h).ok()?, n, ycc)?;
count_decoded(u64::from(w) * u64::from(h));
(v.len() == bytes).then_some(v)
} else {
let v = doc.with_direct_parms(s).decoded_within(allowance(bytes, h)?).ok()?;
let v = budget.decode_filters(doc, s, allowance(bytes, h)?).ok()?;
Some(v.get(..bytes)?.to_vec())
}
}
@@ -618,13 +635,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));
@@ -686,7 +719,14 @@ fn image_stream(old: &Dict, px: &[u8], n: usize, w: u32, h: u32, compression: Co
/// One image: resample and/or recompress per `settings`; `ppi` is its effective resolution.
/// Returns the new image and its new soft mask, if the result is smaller.
fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: usize) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
fn process(
doc: &Document,
s: &Stream,
ppi: f64,
settings: &ImageSettings,
n: usize,
budget: &mut ImageBudget,
) -> Option<(Stream, Option<(ObjRef, Stream)>)> {
let d = &s.dict;
if d.get(b"Decode").is_some_and(|a| !is_identity_decode(doc, a, n))
|| d.contains(b"Mask")
@@ -718,7 +758,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us
} else {
(w, h)
};
let px = pixels(doc, s, n)?;
let px = pixels(doc, s, n, budget)?;
let px = if (nw, nh) != (w, h) { resample(&px, n, w, h, nw, nh)? } else { px };
let new = image_stream(d, &px, n, nw, nh, settings.compression, was_dct)?;
// A soft mask must keep the image's dimensions: resample it with the image (lossless).
@@ -731,7 +771,7 @@ fn process(doc: &Document, s: &Stream, ppi: f64, settings: &ImageSettings, n: us
return None;
}
let (mw, mh) = (u32::try_from(m.dict.int(b"Width")?).ok()?, u32::try_from(m.dict.int(b"Height")?).ok()?);
let mpx = pixels(doc, m, 1)?;
let mpx = pixels(doc, m, 1, budget)?;
let mpx = resample(&mpx, 1, mw, mh, nw, nh)?;
smask = Some((mr, image_stream(&m.dict, &mpx, 1, nw, nh, Compression::Flate, false)?));
}
@@ -754,7 +794,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 +811,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 ([`ImageBudget::decode`], 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
@@ -785,7 +825,7 @@ type Pixel = fn(&[u8]) -> Option<bool>;
/// - an indexed image of 2, 4 or 8 bits that uses only palette entries 0 and 1, which stay 0 and
/// 1 at one bit, whatever their colours, so the colour space stays. A decode array other than
/// the default stops it: in index units it would mean something else at one bit.
fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Vec<u8>, u32, u32, Option<Dict>)> {
fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool, budget: &mut ImageBudget) -> Option<(Vec<u8>, u32, u32, Option<Dict>)> {
let d = &s.dict;
// Through u32 first, as everywhere here, so the product can't overflow.
let (w, h) = (u32::try_from(d.int(b"Width")?).ok()?, u32::try_from(d.int(b"Height")?).ok()?);
@@ -802,9 +842,11 @@ 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)?,
};
let mut data = doc.decoded_image_within(s, max).ok()?;
count_decoded(u64::from(w) * u64::from(h));
let mut data = budget.decode(doc, s, max).ok()?;
if data.len() < len {
return None;
}
@@ -827,7 +869,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 => {}
@@ -872,7 +914,7 @@ fn one_bit(doc: &Document, s: &Stream, pack: bool, defaults: bool) -> Option<(Ve
let row_len = wu.checked_mul(n)?.checked_mul(bpc)?.div_ceil(8);
// Through its parameters given by reference too: without a PNG predictor's tag bytes taken
// off, rows of 0 and 255 (or palette entries 0 and 1) would still pass below, each shifted.
let samples = doc.with_direct_parms(s).decoded_within(allowance(row_len.checked_mul(hu)?, h)?).ok()?;
let samples = budget.decode_filters(doc, s, allowance(row_len.checked_mul(hu)?, h)?).ok()?;
Some((packed_rows(&samples, row_len, wu, hu, (n, bpc, white))?, w, h, Some(packed)))
}
@@ -908,8 +950,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 +969,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,33 +1096,45 @@ 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
/// decode array, which goes as the default at one bit.
fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings) -> Option<(Stream, bool)> {
fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSettings, budget: &mut ImageBudget) -> Option<(Stream, bool)> {
let d = &s.dict;
let downsample = settings.downsample && ppi.is_finite() && ppi > settings.above_ppi && settings.target_ppi > 0.0 && !d.contains(b"SMask");
let retain = settings.compression == MonoCompression::Retain;
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)));
// What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that
// would spend the run's allowance on it.
let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS));
if only_to_resample && (!downsample || defaults || too_wide || settings.target_ppi >= ppi || (jbig2 && dark_is_one(doc, d).is_none())) {
return None;
}
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults)?;
let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?;
let packed_from_more = packed.is_some();
let k = settings.target_ppi / ppi;
// `downsample_bits` keeps a counter for each column: no more columns than a fax row may have,
@@ -1038,8 +1146,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 +1163,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 +1186,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,25 +1195,30 @@ 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))
}
/// One pass over the document's images, within `budget`. An image past it stays as it is, and is
/// counted in the report.
pub(crate) fn run(
doc: &mut Document,
pages: &[ObjRef],
settings: &Settings,
report: &mut Report,
progress: &mut dyn FnMut(Stage) -> bool,
budget: &mut ImageBudget,
) -> 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()
@@ -1125,10 +1238,11 @@ pub(crate) fn run(
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
report.images += 1;
let refused = budget.refused();
// Packed from more bits a sample, it is lossless at one bit, but the grey or colour
// settings can make it smaller still (below).
let packed_from_more = s.dict.int(b"BitsPerComponent").is_some_and(|b| b > 1);
let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono) {
let (packed, mono) = match mono(doc, s, ppi, defaults.contains(&r.num), &settings.mono, budget) {
Some(m) if packed_from_more => (Some(m), None),
m => (None, m),
};
@@ -1145,7 +1259,7 @@ pub(crate) fn run(
continue;
}
let n = s.dict.get(b"ColorSpace").and_then(|cs| components(doc, cs));
let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n));
let processed = n.and_then(|n| process(doc, s, ppi, if n == 1 { &settings.gray } else { &settings.color }, n, budget));
// A dithered scan, say, downsampled and JPEG-compressed: the smaller wins, counting a soft
// mask resampled with it.
let smask_len = |smask: Option<&(ObjRef, Stream)>| match smask {
@@ -1171,7 +1285,11 @@ pub(crate) fn run(
doc.set(r, Object::Stream(new));
continue;
}
let Some((new, smask)) = processed else { continue };
let Some((new, smask)) = processed else {
// Read to be packed and again to be resampled, it is still one image, and it stayed.
report.images_refused += usize::from(budget.refused() > refused);
continue;
};
let resized = new.dict.int(b"Width") != s.dict.int(b"Width") || new.dict.int(b"Height") != s.dict.int(b"Height");
report.image_bytes_before += s.raw.len();
report.image_bytes_after += new.raw.len();
@@ -1219,13 +1337,321 @@ mod dimension_tests {
dict.set(b"Width".to_vec(), Object::Int(width));
dict.set(b"Height".to_vec(), Object::Int(height));
let stream = Stream::from_raw(dict, vec![0; 4]);
assert!(pixels(&Document::new_empty(), &stream, channels).is_none(), "{width} x {height} x {channels}");
assert!(
pixels(&Document::new_empty(), &stream, channels, &mut ImageBudget::with_work(u64::MAX)).is_none(),
"{width} x {height} x {channels}"
);
}
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(2));
dict.set(b"Height".to_vec(), Object::Int(2));
let stream = Stream::from_raw(dict, vec![1, 2, 3, 4, 5]);
assert_eq!(pixels(&Document::new_empty(), &stream, 1), Some(vec![1, 2, 3, 4]));
assert_eq!(pixels(&Document::new_empty(), &stream, 1, &mut ImageBudget::with_work(u64::MAX)), 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, &mut ImageBudget::with_work(u64::MAX));
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 run has one allowance for all its JBIG2 images. Those past it stay as they are, and the
/// report says how many: a file of thousands of them can't hold a run for hours.
#[test]
fn jbig2_images_past_the_runs_allowance_stay_as_they_are() {
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap();
let mut dict = Dict::new();
dict.set(b"Type".to_vec(), Object::name("XObject"));
dict.set(b"Subtype".to_vec(), Object::name("Image"));
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(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, coded);
let mut cost = ImageBudget::with_work(u64::MAX);
cost.decode(&Document::new_empty(), &image, 1 << 20).unwrap();
let cost = u64::MAX - cost.work_left();
let mut doc = Document::new_empty();
let refs = [doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone())), doc.add(Object::Stream(image.clone()))];
let mut xobjects = Dict::new();
for (i, r) in refs.iter().enumerate() {
xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r));
}
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
// Drawn small enough, at 720 ppi, for the settings to resample all three.
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 1.6 0 0 .2 0 0 cm /I0 Do /I1 Do /I2 Do Q")));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() };
let mut report = Report::default();
let mut budget = ImageBudget::with_work(2 * cost);
run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
assert_eq!(report.images_refused, 1);
assert_eq!(budget.work_left(), 0, "two decoded and the third refused");
assert_eq!(report.images_resampled, 2);
let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count();
assert_eq!(changed, 2, "the third is as it was");
}
/// Images that aren't JBIG2 share the run's allowance too, both on the way to being resampled
/// and on the way to being packed to a bit a sample: a file of filter bombs would hold a run
/// as long as one of JBIG2, and those past it stay as they are.
#[test]
fn flate_images_past_the_runs_allowance_stay_as_they_are() {
// Noise, resampled, or black and white, packed.
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let noise: Vec<u8> = (0..256 * 256)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect();
let black_and_white: Vec<u8> = noise.iter().map(|v| if v & 1 == 0 { 0 } else { 255 }).collect();
for (name, samples, settings) in [
("resampled", &noise, Settings::default()),
(
"packed",
&black_and_white,
Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() },
),
] {
let page_of = |images: usize| {
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(256));
dict.set(b"Height".to_vec(), Object::Int(256));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = Stream::flate(dict, samples);
let refs: Vec<ObjRef> = (0..images).map(|_| doc.add(Object::Stream(image.clone()))).collect();
let mut xobjects = Dict::new();
let mut drawn = String::from("q 10 0 0 10 0 0 cm ");
for (i, r) in refs.iter().enumerate() {
xobjects.set(format!("I{i}").into_bytes(), Object::Ref(*r));
drawn.push_str(&format!("/I{i} Do "));
}
drawn.push('Q');
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), drawn.as_bytes())));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
(doc, page, refs, image)
};
let (mut doc, page, ..) = page_of(1);
let mut budget = ImageBudget::with_work(u64::MAX);
let mut report = Report::default();
run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
let cost = u64::MAX - budget.work_left();
assert!(cost > 0, "{name}");
assert_eq!(report.images_refused, 0, "{name}");
let (mut doc, page, refs, image) = page_of(3);
let mut budget = ImageBudget::with_work(2 * cost);
let mut report = Report::default();
run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
assert_eq!(report.images_refused, 1, "{name}: {report:?}");
assert_eq!(budget.work_left(), 0, "{name}");
let changed = refs.iter().filter(|r| matches!(&*doc.get(**r), Object::Stream(s) if *s != image)).count();
assert_eq!(changed, 2, "{name}: the third is as it was");
}
}
/// An image the allowance ran out on between being packed and being resampled is rewritten,
/// as the packed one, so it isn't one that stayed as it was.
#[test]
fn a_packed_image_isnt_counted_as_refused() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let samples: Vec<u8> = (0..256 * 256)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
if seed & 1 == 0 { 0 } else { 255 }
})
.collect();
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(256));
dict.set(b"Height".to_vec(), Object::Int(256));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = Stream::flate(dict, &samples);
let read = (image.raw.len() + samples.len()) as u64 + 256 * 256;
let original = doc.add(Object::Stream(image.clone()));
let mut xobjects = Dict::new();
xobjects.set(b"I0".to_vec(), Object::Ref(original));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 10 0 0 10 0 0 cm /I0 Do Q")));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
let settings = Settings { mono: MonoSettings { compression: MonoCompression::CcittG4, ..MonoSettings::REDUCE }, ..Settings::default() };
let mut report = Report::default();
let mut budget = ImageBudget::with_work(read);
run(&mut doc, &[page], &settings, &mut report, &mut |_| true, &mut budget).unwrap();
assert_eq!(budget.refused(), 1, "its second read was refused");
assert_eq!(report.images_refused, 0, "{report:?}");
assert_eq!(report.images_recompressed + report.images_resampled, 1, "{report:?}");
assert!(matches!(&*doc.get(original), Object::Stream(s) if *s != image));
}
/// An image the run has no use for stayed as it was too, but nothing refused it: this one is
/// too small to compress and drawn at no more than its size.
#[test]
fn an_image_left_alone_isnt_counted_as_refused() {
let mut doc = Document::new_empty();
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(8));
dict.set(b"Height".to_vec(), Object::Int(8));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
let image = doc.add(Object::Stream(Stream::flate(dict, &[0x80; 64])));
let mut xobjects = Dict::new();
xobjects.set(b"I0".to_vec(), Object::Ref(image));
let mut resources = Dict::new();
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), b"q 72 0 0 72 0 0 cm /I0 Do Q")));
let mut page = Dict::new();
page.set(b"Type".to_vec(), Object::name("Page"));
page.set(b"Resources".to_vec(), Object::Dict(resources));
page.set(b"Contents".to_vec(), Object::Ref(content));
let page = doc.add(Object::Dict(page));
let mut report = Report::default();
run(&mut doc, &[page], &Settings::default(), &mut report, &mut |_| true, &mut ImageBudget::with_work(u64::MAX)).unwrap();
assert_eq!((report.images, report.images_refused), (1, 0), "{report:?}");
}
/// A JPEG pays for its samples before it is decoded, however few bytes it takes: past what is
/// left it isn't, and counts as refused.
#[test]
fn a_jpeg_pays_for_its_samples() {
let mut dict = Dict::new();
dict.set(b"Width".to_vec(), Object::Int(16));
dict.set(b"Height".to_vec(), Object::Int(8));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name("DeviceRGB"));
let jpeg = image_stream(&dict, &[120; 16 * 8 * 3], 3, 16, 8, Compression::Jpeg(60), false).expect("a JPEG");
let doc = Document::new_empty();
let cost = 2 * 16 * 8 * 3 + 4 * jpeg.raw.len() as u64;
let mut budget = ImageBudget::with_work(cost);
assert_eq!(pixels(&doc, &jpeg, 3, &mut budget).map(|p| p.len()), Some(16 * 8 * 3));
assert_eq!((budget.work_left(), budget.refused()), (0, 0), "exactly that");
let mut budget = ImageBudget::with_work(cost - 1);
assert!(pixels(&doc, &jpeg, 3, &mut budget).is_none());
assert_eq!((budget.work_left(), budget.refused()), (0, 1));
}
/// A soft mask resampled with its image is read from the same allowance as the image.
#[test]
fn a_soft_mask_resampled_with_its_image_spends_the_allowance() {
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let noise: Vec<u8> = (0..256 * 256)
.map(|_| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed as u8
})
.collect();
let image = |space: &str| {
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
dict.set(b"Width".to_vec(), Object::Int(256));
dict.set(b"Height".to_vec(), Object::Int(256));
dict.set(b"BitsPerComponent".to_vec(), Object::Int(8));
dict.set(b"ColorSpace".to_vec(), Object::name(space));
Stream::flate(dict, &noise)
};
let mut doc = Document::new_empty();
let mask = image("DeviceGray");
let cost = (mask.raw.len() + noise.len()) as u64 + 256 * 256;
let masked = doc.add(Object::Stream(mask));
let mut with_mask = image("DeviceGray");
with_mask.dict.set(b"SMask".to_vec(), Object::Ref(masked));
let spent = |s: &Stream| {
let mut budget = ImageBudget::with_work(u64::MAX);
process(&doc, s, 720.0, &Settings::default().gray, 1, &mut budget).expect("resampled, and smaller");
u64::MAX - budget.work_left()
};
assert_eq!(spent(&with_mask), spent(&image("DeviceGray")) + cost);
}
/// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it
/// takes nothing from the images that can be, and isn't counted as refused past the allowance.
#[test]
fn a_jbig2_image_that_cant_be_averaged_spends_nothing() {
let image = |edit: &dyn Fn(&mut Dict)| {
let mut dict = Dict::new();
dict.set(b"Subtype".to_vec(), Object::name("Image"));
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(), Object::name("JBIG2Decode"));
edit(&mut dict);
Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap())
};
let spent_at = |target_ppi: f64, s: &Stream, defaults: bool| {
let settings = MonoSettings { compression: MonoCompression::Retain, above_ppi: 100.0, target_ppi, ..MonoSettings::REDUCE };
let mut budget = ImageBudget::with_work(u64::MAX);
let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget);
u64::MAX - budget.work_left()
};
let spent = |s: &Stream, defaults: bool| spent_at(300.0, s, defaults);
let plain = image(&|_| {});
assert_eq!(spent_at(720.0, &plain, false), 0, "already at the target");
assert!(spent_at(719.0, &plain, false) > 0, "just above it");
assert!(spent(&plain, false) > 0, "one that can be averaged is decoded");
assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped");
let width = |w: i64| image(&move |d| d.set(b"Width".to_vec(), Object::Int(w)));
let widest = i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS);
assert!(spent(&width(widest), false) > 0, "the widest that can be averaged is decoded");
assert_eq!(spent(&width(widest + 1), false), 0, "too wide to average");
let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())])));
assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark");
}
/// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded.
@@ -1240,9 +1666,69 @@ mod dimension_tests {
Stream::from_raw(dict, data.clone())
};
DECODED_PIXELS.with(|c| c.set(0));
assert!(pixels(&Document::new_empty(), &stream(10, 10), 1).is_none());
assert!(pixels(&Document::new_empty(), &stream(10, 10), 1, &mut ImageBudget::with_work(u64::MAX)).is_none());
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));
assert_eq!(pixels(&Document::new_empty(), &stream(1000, 1000), 1, &mut ImageBudget::with_work(u64::MAX)).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);
}
}
@@ -1307,8 +1793,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 +1819,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 +1828,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");
+50 -12
View File
@@ -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.
@@ -35,7 +36,7 @@ mod unused;
pub use audit::{SpaceCategory, SpaceUse, audit_space};
use pdfcraft_cos::{Dict, Document, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize};
use pdfcraft_cos::{Dict, Document, ImageBudget, MAX_PAGE_DECODED, ObjRef, Object, Stream, serialize};
use pdfcraft_filters::{Filter, Params};
pub use images::effective_resolutions;
@@ -278,6 +279,8 @@ pub struct Report {
pub images: usize,
pub images_resampled: usize,
pub images_recompressed: usize,
/// Images left as they are: decoding them all would take more than one run may spend.
pub images_refused: usize,
/// Encoded image bytes before and after (images that changed).
pub image_bytes_before: usize,
pub image_bytes_after: usize,
@@ -328,10 +331,12 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
}
let mut report = Report::default();
let pages = pdfcraft_annot::page_refs(doc).map_err(|_| OptimizeError::NoPages)?;
// Merging fragments, the image pass and the LZW pass read the same images, so they share it.
let mut budget = ImageBudget::new(doc);
if settings.merge_fragments {
fragments::run(doc, &pages, &mut report, progress)?;
fragments::run(doc, &pages, &mut report, progress, &mut budget)?;
}
images::run(doc, &pages, settings, &mut report, progress)?;
images::run(doc, &pages, settings, &mut report, progress, &mut budget)?;
if !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
@@ -427,7 +432,11 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
let r = ObjRef { num, generation: doc.generation(num) };
let obj = doc.get(r);
let Object::Stream(s) = &*obj else { continue };
if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED) {
// Each can take a second or more, of a pass that has no other way to stop.
if s.filters().iter().any(|(f, _)| *f == Filter::Lzw) && !progress(Stage::CleanUp) {
return Err(OptimizeError::Cancelled);
}
if let Some(replacement) = lzw_to_flate(s, MAX_LZW_DECODED, &mut budget) {
doc.set(r, Object::Stream(replacement));
report.lzw_streams += 1;
}
@@ -462,14 +471,17 @@ pub fn optimize_with_progress(doc: &mut Document, settings: &Settings, progress:
/// Largest decoded LZW stream converted, as for a page's content: the data and its check are
/// held together, so a few hundred kilobytes of hostile LZW can't ask for gigabytes.
const MAX_LZW_DECODED: usize = MAX_PAGE_DECODED;
/// The steps of a run's allowance a decoded byte costs to store with Flate and read back: up to
/// 78 ns a byte to encode and 4 to check, at the 6 to 16 ns of a step.
const LZW_REWRITE_WORK: u64 = 8;
/// `s` with its LZW compression replaced by Flate (Acrobat's "In streams that use LZW encoding,
/// use Flate instead"). The filters up to the last LZW one are decoded, strictly and with its
/// predictor; the result is Flate-encoded under the same predictor, without `EarlyChange`, and
/// the filters after it keep their encoded bytes. A stream that doesn't decode cleanly, or
/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes, is
/// left as it is.
fn lzw_to_flate(s: &Stream, max: usize) -> Option<Stream> {
/// whose filters or parameters can't be read for certain, or that decodes past `max` bytes or
/// what `budget` has left, is left as it is.
fn lzw_to_flate(s: &Stream, max: usize, budget: &mut ImageBudget) -> Option<Stream> {
// An external file's data isn't in `raw`; cross-reference and object streams are rewritten
// on save, so converting them would change nothing in the file.
if s.dict.contains(b"F") || matches!(s.dict.name(b"Type"), Some(b"XRef" | b"ObjStm")) {
@@ -512,7 +524,10 @@ fn lzw_to_flate(s: &Stream, max: usize) -> Option<Stream> {
if !parms.get(..=lzw)?.iter().all(direct) {
return None;
}
let data = pdfcraft_filters::decode(stages, &s.raw, max).ok()?;
let data = budget.decode_chain(stages, &s.raw, max).ok()?;
if !budget.spend((data.len() as u64).saturating_mul(LZW_REWRITE_WORK)) {
return None;
}
let flate = Params { early_change: 1, ..params.clone() };
let raw = pdfcraft_filters::encode(&Filter::Flate, &flate, &data).ok()?;
// The predictor's encoder must give back exactly what its decoder read (a short last row,
@@ -596,8 +611,31 @@ mod tests {
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("LZWDecode"));
let s = Stream::from_raw(dict, raw);
assert!(lzw_to_flate(&s, data.len() - 1).is_none());
let converted = lzw_to_flate(&s, data.len()).unwrap();
let mut budget = ImageBudget::with_work(u64::MAX);
assert!(lzw_to_flate(&s, data.len() - 1, &mut budget).is_none());
let converted = lzw_to_flate(&s, data.len(), &mut budget).unwrap();
assert_eq!(converted.decoded_strict().unwrap(), data);
}
/// Rewriting a stream spends the run's allowance: reading it, decoding it, and storing and
/// checking what it decoded. One the allowance can't pay for stays LZW, and ends the rewriting.
#[test]
fn lzw_streams_spend_the_runs_allowance() {
let data = b"0 0 m 612 792 l S\n".repeat(64);
let raw = pdfcraft_filters::encode(&Filter::Lzw, &Params::default(), &data).unwrap();
let mut dict = Dict::new();
dict.set(b"Filter".to_vec(), Object::name("LZWDecode"));
let s = Stream::from_raw(dict, raw);
let cost = (s.raw.len() + data.len()) as u64 + data.len() as u64 * LZW_REWRITE_WORK;
let mut budget = ImageBudget::with_work(cost + 7);
assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some());
assert_eq!(budget.work_left(), 7);
let mut budget = ImageBudget::with_work(cost);
assert!(lzw_to_flate(&s, data.len(), &mut budget).is_some(), "exactly enough");
assert_eq!(budget.work_left(), 0);
let mut budget = ImageBudget::with_work(cost - 1);
assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none(), "a step short");
assert_eq!(budget.work_left(), 0, "and the next stream isn't read");
assert!(lzw_to_flate(&s, data.len(), &mut budget).is_none());
}
}
+329 -19
View File
@@ -9,6 +9,12 @@ use pdfcraft_optimize::{
effective_resolutions, optimize,
};
/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the
/// samples an operation would read through its `ImageBudget`.
fn decoded_image(doc: &Document, s: &Stream) -> Result<Vec<u8>, pdfcraft_cos::CosError> {
pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED)
}
fn image(doc: &mut Document, w: u32, h: u32, n: usize, jpeg: bool, smask: Option<ObjRef>) -> ObjRef {
// A smooth gradient with a little texture (photo-like).
let mut px = Vec::with_capacity((w * h) as usize * n);
@@ -910,7 +916,7 @@ fn rgb_and_two_colour_indexed_black_and_white_images_become_one_bit() {
(Some(1), Some(&b"CCITTFaxDecode"[..]), Some(&cs)),
"{i:?}"
);
assert_eq!(doc.decoded_image(&s).unwrap(), text, "{i:?}");
assert_eq!(decoded_image(&doc, &s).unwrap(), text, "{i:?}");
let decode = if i == rgb_inverted { Some(Object::Array(vec![1.into(), 0.into()])) } else { None };
assert_eq!(s.dict.get(b"Decode").cloned(), decode, "{i:?}");
}
@@ -1066,7 +1072,7 @@ fn retain_keeps_a_group_3_halftone_as_it_is() {
let fax = doc.add(Object::Stream(Stream::from_raw(d, g3.clone())));
// 7.68 pt for 64 samples is 600 ppi.
page(&mut doc, &[("A", fax)], "q 7.68 0 0 7.68 100 600 cm /A Do Q");
assert_eq!(doc.decoded_image(&stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes");
assert_eq!(decoded_image(&doc, &stream(&doc, fax)).unwrap().len(), 64 * 8, "the fixture decodes");
let r = optimize(&mut doc, &Settings { mono: RETAIN, ..Settings::default() }).unwrap();
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
assert_eq!(stream(&doc, fax).raw[..], g3[..]);
@@ -1564,7 +1570,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
}
assert_eq!(raw(&kept, hex_at_400), raw(&doc, hex_at_400), "and nothing else changes");
for i in [flate, fax, hex_fax] {
assert_eq!((size(&kept, i), kept.decoded_image(&stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}");
assert_eq!((size(&kept, i), decoded_image(&kept, &stream(&kept, i)).unwrap()), ((1200, 480), text.clone()), "{i:?}");
}
let bits = [eight, rgb, rgb_inverted].map(|i| stream(&kept, i).dict.int(b"BitsPerComponent"));
assert_eq!(bits, [Some(8); 3], "Retain leaves eight bits to the grey and colour settings");
@@ -1578,7 +1584,7 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
let s = stream(&doc, i);
assert_eq!((size(&doc, i), s.dict.int(b"BitsPerComponent")), ((1200, 480), if i == mask { None } else { Some(1) }));
assert!(matches!(s.filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "{i:?}");
assert_eq!(doc.decoded_image(&s).unwrap(), text.clone(), "{i:?}");
assert_eq!(decoded_image(&doc, &s).unwrap(), text.clone(), "{i:?}");
}
assert_eq!(stream(&doc, mask).dict.get(b"Decode"), Some(&decode), "the decode array stays");
// Grey now, with grey's decode pair, which makes 1 black: its ties are black too.
@@ -1588,12 +1594,12 @@ fn ambiguous_monochrome_text_is_recompressed_without_lossy_reduction() {
(size(&doc, rgb_inverted), s.dict.get(b"ColorSpace"), s.dict.get(b"Decode")),
((1200, 480), Some(&Object::name("DeviceGray")), Some(&inverting))
);
assert_eq!(doc.decoded_image(&s).unwrap(), text.iter().map(|b| !b).collect::<Vec<u8>>(), "the same picture, ties black");
assert_eq!(decoded_image(&doc, &s).unwrap(), text.iter().map(|b| !b).collect::<Vec<u8>>(), "the same picture, ties black");
for i in [at_400, soft, hex_at_400] {
assert_eq!((size(&doc, i), stream(&doc, i).dict.name(b"Filter")), ((1200, 480), Some(&b"CCITTFaxDecode"[..])), "{i:?}");
}
assert!(raw(&doc, hex_at_400).len() < hex_len, "the ASCIIHexDecode is dropped");
assert_eq!(doc.decoded_image(&stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept");
assert_eq!(decoded_image(&doc, &stream(&doc, hex_at_400)).unwrap(), text, "and the picture kept");
assert_eq!([fax_at_400].map(|r| raw(&doc, r)), untouched);
let after = render(&doc);
assert_eq!(after, before, "conservative retention preserves the rendered page exactly");
@@ -1681,7 +1687,7 @@ fn a_fax_image_behind_a_decompression_bomb_is_left_alone() {
let r = optimize(&mut doc, &Settings::default()).unwrap();
if padding == 0 {
assert_eq!(r.images_resampled, 0, "ambiguous local tones are retained: {r:?}");
assert_eq!(doc.decoded_image(&stream(&doc, img)).unwrap(), bilevel(w, h, false));
assert_eq!(decoded_image(&doc, &stream(&doc, img)).unwrap(), bilevel(w, h, false));
} else {
assert_eq!((r.images_resampled, r.images_recompressed), (0, 0), "{r:?}");
assert_eq!(stream(&doc, img).raw, before);
@@ -1776,7 +1782,7 @@ fn monochrome_images_keep_what_they_should() {
assert_eq!([alone, deep].map(|r| raw(&doc, r)), before, "a Group 4 halftone alone, and sixteen-bit grey");
assert_eq!(filters(&doc, wrapped), 1, "a wrapped Group 4 halftone is stored again alone by default");
assert!(matches!(stream(&doc, scan).filters().as_slice(), [(Filter::CcittFax | Filter::Flate, _)]), "the scan: Group 4 or Flate");
assert_eq!(doc.decoded_image(&stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!(decoded_image(&doc, &stream(&doc, scan)).unwrap(), ragged, "the scan preserves every sample");
assert_eq!((stream(&doc, fax).dict.name(b"Filter"), filters(&doc, fax)), (Some(&b"CCITTFaxDecode"[..]), 1), "the busy fax image");
let (mut doc, [_, wrapped, scan, _, _]) = build();
@@ -1785,7 +1791,7 @@ fn monochrome_images_keep_what_they_should() {
assert_eq!(raw(&doc, wrapped), before, "Retain: a wrapped Group 4 halftone stays as it is");
let s = stream(&doc, scan);
assert_eq!((s.dict.int(b"Width"), s.dict.name(b"Filter")), (Some(1200), Some(&b"FlateDecode"[..])), "Retain: {r:?}");
assert_eq!(doc.decoded_image(&s).unwrap(), ragged, "Retain preserves every sample");
assert_eq!(decoded_image(&doc, &s).unwrap(), ragged, "Retain preserves every sample");
}
/// `rows` coded as CCITT Group 3, one-dimensional (`/K 0`): each row its runs in turn, white first,
@@ -1853,7 +1859,12 @@ fn group_3_fax_images_become_group_4() {
let g3 = group_3(&runs, k > 0, on_bytes);
let (end_of_line, encoded_byte_align) = (k > 0 && on_bytes.is_none(), on_bytes.is_some());
let p = pdfcraft_filters::CcittParams { k, columns: w.into(), end_of_line, encoded_byte_align, ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&g3, &p, h).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture");
let mut work = u64::MAX;
assert_eq!(
pdfcraft_filters::decode_ccitt_within(&g3, &p, h, &mut work).unwrap(),
bits,
"/K {k}, on bytes {on_bytes:?}: the Group 3 data is the picture"
);
let mut doc = Document::new_empty();
let mut d = Dict::new();
@@ -1886,7 +1897,7 @@ fn group_3_fax_images_become_group_4() {
let s = stream(&doc, fax);
assert_eq!(doc.fax_params(&s).map(|p| p.k), Some(-1), "/K {k}, on bytes {on_bytes:?}: Group 4");
assert!(s.raw.len() < g3.len(), "{} bytes, from {}", s.raw.len(), g3.len());
assert_eq!(doc.decoded_image(&s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture");
assert_eq!(decoded_image(&doc, &s).unwrap(), bits, "/K {k}, on bytes {on_bytes:?}: the same picture");
assert!(render(&doc) == render(&control), "/K {k}, on bytes {on_bytes:?}: the page looks as the picture does");
}
}
@@ -2045,6 +2056,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 = decoded_image(&doc, &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!(decoded_image(&doc, &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!(decoded_image(&doc, &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!(decoded_image(&doc, &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!(decoded_image(&doc, &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]
@@ -3278,6 +3410,42 @@ fn lzw_streams_become_flate_and_decode_to_the_same_bytes() {
assert!(!stream(&doc, c_late).dict.contains(b"DecodeParms"), "nothing left to say for the content stream");
}
/// A stream can take a second or more to store again, so a cancel lands between any two of them,
/// and nothing runs after it.
#[test]
fn a_cancel_lands_during_the_lzw_pass() {
use pdfcraft_optimize::{OptimizeError, Stage, optimize_with_progress};
let build = |streams: usize| {
let mut doc = Document::new_empty();
let mut d = Dict::new();
d.set(b"Filter".to_vec(), Object::name("LZWDecode"));
for _ in 0..streams {
doc.add(Object::Stream(Stream::from_raw(d.clone(), lzw(&lzw_ops(), &Params::default()))));
}
page(&mut doc, &[], "0 0 1 1 re f");
doc
};
let asked = |streams: usize| {
let mut asked = 0;
optimize_with_progress(&mut build(streams), &Settings::default(), &mut |s| {
asked += usize::from(s == Stage::CleanUp);
true
})
.unwrap();
asked
};
let none = asked(0);
assert_eq!(asked(3), none + 3, "once a stream");
let mut left = none + 1;
let mut doc = build(3);
let r = optimize_with_progress(&mut doc, &Settings::default(), &mut |s| {
left -= usize::from(s == Stage::CleanUp);
left > 0
});
assert!(matches!(r, Err(OptimizeError::Cancelled)), "{r:?}");
assert_eq!(left, 0, "nothing runs after it");
}
#[test]
fn a_page_drawn_from_lzw_streams_renders_the_same_after_conversion() {
let png = Params { predictor: 12, colors: 3, columns: 64, early_change: 0, ..Params::default() };
@@ -3394,10 +3562,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,8 +3579,9 @@ 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();
let gray_samples = decoded_image(&gray, &gray_stream).unwrap();
assert_eq!((gray_stream.dict.int(b"Width"), gray_stream.dict.int(b"Height")), (Some(600), Some(240)));
assert!(gray_samples.iter().any(|b| *b != 0xFF), "the DeviceGray thin rule survives");
for white_first in [false, true] {
@@ -3440,14 +3611,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!(decoded_image(&doc, &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"), decoded_image(&doc, &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() {
@@ -3473,7 +3783,7 @@ fn mixed_text_and_small_halftone_regions_keep_their_tones_with_wide_white_margin
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, "screened samples survive, not a solid patch");
assert_eq!(decoded_image(&doc, &s).unwrap(), data, "screened samples survive, not a solid patch");
let saved = Document::open(Arc::new(write_full(&doc, &SaveOptions::default()).unwrap())).unwrap();
assert_eq!(render(&saved), before, "local halftone {side}x{side}");
}
+2 -1
View File
@@ -17,6 +17,8 @@ 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 }
# NFKC for the metadata check; already in the build through pdfcraft-crypt (stringprep).
unicode-normalization = "0.1"
@@ -24,5 +26,4 @@ unicode-normalization = "0.1"
roxmltree = "0.21"
[dev-dependencies]
pdfcraft-filters = { workspace = true }
pdfcraft-testkit = { workspace = true }
+19 -6
View File
@@ -18,16 +18,29 @@ 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
and a byte per row, plus 4 KiB (ASCIIHex text broken into lines under Flate is a little over
twice; for a fax image's code, about a byte per pixel), are removed whole;
cleared copies drop `/Alternates` so alternate pixels cannot survive;
A run that would decode more of its images than one redaction may (`pdfcraft_cos::ImageBudget`,
filters, decoders and pixels alike) is refused
and changes nothing: such an image can't be cleared, and removing it whole would take what no mark
covers with it.
- **vectors**: covered paths are removed; partly covered paths and shadings are clipped so
nothing paints inside the areas;
- **form XObjects**: rewritten recursively into new objects (pages sharing the original keep it);
@@ -157,5 +170,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).
+104 -23
View File
@@ -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 `ImageBudget::decode`, 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, ImageBudget, Object, Stream};
/// Most pixels an image may have to be cleared in place (larger ones are removed).
const MAX_PIXELS: u64 = 64 << 20;
@@ -38,11 +39,11 @@ fn components(doc: &Document, s: &Stream) -> Option<usize> {
/// `Ok(Some(copy))` with the covered pixels cleared, `Ok(None)` when no pixel intersects,
/// `Err(())` when the image can't be cleared (the caller removes it).
#[allow(clippy::result_unit_err)]
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Result<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects).ok_or(())
pub(crate) fn clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Result<Option<Stream>, ()> {
decode_and_clear(doc, s, ctm, rects, budget).ok_or(())
}
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]]) -> Option<Option<Stream>> {
fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]], budget: &mut ImageBudget) -> Option<Option<Stream>> {
// Separate masks can retain the original image data.
if s.dict.contains(b"SMask") || s.dict.contains(b"Mask") {
return None;
@@ -53,18 +54,26 @@ 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 `ImageBudget::decode`,
// 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)),
Some(Object::Array(a)) => match a.split_last() {
Some((last, before)) if fax(last) => (before.iter().all(readable), true),
_ => (a.iter().all(readable), false),
},
_ => (false, false),
// `/Crypt` was undone when the object was loaded (ISO 32000-2 §7.4.10), and decoding leaves it
// out wherever it stands.
let crypt = |f: &Object| f.as_name() == Some(b"Crypt".as_slice());
let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") {
None => (true, false, false),
Some(f @ Object::Name(_)) => (readable(f) || crypt(f) || fax(f) || jbig2(f), fax(f), jbig2(f)),
Some(Object::Array(a)) => {
let chain: Vec<&Object> = a.iter().filter(|f| !crypt(f)).collect();
match chain.split_last() {
Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(|f| readable(f)), fax(last), jbig2(last)),
_ => (chain.iter().all(|f| readable(f)), false, false),
}
}
_ => (false, false, false),
};
if !ok {
return None;
@@ -83,7 +92,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,8 +115,10 @@ 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).
let mut data = doc.decoded_image_within(s, allowance).ok()?;
// 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 = budget.decode(doc, s, allowance).ok()?;
if data.len() < bytes {
return None;
}
@@ -160,5 +172,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,
/// past what is left of `budget`, 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, budget: &mut ImageBudget) -> 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 = budget.decode(doc, 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))
}
+6 -3
View File
@@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream};
use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, ImageBudget, MAX_PAGE_FORMS, ObjRef, Object, Stream};
use crate::{RedactError, Report, image, page_streams, tags};
use pdfcraft_fonts::pdf::Metrics;
@@ -113,6 +113,8 @@ pub(crate) struct Scope<'a> {
pub rects: &'a [[f64; 4]],
pub mode: Mode,
pub report: &'a mut Report,
/// What the images it clears may spend, shared by all the pages of a run.
pub images: &'a mut ImageBudget,
/// Sanitize: remove hidden text (invisible render modes, or wholly outside this page box).
pub hidden_text: Option<[f64; 4]>,
/// Sanitize: optional content groups that are off; content marked with them is removed.
@@ -155,11 +157,12 @@ pub(crate) struct Scope<'a> {
impl<'a> Scope<'a> {
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
/// content streams were decoded.
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget, images: &'a mut ImageBudget) -> Self {
Scope {
rects,
mode,
report,
images,
hidden_text: None,
hidden_layers: HashSet::new(),
layers_enabled: false,
@@ -839,7 +842,7 @@ fn xobject(
out.doomed.push(r);
return Some(None);
}
match image::clear(doc, s, &gs.ctm, scope.rects) {
match image::clear(doc, s, &gs.ctm, scope.rects, &mut *scope.images) {
Ok(None) => None,
Ok(Some(new)) => {
let nr = doc.add(Object::Stream(new));
+112 -4
View File
@@ -78,6 +78,16 @@ 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,
/// Past what one redaction may decode an image can't be cleared, and removing it whole would
/// take what no mark covers with it, unreported.
#[error("the images under these marks are more than one redaction can decode, so nothing was changed; redact fewer pages at a time")]
ImagesPastAllowance,
/// 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)]
@@ -140,6 +150,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,
@@ -395,6 +408,12 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
/// [`apply`] without the copy: an error can leave `doc` part redacted.
fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, RedactError> {
let images = pdfcraft_cos::ImageBudget::new(doc);
apply_within(doc, pages, images)
}
/// [`apply_to`], its images decoded within `images`.
fn apply_within(doc: &mut Document, pages: Option<&[usize]>, images: pdfcraft_cos::ImageBudget) -> Result<Report, RedactError> {
let (all_marks, unreadable) = marks_checked(doc);
// A mark with no readable area is not applied, and applying the others would look complete:
// refuse before anything changes. Only the pages being applied count.
@@ -440,6 +459,7 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
removed_cut: false,
removed_room: interp::MAX_REMOVED_BYTES,
removed_everywhere: HashSet::new(),
images,
};
for &(pi, page_marks) in &groups {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
@@ -447,7 +467,20 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
redact_page_content(doc, &mut applying, pi, &page, &rects)?;
remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?;
}
let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, removed_text, removed_cut, .. } = applying;
if applying.images.refused() > 0 {
return Err(RedactError::ImagesPastAllowance);
}
let Applying {
mut report,
doomed_fields,
doomed_xobjects,
retired_properties,
mut removed_everywhere,
removed_text,
removed_cut,
mut images,
..
} = applying;
// 2b. Replies and pop-ups of a removed annotation can be listed on another page than their
// parent: their `/IRT` or `/Parent` would keep the removed comment reachable.
@@ -530,6 +563,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, &mut images)?;
// 5. Verify: nothing readable may remain under a region.
let mut residue = 0;
@@ -543,7 +577,9 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Redac
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut scratch = Report::default();
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget);
// Verifying doesn't decode images: what clearing left is read as the page draws it.
let mut images = pdfcraft_cos::ImageBudget::with_work(0);
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget, &mut images);
residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue;
}
if residue > 0 {
@@ -560,6 +596,76 @@ 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. They are decoded within what is left of the run's allowance (`images`);
/// past it the redaction is refused as one clearing too many images is. Returns how many images
/// that weren't removed or replaced were stored again.
fn restore_shared_jbig2(doc: &mut Document, doomed: &[ObjRef], images: &mut pdfcraft_cos::ImageBudget) -> 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, mut past_allowance) = (0, 0, false);
for r in sharing {
let object = doc.get(r);
let Object::Stream(s) = &*object else { continue };
let refused = images.refused();
match image::without_globals(doc, s, images) {
Some(new) => {
doc.set(r, Object::Stream(new));
restored += usize::from(!doomed.contains(&r));
}
None if doomed.contains(&r) => {}
None if images.refused() > refused => past_allowance = true,
None => stuck += 1,
}
}
if past_allowance {
return Err(RedactError::ImagesPastAllowance);
}
if stuck > 0 {
return Err(RedactError::SharedJbig2Globals(stuck));
}
Ok(restored)
}
/// What applying the marks collects across pages.
struct Applying {
report: Report,
@@ -576,6 +682,8 @@ struct Applying {
/// Bytes of removed text later pages may still keep (`interp::MAX_REMOVED_BYTES` for the
/// document).
removed_room: usize,
/// What clearing images may spend, across the run's pages.
images: pdfcraft_cos::ImageBudget,
}
/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows.
@@ -586,13 +694,13 @@ fn redact_page_content(
page: &pdfcraft_model::Page,
rects: &[[f64; 4]],
) -> Result<(), RedactError> {
let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, removed_room, .. } = applying;
let Applying { report, doomed_xobjects, retired_properties, removed_text, removed_cut, removed_room, images, .. } = applying;
// 1. Content.
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let out = {
let mut scope = Scope::new(rects, Mode::Apply, report, budget).with_removed_room(*removed_room);
let mut scope = Scope::new(rects, Mode::Apply, report, budget, images).with_removed_room(*removed_room);
let out = process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY);
scope.flush();
removed_text.append(&mut scope.removed_text);
+2 -1
View File
@@ -410,7 +410,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut report = Report::default();
let mode = if write { Mode::Apply } else { Mode::Verify };
let mut scope = Scope::new(&[], mode, &mut report, budget);
let mut images = pdfcraft_cos::ImageBudget::new(doc);
let mut scope = Scope::new(&[], mode, &mut report, budget, &mut images);
if text {
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
+594 -6
View File
@@ -52,7 +52,8 @@ fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize {
let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap();
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut rep = Report::default();
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget);
let mut images = pdfcraft_cos::ImageBudget::with_work(0);
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget, &mut images);
process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue
}
@@ -326,6 +327,7 @@ fn removed_text_kept_for_the_metadata_check_is_bounded_per_document() {
removed_cut: false,
removed_room: interp::MAX_REMOVED_BYTES,
removed_everywhere: HashSet::new(),
images: pdfcraft_cos::ImageBudget::new(&doc),
};
let rects = [[5.0, 99.0, 200.0, 102.0]];
let kept = |a: &Applying| a.removed_text.iter().map(String::len).sum::<usize>();
@@ -461,7 +463,8 @@ fn removed_text_is_collected_in_runs() {
let res = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut rep = Report::default();
let rects = [[10.0, 95.0, 300.0, 110.0], [10.0, 195.0, 30.0, 210.0]];
let mut scope = Scope::new(&rects, Mode::Apply, &mut rep, pdfcraft_cos::DecodeBudget::page());
let mut images = pdfcraft_cos::ImageBudget::new(&doc);
let mut scope = Scope::new(&rects, Mode::Apply, &mut rep, pdfcraft_cos::DecodeBudget::page(), &mut images);
process(&mut doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY);
scope.flush();
assert_eq!(scope.removed_text, ["BLUEF IN 7", "Gary"]);
@@ -914,13 +917,25 @@ fn two_pages_one_resource_dictionary(inherited: bool, again: bool) -> Document {
])
}
/// The samples of the image a page's (possibly inherited) resources name `name`.
/// What image `s` decodes to, its fax or JBIG2 data included, with no limit on the work: the
/// samples an operation would read through its `ImageBudget`.
fn decoded_image(doc: &Document, s: &pdfcraft_cos::Stream) -> Result<Vec<u8>, pdfcraft_cos::CosError> {
pdfcraft_cos::ImageBudget::with_work(u64::MAX).decode(doc, s, pdfcraft_cos::MAX_DECODED)
}
/// The samples of the image a page's (possibly inherited) resources name `name`, decoded through
/// its image codec too ([`decoded_image`]).
fn named_image(doc: &Document, page: usize, name: &[u8]) -> Option<Vec<u8>> {
named_stream(doc, page, name).and_then(|s| decoded_image(doc, &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,
}
}
@@ -1096,7 +1111,8 @@ fn group_3_on_bytes_is_removed_whole_and_other_fax_images_cleared() {
(-1, true, "<< /K -1 /Columns 16 /EncodedByteAlign true >>", vec![0x80, 0x80], vec![], false),
] {
let p = pdfcraft_filters::CcittParams { k, columns: 16, encoded_byte_align: on_bytes, ..Default::default() };
assert_eq!(pdfcraft_filters::decode_ccitt(&data, &p, 2).unwrap(), [0xFF; 4], "{parms}: the picture is white");
let mut work = u64::MAX;
assert_eq!(pdfcraft_filters::decode_ccitt_within(&data, &p, 2, &mut work).unwrap(), [0xFF; 4], "{parms}: the picture is white");
let dict = format!("{image} /DecodeParms {parms}");
let mut objs = vec![stream(&dict, data)];
objs.extend(extra);
@@ -1131,6 +1147,577 @@ 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) => decoded_image(doc, 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
}
/// An image the run's allowance can't decode can't be cleared. Removed whole, like an image that
/// can't be read, it would take what no mark covers with it without a word, so the run stops there,
/// and [`apply`] leaves the document as it was.
#[test]
fn a_jbig2_image_past_the_runs_allowance_stops_the_run() {
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(), Object::name("JBIG2Decode"));
let image = Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap());
let doc = Document::new_empty();
let half = [[0.0, 0.0, 0.5, 1.0]];
let clear = |work| crate::image::clear(&doc, &image, &pdfcraft_content::Matrix::IDENTITY, &half, &mut pdfcraft_cos::ImageBudget::with_work(work));
assert!(matches!(clear(u64::MAX), Ok(Some(_))), "cleared in a copy");
assert!(matches!(clear(0), Err(())), "it can't be cleared");
let coded = pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap();
let dict = "/Type /XObject /Subtype /Image /Width 16 /Height 2 /BitsPerComponent 1 /ColorSpace /DeviceGray /Filter /JBIG2Decode";
let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, coded)]);
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0));
assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}");
let mut doc = one_page(
b"q 160 0 0 20 0 0 cm /Im Do Q",
"/XObject << /Im 7 0 R >>",
vec![stream(dict, pdfcraft_filters::encode_jbig2_generic(&[0xFF; 4], 16, 2).unwrap())],
);
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok());
}
/// A Flate image past the run's allowance stops the run as a JBIG2 image does: removed whole, it
/// would take what no mark covers with it, unreported. A JPEG, which clearing doesn't read, is
/// removed whole as it always was, and spends nothing.
#[test]
fn a_flate_image_past_the_runs_allowance_stops_the_run() {
let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /FlateDecode";
let page = || one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, pdfcraft_filters::encode_flate(&[9; 4]))]);
let mut doc = page();
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
let refused = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0));
assert!(matches!(refused, Err(crate::RedactError::ImagesPastAllowance)), "{refused:?}");
let mut doc = page();
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
assert!(crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(u64::MAX)).is_ok());
let dict = "/Type /XObject /Subtype /Image /Width 2 /Height 2 /BitsPerComponent 8 /ColorSpace /DeviceGray /Filter /DCTDecode";
let mut doc = one_page(b"q 160 0 0 20 0 0 cm /Im Do Q", "/XObject << /Im 7 0 R >>", vec![stream(dict, b"not read")]);
mark(&mut doc, 0, &[[0.0, 12.0, 75.0, 18.0]], "");
let report = crate::apply_within(&mut doc, None, pdfcraft_cos::ImageBudget::with_work(0)).unwrap();
assert_eq!(report.images_removed, 1);
}
/// 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.clone()],
[0x00, 0xFF, 0xFF, 0xFF],
),
// `/Crypt` was undone on load, and takes a place in the parameters.
(
format!("{image} /ColorSpace /DeviceGray /Filter [/Crypt /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"),
white.clone(),
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) => decoded_image(doc, 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))));
// Storing the second page's image again decodes it, within what is left of the run's
// allowance: with nothing left the redaction is refused as one past the allowance, not
// reported as an image that can't be read.
let mut doc = build(&image);
let first_image = [ObjRef::new(7, 0)];
let refused = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(0));
assert!(matches!(refused, Err(RedactError::ImagesPastAllowance)), "{refused:?}");
let restored = crate::restore_shared_jbig2(&mut doc, &first_image, &mut pdfcraft_cos::ImageBudget::with_work(u64::MAX));
assert_eq!(restored.ok(), Some(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.
@@ -2772,7 +3359,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(|_| decoded_image(&saved, s)).unwrap();
assert!(!data.windows(secret.len()).any(|w| w == secret), "secret remains in stream {r:?}");
}
}
+8 -2
View File
@@ -83,6 +83,10 @@ pub struct RenderConfig {
/// viewer and preview rasters: exports, printing and OCR render without it, so nothing drawn with an
/// installed font is saved or printed (AGENTS.md §1.2, §1.4).
pub system_fonts: bool,
/// Steps of JBIG2 decoding a render may take across the page's images, instead of the usual
/// `hayro_syntax::MAX_PAGE_JBIG2_WORK`. Those past it aren't drawn, like an image that can't
/// be decoded.
pub jbig2_work: Option<u64>,
}
impl RenderConfig {
@@ -100,7 +104,8 @@ impl RenderConfig {
/// Passwords belong to parser construction; request-specific preview overrides are applied
/// by `render_page`, which also preserves the caller's warning and cancellation hooks.
fn settings(&self) -> InterpreterSettings {
let standard = InterpreterSettings::default().font_resolver;
let defaults = InterpreterSettings::default();
let standard = defaults.font_resolver.clone();
let system_fonts = self.system_fonts;
InterpreterSettings {
ocg_overrides: self.layers.clone(),
@@ -108,7 +113,8 @@ impl RenderConfig {
font_resolver: Arc::new(move |query| {
japanese_fallback(query).or_else(|| system_fonts.then(|| system_fallback(query)).flatten()).or_else(|| standard(query))
}),
..InterpreterSettings::default()
jbig2_work: self.jbig2_work.unwrap_or(defaults.jbig2_work),
..defaults
}
}
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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
View File
@@ -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");
+2
View File
@@ -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 }
+51 -1
View File
@@ -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 {
+299 -6
View File
@@ -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());
}
}
+25
View File
@@ -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;
+2 -1
View File
@@ -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.
+1 -3
View File
@@ -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);
}
}
+28 -4
View File
@@ -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);
}
+615 -39
View File
@@ -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)]
@@ -382,6 +551,8 @@ pub struct DocView {
/// Screen rects of the pages drawn last frame (hit-testing, tests, automation).
screen_rects: Vec<(usize, Rect)>,
screen_xforms: Vec<(usize, PageXform)>,
/// Pixels per point last frame, for telling a rounding sliver of a page from a visible one.
screen_ppp: f32,
/// The scroll viewport on screen last frame.
viewport_screen: Rect,
/// Pending zoom anchor: page, fractional position (also in gutters), and viewport offset.
@@ -569,6 +740,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 {
@@ -669,6 +938,7 @@ impl DocView {
page_heights: info.pages.iter().map(|p| p.height).collect(),
screen_rects: Vec::new(),
screen_xforms: Vec::new(),
screen_ppp: 1.0,
viewport_screen: Rect::NOTHING,
zoom_anchor: None,
shown: false,
@@ -886,6 +1156,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 +1174,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 +1218,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 +1324,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 +1361,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 +1404,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 +1429,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 +1693,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 +1742,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 +1804,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 +2326,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 +2360,8 @@ fn draw_pages(
prefs,
preparing,
selection_wheel,
ppp,
rects,
single_paging,
tool,
unobstructed,
@@ -2010,6 +2404,7 @@ fn draw_pages(
let origin = resp_rect.min - vec2(0.0, y_shift);
let visible = viewport.translate(resp_rect.min.to_vec2());
view.screen_rects.clear();
view.screen_ppp = ui.ctx().pixels_per_point();
view.screen_xforms.clear();
// The pointer as the pages see it: nothing over them (a dialog from the frame it opens),
// or a press they hold. The page modules read it rather than egui's raw pointer state.
@@ -2034,6 +2429,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 +2490,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 +2625,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 +2675,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 +2710,7 @@ fn draw_pages(
open_initials,
open_signature,
open_table,
page_action,
primary_down: ptr.primary_down,
refused,
stamp_placed,
@@ -2359,6 +2821,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 +3455,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 +3700,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 +3719,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 +3759,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 +3810,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 +3832,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 +3847,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 +3918,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 {
@@ -3486,7 +4059,10 @@ fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui,
// Read mode hides the tools (#2646), and full screen is the page and nothing else (#1459);
// the quick tools come back with the window.
if app.mode != crate::Mode::Read && !app.full_screen {
quick_bar(app, avail, ui);
// The page nearest the left edge, whose margin the bar sits in.
let page_left = app.views[index].screen_rects.iter().map(|(_, r)| r.left()).reduce(f32::min);
quick_bar(app, avail, page_left, ui);
crate::hotbar::show(app, avail, ui.ctx());
}
if let Some((source, file, destination)) = remote_open {
app.open_remote_link(source, file, destination);
+10 -2
View File
@@ -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).
+73 -4
View File
@@ -276,11 +276,39 @@ 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) {
/// The bar's narrowest form: one 32 pt button, 6 pt margins and a 1 pt border each side.
const QUICK_BAR_WIDTH: f32 = 46.0;
const QUICK_BAR_EDGE: f32 = 14.0;
/// How far from the side panel the quick-action bar stands, given the width of the margin between
/// the panel and the page and the bar's own width: centred in the margin when it holds the bar,
/// else 14 pt in (a page filling the width). With no page on screen the caller passes a margin of 0,
/// which is 14 pt too.
fn quick_bar_offset(margin: f32, width: f32) -> f32 {
// shortcut: centred in at most 100 pt of margin, so a wide margin keeps the bar by the panel.
// `f32::min` returns the other operand for NaN, which would pass the fit check below.
let margin = if margin.is_nan() { 0.0 } else { margin.min(100.0) };
if margin >= width + 2.0 * 8.0 { (margin - width) / 2.0 } else { QUICK_BAR_EDGE }
}
/// The floating quick-action bar in the margin between the side panel and the page.
pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, page_left: Option<f32>, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let pos = area.left_top() + vec2(14.0, 14.0);
egui::Area::new(egui::Id::unique("quick-bar")).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
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 id = egui::Id::unique("quick-bar");
// Last frame's bar: Fill & Sign's Text and Date tools add a wider row of controls to it.
let width = ui.ctx().memory(|m| m.area_rect(id)).map_or(QUICK_BAR_WIDTH, |bar| bar.width().max(QUICK_BAR_WIDTH));
// The margin starts past a hotbar docked on the left.
let x = quick_bar_offset(page_left.map_or(0.0, |left| left - area.left() - offset.x), width);
let pos = area.left_top() + vec2(x, QUICK_BAR_EDGE) + offset;
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ui.ctx(), |ui| {
egui::Frame::NONE
.fill(t.card)
.stroke(Stroke::new(1.0, t.border))
@@ -302,6 +330,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)];
@@ -496,3 +530,38 @@ pub(super) fn quick_bar(app: &mut PdfCraftApp, area: Rect, ui: &mut egui::Ui) {
});
});
}
#[cfg(test)]
mod tests {
use super::quick_bar_offset;
#[test]
fn the_quick_bar_centres_in_the_margin_that_holds_it() {
assert_eq!(quick_bar_offset(72.0, 46.0), 13.0);
assert_eq!(quick_bar_offset(62.0, 46.0), 8.0);
// A wide margin centres in its first 100 pt, not all of it.
assert_eq!(quick_bar_offset(400.0, 46.0), 27.0);
}
#[test]
fn the_margin_that_just_holds_the_bar_centres_and_a_point_less_does_not() {
// 46 pt of bar and 8 pt to spare each side: 62 pt.
assert_eq!(quick_bar_offset(62.0, 46.0), 8.0);
assert_eq!(quick_bar_offset(61.0, 46.0), 14.0);
assert_eq!(quick_bar_offset(1000.0, 46.0), 27.0);
}
#[test]
fn a_wider_quick_bar_centres_by_its_own_width_and_needs_a_wider_margin() {
// Fill & Sign's Text tool widens the bar to 70 pt: it centres by that, and 72 pt no longer holds it.
assert_eq!(quick_bar_offset(120.0, 70.0), 15.0);
assert_eq!(quick_bar_offset(72.0, 70.0), 14.0);
}
#[test]
fn the_quick_bar_keeps_14_pt_in_when_the_margin_cannot_hold_it() {
for margin in [61.9, 30.0, 0.0, -50.0, f32::NEG_INFINITY, f32::NAN] {
assert_eq!(quick_bar_offset(margin, 46.0), 14.0, "{margin}");
}
}
}
+6
View File
@@ -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)
}
+15 -5
View File
@@ -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")]
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more