diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md
index 5b1695604..593ae6e68 100644
--- a/ATTRIBUTION.md
+++ b/ATTRIBUTION.md
@@ -13,13 +13,13 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `crates/ui-egui/src/i18n/zh-hant.tsv` | Traditional Chinese (Taiwan) interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/cs.tsv` | Czech interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check and standard-identification wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/pt-br.tsv` | Brazilian Portuguese interface translation catalog | André Cardoso (andrecards), PdfCraft contributors | MIT OR Apache-2.0 | Clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources: the ~1,826-entry gist posted in issue #288 (https://gist.github.com/andrecards/ac388c25fba17312ce83207f8b7b467e), completed and reviewed by PdfCraft contributors; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 text-field-table limit messages are contributor-original AI-assisted translations from English, not separately native-reviewed | Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/es.tsv` | Spanish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/fr.tsv` | French interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/de.tsv` | German interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/lo.tsv` | Lao interface translation catalog | @bounkirdni-2025 | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Lao UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/te.tsv` | Telugu interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/it.tsv` | Italian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
-| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/pl.tsv` | Polish interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/ru.tsv` | Russian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/hu.tsv` | Hungarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/bg.tsv` | Bulgarian interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
@@ -275,17 +275,17 @@ Every asset PdfCraft includes, bundles or uses to build its published material,
| `assets/icons/thumbs-down.svg` | Lucide icon "thumbs-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/thumbs-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-plus.svg` | Lucide icon "message-square-plus" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-plus.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/circle-x.svg` | Lucide icon "circle-x" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/circle-x.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs) |
+| `crates/ui-egui/tests/data/form.pdf` | One-page AcroForm test fixture (text, check box, radio group, combo box) | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects, no third-party content | Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs) |
| `assets/icons/pentagon.svg` | Lucide icon "pentagon" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/pentagon.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/message-square-quote.svg` | Lucide icon "message-square-quote" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/message-square-quote.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make |
+| `crates/sign/tests/data/openssl-signed.pdf` | One-page PDF signed with OpenSSL CMS (adbe.pkcs7.detached) by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl cms -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make |
| `assets/icons/clipboard-paste.svg` | Lucide icon "clipboard-paste" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/clipboard-paste.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/fonts/DancingScript.ttf` | Dancing Script (variable) | The Dancing Script Project Authors (Pablo Impallari) | OFL-1.1 | https://github.com/google/fonts/blob/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dancingscript/DancingScript%5Bwght%5D.ttf | Fill & Sign typed signatures and initials: the typed name's glyph outlines, drawn as filled paths (the font file itself is not embedded in PDFs) |
| `packaging/macos/dmg/background.svg` | PdfCraft macOS DMG window background | @XusBadia | MIT OR Apache-2.0 | Original work for this repository, derived from the PdfCraft app icon (assets/app-icon/pdfcraft.svg, referenced via an SVG image element, not copied) | Finder window of the macOS DMG; rendered to background.tiff by packaging/macos/dmg/generate.py |
| `packaging/macos/dmg/background.tiff` | PdfCraft macOS DMG window background (rendered) | @XusBadia | MIT OR Apache-2.0 | Rendered from packaging/macos/dmg/background.svg by packaging/macos/dmg/generate.py | 1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packaging/macos/package.sh; includes the app icon and the free LittleCMS built-in sRGB profile |
-| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
+| `crates/sign/tests/data/x509-rsa-sha1.pdf` | One-page PDF with a legacy adbe.x509.rsa_sha1 signature by the RSA test key | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: hand-written PDF objects; signature made with `openssl dgst -sha1 -sign` over its byte ranges (see crates/sign/tests/data/README.md) | Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format |
| `assets/icons/monitor.svg` | Lucide icon "monitor" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/monitor.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
-| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
+| `crates/ui-egui/src/i18n/vi.tsv` | Vietnamese interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed | Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `crates/ui-egui/src/i18n/sl.tsv` | Slovene interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Slovene UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
| `vendor/vello_common/assets/probe.png` | vello_common probe reference image (rendered from probe.rgba) | Linebender vello contributors | MIT OR Apache-2.0 | vello_common 0.0.8 (crates.io), vendored in vendor/vello_common; regenerated by its vello_sparse_tests regenerate_probe_reference binary | Reference picture of the vendored vello_common probe module, for people only; the build reads probe.rgba, not this file |
| `crates/ui-egui/src/i18n/nb.tsv` | Norwegian Bokmål interface translation catalog | PdfCraft contributors | MIT OR Apache-2.0 | Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-22 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed | Norwegian Bokmål UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs |
diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml
index 6c9c88a2e..3f42a5401 100644
--- a/ATTRIBUTION.toml
+++ b/ATTRIBUTION.toml
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Czech UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7358300715a54e72a477c5888dad53f426ddf76da141a6202bec74594eb5ff0a"
+sha256 = "b08f8282923d2faaf1f658d2aff8f71fe5f2d3a33e792eeb50fb0e2b396ef24f"
[[asset]]
path = "crates/ui-egui/src/i18n/pt-br.tsv"
@@ -63,18 +63,18 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "265083ea7dfa129fcae0b9e401aef8f7c84982011ba91fb2023c52705b81e639"
+sha256 = "df17dada3df736e75bd9b03e5ecceec164cce63767e33a1efdb98c56a121c890"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
title = "Spanish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; the 2026-10-20 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "ffcf42859cfe38ae13c6fe4219cbe6225a20b4572c202db9e82ee38bfb433799"
+sha256 = "8e8c0cfda0886c584e5286370ec00814206c3af3699c76665e636fa6fdb6310b"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -135,12 +135,12 @@ sha256 = "76f138293fc12ea3584568a81f6f90a1cedfff6256e78b4f60cb21b1b66bbba4"
path = "crates/ui-egui/src/i18n/pl.tsv"
title = "Polish interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels, no proprietary localisation resources; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7bca90240f873fcec687bea84e9ea5835f0903827501d0e73ec26b98b82e8a52"
+sha256 = "0bd75a93f8d18deb832223bd0642cc4f4abde04a322b684a143a9d4b4687a344"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -250,7 +250,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Lithuanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "dea5076ce9c19f054812b678241931ad5e7f85dee08acf1c1e12c7ecaaa5d0f5"
+sha256 = "bbef7f4e16f83e9e0116278e7a0a175bad79757f96a18dc639516572533f6d5b"
[[asset]]
path = "crates/ui-egui/src/i18n/hi.tsv"
@@ -292,7 +292,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Romanian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "18d4beda8495d7f0b91f0dc0a52c2248d9ce6fb3f4e3de0d51f42235eaab7aa8"
+sha256 = "00a2153c20a191fbe04f1ce61f140fd9628cfe2f8f75dad5bce8a7406e1bd091"
[[asset]]
@@ -304,7 +304,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Finnish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "7b13e11191a3f2c0e38f1940996ae62da16e3bdb057215b2737f3a9c6a840000"
+sha256 = "609efa92054aa76de4bb6062c0fcc0a8b450519f4b253b4c0f395b3cbb34d2ba"
[[asset]]
path = "assets/icons/accessibility.svg"
@@ -3277,7 +3277,7 @@ source = "Contributor-original: hand-written PDF objects, no third-party content
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Form-filling UI tests (crates/ui-egui/tests/forms.rs, commands.rs)"
+usage = "Form-filling UI tests (crates/ui-egui/tests/it/forms.rs, commands.rs)"
sha256 = "c6f8b656e225101129aa36bd5467adc8dba16730915f19caca17f6a546d0b040"
[[asset]]
@@ -3310,7 +3310,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Signature validation tests (crates/sign/tests/pdf.rs): a signature PdfCraft did not make"
+usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): a signature PdfCraft did not make"
sha256 = "8b91c3b4e39c4a31717a0dd0685bd7d0468671df32aee5b9818d44dc72c2bc3c"
[[asset]]
@@ -3403,7 +3403,7 @@ source = "Contributor-original: hand-written PDF objects; signature made with `o
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "document"
-usage = "Signature validation tests (crates/sign/tests/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
+usage = "Signature validation tests (crates/sign/tests/it/pdf.rs): the legacy adbe.x509.rsa_sha1 format"
sha256 = "b77bfb21166f4bd043597e45a1ea28b9d9f4ba6df0142b3a0fc8b7f8e0fc111b"
[[asset]]
@@ -3421,12 +3421,12 @@ sha256 = "d1a443233345724859de8e4fb48968ba91e885e12ddbd710701548bd91b39428"
path = "crates/ui-egui/src/i18n/vi.tsv"
title = "Vietnamese interface translation catalog"
author = "PdfCraft contributors"
-source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed"
+source = "Contributor-original: clean-room translations of PdfCraft's English UI labels; PhotoCraft and VectorCraft Vietnamese catalogs used only as translation memory when English source and semantic context matched; original catalog reviewed by a native Vietnamese-speaking contributor; 2026-10-15 coverage additions are AI-assisted and not separately native-reviewed; 2026-10-18 page-gap labels are contributor-original AI-assisted translations from English, not separately native-reviewed; 2026-10-22 signature-check wordings are contributor-original AI-assisted translations from English, not separately native-reviewed"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Vietnamese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "1c73f9d51b599923d704bf00c8e56b23696de16141b7a2c20aae6f60f88927a5"
+sha256 = "57f0dd007ad16518fbc87d8aeac840ef6d86412bb460aff9fb17b176408fb7a8"
[[asset]]
path = "crates/ui-egui/src/i18n/sl.tsv"
@@ -3537,4 +3537,4 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Catalan UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
-sha256 = "cde106ae68eea0a2d6229863ea21bbea235e4c3c5e3e728d52689d54041f0593"
+sha256 = "5879ee3540ffb630fc83d0f2cb293cc6cbf2e081ea4577161a770794e8cbd22c"
diff --git a/Cargo.lock b/Cargo.lock
index a95014924..47d73be44 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3936,7 +3936,7 @@ dependencies = [
[[package]]
name = "pdfcraft"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"chrono",
"craft-quit",
@@ -3962,7 +3962,7 @@ dependencies = [
[[package]]
name = "pdfcraft-a11y"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3974,7 +3974,7 @@ dependencies = [
[[package]]
name = "pdfcraft-annot"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"hayro-syntax",
"pdfcraft-cos",
@@ -3985,7 +3985,7 @@ dependencies = [
[[package]]
name = "pdfcraft-audit"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-content",
"pdfcraft-cos",
@@ -3996,7 +3996,7 @@ dependencies = [
[[package]]
name = "pdfcraft-automation"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"base64 0.22.1",
"flate2",
@@ -4018,7 +4018,7 @@ dependencies = [
[[package]]
name = "pdfcraft-cli"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"base64 0.22.1",
"pdfcraft-audit",
@@ -4036,18 +4036,19 @@ dependencies = [
[[package]]
name = "pdfcraft-compare"
-version = "0.11.0"
+version = "0.13.0"
[[package]]
name = "pdfcraft-content"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
+ "hayro-syntax",
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-cos"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"chrono",
"hayro-syntax",
@@ -4062,7 +4063,7 @@ dependencies = [
[[package]]
name = "pdfcraft-create"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"encoding_rs",
"hayro-syntax",
@@ -4078,7 +4079,7 @@ dependencies = [
[[package]]
name = "pdfcraft-crypt"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"aes",
"md-5",
@@ -4090,7 +4091,7 @@ dependencies = [
[[package]]
name = "pdfcraft-edit"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"hayro-syntax",
"kurbo 0.13.1",
@@ -4105,7 +4106,7 @@ dependencies = [
[[package]]
name = "pdfcraft-engine"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"chrono",
"craft-docpath",
@@ -4146,14 +4147,14 @@ dependencies = [
[[package]]
name = "pdfcraft-export"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"flate2",
]
[[package]]
name = "pdfcraft-filters"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"flate2",
"proptest",
@@ -4163,7 +4164,7 @@ dependencies = [
[[package]]
name = "pdfcraft-fonts"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"harfrust",
"pdfcraft-cos",
@@ -4173,7 +4174,7 @@ dependencies = [
[[package]]
name = "pdfcraft-forms"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4186,11 +4187,11 @@ dependencies = [
[[package]]
name = "pdfcraft-geom"
-version = "0.11.0"
+version = "0.13.0"
[[package]]
name = "pdfcraft-js"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"boa_engine",
"chrono",
@@ -4198,7 +4199,7 @@ dependencies = [
[[package]]
name = "pdfcraft-measure"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4211,14 +4212,14 @@ dependencies = [
[[package]]
name = "pdfcraft-model"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-cos",
]
[[package]]
name = "pdfcraft-ocr"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"ocrs",
"pdfcraft-fonts",
@@ -4228,7 +4229,7 @@ dependencies = [
[[package]]
name = "pdfcraft-optimize"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"image",
"pdfcraft-annot",
@@ -4241,11 +4242,11 @@ dependencies = [
[[package]]
name = "pdfcraft-oracle"
-version = "0.11.0"
+version = "0.13.0"
[[package]]
name = "pdfcraft-organize"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"hayro-syntax",
"pdfcraft-content",
@@ -4256,7 +4257,7 @@ dependencies = [
[[package]]
name = "pdfcraft-platform"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"async-channel",
"futures-lite",
@@ -4266,7 +4267,7 @@ dependencies = [
[[package]]
name = "pdfcraft-preflight"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"flate2",
"pdfcraft-content",
@@ -4277,7 +4278,7 @@ dependencies = [
[[package]]
name = "pdfcraft-print"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"craft-print",
"pdfcraft-content",
@@ -4289,7 +4290,7 @@ dependencies = [
[[package]]
name = "pdfcraft-redact"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-content",
@@ -4305,7 +4306,7 @@ dependencies = [
[[package]]
name = "pdfcraft-render"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"bytemuck",
"flate2",
@@ -4324,7 +4325,7 @@ dependencies = [
[[package]]
name = "pdfcraft-sign"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"aes",
"aws-lc-rs",
@@ -4359,14 +4360,15 @@ dependencies = [
[[package]]
name = "pdfcraft-testkit"
-version = "0.11.0"
+version = "0.13.0"
[[package]]
name = "pdfcraft-ui-egui"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"arboard",
"base64 0.22.1",
+ "block2 0.6.2",
"bytemuck",
"chrono",
"craft-quit",
@@ -4390,6 +4392,7 @@ dependencies = [
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
+ "plist",
"png 0.18.1",
"pollster 1.0.1",
"rfd",
@@ -4404,11 +4407,12 @@ dependencies = [
"wasm-bindgen-futures",
"web-sys",
"winsafe",
+ "xattr",
]
[[package]]
name = "pdfcraft-web"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"eframe",
"egui",
@@ -4420,7 +4424,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfa"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"base64 0.22.1",
"image",
@@ -4434,7 +4438,7 @@ dependencies = [
[[package]]
name = "pdfcraft-xfdf"
-version = "0.11.0"
+version = "0.13.0"
dependencies = [
"pdfcraft-annot",
"pdfcraft-cos",
@@ -4661,6 +4665,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+[[package]]
+name = "plist"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65dca778f41c79ebbe8b2b2139fee86f01b766b67130d108c7c2b8ffbd949f77"
+dependencies = [
+ "base64 0.23.1",
+ "indexmap",
+ "quick-xml 0.42.0",
+ "serde",
+ "time",
+]
+
[[package]]
name = "png"
version = "0.17.16"
@@ -4888,6 +4905,15 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "quick-xml"
+version = "0.42.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.12"
@@ -7260,7 +7286,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
dependencies = [
"proc-macro2",
- "quick-xml",
+ "quick-xml 0.41.0",
"quote",
]
diff --git a/Cargo.toml b/Cargo.toml
index 201240314..92f51290d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,7 +5,7 @@ exclude = ["vendor"]
default-members = ["crates/*", "apps/pdfcraft", "apps/pdfcraft-cli", "xtask"]
[workspace.package]
-version = "0.11.0"
+version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -21,39 +21,39 @@ craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.
# 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# internal
-pdfcraft-geom = { path = "crates/geom", version = "0.11.0" }
-pdfcraft-filters = { path = "crates/filters", version = "0.11.0" }
-pdfcraft-cos = { path = "crates/cos", version = "0.11.0" }
-pdfcraft-crypt = { path = "crates/crypt", version = "0.11.0" }
-pdfcraft-organize = { path = "crates/organize", version = "0.11.0" }
-pdfcraft-measure = { path = "crates/measure", version = "0.11.0" }
-pdfcraft-annot = { path = "crates/annot", version = "0.11.0" }
-pdfcraft-audit = { path = "crates/audit", version = "0.11.0" }
-pdfcraft-fonts = { path = "crates/fonts", version = "0.11.0" }
-pdfcraft-model = { path = "crates/model", version = "0.11.0" }
-pdfcraft-content = { path = "crates/content", version = "0.11.0" }
-pdfcraft-redact = { path = "crates/redact", version = "0.11.0" }
-pdfcraft-print = { path = "crates/print", version = "0.11.0" }
-pdfcraft-xfdf = { path = "crates/xfdf", version = "0.11.0" }
-pdfcraft-sign = { path = "crates/sign", version = "0.11.0" }
-pdfcraft-optimize = { path = "crates/optimize", version = "0.11.0" }
-pdfcraft-a11y = { path = "crates/a11y", version = "0.11.0" }
-pdfcraft-oracle = { path = "crates/oracle", version = "0.11.0" }
-pdfcraft-testkit = { path = "crates/testkit", version = "0.11.0" }
-pdfcraft-ocr = { path = "crates/ocr", version = "0.11.0" }
-pdfcraft-js = { path = "crates/js", version = "0.11.0" }
-pdfcraft-compare = { path = "crates/compare", version = "0.11.0" }
-pdfcraft-preflight = { path = "crates/preflight", version = "0.11.0" }
-pdfcraft-export = { path = "crates/export", version = "0.11.0" }
-pdfcraft-edit = { path = "crates/edit", version = "0.11.0" }
-pdfcraft-create = { path = "crates/create", version = "0.11.0" }
-pdfcraft-forms = { path = "crates/forms", version = "0.11.0" }
-pdfcraft-xfa = { path = "crates/xfa", version = "0.11.0" }
-pdfcraft-render = { path = "crates/render", version = "0.11.0" }
-pdfcraft-engine = { path = "crates/engine", version = "0.11.0" }
-pdfcraft-platform = { path = "crates/platform", version = "0.11.0" }
-pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.11.0" }
-pdfcraft-automation = { path = "crates/automation", version = "0.11.0", default-features = false }
+pdfcraft-geom = { path = "crates/geom", version = "0.13.0" }
+pdfcraft-filters = { path = "crates/filters", version = "0.13.0" }
+pdfcraft-cos = { path = "crates/cos", version = "0.13.0" }
+pdfcraft-crypt = { path = "crates/crypt", version = "0.13.0" }
+pdfcraft-organize = { path = "crates/organize", version = "0.13.0" }
+pdfcraft-measure = { path = "crates/measure", version = "0.13.0" }
+pdfcraft-annot = { path = "crates/annot", version = "0.13.0" }
+pdfcraft-audit = { path = "crates/audit", version = "0.13.0" }
+pdfcraft-fonts = { path = "crates/fonts", version = "0.13.0" }
+pdfcraft-model = { path = "crates/model", version = "0.13.0" }
+pdfcraft-content = { path = "crates/content", version = "0.13.0" }
+pdfcraft-redact = { path = "crates/redact", version = "0.13.0" }
+pdfcraft-print = { path = "crates/print", version = "0.13.0" }
+pdfcraft-xfdf = { path = "crates/xfdf", version = "0.13.0" }
+pdfcraft-sign = { path = "crates/sign", version = "0.13.0" }
+pdfcraft-optimize = { path = "crates/optimize", version = "0.13.0" }
+pdfcraft-a11y = { path = "crates/a11y", version = "0.13.0" }
+pdfcraft-oracle = { path = "crates/oracle", version = "0.13.0" }
+pdfcraft-testkit = { path = "crates/testkit", version = "0.13.0" }
+pdfcraft-ocr = { path = "crates/ocr", version = "0.13.0" }
+pdfcraft-js = { path = "crates/js", version = "0.13.0" }
+pdfcraft-compare = { path = "crates/compare", version = "0.13.0" }
+pdfcraft-preflight = { path = "crates/preflight", version = "0.13.0" }
+pdfcraft-export = { path = "crates/export", version = "0.13.0" }
+pdfcraft-edit = { path = "crates/edit", version = "0.13.0" }
+pdfcraft-create = { path = "crates/create", version = "0.13.0" }
+pdfcraft-forms = { path = "crates/forms", version = "0.13.0" }
+pdfcraft-xfa = { path = "crates/xfa", version = "0.13.0" }
+pdfcraft-render = { path = "crates/render", version = "0.13.0" }
+pdfcraft-engine = { path = "crates/engine", version = "0.13.0" }
+pdfcraft-platform = { path = "crates/platform", version = "0.13.0" }
+pdfcraft-ui-egui = { path = "crates/ui-egui", version = "0.13.0" }
+pdfcraft-automation = { path = "crates/automation", version = "0.13.0", default-features = false }
# pdf
hayro = "0.7.1"
diff --git a/README.md b/README.md
index 7d3069258..d39bec5aa 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,10 @@
+
+
+
+
PdfCraft
diff --git a/ROADMAP.md b/ROADMAP.md
index bc057209c..27c8e1b6b 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,6 +2,7 @@
**Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away
+> **Last reviewed:** 2026-10-22 · **Last updated:** 2026-10-22 · **Change:** minor (M14, #3558: rebase guarded macOS per-file association recovery onto current main; estimates unchanged)
> **Last reviewed:** 2026-10-21 · **Last updated:** 2026-10-21 · **Change:** minor (M3, #3808: the Pages inspector has an independent 50–150% thumbnail size slider and buttons; selection and current-page following are retained, with headless ui.set coverage. Estimates unchanged.)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M11 #3507: image merging preserves content stream boundaries; estimates unchanged)
> **Last reviewed:** 2026-10-20 | **Last updated:** 2026-10-20 | **Change:** minor (M10, #3532 partial: vector printing retains existing appearances with absent Subtype; estimates unchanged)
@@ -189,6 +190,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
| 8 | 1.0 polish: performance, right-to-left, missing languages, keyboard-only (M14) | 55–95 |
## Progress log
+- **2026-10-22 (M14, macOS association recovery candidate):** Successful desktop open/save/sign can queue recovery of an existing per-file binding to the running PDFCraft bundle through NSWorkspace. Metadata-only watcher changes retain undo and avoid false overwrite prompts; real same-length/same-mtime edits remain protected. Rebased on 0.12.0 and reuses #3042's shared atomic metadata preservation; the bounded recovery parser does not narrow save limits. Prior native Preview TEST-signature and Finder workflows passed on macOS 27.0.1; other versions remain unqualified. Estimates unchanged.
- **2026-10-13 (M7, free image rotation):** A selected page image has a rotate knob (Shift: 15 degree steps) and `image_edit` a `rotate_by` action; the angle is written as one undoable `cm`. Snapping to page edges while moving is in #1740; resize snapping is not done. Estimates unchanged.
- **2026-10-20 (M4, initial view):** Window and interface options read indirect boolean ViewerPreferences values. Synthetic regressions cover true/false, direct/indirect preference dictionaries and save/reopen; malformed entries retain false defaults. Estimates unchanged.
diff --git a/apps/pdfcraft-cli/Cargo.toml b/apps/pdfcraft-cli/Cargo.toml
index 991cbf98f..7b5f9a82f 100644
--- a/apps/pdfcraft-cli/Cargo.toml
+++ b/apps/pdfcraft-cli/Cargo.toml
@@ -24,7 +24,7 @@ pdfcraft-platform = { workspace = true }
base64 = "0.22"
[dev-dependencies]
-# Decodes `render` output in tests/owner_password.rs; already a dependency of the engine.
+# Decodes `render` output in tests/it/owner_password.rs; already a dependency of the engine.
png = "0.18"
[target.'cfg(unix)'.dependencies]
diff --git a/apps/pdfcraft-cli/tests/audit.rs b/apps/pdfcraft-cli/tests/it/audit.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/audit.rs
rename to apps/pdfcraft-cli/tests/it/audit.rs
diff --git a/apps/pdfcraft-cli/tests/automation_roots.rs b/apps/pdfcraft-cli/tests/it/automation_roots.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/automation_roots.rs
rename to apps/pdfcraft-cli/tests/it/automation_roots.rs
diff --git a/apps/pdfcraft-cli/tests/batch.rs b/apps/pdfcraft-cli/tests/it/batch.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/batch.rs
rename to apps/pdfcraft-cli/tests/it/batch.rs
diff --git a/apps/pdfcraft-cli/tests/check.rs b/apps/pdfcraft-cli/tests/it/check.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/check.rs
rename to apps/pdfcraft-cli/tests/it/check.rs
diff --git a/apps/pdfcraft-cli/tests/edit.rs b/apps/pdfcraft-cli/tests/it/edit.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/edit.rs
rename to apps/pdfcraft-cli/tests/it/edit.rs
diff --git a/apps/pdfcraft-cli/tests/extract.rs b/apps/pdfcraft-cli/tests/it/extract.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/extract.rs
rename to apps/pdfcraft-cli/tests/it/extract.rs
diff --git a/apps/pdfcraft-cli/tests/help.rs b/apps/pdfcraft-cli/tests/it/help.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/help.rs
rename to apps/pdfcraft-cli/tests/it/help.rs
diff --git a/apps/pdfcraft-cli/tests/info_standards.rs b/apps/pdfcraft-cli/tests/it/info_standards.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/info_standards.rs
rename to apps/pdfcraft-cli/tests/it/info_standards.rs
diff --git a/apps/pdfcraft-cli/tests/it/main.rs b/apps/pdfcraft-cli/tests/it/main.rs
new file mode 100644
index 000000000..f0b87c97a
--- /dev/null
+++ b/apps/pdfcraft-cli/tests/it/main.rs
@@ -0,0 +1,24 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod audit;
+mod automation_roots;
+mod batch;
+mod check;
+mod edit;
+mod extract;
+mod help;
+mod info_standards;
+mod mcp_compact;
+mod options;
+mod owner_password;
+mod render;
+mod root_arguments;
+mod run_report;
+mod run_root;
+mod split;
+mod stderr;
+mod stdout;
+mod text;
+mod ui_control;
+mod version_resource;
+mod xfa;
diff --git a/apps/pdfcraft-cli/tests/mcp_compact.rs b/apps/pdfcraft-cli/tests/it/mcp_compact.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/mcp_compact.rs
rename to apps/pdfcraft-cli/tests/it/mcp_compact.rs
diff --git a/apps/pdfcraft-cli/tests/options.rs b/apps/pdfcraft-cli/tests/it/options.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/options.rs
rename to apps/pdfcraft-cli/tests/it/options.rs
diff --git a/apps/pdfcraft-cli/tests/owner_password.rs b/apps/pdfcraft-cli/tests/it/owner_password.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/owner_password.rs
rename to apps/pdfcraft-cli/tests/it/owner_password.rs
diff --git a/apps/pdfcraft-cli/tests/render.rs b/apps/pdfcraft-cli/tests/it/render.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/render.rs
rename to apps/pdfcraft-cli/tests/it/render.rs
diff --git a/apps/pdfcraft-cli/tests/root_arguments.rs b/apps/pdfcraft-cli/tests/it/root_arguments.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/root_arguments.rs
rename to apps/pdfcraft-cli/tests/it/root_arguments.rs
diff --git a/apps/pdfcraft-cli/tests/run_report.rs b/apps/pdfcraft-cli/tests/it/run_report.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/run_report.rs
rename to apps/pdfcraft-cli/tests/it/run_report.rs
diff --git a/apps/pdfcraft-cli/tests/run_root.rs b/apps/pdfcraft-cli/tests/it/run_root.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/run_root.rs
rename to apps/pdfcraft-cli/tests/it/run_root.rs
diff --git a/apps/pdfcraft-cli/tests/split.rs b/apps/pdfcraft-cli/tests/it/split.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/split.rs
rename to apps/pdfcraft-cli/tests/it/split.rs
diff --git a/apps/pdfcraft-cli/tests/stderr.rs b/apps/pdfcraft-cli/tests/it/stderr.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/stderr.rs
rename to apps/pdfcraft-cli/tests/it/stderr.rs
diff --git a/apps/pdfcraft-cli/tests/stdout.rs b/apps/pdfcraft-cli/tests/it/stdout.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/stdout.rs
rename to apps/pdfcraft-cli/tests/it/stdout.rs
diff --git a/apps/pdfcraft-cli/tests/text.rs b/apps/pdfcraft-cli/tests/it/text.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/text.rs
rename to apps/pdfcraft-cli/tests/it/text.rs
diff --git a/apps/pdfcraft-cli/tests/ui_control.rs b/apps/pdfcraft-cli/tests/it/ui_control.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/ui_control.rs
rename to apps/pdfcraft-cli/tests/it/ui_control.rs
diff --git a/apps/pdfcraft-cli/tests/version_resource.rs b/apps/pdfcraft-cli/tests/it/version_resource.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/version_resource.rs
rename to apps/pdfcraft-cli/tests/it/version_resource.rs
diff --git a/apps/pdfcraft-cli/tests/xfa.rs b/apps/pdfcraft-cli/tests/it/xfa.rs
similarity index 100%
rename from apps/pdfcraft-cli/tests/xfa.rs
rename to apps/pdfcraft-cli/tests/it/xfa.rs
diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs
index 83923fbe5..1a36d64bf 100644
--- a/apps/pdfcraft/src/main.rs
+++ b/apps/pdfcraft/src/main.rs
@@ -780,7 +780,7 @@ mod tests {
/// graphics libraries at a time. The test threads otherwise run them together, and on
/// FreeBSD's CI machine the test process then died with SIGSEGV, in about half the runs:
/// the first device was still being set up when the second test began its own. The UI
- /// tests take turns the same way (`crates/ui-egui/tests/view.rs`). Hold it for the whole
+ /// tests take turns the same way (`crates/ui-egui/tests/it/main.rs`). Hold it for the whole
/// test (declared before the instance, so it's released after the device is dropped).
static GPU: std::sync::Mutex<()> = std::sync::Mutex::new(());
diff --git a/apps/pdfcraft/tests/it/main.rs b/apps/pdfcraft/tests/it/main.rs
new file mode 100644
index 000000000..bc05099a5
--- /dev/null
+++ b/apps/pdfcraft/tests/it/main.rs
@@ -0,0 +1,21 @@
+//! The crate's integration tests, linked into one test binary.
+
+// Compile winit's scroll policy against its real event type, without a compositor. The vendored
+// source (`winit_wayland_scroll`) names it as `crate::event`.
+#[cfg(target_os = "linux")]
+mod event {
+ pub use winit::event::TouchPhase;
+}
+
+mod winit_macos_emoji_picker;
+mod winit_macos_ime_commit;
+mod winit_macos_ime_discard;
+mod winit_macos_korean_first_key;
+mod winit_macos_korean_space;
+mod winit_macos_title_bar_drag;
+mod winit_wayland_scroll;
+mod winit_wayland_text_input;
+mod winit_windows_hidden_maximized;
+mod winit_windows_ime_cursor;
+mod winit_x11_dpi;
+mod winit_x11_ime_focus;
diff --git a/apps/pdfcraft/tests/winit_macos_emoji_picker.rs b/apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_emoji_picker.rs
rename to apps/pdfcraft/tests/it/winit_macos_emoji_picker.rs
diff --git a/apps/pdfcraft/tests/winit_macos_ime_commit.rs b/apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_ime_commit.rs
rename to apps/pdfcraft/tests/it/winit_macos_ime_commit.rs
diff --git a/apps/pdfcraft/tests/winit_macos_ime_discard.rs b/apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_ime_discard.rs
rename to apps/pdfcraft/tests/it/winit_macos_ime_discard.rs
diff --git a/apps/pdfcraft/tests/winit_macos_korean_first_key.rs b/apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_korean_first_key.rs
rename to apps/pdfcraft/tests/it/winit_macos_korean_first_key.rs
diff --git a/apps/pdfcraft/tests/winit_macos_korean_space.rs b/apps/pdfcraft/tests/it/winit_macos_korean_space.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_korean_space.rs
rename to apps/pdfcraft/tests/it/winit_macos_korean_space.rs
diff --git a/apps/pdfcraft/tests/winit_macos_title_bar_drag.rs b/apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_macos_title_bar_drag.rs
rename to apps/pdfcraft/tests/it/winit_macos_title_bar_drag.rs
diff --git a/apps/pdfcraft/tests/winit_wayland_scroll.rs b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
similarity index 93%
rename from apps/pdfcraft/tests/winit_wayland_scroll.rs
rename to apps/pdfcraft/tests/it/winit_wayland_scroll.rs
index ec76d2b4b..96686f7ec 100644
--- a/apps/pdfcraft/tests/winit_wayland_scroll.rs
+++ b/apps/pdfcraft/tests/it/winit_wayland_scroll.rs
@@ -3,11 +3,8 @@
use egui::{Event, Modifiers, MouseWheelUnit, RawInput, TouchPhase, vec2};
use wayland_client::protocol::wl_pointer::AxisSource;
-// Compile the production policy against winit's real event type, without a compositor.
-mod event {
- pub use winit::event::TouchPhase;
-}
-#[path = "../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
+use crate::event;
+#[path = "../../../../vendor/winit/src/platform_impl/linux/wayland/seat/pointer/scroll.rs"]
mod scroll;
fn egui_phase(phase: event::TouchPhase) -> TouchPhase {
diff --git a/apps/pdfcraft/tests/winit_wayland_text_input.rs b/apps/pdfcraft/tests/it/winit_wayland_text_input.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_wayland_text_input.rs
rename to apps/pdfcraft/tests/it/winit_wayland_text_input.rs
diff --git a/apps/pdfcraft/tests/winit_windows_hidden_maximized.rs b/apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_windows_hidden_maximized.rs
rename to apps/pdfcraft/tests/it/winit_windows_hidden_maximized.rs
diff --git a/apps/pdfcraft/tests/winit_windows_ime_cursor.rs b/apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_windows_ime_cursor.rs
rename to apps/pdfcraft/tests/it/winit_windows_ime_cursor.rs
diff --git a/apps/pdfcraft/tests/winit_x11_dpi.rs b/apps/pdfcraft/tests/it/winit_x11_dpi.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_x11_dpi.rs
rename to apps/pdfcraft/tests/it/winit_x11_dpi.rs
diff --git a/apps/pdfcraft/tests/winit_x11_ime_focus.rs b/apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
similarity index 100%
rename from apps/pdfcraft/tests/winit_x11_ime_focus.rs
rename to apps/pdfcraft/tests/it/winit_x11_ime_focus.rs
diff --git a/assets/demo/showcase.html b/assets/demo/showcase.html
index 267e56a56..22e0619ea 100644
--- a/assets/demo/showcase.html
+++ b/assets/demo/showcase.html
@@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
The families in this specimen
-
Ag Playfair Display Display
-
Ag EB Garamond Body text
-
Ag Inter Labels
-
Ag JetBrains Mono Code
-
Ag Great Vibes Script
-
∑x STIX Two Math Mathematics
+
Ag Playfair Display Display
+
Ag EB Garamond Body text
+
Ag Inter Labels
+
Ag JetBrains Mono Code
+
Ag Great Vibes Script
+
∑x STIX Two Math Mathematics
Foreword ii
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
onum Old-style figures Since 1885: 3,467,920 Since 1885: 3,467,920
frac Fractions 1/2 cup, 3/4 inch, 7/8 1/2 cup, 3/4 inch, 7/8
swsh Swash capitals The Quiet River The Quiet River
- ss01 Stylistic set 1 a 1 4 6 9 G ? a 1 4 6 9 G ?
+ ss01 Stylistic set 1 a 1 4 6 9 G ? a 1 4 6 9 G ?
kern Kerning AVATAR Type Wave AVATAR Type Wave
tnum Tabular figures 1111 / 8888 1111 / 8888
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
A short greeting — roughly, “every letter deserves a good home” — in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.
-
Arabic RTL · joining
لكل حرف بيت
Amiri
+
Arabic RTL · joining
لكل حرف بيت
Amiri
Hebrew RTL
לכל אות יש בית טוב
Noto Sans Hebrew
-
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
+
Devanagari conjuncts
हर अक्षर का घर
Noto Sans Devanagari
Thai no spaces
ทุกอักษรมีบ้าน
Noto Sans Thai
Greek polytonic
Κάθε γράμμα ἔχει σπίτι
EB Garamond
Cyrillic
Каждой букве — дом
EB Garamond
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
Mixed direction in one line
-
The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.
+
The word كتاب means “book”, and ספר does too — the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like ٣٤٥ and 345.
Colour emoji
📄🖋️🎨📐🔍🌍🧮✅🦀📎
diff --git a/crates/a11y/src/alt.rs b/crates/a11y/src/alt.rs
index e4755ab38..87cfce5ac 100644
--- a/crates/a11y/src/alt.rs
+++ b/crates/a11y/src/alt.rs
@@ -4,7 +4,7 @@
use std::collections::{HashMap, HashSet};
use pdfcraft_content::Matrix;
-use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
+use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, PdfString, Stream};
/// A Figure element (after the role map).
#[derive(Clone, Debug, PartialEq)]
@@ -23,6 +23,8 @@ pub enum AltError {
Untagged,
#[error("object {0} is not a figure")]
NotAFigure(u32),
+ #[error("the page has more content than can be edited")]
+ TooLarge,
#[error("{0}")]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -48,6 +50,8 @@ pub enum ArtifactError {
AmbiguousParent,
#[error("couldn't update the parent tree: {0}")]
ParentTree(String),
+ #[error("the page has more content than can be edited")]
+ TooLarge,
#[error(transparent)]
Cos(#[from] pdfcraft_cos::CosError),
}
@@ -157,22 +161,19 @@ fn bdc_mcid(doc: &Document, props: &Dict, op: &pdfcraft_content::Op) -> Option
Vec {
+ page_content_within(doc, page, &mut DecodeBudget::page())
+}
+
+/// The page's content streams, decoded against `budget` and joined with a space. Streams that
+/// don't decode, or arrive after the budget is spent, are left out.
+pub(crate) fn page_content_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Vec {
let Some(c) = page.get(b"Contents") else { return Vec::new() };
- match &*doc.resolve(c) {
- Object::Stream(s) => s.decoded().unwrap_or_default(),
- Object::Array(a) => a
- .iter()
- .flat_map(|x| match &*doc.resolve(x) {
- Object::Stream(s) => {
- let mut v = s.decoded().unwrap_or_default();
- v.push(b' ');
- v
- }
- _ => Vec::new(),
- })
- .collect(),
- _ => Vec::new(),
+ let mut out = Vec::new();
+ for data in budget.decode_contents(doc, c).into_iter().flatten() {
+ out.extend_from_slice(&data);
+ out.push(b' ');
}
+ out
}
fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
@@ -182,17 +183,51 @@ fn union(a: Option<[f64; 4]>, b: [f64; 4]) -> [f64; 4] {
}
}
-/// Where the marked content `ids` is drawn on a page (images, forms and paths).
-fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64; 4]> {
+/// An open marked-content sequence while bounds are gathered: its id, when it is one of those asked
+/// for, and the bounds of what has been drawn in it so far.
+struct Mark {
+ id: Option,
+ drawn: Option<[f64; 4]>,
+}
+
+/// Adds `b` to what the innermost open sequence has drawn.
+fn draw(marks: &mut [Mark], b: [f64; 4]) {
+ if let Some(m) = marks.last_mut() {
+ m.drawn = Some(union(m.drawn, b));
+ }
+}
+
+/// Closes the innermost open sequence: what it drew counts for its id and for the sequence around it.
+fn close(marks: &mut Vec, matching: &mut usize, out: &mut HashMap) {
+ let Some(m) = marks.pop() else { return };
+ if m.id.is_some() {
+ *matching = matching.saturating_sub(1);
+ }
+ let Some(b) = m.drawn else { return };
+ if let Some(id) = m.id {
+ let e = out.entry(id).or_insert(b);
+ *e = union(Some(*e), b);
+ }
+ draw(marks, b);
+}
+
+/// Where each of the marked content `ids` is drawn on a page (images, forms and paths), in one pass
+/// over the page. What a sequence draws counts for every sequence of `ids` around it too.
+pub(crate) fn content_bboxes(doc: &Document, page: &Dict, ids: &HashSet) -> HashMap {
let res = page.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let xobjects = res.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
let props = page_properties(doc, page);
+ #[cfg(test)]
+ crate::BOUNDS_PASSES.with(|c| c.set(c.get() + 1));
let mut ctm = vec![Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0])];
- let mut marks: Vec = Vec::new();
- let mut bbox = None;
+ // The open marked-content sequences, and how many of them are one of `ids`.
+ let mut marks: Vec = Vec::new();
+ let mut matching = 0usize;
+ let mut out = HashMap::new();
let mut path: Vec<(f64, f64)> = Vec::new();
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
- let inside = marks.iter().any(|m| *m);
+ crate::count_mark_reads(1);
+ let inside = matching > 0;
let top = ctm.last().copied().unwrap_or(Matrix([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
match op.op.as_slice() {
b"q" => ctm.push(top),
@@ -206,11 +241,13 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64;
*t = m.then(&top);
}
}
- b"BMC" => marks.push(false),
- b"BDC" => marks.push(bdc_mcid(doc, &props, &op).is_some_and(|n| ids.contains(&n))),
- b"EMC" => {
- marks.pop();
+ b"BMC" => marks.push(Mark { id: None, drawn: None }),
+ b"BDC" => {
+ let id = bdc_mcid(doc, &props, &op).filter(|n| ids.contains(n));
+ matching += usize::from(id.is_some());
+ marks.push(Mark { id, drawn: None });
}
+ b"EMC" => close(&mut marks, &mut matching, &mut out),
b"m" | b"l" => {
if let Some([x, y]) = op.nums::<2>() {
path.push(top.apply(x, y));
@@ -231,7 +268,7 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64;
b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"S" | b"s" | b"n" => {
if inside && !matches!(op.op.as_slice(), b"n") {
for (x, y) in &path {
- bbox = Some(union(bbox, [*x, *y, *x, *y]));
+ draw(&mut marks, [*x, *y, *x, *y]);
}
}
path.clear();
@@ -251,13 +288,16 @@ fn content_bbox(doc: &Document, page: &Dict, ids: &HashSet) -> Option<[f64;
}
_ => top.bbox([0.0, 0.0, 1.0, 1.0]),
};
- bbox = Some(union(bbox, unit));
+ draw(&mut marks, unit);
}
- b"BI" if inside => bbox = Some(union(bbox, top.bbox([0.0, 0.0, 1.0, 1.0]))),
+ b"BI" if inside => draw(&mut marks, top.bbox([0.0, 0.0, 1.0, 1.0])),
_ => {}
}
}
- bbox
+ while !marks.is_empty() {
+ close(&mut marks, &mut matching, &mut out);
+ }
+ out
}
/// The figures, in document order.
@@ -266,14 +306,28 @@ pub fn figures(doc: &Document) -> Vec {
let role_map = root.get(b"RoleMap").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let pages = pdfcraft_model::pages(doc);
let index: HashMap = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
- elements(doc)
+ let figures: Vec<(Element, Option)> = elements(doc)
.into_iter()
.filter(|e| e.dict.name(b"S").is_some_and(|s| standard(doc, &role_map, s) == b"Figure"))
.map(|e| {
let page = e.page.and_then(|p| index.get(&p).copied());
+ (e, page)
+ })
+ .collect();
+ let mut wanted: HashMap> = HashMap::new();
+ for (e, page) in &figures {
+ if let Some(p) = page {
+ wanted.entry(*p).or_default().extend(mcids(&e.dict));
+ }
+ }
+ let bounds: HashMap> =
+ wanted.into_iter().filter_map(|(p, ids)| Some((p, content_bboxes(doc, &pages.get(p)?.dict, &ids)))).collect();
+ figures
+ .into_iter()
+ .map(|(e, page)| {
let alt = e.dict.get(b"Alt").map(|o| doc.resolve(o)).and_then(|o| o.as_string().map(|s| s.to_text()));
- let ids: HashSet = mcids(&e.dict).into_iter().collect();
- let bbox = page.and_then(|p| content_bbox(doc, &pages[p].dict, &ids));
+ let on_page = page.and_then(|p| bounds.get(&p));
+ let bbox = on_page.and_then(|b| mcids(&e.dict).iter().filter_map(|id| b.get(id)).fold(None, |acc, b| Some(union(acc, *b))));
Figure { obj: e.object, page, alt, bbox }
})
.collect()
@@ -322,9 +376,19 @@ fn artifact_marked_content(doc: &mut Document, page: &pdfcraft_model::Page, ids:
let Some(entries) = content_entries(doc, &page.dict) else { return Ok(false) };
let props = page_properties(doc, &page.dict);
let mut streams: Vec)>> = Vec::with_capacity(entries.len());
+ let mut budget = DecodeBudget::page();
for entry in &entries {
match &*doc.resolve(entry) {
- Object::Stream(s) => streams.push(Some((s.clone(), s.decoded().unwrap_or_default()))),
+ Object::Stream(s) => {
+ let data = match budget.decode(s) {
+ Ok(data) => data,
+ // Content past the budget may hold more of the figure: editing the rest
+ // would leave that part marked as a figure the tree no longer has.
+ Err(_) if budget.exhausted() => return Err(AltError::TooLarge),
+ Err(_) => Vec::new(),
+ };
+ streams.push(Some((s.clone(), data)));
+ }
_ => streams.push(None),
}
}
@@ -421,7 +485,23 @@ pub fn mark_tag_artifact(doc: &mut Document, obj: ObjRef) -> Result<(), Artifact
let pages = pdfcraft_model::pages(doc);
let page_ref = element.page.ok_or(ArtifactError::NoPage)?;
let page = pages.iter().find(|page| page.obj == page_ref).ok_or(ArtifactError::NoPage)?;
- let mut ops = pdfcraft_content::parse(&page_content(doc, &page.dict)).ops;
+ let mut content = Vec::new();
+ if let Some(c) = page.dict.get(b"Contents") {
+ let mut budget = DecodeBudget::page();
+ for data in budget.decode_contents(doc, c) {
+ match data {
+ Ok(data) => {
+ content.extend_from_slice(&data);
+ content.push(b' ');
+ }
+ // The page's /Contents is replaced with what was decoded: content past the
+ // budget would be dropped from the page.
+ Err(_) if budget.exhausted() => return Err(ArtifactError::TooLarge),
+ Err(_) => {}
+ }
+ }
+ }
+ let mut ops = pdfcraft_content::parse(&content).ops;
let mut found = HashSet::new();
for op in &mut ops {
let id = (op.op == b"BDC")
diff --git a/crates/a11y/src/content.rs b/crates/a11y/src/content.rs
index 8f7221b2c..fd5f0d305 100644
--- a/crates/a11y/src/content.rs
+++ b/crates/a11y/src/content.rs
@@ -6,7 +6,7 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
-use pdfcraft_cos::{Dict, Document, ObjRef, Object};
+use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object};
use pdfcraft_model::Page;
use crate::{Finding, Rule};
@@ -20,6 +20,8 @@ pub(crate) struct Scan {
pub untagged: BTreeMap,
/// Page → fonts used.
pub fonts: BTreeMap>,
+ /// Pages read only in part: content or forms past the page's decode budget are missing.
+ pub incomplete: BTreeSet,
}
const MAX_FORM_DEPTH: usize = 12;
@@ -30,38 +32,25 @@ impl Scan {
for &p in list {
let Some(page) = pages.get(p) else { continue };
let res = page.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
- let data = page_content(doc, &page.dict);
- let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new() };
+ let mut budget = DecodeBudget::page();
+ let data = crate::alt::page_content_within(doc, &page.dict, &mut budget);
+ if budget.remaining() == 0 {
+ s.incomplete.insert(p);
+ }
+ let mut w = Walk { doc, scan: &mut s, page: p, forms: HashSet::new(), budget };
w.content(&data, &res, false, 0);
}
s
}
}
-fn page_content(doc: &Document, page: &Dict) -> Vec {
- let Some(c) = page.get(b"Contents") else { return Vec::new() };
- match &*doc.resolve(c) {
- Object::Stream(s) => s.decoded().unwrap_or_default(),
- Object::Array(a) => a
- .iter()
- .flat_map(|x| match &*doc.resolve(x) {
- Object::Stream(s) => {
- let mut v = s.decoded().unwrap_or_default();
- v.push(b' ');
- v
- }
- _ => Vec::new(),
- })
- .collect(),
- _ => Vec::new(),
- }
-}
-
struct Walk<'a> {
doc: &'a Document,
scan: &'a mut Scan,
page: usize,
forms: HashSet,
+ /// Decoded bytes and form entries left for this page.
+ budget: DecodeBudget,
}
impl Walk<'_> {
@@ -116,14 +105,27 @@ impl Walk<'_> {
self.scan.has_images = true;
untagged += usize::from(!inside);
}
- Some(b"Form") if depth < MAX_FORM_DEPTH && self.forms.insert(r) => {
+ Some(b"Form") if depth < MAX_FORM_DEPTH && !self.forms.contains(&r) => {
+ if !self.budget.enter_form() {
+ self.scan.incomplete.insert(self.page);
+ continue;
+ }
+ let data = match self.budget.decode(x) {
+ Ok(data) => data,
+ Err(_) => {
+ if self.budget.exhausted() {
+ self.scan.incomplete.insert(self.page);
+ }
+ continue;
+ }
+ };
+ self.forms.insert(r);
let inner = x
.dict
.get(b"Resources")
.map(|o| self.doc.resolve(o))
.and_then(|o| o.as_dict().cloned())
.unwrap_or_else(|| res.clone());
- let data = x.decoded().unwrap_or_default();
self.content(&data, &inner, inside, depth + 1);
self.forms.remove(&r);
}
diff --git a/crates/a11y/src/headings.rs b/crates/a11y/src/headings.rs
index 537ed908d..41c749ed3 100644
--- a/crates/a11y/src/headings.rs
+++ b/crates/a11y/src/headings.rs
@@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap, Metrics> = HashMap::new();
+ // The id each open marked-content sequence is in: its own, else the enclosing one's.
let mut stack: Vec> = Vec::new();
let mut out: HashMap = HashMap::new();
// Where each id's last glyph ended, in user space.
@@ -191,16 +192,20 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
let mut unsaved = 0usize;
let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY);
for op in pdfcraft_content::parse(&page_content(doc, page)).ops {
- let id = stack.iter().rev().find_map(|m| *m);
+ crate::count_mark_reads(1);
+ let id = stack.last().copied().flatten();
match op.op.as_slice() {
- b"BMC" => stack.push(None),
- b"BDC" => stack.push(match op.operands.get(1) {
- Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
- Some(Object::Name(n)) => {
- props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
+ b"BMC" => stack.push(id),
+ b"BDC" => stack.push(
+ match op.operands.get(1) {
+ Some(Object::Dict(d)) => d.get(b"MCID").and_then(Object::as_int),
+ Some(Object::Name(n)) => {
+ props.get(n).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().and_then(|d| d.get(b"MCID").and_then(Object::as_int)))
+ }
+ _ => None,
}
- _ => None,
- }),
+ .or(id),
+ ),
b"EMC" => {
stack.pop();
}
@@ -280,17 +285,17 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap {
};
for (code, len) in m.codes(&s.bytes) {
let trm = tm.then(&gs.ctm);
+ // A glyph's text is only built while its id still takes more.
if let Some(id) = id
+ && out.get(&id).is_none_or(|t| t.len() < MAX_TITLE * 4)
&& let Some(glyph) = m.text_of(code)
{
let text = out.entry(id).or_default();
- if text.len() < MAX_TITLE * 4 {
- let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
- if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
- text.push(' ');
- }
- text.push_str(&glyph);
+ let breaks = ends.get(&id).is_some_and(|end| new_word(&trm, *end, gs.size, gs.scale));
+ if breaks && !text.is_empty() && !text.ends_with(' ') && !glyph.starts_with(' ') {
+ text.push(' ');
}
+ text.push_str(&glyph);
}
let advance = m.width(code) * gs.size + gs.char_spacing + if m.is_space(code, len) { gs.word_spacing } else { 0.0 };
tm = Matrix::translate(advance * gs.scale, 0.0).then(&tm);
diff --git a/crates/a11y/src/lib.rs b/crates/a11y/src/lib.rs
index ff81cc4c9..280f4e845 100644
--- a/crates/a11y/src/lib.rs
+++ b/crates/a11y/src/lib.rs
@@ -21,6 +21,22 @@ mod report;
mod structure;
mod tags;
+#[cfg(test)]
+thread_local! {
+ /// Marked-content stack entries read to decide which marks enclose an operator, for the tests
+ /// that keep that constant per operator.
+ static MARK_READS: std::cell::Cell = const { std::cell::Cell::new(0) };
+ /// Page content passes made to find figure bounds.
+ static BOUNDS_PASSES: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Counts `n` marked-content stack reads (tests only).
+#[inline]
+fn count_mark_reads(_n: usize) {
+ #[cfg(test)]
+ MARK_READS.with(|c| c.set(c.get().saturating_add(_n)));
+}
+
pub use alt::{AltError, ArtifactError, Figure, figures, mark_decorative, mark_tag_artifact, set_alt};
pub use autotag::{AutoTagError, AutoTagResult, autotag_text};
pub use autotag_fields::tag_form_fields;
@@ -665,6 +681,18 @@ pub fn check(doc: &Document, options: &Options) -> Report {
}
}
}
+ // A page read only in part may hold what the scan didn't find: text that makes the
+ // document more than images, untagged content, or a font without Unicode mappings.
+ let unsure = !scan.incomplete.is_empty()
+ && match rule {
+ Rule::ImageOnly => !scan.has_text,
+ Rule::TaggedContent | Rule::CharacterEncoding => findings.is_empty(),
+ _ => false,
+ };
+ if unsure {
+ results.push(RuleResult { rule, status: Status::Manual, findings: Vec::new() });
+ continue;
+ }
if total > MAX_FINDINGS {
let more = total - MAX_FINDINGS;
findings.truncate(MAX_FINDINGS);
diff --git a/crates/a11y/src/tests.rs b/crates/a11y/src/tests.rs
index 15ca643e8..399aded3c 100644
--- a/crates/a11y/src/tests.rs
+++ b/crates/a11y/src/tests.rs
@@ -1023,6 +1023,67 @@ fn glyph_by_tm(text: &str, size: f64, y: f64) -> String {
ops + " ET"
}
+/// Marked-content stack reads while `f` runs.
+fn mark_reads(f: impl FnOnce()) -> usize {
+ MARK_READS.with(|c| c.set(0));
+ f();
+ MARK_READS.with(|c| c.get())
+}
+
+/// Page content nesting `depth` unmarked `BMC` blocks around a marked one, with `ops` operators inside.
+fn deeply_nested_page(doc: &mut Document, depth: usize, ops: usize) -> pdfcraft_cos::Dict {
+ use pdfcraft_cos::{Dict, Stream};
+ let mut data = "/Figure << /MCID 0 >> BDC ".to_string();
+ data.push_str(&"/X BMC ".repeat(depth));
+ data.push_str(&"0 0 1 1 re f ".repeat(ops));
+ data.push_str(&"EMC ".repeat(depth + 1));
+ let content = doc.add(Stream::flate(Dict::new(), data.as_bytes()));
+ [(b"Contents".to_vec(), Object::Ref(content))].into_iter().collect()
+}
+
+#[test]
+fn marked_text_reads_its_enclosing_id_in_constant_steps() {
+ let mut doc = Document::new_empty();
+ let page = deeply_nested_page(&mut doc, 5_000, 5_000);
+ let reads = mark_reads(|| {
+ crate::headings::mcid_texts(&doc, &page);
+ });
+ // About 15 000 operators; a scan of the stack per operator reads millions of entries.
+ assert!(reads <= 4 * 15_000, "{reads} stack reads");
+}
+
+#[test]
+fn figure_bounds_read_the_marked_content_state_in_constant_steps() {
+ let mut doc = Document::new_empty();
+ let page = deeply_nested_page(&mut doc, 5_000, 5_000);
+ // No mark encloses figure 7, so a scan of the stack per operator reads all of it.
+ let reads = mark_reads(|| {
+ crate::alt::content_bboxes(&doc, &page, &std::collections::HashSet::from([7]));
+ });
+ assert!(reads <= 4 * 15_000, "{reads} stack reads");
+}
+
+#[test]
+fn figure_bounds_take_one_pass_over_each_page() {
+ let n = 50;
+ let content: Vec = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
+ let kids: Vec = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
+ let mut objs: Vec = vec![
+ "<< /Type /Catalog /Pages 2 0 R /StructTreeRoot 5 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>".into(),
+ stream(&content.join(" ")),
+ format!("<< /Type /StructTreeRoot /K [{}] >>", kids.join(" ")),
+ ];
+ objs.extend((0..n).map(|i| format!("<< /S /Figure /Pg 3 0 R /K {i} >>")));
+ let doc = pdf(&objs, "");
+ BOUNDS_PASSES.with(|c| c.set(0));
+ let f = figures(&doc);
+ assert_eq!(BOUNDS_PASSES.with(|c| c.get()), 1, "page passes");
+ assert_eq!(f.len(), n);
+ assert!(f.iter().enumerate().all(|(i, f)| f.bbox == Some([i as f64, 0.0, i as f64 + 1.0, 1.0])));
+}
+
/// One tagged page whose H1 headings show `contents` (one marked-content sequence each).
fn heading_page(contents: &[String]) -> Document {
let marked: Vec = contents.iter().enumerate().map(|(i, c)| format!("/H1 << /MCID {i} >> BDC {c} EMC")).collect();
@@ -1191,3 +1252,75 @@ fn horizontally_scaled_wrapped_heading_keeps_a_space_between_lines() {
let titles: Vec = headings(&doc).into_iter().map(|h| h.title).collect();
assert_eq!(titles, ["I Overview"]);
}
+
+#[test]
+fn repeated_content_references_share_one_decode_budget() {
+ use pdfcraft_cos::{Dict, MAX_PAGE_DECODED, Object, Stream};
+ let mut doc = Document::new_empty();
+ let stream = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ let page: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(stream); 300]))].into_iter().collect();
+ let data = crate::alt::page_content(&doc, &page);
+ assert!(data.len() <= MAX_PAGE_DECODED + 300, "{} bytes", data.len());
+}
+
+/// A figure whose marked content goes on in a stream past the page's decode budget is refused,
+/// not removed with that content still pointing at it.
+#[test]
+fn marking_decorative_refuses_content_past_the_decode_budget() {
+ use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
+ let mut doc = figures_doc();
+ let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ let last = doc.add(Stream::from_raw(Dict::new(), b"/Figure << /MCID 0 >> BDC 0 0 5 5 re f EMC".to_vec()));
+ let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
+ contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
+ contents.push(Object::Ref(last));
+ doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
+ assert!(mark_decorative(&mut doc, ObjRef::new(7, 0)).is_err());
+ assert_eq!(figures(&doc).len(), 2, "the figure stays");
+}
+
+/// Text past the page's decode budget is unread, so a page that shows images and no text so far
+/// can't be called image-only, nor its content all tagged.
+#[test]
+fn content_past_the_decode_budget_needs_a_manual_check() {
+ use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
+ let mut doc = pdf(
+ &[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
+ "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> /Font << /F1 6 0 R >> >> >>"
+ .into(),
+ stream("/Artifact BMC q 200 0 0 200 0 0 cm /Im Do Q EMC"),
+ "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Length 1 >>\nstream\n\u{0}\nendstream"
+ .into(),
+ FONT.into(),
+ ],
+ "",
+ );
+ let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ let text = doc.add(Stream::from_raw(Dict::new(), b"BT /F1 12 Tf 10 10 Td (Hello) Tj ET".to_vec()));
+ let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
+ contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
+ contents.push(Object::Ref(text));
+ doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), Object::Array(contents))).unwrap();
+ let report = check(&doc, &Options::default());
+ assert_eq!(status(&report, Rule::ImageOnly), Status::Manual);
+ assert_eq!(status(&report, Rule::TaggedContent), Status::Manual);
+}
+
+/// Marking a tag as an artifact replaces the page's content with what it decoded, so content past
+/// the page's decode budget is refused rather than dropped from the page.
+#[test]
+fn marking_a_tag_artifact_refuses_content_past_the_decode_budget() {
+ use pdfcraft_cos::{Dict, ObjRef, Object, Stream};
+ let mut doc = figures_doc();
+ let filler = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ let last = doc.add(Stream::from_raw(Dict::new(), b"0 0 5 5 re f".to_vec()));
+ let mut contents = vec![Object::Ref(ObjRef::new(4, 0))];
+ contents.extend(std::iter::repeat_n(Object::Ref(filler), 256));
+ contents.push(Object::Ref(last));
+ let contents = Object::Array(contents);
+ doc.update_dict(ObjRef::new(3, 0), |p| p.set(b"Contents".to_vec(), contents.clone())).unwrap();
+ assert_eq!(mark_tag_artifact(&mut doc, ObjRef::new(7, 0)), Err(ArtifactError::TooLarge));
+ assert_eq!(doc.get(ObjRef::new(3, 0)).as_dict().unwrap().get(b"Contents"), Some(&contents), "the page keeps its content");
+}
diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs
index 4668881a6..59cacbbdd 100644
--- a/crates/annot/src/appearance.rs
+++ b/crates/annot/src/appearance.rs
@@ -120,12 +120,18 @@ fn smooth_curve(pts: &[(f64, f64)]) -> String {
smooth_segments(pts).iter().map(|[a, b, e]| format!("{} {} {} {} {} {} c\n", n(a.0), n(a.1), n(b.0), n(b.1), n(e.0), n(e.1))).collect()
}
+/// Bumps one cloudy outline draws at most (plus one per edge); past it the bumps grow.
+pub(crate) const MAX_CLOUD_BUMPS: usize = 20_000;
+
/// A closed cloudy outline through `pts` (§12.5.4 `/BE /S /C`): each edge becomes a row of
/// half-circle bumps of about `r`, bulging outwards.
pub fn cloud_path(pts: &[(f64, f64)], r: f64) -> String {
let area: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| a.0 * b.1 - b.0 * a.1).sum();
// Outward normal of an edge: to the right of travel for counter-clockwise outlines.
let sign = if area >= 0.0 { 1.0 } else { -1.0 };
+ let perimeter: f64 = pts.iter().zip(pts.iter().cycle().skip(1)).map(|(a, b)| (b.0 - a.0).hypot(b.1 - a.1)).sum();
+ // Each edge rounds to at least one bump, so the total stays within the budget plus one per edge.
+ let r = r.max(perimeter / (2.0 * MAX_CLOUD_BUMPS as f64));
let mut c = String::new();
for (i, (a, b)) in pts.iter().zip(pts.iter().cycle().skip(1)).enumerate() {
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
diff --git a/crates/annot/src/links.rs b/crates/annot/src/links.rs
index 4d3eef632..657488137 100644
--- a/crates/annot/src/links.rs
+++ b/crates/annot/src/links.rs
@@ -78,11 +78,15 @@ pub struct LinkItem {
pub style: LinkStyle,
}
+/// Bytes of web-address text one listing keeps, counted after conversion to UTF-8; past it a link
+/// lists as [`LinkAction::Other`].
+pub(crate) const MAX_URI_BYTES: usize = 16 << 20;
+
fn nums(doc: &Document, o: Option<&Object>) -> Vec {
o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())).unwrap_or_default()
}
-fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
+fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef], uri_budget: &mut usize) -> LinkAction {
let dest_page = |dest: &Object| -> Option {
let a = doc.resolve(dest);
let first = a.as_array()?.first()?.clone();
@@ -97,9 +101,20 @@ fn action_of(doc: &Document, d: &Dict, pages: &[ObjRef]) -> LinkAction {
}
let Some(a) = d.get(b"A").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()) else { return LinkAction::Other("no action".into()) };
match a.name(b"S") {
- Some(b"URI") => LinkAction::Uri(
- a.get(b"URI").and_then(|u| doc.resolve(u).as_string().map(|s| String::from_utf8_lossy(&s.bytes).into_owned())).unwrap_or_default(),
- ),
+ Some(b"URI") => {
+ let uri = a.get(b"URI").map(|u| doc.resolve(u));
+ let bytes = uri.as_ref().and_then(|u| u.as_string()).map_or(&[][..], |s| &s.bytes[..]);
+ // The raw length first, so an address already over the budget is never converted; then the
+ // converted text, which is up to three times longer where the bytes are not UTF-8.
+ let text = (bytes.len() <= *uri_budget).then(|| String::from_utf8_lossy(bytes));
+ match text.and_then(|t| Some((uri_budget.checked_sub(t.len())?, t))) {
+ Some((left, text)) => {
+ *uri_budget = left;
+ LinkAction::Uri(text.into_owned())
+ }
+ None => LinkAction::Other("web address too long".into()),
+ }
+ }
Some(b"GoTo") => a.get(b"D").and_then(dest_page).map_or_else(|| LinkAction::Other("named destination".into()), LinkAction::Page),
Some(s) => LinkAction::Other(String::from_utf8_lossy(s).into_owned()),
None => LinkAction::Other("no action".into()),
@@ -133,8 +148,16 @@ fn style_of(doc: &Document, d: &Dict) -> LinkStyle {
pub fn list(doc: &Document) -> Vec {
let Ok(pages) = page_refs(doc) else { return Vec::new() };
let mut out = Vec::new();
+ let mut seen = std::collections::HashSet::new();
+ let mut uri_budget = MAX_URI_BYTES;
for (pi, p) in pages.iter().enumerate() {
for (k, a) in annots(doc, *p).iter().enumerate() {
+ // One annotation object listed under several entries or pages is one link.
+ if let Some(r) = a.as_ref()
+ && !seen.insert(r)
+ {
+ continue;
+ }
let Some(d) = doc.resolve(a).as_dict().cloned() else { continue };
if d.name(b"Subtype") != Some(b"Link") {
continue;
@@ -148,7 +171,7 @@ pub fn list(doc: &Document) -> Vec {
index: k,
obj: a.as_ref(),
rect: [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])],
- action: action_of(doc, &d, &pages),
+ action: action_of(doc, &d, &pages, &mut uri_budget),
style: style_of(doc, &d),
});
}
diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs
index a65996681..6eb94b830 100644
--- a/crates/annot/src/tests.rs
+++ b/crates/annot/src/tests.rs
@@ -1504,3 +1504,66 @@ fn typed_signature_uses_nonzero_winding_fill() {
assert!(ap.ends_with("h\nf\n"), "expected non-zero winding fill (`f`), got: {ap}");
assert!(!ap.contains("f*"), "must not use even-odd fill (`f*`): {ap}");
}
+
+fn doc_of(objs: &[String]) -> Document {
+ 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{o}\nendobj\n", i + 1).as_bytes());
+ }
+ 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());
+ Document::open(Arc::new(out)).expect("opens")
+}
+
+#[test]
+fn link_listing_skips_repeated_refs_and_caps_web_address_text() {
+ // One link referenced 1000 times, and 300 small links sharing one 64 KiB address.
+ let annots = format!("{}{}", " 4 0 R".repeat(1000), "<< /Subtype /Link /Rect [0 0 1 1] /A 5 0 R >>".repeat(300));
+ let doc = doc_of(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
+ format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
+ "<< /Type /Annot /Subtype /Link /Rect [0 0 10 10] /A 5 0 R >>".into(),
+ "<< /S /URI /URI 6 0 R >>".into(),
+ format!("({})", "u".repeat(64 << 10)),
+ ]);
+ let all = links::list(&doc);
+ assert_eq!(all.iter().filter(|l| l.obj == Some(ObjRef::new(4, 0))).count(), 1);
+ assert_eq!(all.len(), 301, "every distinct link is still listed");
+ let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
+ assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
+ assert!(matches!(&all[0].action, links::LinkAction::Uri(u) if u.len() == 64 << 10));
+}
+
+#[test]
+fn web_address_cap_counts_the_text_kept_after_conversion() {
+ // 200 links sharing a 64 KiB address of invalid UTF-8: 12.5 MiB of bytes, each byte kept as a
+ // three-byte replacement character.
+ let annots = "<< /Subtype /Link /Rect [0 0 1 1] /A 4 0 R >>".repeat(200);
+ let doc = doc_of(&[
+ "<< /Type /Catalog /Pages 2 0 R >>".into(),
+ "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] >>".into(),
+ format!("<< /Type /Page /Parent 2 0 R /Annots [{annots}] >>"),
+ "<< /S /URI /URI 5 0 R >>".into(),
+ format!("<{}>", "FF".repeat(64 << 10)),
+ ]);
+ let all = links::list(&doc);
+ assert_eq!(all.len(), 200);
+ let kept: usize = all.iter().map(|l| if let links::LinkAction::Uri(u) = &l.action { u.len() } else { 0 }).sum();
+ assert!(kept <= links::MAX_URI_BYTES, "{kept} bytes of web addresses kept");
+ assert!(kept > 0);
+}
+
+#[test]
+fn cloud_outline_has_a_bump_budget() {
+ // 50 long edges would each ask for 10,000 bumps of radius 1.
+ let pts: Vec<(f64, f64)> = (0..50).map(|i| (f64::from(i) * std::f64::consts::TAU / 50.0).sin_cos()).map(|(s, c)| (1e7 * c, 1e7 * s)).collect();
+ let bumps = appearance::cloud_path(&pts, 1.0).lines().filter(|l| l.ends_with(" c")).count();
+ assert!((pts.len()..=appearance::MAX_CLOUD_BUMPS + 2 * pts.len()).contains(&bumps), "{bumps} bumps");
+}
diff --git a/crates/audit/src/cover.rs b/crates/audit/src/cover.rs
index 3df666c19..93394ccfb 100644
--- a/crates/audit/src/cover.rs
+++ b/crates/audit/src/cover.rs
@@ -16,7 +16,7 @@
//! Positions compare lexicographically in paint order.
use pdfcraft_content::{Matrix, Op, Pieces, parse};
-use pdfcraft_cos::{Dict, Document, Object};
+use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::AuditError;
@@ -109,6 +109,9 @@ struct Walker<'a> {
pieces: &'a Pieces,
out: Vec,
ops_seen: usize,
+ /// Decoded bytes and form entries left for the page, its forms included: spending either
+ /// fails the audit.
+ budget: DecodeBudget,
}
impl<'a> Walker<'a> {
@@ -271,8 +274,11 @@ impl<'a> Walker<'a> {
})
.unwrap_or(Matrix::IDENTITY);
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
+ if !self.budget.enter_form() {
+ return Err(AuditError::TooManyForms);
+ }
// A form stream that cannot be decoded fails the audit: it might hide a cover.
- let data = stream.decoded_strict()?;
+ let data = self.budget.decode_strict(stream)?;
let ops = parse(&data).ops;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
@@ -304,7 +310,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
/// positions. A page's content streams are walked once, joined, with one graphics state, so
/// state set in an earlier stream is still in force in the later ones. Any content stream that
-/// cannot be decoded is an audit failure, never an empty result.
+/// cannot be decoded, or content and forms that decode or enter past one page's [`DecodeBudget`]
+/// in all, is an audit failure, never an empty result.
pub fn filled_rects(doc: &Document, page: &Dict) -> Result, AuditError> {
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let contents: Vec = match page.get(b"Contents") {
@@ -314,15 +321,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result, Audi
_ => vec![c.clone()],
},
};
+ let mut budget = DecodeBudget::page();
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
- streams.push(s.decoded_strict()?);
+ streams.push(budget.decode_strict(s)?);
}
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
- let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
+ let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
diff --git a/crates/audit/src/lib.rs b/crates/audit/src/lib.rs
index 365889d61..8277cb0aa 100644
--- a/crates/audit/src/lib.rs
+++ b/crates/audit/src/lib.rs
@@ -77,6 +77,8 @@ pub enum AuditError {
TooManyOps,
#[error("form XObjects nest deeper than the audit follows")]
TooDeep,
+ #[error("content walk entered more form XObjects than the audit follows")]
+ TooManyForms,
#[error("page has a malformed /Annots entry")]
BadAnnots,
#[error(transparent)]
diff --git a/crates/audit/src/tests.rs b/crates/audit/src/tests.rs
index 4ca3b0bf5..3d0c80e0b 100644
--- a/crates/audit/src/tests.rs
+++ b/crates/audit/src/tests.rs
@@ -592,3 +592,36 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
}
}
+
+#[test]
+fn repeated_stream_references_share_one_decode_budget() {
+ use pdfcraft_cos::{Dict, Document, Object, Stream};
+ // One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents,
+ // and by 300 draws of a form holding it.
+ let mut doc = Document::new_empty();
+ let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect();
+ assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams");
+
+ let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
+ let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20]));
+ let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes()));
+ let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
+ let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
+ let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
+ assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws");
+}
+
+#[test]
+fn form_entries_past_the_page_allowance_fail_the_audit() {
+ use pdfcraft_cos::{Dict, Document, Object, Stream};
+ // An empty form decodes to no bytes, so only the count of entries bounds drawing it again.
+ let mut doc = Document::new_empty();
+ let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
+ let form = doc.add(Stream::from_raw(form_dict, Vec::new()));
+ let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(pdfcraft_cos::MAX_PAGE_FORMS + 1).into_bytes()));
+ let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
+ let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
+ let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
+ assert!(matches!(crate::cover::filled_rects(&doc, &page), Err(crate::AuditError::TooManyForms)));
+}
diff --git a/crates/automation/README.md b/crates/automation/README.md
index 87111b2ea..7eb8ca119 100644
--- a/crates/automation/README.md
+++ b/crates/automation/README.md
@@ -128,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root
1. Add the engine capability first, with its tests (the tool is a thin adapter).
2. Add a `ToolDef` in `src/tools.rs`: a precise description, the schema, `ro()`/`destructive()`, and `cmd()` if a registry command exists.
3. Handle it in `Automation::call`, validating pages and positions with the existing helpers.
-4. Add an end-to-end test in `tests/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
+4. Add an end-to-end test in `tests/it/automation.rs`. `tool_table_is_well_formed` checks names, schemas and command links.
## Moving several page objects
diff --git a/crates/automation/src/a11y.rs b/crates/automation/src/a11y.rs
index 212454973..21bde353e 100644
--- a/crates/automation/src/a11y.rs
+++ b/crates/automation/src/a11y.rs
@@ -626,13 +626,17 @@ impl Automation {
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result {
let settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
- let id = self.doc(a)?.id;
+ let doc = self.doc(a)?;
+ // Recognizing needs only permission to change the document; the words it found are
+ // returned only when copying is allowed too.
+ let (id, copy) = (doc.id, doc.allows_copying());
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let list: Vec = found
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
- None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
+ None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
+ None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
})
.collect();
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::(), "pages": list }))
diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs
index c90240875..522705bb1 100644
--- a/crates/automation/src/lib.rs
+++ b/crates/automation/src/lib.rs
@@ -435,7 +435,7 @@ impl Automation {
"doc_revisions" => self.doc_revisions(&a)?,
"text_lines" => {
let page = self.page(&a)?;
- let doc = self.doc(&a)?;
+ let doc = self.doc_to_copy(&a)?;
let info = &doc.info.pages[page];
let r = |x: f32| (x as f64 * 100.0).round() / 100.0;
let lines: Vec = doc
@@ -709,7 +709,11 @@ impl Automation {
let moved = [block.rect[0] + dx, block.rect[1] + dy, block.rect[2] + dx, block.rect[3] + dy];
let wanted: String = text.split_whitespace().collect();
let mut out = self.apply(&a, Edit::EditTextBlock { page, block: index, text, style })?;
- let after = self.doc(&a)?.text_blocks(page);
+ let doc = self.doc(&a)?;
+ let after = doc.text_blocks(page);
+ // Without `text` the paragraph keeps the document's own words: not echoed when
+ // the document doesn't allow copying them.
+ let copy = doc.allows_copying();
// Only a paragraph that now shows exactly the requested text is the edited one. A
// neighbour that overlaps the old box (overlaid text, a text layer) must not take
// its number, and neither must a paragraph the edit merged it into: rewriting that
@@ -717,7 +721,10 @@ impl Automation {
let edited = block_overlapping(&after, moved)
.filter(|&i| !wanted.is_empty() && after.get(i).is_some_and(|b| b.text.split_whitespace().collect::() == wanted));
if let Some(b) = edited.and_then(|i| after.get(i)) {
- out["paragraph"] = json!({ "text": b.text, "lines": b.lines.len(), "font": b.base_font });
+ out["paragraph"] = json!({ "lines": b.lines.len(), "font": b.base_font });
+ if copy {
+ out["paragraph"]["text"] = json!(b.text);
+ }
}
let stale = self.follow_edit(id, page, k, edited.and_then(|i| after.get(i)), &after);
if !stale.is_empty() {
diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs
index 9ce6b12d4..4fff59c07 100644
--- a/crates/automation/src/tools.rs
+++ b/crates/automation/src/tools.rs
@@ -1172,7 +1172,7 @@ pub fn tools() -> Vec {
)),
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
- t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text, word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
+ t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
.cmd("ocr.recognize")
.with(schema(
json!({
@@ -1332,7 +1332,7 @@ pub fn tools() -> Vec {
.ro()
.cmd("edit.edit_text")
.with(schema(json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "paragraph": { "type": "integer", "minimum": 1 }, "text": { "type": "string" }, "size": { "type": "number" }, "width": { "type": "number" }, "font_resource":{"type":"string"}, "ranges":{"type":"array","maxItems":1024,"items":{"type":"object"}} }), &["doc", "page", "paragraph"])),
- t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.")
+ t("text_edit", "Edit text", "Replace the text of one paragraph (paragraph, from text_paragraphs: rewrapped to the paragraph's width with its line spacing) or one line (line, from text_lines) in place, keeping position, size and colour. For a mixed-style region, editor_text supplies visual row breaks; surviving text keeps its source-run styles and inserted text inherits the surrounding run. For a paragraph, also change its formatting: font (helvetica, times, courier) with bold/italic, size (points), color (#rrggbb), align (left, center, right, justify), underline, line_spacing (× size), char_spacing (points) and scale (horizontal, percent), or move it (dx, dy in points; up is +dy) and rewrap it to a new width (points); text may then be omitted. Its own font is reused when it can show every character and no font/weight change is requested; otherwise it uses a standard font matching the source style or, for text no standard font can show, real fallback outlines from PdfCraft's own faces, from the one that has every character: the craft-fonts Japanese faces (Mincho/Gothic matching the requested/source family and available weight) or, behind them, the bundled Inter face, which carries the Latin, Greek and Cyrillic letters they lack (Uzbek қ, Kazakh ә). The result shows the font used, and for a paragraph its text unless the document's security doesn't allow copying. Missing Japanese weights use Regular, not synthetic bold; the small web build has only Gothic Regular. Paragraph bold may be set without font to keep the source family. Text that no available font can show is refused. A paragraph number is refused when an earlier edit merged that paragraph into a neighbour (list the page again); the result's stale_paragraphs names the numbers gone. Undoable.")
.cmd("edit.edit_text")
.with(schema(
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/it/automation.rs
similarity index 99%
rename from crates/automation/tests/automation.rs
rename to crates/automation/tests/it/automation.rs
index bf6169f42..778deaaec 100644
--- a/crates/automation/tests/automation.rs
+++ b/crates/automation/tests/it/automation.rs
@@ -539,7 +539,7 @@ fn tool_table_is_well_formed() {
#[test]
fn explicit_font_and_disjoint_ranges_save_reopen_through_tools() {
let dir = workdir("font-ranges");
- std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../assets/fonts/Inter-Regular.ttf")).unwrap();
+ std::fs::write(dir.join("inter.ttf"), include_bytes!("../../../../assets/fonts/Inter-Regular.ttf")).unwrap();
let mut a = auto(&dir);
std::fs::write(dir.join("broken.otf"), b"not a font").unwrap();
let found = ok(&mut a, "text_font_files", json!({}));
@@ -2130,6 +2130,44 @@ fn protecting_through_tools() {
ok(&mut c, "doc_open", json!({ "path": "open.pdf" }));
}
+/// A document that allows changes but not copying: the tools that return a page's text refuse,
+/// or leave the text out where the tool also changes the page (a restyled paragraph, recognized
+/// words).
+#[test]
+fn copy_restricted_text_stays_in_the_document() {
+ let dir = workdir("copy-restricted-text");
+ let mut a = auto(&dir);
+ let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
+ let locked = json!({ "open_password": "open", "permissions_password": "boss", "changes": "any-except-extract" });
+ let mut protect = locked.clone();
+ protect["doc"] = json!(doc);
+ ok(&mut a, "doc_protect", protect);
+ ok(&mut a, "doc_save", json!({ "doc": doc, "path": "locked.pdf" }));
+ let re = ok(&mut a, "doc_open", json!({ "path": "locked.pdf", "password": "open" }))["doc"].as_u64().unwrap();
+ match a.call("text_lines", &json!({ "doc": re, "page": 1 })) {
+ Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"),
+ other => panic!("text_lines is refused: {other:?}"),
+ }
+ let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 }));
+ assert!(!restyled.to_string().contains("Page"), "{restyled}");
+ if ok(&mut a, "ocr_status", json!({}))["available"] != true {
+ eprintln!("skipped the OCR part: OCR models not installed");
+ return;
+ }
+ let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Recognised words stay private" }))["doc"].as_u64().unwrap();
+ ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] }));
+ let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
+ let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
+ let mut protect = locked;
+ protect["doc"] = json!(scan);
+ ok(&mut a, "doc_protect", protect);
+ ok(&mut a, "doc_save", json!({ "doc": scan, "path": "scan.pdf" }));
+ let scan = ok(&mut a, "doc_open", json!({ "path": "scan.pdf", "password": "open" }))["doc"].as_u64().unwrap();
+ let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan }));
+ assert!(r["words"].as_u64().unwrap() >= 3, "{r}");
+ assert!(r["pages"][0].get("text").is_none() && !r.to_string().to_lowercase().contains("private"), "{r}");
+}
+
/// A form secured with "fill-sign" changes and no open password (how secured forms are usually
/// distributed): its existing signature field can be signed without the permissions password,
/// the update keeps the encryption, and the signature validates after reopening. A new field
diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/it/automation_roots.rs
similarity index 100%
rename from crates/automation/tests/automation_roots.rs
rename to crates/automation/tests/it/automation_roots.rs
diff --git a/crates/automation/tests/batch.rs b/crates/automation/tests/it/batch.rs
similarity index 100%
rename from crates/automation/tests/batch.rs
rename to crates/automation/tests/it/batch.rs
diff --git a/crates/automation/tests/button_caption.rs b/crates/automation/tests/it/button_caption.rs
similarity index 100%
rename from crates/automation/tests/button_caption.rs
rename to crates/automation/tests/it/button_caption.rs
diff --git a/crates/automation/tests/form_bulk.rs b/crates/automation/tests/it/form_bulk.rs
similarity index 100%
rename from crates/automation/tests/form_bulk.rs
rename to crates/automation/tests/it/form_bulk.rs
diff --git a/crates/automation/tests/hostile_args.rs b/crates/automation/tests/it/hostile_args.rs
similarity index 100%
rename from crates/automation/tests/hostile_args.rs
rename to crates/automation/tests/it/hostile_args.rs
diff --git a/crates/automation/tests/it/main.rs b/crates/automation/tests/it/main.rs
new file mode 100644
index 000000000..8bb3c7781
--- /dev/null
+++ b/crates/automation/tests/it/main.rs
@@ -0,0 +1,9 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod automation;
+mod automation_roots;
+mod batch;
+mod button_caption;
+mod form_bulk;
+mod hostile_args;
+mod optimize_content_boundaries;
diff --git a/crates/automation/tests/optimize_content_boundaries.rs b/crates/automation/tests/it/optimize_content_boundaries.rs
similarity index 100%
rename from crates/automation/tests/optimize_content_boundaries.rs
rename to crates/automation/tests/it/optimize_content_boundaries.rs
diff --git a/crates/content/Cargo.toml b/crates/content/Cargo.toml
index c553df0b8..e6b4c28a4 100644
--- a/crates/content/Cargo.toml
+++ b/crates/content/Cargo.toml
@@ -10,3 +10,6 @@ workspace = true
[dependencies]
pdfcraft-cos = { workspace = true }
+# The renderer's content tokenizer, already in the tree through pdfcraft-cos: inline images end
+# where the renderer ends them.
+hayro-syntax = { workspace = true }
diff --git a/crates/content/README.md b/crates/content/README.md
index 6ab984dd4..fedeadc56 100644
--- a/crates/content/README.md
+++ b/crates/content/README.md
@@ -13,7 +13,10 @@ The parser is tolerant (unreadable bytes are skipped and counted, never fatal) a
panics. `redact` and (later) `edit` build their interpreters on it; the graphics-state tracker
and the `Patch` API of architecture §6 grow here.
-Unfiltered inline images with built-in or Indexed colour spaces, and image masks, use their
-sample layout (including per-row byte padding) to locate `EI`. This preserves pixel bytes
-that look like the terminator. Filtered images and resource-defined colour spaces retain
-the tolerant delimiter heuristic; damaged or truncated layouts fall back to it too.
+An inline image ends where the renderer's tokenizer (`hayro-syntax`) ends it, so the parser
+and the renderer read the same operators after the image, and redaction removes what the page
+actually draws. Only where that tokenizer reads no image (its parameters don't read, or no `EI`
+passes its checks) does the parser locate `EI` by its own rules: unfiltered images with built-in
+or Indexed colour spaces, and image masks, by their sample layout (including per-row byte
+padding), other images by the tolerant delimiter heuristic. Such an image is counted in
+`Parsed::ambiguous_images`, because the operators after it may not be the ones the page draws.
diff --git a/crates/content/src/lib.rs b/crates/content/src/lib.rs
index eb126ffa2..0459fbe89 100644
--- a/crates/content/src/lib.rs
+++ b/crates/content/src/lib.rs
@@ -60,6 +60,10 @@ pub struct Parsed {
pub ops: Vec,
/// Bytes that could not be read as operands or operators.
pub skipped: usize,
+ /// Inline images the renderer's tokenizer reads no image for (it stops, or finds no `EI` it
+ /// accepts). Their end here is a guess, so the operators after them may not be the ones the
+ /// page draws.
+ pub ambiguous_images: usize,
/// For [`Pieces::parse`]: per piece, whether a comment runs into it from the piece before
/// and whether one runs out of it into the next. Only boundaries are recorded, so the size
/// depends on the number of pieces, not of comments.
@@ -74,6 +78,9 @@ fn is_delim(b: u8) -> bool {
matches!(b, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
}
+/// Longest unreadable number token retried from its next byte. Real numbers are far shorter.
+const MAX_RETRIED_TOKEN: usize = 64;
+
/// Parse a (decoded) content stream.
pub fn parse(data: &[u8]) -> Parsed {
parse_pieces(data, &[])
@@ -110,9 +117,19 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
lx.pos = data.len();
}
Err(_) => {
- // Skip the offending byte and carry on.
- lx.pos = at + 1;
- out.skipped += 1;
+ let read = lx.pos.saturating_sub(at);
+ #[cfg(test)]
+ tests::RESCANNED.with(|c| c.set(c.get() + read));
+ let number = matches!(b, b'+' | b'-' | b'.') || b.is_ascii_digit();
+ if number && read > MAX_RETRIED_TOKEN {
+ // A long unreadable number is skipped whole: retrying from each of its
+ // bytes would read the rest of it again every time.
+ out.skipped += read;
+ } else {
+ // Skip the offending byte and carry on.
+ lx.pos = at + 1;
+ out.skipped += 1;
+ }
}
}
continue;
@@ -142,19 +159,27 @@ fn parse_pieces(data: &[u8], ends: &[usize]) -> Parsed {
start.get_or_insert(at);
operands.push(Object::Null);
}
- b"BI" => match inline_image(data, lx.pos) {
- Ok((dict, img, end)) => {
- lx.pos = end;
- out.ops.push(Op { op: b"BI".to_vec(), operands: Vec::new(), inline: Some((dict, img)), span: start.take().unwrap_or(at)..end });
- operands.clear();
+ b"BI" => {
+ let image = inline_image(data, at, lx.pos);
+ out.ambiguous_images += usize::from(image.ambiguous);
+ let end = image.end;
+ lx.pos = end;
+ match image.image {
+ Some((dict, img)) => {
+ out.ops.push(Op {
+ op: b"BI".to_vec(),
+ operands: Vec::new(),
+ inline: Some((dict, img)),
+ span: start.take().unwrap_or(at)..end,
+ });
+ }
+ None => {
+ out.skipped += end.saturating_sub(at);
+ start = None;
+ }
}
- Err(end) => {
- out.skipped += end.saturating_sub(at);
- lx.pos = end;
- start = None;
- operands.clear();
- }
- },
+ operands.clear();
+ }
_ => {
let s = start.take().unwrap_or(at);
out.ops.push(Op { op: kw.to_vec(), operands: std::mem::take(&mut operands), inline: None, span: s..lx.pos });
@@ -198,11 +223,29 @@ fn comment_edges(data: &[u8], trivia: std::ops::Range, ends: &[usize], bo
}
}
-/// Read an inline image starting after `BI`: (parameters, data, position after `EI`). `Err` holds
-/// the position after the `EI` (or the end of the stream) of an image whose parameters can't be read.
-fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize> {
+/// An inline image as [`inline_image`] reads it.
+struct Inline {
+ /// Its parameters and data; `None` when the parameters can't be read.
+ image: Option<(Dict, Vec)>,
+ /// The position after its `EI` (or the end of the stream).
+ end: usize,
+ /// The renderer's tokenizer reads no image here, so `end` is this parser's guess.
+ ambiguous: bool,
+}
+
+/// Read the inline image whose `BI` keyword starts at `bi` (its parameters start at `pos`).
+///
+/// Filtered image data has no length, and `EI` can occur inside it, so where it ends is a
+/// heuristic choice among `EI` candidates. The renderer's choice decides which operators the
+/// page draws after the image, and redaction must see the same ones, so the end is taken from
+/// the renderer's tokenizer (hayro-syntax) rather than worked out here by another rule. Only
+/// when that tokenizer reads no image does this parser fall back to its own rules, and the
+/// image is marked ambiguous.
+fn inline_image(data: &[u8], bi: usize, pos: usize) -> Inline {
let mut lx = Lexer::new(data, pos);
let mut dict = Dict::new();
+ let mut readable = true;
+ let mut stop = pos;
loop {
lx.skip_ws();
if lx.eat_keyword(b"ID") {
@@ -216,22 +259,68 @@ fn inline_image(data: &[u8], pos: usize) -> Result<(Dict, Vec, usize), usize
let Some((k, v)) = entry else {
// A stream that ends inside the parameters is cut short, not unreadable: keep what was read.
if at >= data.len() {
- return Ok((dict, Vec::new(), data.len()));
+ return Inline { image: Some((dict, Vec::new())), end: data.len(), ambiguous: false };
}
- // Without an `EI` after it, the image runs to the end of the stream.
- return Err(end_of_image(data, at, b"").map_or(data.len(), |(_, end)| end));
+ readable = false;
+ stop = at;
+ break;
};
dict.set(k, v);
}
+ if let Some((start, end_data, end)) = renderer_image_end(data, bi) {
+ // Where the renderer ends the data at an `EI` found by its heuristic, one whitespace byte
+ // before that `EI` separates it from the data.
+ let mut samples = data.get(start..end_data).unwrap_or_default();
+ if data.get(end_data..).is_some_and(|rest| rest.starts_with(b"EI"))
+ && let Some((last, rest)) = samples.split_last()
+ && is_ws(*last)
+ {
+ samples = rest;
+ }
+ return Inline { image: readable.then(|| (dict, samples.to_vec())), end, ambiguous: false };
+ }
+ if !readable {
+ // Without an `EI` after it, the image runs to the end of the stream.
+ return Inline { image: None, end: end_of_image(data, stop, b"").map_or(data.len(), |(_, end)| end), ambiguous: true };
+ }
// One whitespace byte separates ID from the data.
let begin = (lx.pos + 1).min(data.len());
let end = unfiltered_image_end(data, begin, &dict)
.or_else(|| declared_image_end(data, begin, &dict))
.or_else(|| image_data_end(data, begin, filter_eod(&dict)));
- Ok(match end {
- Some((end_data, end)) => (dict, data[begin..end_data].to_vec(), end),
- None => (dict, data[begin..].to_vec(), data.len()),
- })
+ let (image, end) = match end {
+ Some((end_data, end)) => (data.get(begin..end_data).unwrap_or_default().to_vec(), end),
+ None => (data.get(begin..).unwrap_or_default().to_vec(), data.len()),
+ };
+ Inline { image: Some((dict, image)), end, ambiguous: true }
+}
+
+/// Where the renderer's tokenizer ends the inline image whose `BI` starts at `bi`: the start and
+/// end of its data and the position after its `EI`. `None` when it reads no image there (its
+/// parameters don't read, or no `EI` passes its checks).
+fn renderer_image_end(data: &[u8], bi: usize) -> Option<(usize, usize, usize)> {
+ use hayro_syntax::content::TypedIter;
+ use hayro_syntax::content::ops::{InlineImage, TypedInstruction};
+ let tail = data.get(bi..)?;
+ let mut iter = TypedIter::new(tail);
+ let (start, len) = match iter.next()? {
+ TypedInstruction::InlineImage(InlineImage(stream)) => match stream.raw_data() {
+ // The data is a slice of `tail`: its offset is where it starts.
+ std::borrow::Cow::Borrowed(samples) => ((samples.as_ptr() as usize).checked_sub(tail.as_ptr() as usize)?, samples.len()),
+ std::borrow::Cow::Owned(_) => return None,
+ },
+ _ => return None,
+ };
+ let end_data = start.checked_add(len)?;
+ // `committed` is past the whitespace after `EI`; the image ends at the `EI`.
+ let mut end = iter.committed();
+ while end > end_data && tail.get(end - 1).is_some_and(|b| is_ws(*b)) {
+ end -= 1;
+ }
+ if end < end_data.checked_add(2)? || tail.get(end - 2..end) != Some(b"EI") {
+ return None;
+ }
+ Some((bi.checked_add(start)?, bi.checked_add(end_data)?, bi.checked_add(end)?))
}
/// The end-of-data marker (`>` for ASCIIHex, `~>` for ASCII85) of the outer stream filter, if any.
@@ -491,9 +580,20 @@ impl Pieces {
/// for byte. Returns every piece's new bytes (unchanged pieces come back equal).
pub fn splice(&self, edits: impl IntoIterator- , Vec
)>) -> Vec> {
let mut out: Vec> = (0..self.len()).map(|i| Vec::with_capacity(self.range(i).len())).collect();
+ // Only the pieces from the one holding `from` up to `to`: one call per edit must not
+ // visit every piece.
let copy = |out: &mut Vec>, from: usize, to: usize| {
- for (i, piece) in out.iter_mut().enumerate() {
+ if from >= to {
+ return;
+ }
+ let first = self.piece_of(from);
+ for (i, piece) in out.iter_mut().enumerate().skip(first) {
+ #[cfg(test)]
+ tests::PIECE_VISITS.with(|c| c.set(c.get() + 1));
let r = self.range(i);
+ if r.start >= to {
+ break;
+ }
let (a, b) = (from.max(r.start), to.min(r.end));
if a < b {
piece.extend_from_slice(self.data.get(a..b).unwrap_or_default());
diff --git a/crates/content/src/tests.rs b/crates/content/src/tests.rs
index 094a0665d..e84e29ad1 100644
--- a/crates/content/src/tests.rs
+++ b/crates/content/src/tests.rs
@@ -1,5 +1,51 @@
use super::*;
+thread_local! {
+ /// Bytes the lexer read before an operand failed, on this thread.
+ pub(crate) static RESCANNED: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+thread_local! {
+ /// Pieces [`Pieces::splice`] looked at while copying, on this thread.
+ pub(crate) static PIECE_VISITS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+#[test]
+fn splicing_visits_each_piece_a_few_times() {
+ let n = 5_000;
+ let pieces: Vec = (0..n).map(|i| format!("({i}) Tj")).collect();
+ let joined = Pieces::join(&pieces);
+ let parsed = joined.parse();
+ let edits: Vec<_> = parsed.ops.iter().map(|op| (op.span.clone(), b"(x) Tj".to_vec())).collect();
+ assert_eq!(edits.len(), n);
+ PIECE_VISITS.with(|c| c.set(0));
+ let out = joined.splice(edits);
+ let visits = PIECE_VISITS.with(|c| c.get());
+ assert!(visits <= 4 * n, "{visits} piece visits for {n} pieces");
+ assert!(out.iter().all(|p| p == b"(x) Tj"));
+ // A replacement spanning two pieces stays whole in the first and leaves the rest of the second.
+ let two = Pieces::join(&["q (a)", "Tj Q"]);
+ let tj = two.parse().ops.into_iter().find(|o| o.is("Tj")).map(|o| o.span).unwrap_or_default();
+ assert_eq!(two.splice([(tj, b"(b) Tj".to_vec())]), [b"q (b) Tj".to_vec(), b" Q".to_vec()]);
+}
+
+#[test]
+fn a_long_unreadable_number_is_read_once() {
+ for junk in ["+", "5"] {
+ let n = 20_000;
+ let src = format!("{}{} 1 0 0 1 0 0 cm", junk.repeat(n), if junk == "5" { "x" } else { "" });
+ RESCANNED.with(|c| c.set(0));
+ let p = parse(src.as_bytes());
+ let rescanned = RESCANNED.with(|c| c.get());
+ assert!(rescanned <= 2 * n, "{rescanned} bytes read for a {n}-byte token");
+ assert_eq!(p.ops.last().and_then(|o| o.nums::<6>()), Some([1.0, 0.0, 0.0, 1.0, 0.0, 0.0]));
+ }
+ // A short damaged number still recovers byte by byte.
+ let p = parse(b"-+5 0 Td");
+ assert_eq!((p.ops.len(), p.skipped), (1, 1));
+ assert_eq!(p.ops[0].nums::<2>(), Some([5.0, 0.0]));
+}
+
#[test]
fn operators_keep_operands_and_spans() {
let src = b"q 1 0 0 1 72 720 cm BT /F1 12 Tf (Hello \\(world\\)) Tj [(A) -120 (B)] TJ ET Q";
@@ -102,10 +148,11 @@ fn unfiltered_inline_masks_default_to_one_bit_and_indexed_images_have_one_compon
let parsed = inline_pixels(parameters.as_bytes(), b" EI ");
assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
assert_eq!(parsed.ops.len(), 3);
- // Indexed samples permit at most 8 bits, even though device spaces permit 16.
+ // Indexed samples permit at most 8 bits, even though device spaces permit 16, so the
+ // length is unknown, and the renderer takes the second `EI`.
let invalid = parameters.replace("/W 4", "/W 2").replace("/BPC 8", "/BPC 16");
let parsed = inline_pixels(invalid.as_bytes(), b" EI ");
- assert!(parsed.ops[1].inline.as_ref().unwrap().1.is_empty());
+ assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b" EI ");
}
}
@@ -127,7 +174,7 @@ fn unfiltered_inline_image_terminator_must_follow_its_samples() {
}
#[test]
-fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
+fn filtered_unknown_and_damaged_inline_images_end_where_the_renderer_ends_them() {
for parameters in [
b"/W 4 /H 1 /BPC 8 /CS /G /F /A85".as_slice(),
b"/W 4 /H 1 /BPC 8 /CS /ResourceColor",
@@ -142,12 +189,20 @@ fn filtered_unknown_and_damaged_inline_images_keep_the_existing_fallback() {
b"/W 9223372036854775807 /H 9223372036854775807 /BPC 16 /CS /CMYK",
b"/W 9223372036854775807 /H 2 /BPC 8 /CS /G",
] {
+ // What follows the first `EI` (`!!~>`) does not read as content, so the renderer takes
+ // the later one.
let parsed = inline_pixels(parameters, b"! EI !!~>");
- assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"!", "{}", String::from_utf8_lossy(parameters));
+ let label = String::from_utf8_lossy(parameters);
+ assert_eq!(parsed.ops[1].inline.as_ref().unwrap().1, b"! EI !!~>", "{label}");
+ assert_eq!(parsed.ops.iter().map(|op| op.op.as_slice()).collect::>(), [b"q".as_slice(), b"BI", b"Q"], "{label}");
+ assert_eq!(parsed.ambiguous_images, 0, "{label}");
}
- // A short payload still uses the tolerant recovery, including when there is no EI.
- for src in [b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc"] {
- assert_eq!(parse(src).ops[0].inline.as_ref().unwrap().1, b"abc");
+ // A short payload is read whole; without an `EI`, the renderer reads no image and this
+ // parser's end is a guess.
+ for (src, ambiguous) in [(b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc\nEI Q".as_slice(), 0), (b"BI /W 4 /H 1 /BPC 8 /CS /G ID abc", 1)] {
+ let parsed = parse(src);
+ assert_eq!(parsed.ops[0].inline.as_ref().unwrap().1, b"abc");
+ assert_eq!(parsed.ambiguous_images, ambiguous);
}
}
@@ -459,3 +514,38 @@ fn ascii85_inline_image_does_not_terminate_at_gt_ei_inside_payload() {
assert_eq!(img, b"!!!!>EI !!~>");
}
}
+
+/// The operators the renderer's tokenizer reads from `data`.
+fn renderer_operators(data: &[u8]) -> Vec> {
+ let mut iter = hayro_syntax::content::UntypedIter::new(data);
+ let mut out = Vec::new();
+ while let Some(instruction) = iter.next() {
+ out.push(instruction.operator.as_ref().to_vec());
+ }
+ out
+}
+
+/// Inline images end where the renderer ends them, so both read the same operators after one:
+/// an `EI` without whitespace before it in the data, upper-case text ending in "EI" after the
+/// image, and an `EI` inside compressed bytes (#3579).
+#[test]
+fn inline_images_end_where_the_renderer_ends_them() {
+ let flate: Vec = (0..FLATE_WITH_EI.len()).step_by(2).map(|i| u8::from_str_radix(&FLATE_WITH_EI[i..i + 2], 16).unwrap()).collect();
+ let mut stored = b"q BI /W 64 /H 1 /BPC 8 /CS /G /F /Fl ID ".to_vec();
+ stored.extend_from_slice(&flate);
+ stored.extend_from_slice(b"\nEI Q BT /F1 24 Tf 72 200 Td (AFTER) Tj ET");
+ let fixtures: [&[u8]; 6] = [
+ b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
+ b"BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7F> EI BT /F1 10 Tf 10 100 Td (ZWEI) Tj (EIN FREI BEI) Tj ET",
+ b"q BI /W 2 /H 1 /CS /G /BPC 8 /F /AHx ID 7F7F> EI Q BT (DREI) Tj ET BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI",
+ b"BI /W 4 /H 1 /BPC 8 /CS /G /F /A85 ID ! EI !!~>\nEI Q",
+ b"q BI /W 4 /H 1 /BPC 8 /CS /G ID EI \nEI Q",
+ &stored,
+ ];
+ for data in fixtures {
+ let parsed = parse(data);
+ let ours: Vec> = parsed.ops.iter().map(|op| op.op.clone()).collect();
+ assert_eq!(ours, renderer_operators(data), "{}", String::from_utf8_lossy(data));
+ assert_eq!((parsed.skipped, parsed.ambiguous_images), (0, 0), "{}", String::from_utf8_lossy(data));
+ }
+}
diff --git a/crates/cos/examples/protect.rs b/crates/cos/examples/protect.rs
index 6f27a6096..48498aefa 100644
--- a/crates/cos/examples/protect.rs
+++ b/crates/cos/examples/protect.rs
@@ -1,4 +1,17 @@
//! Encrypt a PDF: `cargo run -p pdfcraft-cos --example protect in.pdf out.pdf rc4-40|rc4-128|aes-128|aes-256 USER OWNER`
+/// 32 fresh bytes for the file key. `RandomState` is seeded by the operating system (as the
+/// engine's `entropy` uses it), so every run encrypts with a different key.
+fn seed() -> [u8; 32] {
+ use std::hash::{BuildHasher, Hasher};
+ let mut out = [0u8; 32];
+ for (i, chunk) in out.chunks_mut(8).enumerate() {
+ let mut h = std::collections::hash_map::RandomState::new().build_hasher();
+ h.write_usize(i);
+ chunk.copy_from_slice(&h.finish().to_le_bytes());
+ }
+ out
+}
+
fn main() {
let a: Vec = std::env::args().skip(1).collect();
let [input, output, alg, user, owner] = &a[..] else { panic!("usage: protect in out alg user owner") };
@@ -15,7 +28,7 @@ fn main() {
owner_password: owner,
permissions: -1,
encrypt_metadata: true,
- seed: [1; 32],
+ seed: seed(),
})
.unwrap();
std::fs::write(output, pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()).unwrap();
diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs
new file mode 100644
index 000000000..1ddd42eaf
--- /dev/null
+++ b/crates/cos/src/budget.rs
@@ -0,0 +1,220 @@
+//! A shared allowance for operations that decode many streams.
+
+use std::collections::HashSet;
+
+use pdfcraft_filters::FilterError;
+
+use crate::{CosError, Document, ObjRef, Object, Stream};
+
+/// 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;
+
+/// 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, usize), (FilterError, usize)>;
+
+/// 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
+/// `/Contents` names the same compressed stream thousands of times, or a form drawn thousands of
+/// times from other forms: each occurrence would get a fresh allowance. A `DecodeBudget` is
+/// shared by every decode of one operation, so the total decoded bytes and form entries stay
+/// under fixed limits however the file repeats its references. A decode that runs past the
+/// remaining allowance spends all of it, so a bomb referenced many times is inflated once.
+#[derive(Debug)]
+pub struct DecodeBudget {
+ /// The whole allowance, for errors.
+ limit: usize,
+ bytes: usize,
+ forms: usize,
+ seen: HashSet,
+}
+
+impl DecodeBudget {
+ /// An allowance of `bytes` decoded bytes and `forms` form entries.
+ pub fn new(bytes: usize, forms: usize) -> Self {
+ Self { limit: bytes, bytes, forms, seen: HashSet::new() }
+ }
+
+ /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms.
+ pub fn page() -> Self {
+ Self::new(MAX_PAGE_DECODED, MAX_PAGE_FORMS)
+ }
+
+ /// Decoded bytes still allowed.
+ pub fn remaining(&self) -> usize {
+ self.bytes
+ }
+
+ /// Whether a decode was refused for lack of allowance or a form entry for lack of visits.
+ pub fn exhausted(&self) -> bool {
+ self.bytes == 0 || self.forms == 0
+ }
+
+ /// `stream` decoded like [`Stream::decoded`], charged to the allowance. Fails, without
+ /// decoding, once the allowance is spent.
+ pub fn decode(&mut self, stream: &Stream) -> Result, CosError> {
+ self.charge(stream, usize::MAX, Stream::decoded_within_counted)
+ }
+
+ /// Like [`DecodeBudget::decode`], for a stream with a tighter bound of its own: decoding
+ /// past `max` bytes is an error that charges `max`, the work it took to find out.
+ pub fn decode_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> {
+ self.charge(stream, max, Stream::decoded_within_counted)
+ }
+
+ /// `stream` decoded like [`Stream::decoded_strict`] (any corruption is an error), charged to
+ /// the allowance.
+ pub fn decode_strict(&mut self, stream: &Stream) -> Result, CosError> {
+ self.charge(stream, usize::MAX, Stream::decoded_strict_within_counted)
+ }
+
+ /// Decodes `stream` within `max` and the allowance, charging what every filter stage
+ /// decoded: a stage's output counts even when a later stage shrinks it, and a corrupt stream
+ /// pays for the bytes decoded before the error, so referencing a stream again and again
+ /// spends the allowance by the work each decode does.
+ fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result, CosError> {
+ if self.bytes == 0 && !stream.raw.is_empty() {
+ return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()));
+ }
+ if max < self.bytes {
+ let result = decode(stream, max);
+ let spent = match &result {
+ Ok((_, spent)) => *spent,
+ Err((FilterError::LimitExceeded(_), _)) => max,
+ Err((_, spent)) => *spent,
+ };
+ self.bytes = self.bytes.saturating_sub(spent);
+ return result.map(|(data, _)| data).map_err(|(e, _)| CosError::Filter(e.to_string()));
+ }
+ match decode(stream, self.bytes) {
+ Ok((data, spent)) => {
+ self.bytes = self.bytes.saturating_sub(spent);
+ Ok(data)
+ }
+ Err((FilterError::LimitExceeded(_), _)) => {
+ self.bytes = 0;
+ Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string()))
+ }
+ Err((e, spent)) => {
+ self.bytes = self.bytes.saturating_sub(spent);
+ Err(CosError::Filter(e.to_string()))
+ }
+ }
+ }
+
+ /// Counts one form entry. `false` once [`DecodeBudget::new`]'s `forms` entries are spent:
+ /// the caller leaves that form out.
+ pub fn enter_form(&mut self) -> bool {
+ match self.forms.checked_sub(1) {
+ Some(left) => {
+ self.forms = left;
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// `true` the first time `r` is passed to this budget, for callers that need each
+ /// referenced stream only once.
+ pub fn first_visit(&mut self, r: ObjRef) -> bool {
+ self.seen.insert(r)
+ }
+
+ /// Each stream `contents` names, decoded against the allowance, in order. `contents` is a
+ /// page's `/Contents`: one stream, or an array whose non-stream entries are left out.
+ /// A stream that fails to decode, or arrives after the allowance is spent, is an `Err` in its
+ /// place, so the caller chooses between skipping it and giving up.
+ pub fn decode_contents(&mut self, doc: &Document, contents: &Object) -> Vec, CosError>> {
+ match &*doc.resolve(contents) {
+ Object::Stream(s) => vec![self.decode(s)],
+ Object::Array(list) => list
+ .iter()
+ .filter_map(|entry| match &*doc.resolve(entry) {
+ Object::Stream(s) => Some(self.decode(s)),
+ _ => None,
+ })
+ .collect(),
+ _ => Vec::new(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::Dict;
+
+ #[test]
+ fn repeated_references_share_one_allowance() {
+ let mut doc = Document::new_empty();
+ let bomb = doc.add(Object::Stream(Stream::flate(Dict::new(), &vec![b' '; 1 << 20])));
+ let contents = Object::Array(vec![Object::Ref(bomb); 64]);
+ let mut budget = DecodeBudget::new(10 << 20, 1);
+ let decoded = budget.decode_contents(&doc, &contents);
+ assert_eq!(decoded.len(), 64);
+ assert_eq!(decoded.iter().filter(|d| d.is_ok()).count(), 10);
+ let total: usize = decoded.iter().flatten().map(Vec::len).sum();
+ assert_eq!(total, 10 << 20);
+ assert!(budget.exhausted());
+ // A decode past the allowance spends it, so a bomb is inflated at most once more.
+ let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
+ assert!(budget.decode_contents(&doc, &contents).iter().all(Result::is_err));
+ assert_eq!(budget.remaining(), 0);
+ let mut budget = DecodeBudget::new((1 << 20) - 1, 1);
+ let Object::Stream(stream) = &*doc.get(bomb) else { panic!("not a stream") };
+ assert!(budget.decode_strict(stream).is_err());
+ assert_eq!(budget.remaining(), 0);
+
+ let mut budget = DecodeBudget::new(10 << 20, 1);
+ assert!(budget.decode_within(stream, 1000).is_err());
+ assert_eq!(budget.remaining(), (10 << 20) - 1000);
+ assert_eq!(budget.decode_within(stream, 2 << 20).map(|d| d.len()).ok(), Some(1 << 20));
+ assert_eq!(budget.remaining(), (9 << 20) - 1000);
+
+ assert!(budget.enter_form());
+ assert!(!budget.enter_form());
+ assert!(budget.first_visit(bomb));
+ assert!(!budget.first_visit(bomb));
+ }
+
+ /// A corrupt stream fails a strict decode after decoding part of it: each reference pays
+ /// for that part, so references to it can't decode without limit.
+ #[test]
+ fn failed_strict_decodes_charge_what_they_decoded() {
+ let whole = Stream::flate(Dict::new(), &vec![b' '; 1 << 20]);
+ let cut = whole.raw.len() / 2;
+ let corrupt = Stream::from_raw(whole.dict.clone(), whole.raw.get(..cut).unwrap_or_default().to_vec());
+ let partial = corrupt.decoded().map(|d| d.len()).unwrap_or(0);
+ assert!(partial > 0, "a cut flate stream decodes in part");
+ let mut budget = DecodeBudget::new(10 << 20, 1);
+ assert!(budget.decode_strict(&corrupt).is_err());
+ assert_eq!(budget.remaining(), (10 << 20) - partial);
+ let mut budget = DecodeBudget::new(10 << 20, 1);
+ assert!(budget.decode_within(&corrupt, 1 << 30).is_ok());
+ assert_eq!(budget.remaining(), (10 << 20) - partial);
+ }
+
+ /// 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]
+ fn decodes_charge_every_stage_of_a_chain() {
+ let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect();
+ let flate = Stream::flate(Dict::new(), &hex);
+ let mut dict = Dict::new();
+ dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("FlateDecode"), Object::name("ASCIIHexDecode")]));
+ let chained = Stream::from_raw(dict, flate.raw.to_vec());
+ assert_eq!(chained.decoded().map(|d| d.len()).ok(), Some(1 << 19));
+ let charged = (1 << 20) + (1 << 19);
+ let mut budget = DecodeBudget::new(10 << 20, 1);
+ assert_eq!(budget.decode(&chained).map(|d| d.len()).ok(), Some(1 << 19));
+ assert_eq!(budget.remaining(), (10 << 20) - charged);
+ assert!(budget.decode_within(&chained, 2 << 20).is_ok());
+ assert_eq!(budget.remaining(), (10 << 20) - 2 * charged);
+ assert!(budget.decode_strict(&chained).is_ok());
+ assert_eq!(budget.remaining(), (10 << 20) - 3 * charged);
+ }
+}
diff --git a/crates/cos/src/document.rs b/crates/cos/src/document.rs
index ae00234d0..892c8e04d 100644
--- a/crates/cos/src/document.rs
+++ b/crates/cos/src/document.rs
@@ -10,7 +10,7 @@ use std::sync::{Arc, Mutex, Weak};
use crate::CosError;
use crate::object::{Dict, ObjRef, Object};
-use crate::parser::{Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
+use crate::parser::{EndstreamIndex, Lexer, is_whitespace, parse_indirect, parse_indirect_shared};
thread_local! {
/// The objects being loaded on this thread, innermost last (see `Document::try_get`).
@@ -405,6 +405,8 @@ impl std::fmt::Debug for ReadState {
#[derive(Clone)]
pub struct Document {
data: Arc>,
+ /// Where `endstream` occurs in `data`; see [`EndstreamIndex`].
+ endstreams: Arc,
source_identity: Arc<()>,
entries: Arc,
trailer: Dict,
@@ -494,12 +496,22 @@ impl ObjectReader {
}
}
+#[cfg(test)]
+thread_local! {
+ /// Cross-reference sections read on this thread.
+ static SECTION_READS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Nesting [`owned_bytes`] walks: past the parser's own nesting limit, so every parsed object
+/// is measured in full.
+const OWNED_BYTES_DEPTH: usize = 256;
+
/// Bytes `object` holds of its own: string and stream data that is not a view into `data`
-/// (the input file), nested a few levels deep (deeper data is rare and still bounded by the
-/// reader's object count).
+/// (the input file). An object nested deeper than [`OWNED_BYTES_DEPTH`] cannot be measured
+/// and counts as too large to keep.
fn owned_bytes(object: &Object, data: &Arc>, depth: usize) -> usize {
- if depth > 4 {
- return 0;
+ if depth > OWNED_BYTES_DEPTH {
+ return usize::MAX;
}
match object {
Object::String(s) => s.bytes.len(),
@@ -532,6 +544,7 @@ impl Document {
pub fn new_empty() -> Self {
let mut doc = Document {
data: Arc::new(Vec::new()),
+ endstreams: Arc::default(),
source_identity: Arc::new(()),
entries: Arc::new(XrefIndex::empty()),
trailer: Dict::new(),
@@ -614,6 +627,7 @@ impl Document {
};
let mut doc = Document {
data,
+ endstreams: Arc::default(),
source_identity: Arc::new(()),
entries: Arc::new(XrefIndex::empty()),
trailer: Dict::new(),
@@ -858,7 +872,7 @@ impl Document {
/// Decrypt a freshly parsed indirect object if the document is encrypted.
fn decrypted(&self, id: ObjRef, o: Object) -> Object {
match &self.security {
- Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, true),
+ Some(h) if Some(id.num) != self.encrypt_num => crate::security::transform(h, &o, id.num, id.generation, crate::security::Pass::Decrypt),
_ => o,
}
}
@@ -978,7 +992,7 @@ impl Document {
match self.xref_entry(num) {
None | Some(XrefEntry::Free { .. }) => true,
Some(XrefEntry::InFile { offset, .. }) => {
- let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None), Ok((id, _)) if id.num == num);
+ let at = |off: usize| matches!(parse_indirect_shared(&self.data, off, &|_| None, &self.endstreams), Ok((id, _)) if id.num == num);
// Offsets may count from a header that does not start the file.
usize::try_from(offset).is_ok_and(|off| at(off) || (self.header_offset != 0 && at(off.saturating_add(self.header_offset))))
}
@@ -1176,7 +1190,7 @@ impl Document {
length
};
let off = offset as usize;
- let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve) {
+ let at = |off: usize| match parse_indirect_shared(&self.data, off, &resolve, &self.endstreams) {
Ok((id, o)) if id.num == num => Some(self.decrypted(id, o)),
_ => None,
};
@@ -1242,7 +1256,7 @@ impl Document {
if p > 0 && !is_whitespace(data[p - 1]) {
continue;
}
- if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None)
+ if let Ok((id, o)) = parse_indirect_shared(data, p, &|_| None, &self.endstreams)
&& id.num == num
{
found = Some((id, o)); // keep the last (newest) definition
@@ -1740,6 +1754,12 @@ impl Document {
let mut revisions = Vec::new();
let mut oddities = Vec::new();
let mut visited = HashSet::new();
+ // Hybrid streams already read, by offset: their entries, or `None` when unreadable. A newer
+ // section has merged every entry of one into `entries`, so an older section that names it
+ // again only has its own table compared with them, without decoding the stream again.
+ // At most MAX_XREF_OBJECTS entries are kept in all; a stream past that is read again.
+ let mut hybrids: HashMap>> = HashMap::new();
+ let (mut hybrid_rows, mut noted) = (0usize, HashSet::new());
let mut next = Some(first);
while let Some(off) = next.take() {
if !visited.insert(off) || visited.len() > 10_000 {
@@ -1770,11 +1790,24 @@ impl Document {
revisions.push(Revision { xref_offset: off, is_stream, header_relative });
// Hybrid files (§7.5.8.4): the /XRefStm entries supplement the table and replace the
// free entries it gives objects only a cross-reference stream can locate.
- if let Some(x) = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0) {
+ let hybrid = sect_trailer.int(b"XRefStm").filter(|x| *x >= 0);
+ if let Some((x, read)) = hybrid.and_then(|x| hybrids.get(&x).map(|read| (x, read))) {
+ if noted.insert(x) {
+ log.push(format!("hybrid /XRefStm at {x} named again by the section at {off}; read once"));
+ }
+ if let Some(stm) = read {
+ for (num, old) in § {
+ // The table places the object somewhere other than the stream does.
+ if !matches!(old, XrefEntry::Free { .. }) && stm.get(num).is_some_and(|e| e != old) {
+ odd.push(*num);
+ }
+ }
+ }
+ } else if let Some(x) = hybrid {
let mut stm = BTreeMap::new();
- match self.read_section(x as usize, &mut stm, &mut odd, log) {
+ let read = match self.read_section(x as usize, &mut stm, &mut odd, log) {
Ok(_) => {
- for (num, e) in stm {
+ for (&num, &e) in &stm {
match sect.get(&num) {
None | Some(XrefEntry::Free { .. }) => {
sect.insert(num, e);
@@ -1784,8 +1817,17 @@ impl Document {
Some(_) => {}
}
}
+ Some(stm)
}
- Err(e) => log.push(format!("hybrid /XRefStm at {x} unreadable: {e}")),
+ Err(e) => {
+ log.push(format!("hybrid /XRefStm at {x} unreadable: {e}"));
+ None
+ }
+ };
+ let rows = read.as_ref().map_or(0, BTreeMap::len);
+ if let Some(total) = hybrid_rows.checked_add(rows).filter(|total| *total <= MAX_XREF_OBJECTS) {
+ hybrid_rows = total;
+ hybrids.insert(x, read);
}
}
oddities.extend(odd.into_iter().map(|num| (off, num)));
@@ -1814,6 +1856,8 @@ impl Document {
odd: &mut Vec,
log: &mut Vec,
) -> Result<(Dict, bool), CosError> {
+ #[cfg(test)]
+ SECTION_READS.with(|c| c.set(c.get() + 1));
let data = &self.data;
let mut lx = Lexer::new(data, off);
if lx.eat_keyword(b"xref") {
@@ -2051,7 +2095,7 @@ impl Document {
if trailer.as_ref().is_some_and(|((catalog, p), _)| *catalog && *p > at) {
continue;
}
- if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None)
+ if let Ok((_, Object::Stream(s))) = parse_indirect_shared(&data, at, &|_| None, &self.endstreams)
&& s.dict.name(b"Type") == Some(b"XRef")
&& let Some(r) = rank(at, &s.dict)
&& trailer.as_ref().is_none_or(|(best, _)| r > *best)
@@ -2993,6 +3037,56 @@ endobj
}
}
+ #[test]
+ fn streams_without_length_share_one_endstream_search() {
+ let n = 2_000;
+ let mut file = b"%PDF-1.7\n".to_vec();
+ for k in 1..=n {
+ file.extend_from_slice(format!("{k} 0 obj\n<< >>\nstream\nxx\n").as_bytes());
+ }
+ file.extend_from_slice(b"endstream\nendobj\n");
+ file.extend_from_slice(
+ format!(
+ "{0} 0 obj\n<< /Type /Catalog /Pages {1} 0 R >>\nendobj\n{1} 0 obj\n<< /Type /Pages /Kids [] /Count 0 >>\nendobj\n",
+ n + 1,
+ n + 2
+ )
+ .as_bytes(),
+ );
+ file.extend_from_slice(format!("trailer\n<< /Root {} 0 R /Size {} >>\n%%EOF\n", n + 1, n + 3).as_bytes());
+ let len = file.len();
+ crate::parser::ENDSTREAM_SCAN.with(|c| c.set(0));
+ let doc = Document::open(Arc::new(file)).unwrap();
+ for k in [1, n / 2, n] {
+ assert!(matches!(doc.try_get(k).as_deref(), Ok(Object::Stream(_))), "object {k}");
+ }
+ let scanned = crate::parser::ENDSTREAM_SCAN.with(|c| c.get());
+ assert!(scanned <= 4 * len, "{scanned} bytes searched in a {len}-byte file");
+ }
+
+ #[test]
+ fn editor_cache_counts_deeply_nested_strings() {
+ let mut nested = Object::String(crate::PdfString::literal(vec![b'x'; 1 << 20]));
+ for _ in 0..8 {
+ nested = Object::Array(vec![nested]);
+ }
+ let mut source = Document::new_empty();
+ let objects: Vec<_> = (0..48).map(|_| Object::Ref(source.add(nested.clone()))).collect();
+ source.update_dict(source.root().unwrap(), |d| d.set(b"Extension".to_vec(), Object::Array(objects))).unwrap();
+ let bytes = Arc::new(crate::write_full(&source, &crate::SaveOptions::default()).unwrap());
+ let doc = Document::open(bytes).unwrap();
+ assert_eq!(owned_bytes(&nested, &doc.data, 0), 1 << 20);
+ let root = doc.get(doc.root().unwrap());
+ for reference in root.as_dict().unwrap().get(b"Extension").unwrap().as_array().unwrap() {
+ let Object::Ref(reference) = reference else { panic!("indirect object") };
+ drop(doc.get(*reference));
+ let cache = doc.cache.lock().unwrap();
+ let owned = cache.values().fold(0usize, |n, o| n.saturating_add(owned_bytes(o, &doc.data, 0)));
+ let strings = cache.values().filter(|o| o.as_array().is_some()).count();
+ assert!(strings << 20 <= Document::EDITOR_CACHE_BYTES + (1 << 20), "{strings} MiB strings cached, {owned} bytes counted");
+ }
+ }
+
#[test]
fn editor_cache_stays_bounded() {
let mut source = Document::new_empty();
@@ -3141,6 +3235,55 @@ endobj
out
}
+ /// Many revisions naming one hybrid /XRefStm read it once.
+ #[test]
+ fn a_hybrid_stream_named_by_many_revisions_is_read_once() {
+ let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
+ let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
+ let mut prev = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
+ let revisions = 200;
+ for _ in 0..revisions {
+ let at = out.len();
+ out.extend_from_slice(
+ format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {prev} >>\nstartxref\n{at}\n%%EOF\n").as_bytes(),
+ );
+ prev = at;
+ }
+ let doc = Document::open(Arc::new(out)).unwrap();
+ SECTION_READS.with(|c| c.set(0));
+ let (entries, ..) = doc.read_xref_chain_materialized(&mut Vec::new()).unwrap();
+ let reads = SECTION_READS.with(|c| c.get());
+ assert!(reads <= revisions + 2, "{reads} sections read for {} revisions", revisions + 1);
+ assert!(matches!(entries.get(&3), Some(XrefEntry::InStream { stream: 4, .. })), "{:?}", entries.get(&3));
+ assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
+ assert_compact_matches_materialized(&doc);
+ }
+
+ /// An older section that names an /XRefStm a newer section already read still has its own
+ /// table compared with that stream, so an entry it sets aside is recorded for it.
+ #[test]
+ fn a_repeated_hybrid_stream_records_each_sections_conflicts() {
+ let mut out = object_stream_file("3 0 ", "<< /Hidden (yes) >>", 0);
+ let stream = find(&out, b"5 0 obj", 0, out.len()).unwrap();
+ let catalog = find(&out, b"1 0 obj", 0, out.len()).unwrap();
+ let base = rfind(&out, b"\nxref\n", 0).unwrap() + 1;
+ // This section's table places object 3 at the catalog; the stream puts it in object stream 4.
+ let older = out.len();
+ out.extend_from_slice(
+ format!(
+ "xref\n3 1\n{catalog:010} 00000 n \ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {base} >>\nstartxref\n{older}\n%%EOF\n"
+ )
+ .as_bytes(),
+ );
+ let newer = out.len();
+ out.extend_from_slice(
+ format!("xref\ntrailer << /Size 6 /Root 1 0 R /XRefStm {stream} /Prev {older} >>\nstartxref\n{newer}\n%%EOF\n").as_bytes(),
+ );
+ let doc = Document::open(Arc::new(out)).unwrap();
+ assert_eq!(doc.xref_oddities(), [(older as u64, 3)]);
+ assert_eq!(text_of(&doc, 3, b"Hidden").as_deref(), Some("yes"));
+ }
+
fn text_of(doc: &Document, num: u32, key: &[u8]) -> Option {
doc.get(ObjRef::new(num, 0)).as_dict().and_then(|d| d.get(key).and_then(|v| v.as_string().map(|s| s.to_text())))
}
diff --git a/crates/cos/src/lib.rs b/crates/cos/src/lib.rs
index 152daed2d..f3be66b9a 100644
--- a/crates/cos/src/lib.rs
+++ b/crates/cos/src/lib.rs
@@ -13,6 +13,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+mod budget;
mod bytes;
mod document;
mod object;
@@ -21,6 +22,7 @@ mod parser;
mod security;
mod writer;
+pub use budget::{DecodeBudget, MAX_PAGE_DECODED, MAX_PAGE_FORMS};
pub use bytes::Bytes;
pub use document::{Document, ReadState, Revision, SourceIdentity, XrefEntry};
pub use object::{Dict, MAX_DECODED, Name, ObjRef, Object, PdfString, Stream};
diff --git a/crates/cos/src/object.rs b/crates/cos/src/object.rs
index 438b8d5f3..c9b2ff9a3 100644
--- a/crates/cos/src/object.rs
+++ b/crates/cos/src/object.rs
@@ -76,10 +76,37 @@ impl PdfString {
/// A name object (without the leading `/`, with `#xx` escapes already decoded).
pub type Name = Vec;
+#[cfg(test)]
+thread_local! {
+ /// Key comparisons and index lookups made by [`Dict`] on this thread.
+ static KEY_COMPARES: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Entries from which a [`Dict`] keeps a key index. Smaller dictionaries (nearly all of them)
+/// search their entries; a parsed file controls the key count, and a linear search per
+/// inserted key would make a dictionary of many distinct keys quadratic to build.
+const INDEX_FROM: usize = 32;
+
/// An ordered dictionary.
-#[derive(Clone, Debug, Default, PartialEq)]
+#[derive(Clone, Default)]
pub struct Dict {
entries: Vec<(Name, Object)>,
+ /// Key to position in `entries`, kept once the dictionary reaches [`INDEX_FROM`] entries.
+ /// Boxed so that every `Dict`, and so every `Object`, grows by one pointer, not by a map.
+ #[allow(clippy::box_collection)]
+ index: Option>>,
+}
+
+impl PartialEq for Dict {
+ fn eq(&self, other: &Self) -> bool {
+ self.entries == other.entries
+ }
+}
+
+impl std::fmt::Debug for Dict {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("Dict").field("entries", &self.entries).finish()
+ }
}
impl Dict {
@@ -87,26 +114,53 @@ impl Dict {
Self::default()
}
+ fn position(&self, key: &[u8]) -> Option {
+ if let Some(index) = &self.index {
+ #[cfg(test)]
+ KEY_COMPARES.with(|c| c.set(c.get() + 1));
+ return index.get(key).copied();
+ }
+ self.entries.iter().position(|(k, _)| {
+ #[cfg(test)]
+ KEY_COMPARES.with(|c| c.set(c.get() + 1));
+ k == key
+ })
+ }
+
pub fn get(&self, key: &[u8]) -> Option<&Object> {
- self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
+ self.position(key).and_then(|i| self.entries.get(i)).map(|(_, v)| v)
}
pub fn get_mut(&mut self, key: &[u8]) -> Option<&mut Object> {
- self.entries.iter_mut().find(|(k, _)| k == key).map(|(_, v)| v)
+ let i = self.position(key)?;
+ self.entries.get_mut(i).map(|(_, v)| v)
}
/// Insert or replace, keeping the position of an existing key.
pub fn set(&mut self, key: impl Into, value: impl Into) {
let key = key.into();
let value = value.into();
- match self.entries.iter_mut().find(|(k, _)| *k == key) {
+ match self.position(&key).and_then(|i| self.entries.get_mut(i)) {
Some((_, v)) => *v = value,
- None => self.entries.push((key, value)),
+ None => {
+ let at = self.entries.len();
+ if let Some(index) = &mut self.index {
+ index.insert(key.clone(), at);
+ } else if at + 1 >= INDEX_FROM {
+ let index = self.entries.iter().enumerate().map(|(i, (k, _))| (k.clone(), i)).chain([(key.clone(), at)]).collect();
+ self.index = Some(Box::new(index));
+ }
+ self.entries.push((key, value));
+ }
}
}
pub fn remove(&mut self, key: &[u8]) -> Option {
- let i = self.entries.iter().position(|(k, _)| k == key)?;
+ let i = self.position(key)?;
+ if let Some(index) = &mut self.index {
+ index.remove(key);
+ index.values_mut().filter(|at| **at > i).for_each(|at| *at -= 1);
+ }
Some(self.entries.remove(i).1)
}
@@ -204,6 +258,21 @@ impl Stream {
.collect()
}
+ /// [`Stream::filters`], or an error when `/Filter` is present but is not a name or an
+ /// array of names (an indirect reference, say): the data is not unfiltered, so it must not
+ /// read as decoded.
+ fn readable_filters(&self) -> Result, pdfcraft_filters::FilterError> {
+ let readable = match self.dict.get(b"Filter") {
+ None | Some(Object::Null | Object::Name(_)) => true,
+ Some(Object::Array(a)) => a.iter().all(|f| f.as_name().is_some()),
+ Some(_) => false,
+ };
+ if !readable {
+ return Err(pdfcraft_filters::FilterError::Unsupported("/Filter is not a name or an array of names".into()));
+ }
+ Ok(self.filters())
+ }
+
/// Decoded data, tolerating truncated or corrupt encodings the way viewers do (the data
/// decoded before the damage is returned). Streams with image codecs fail with
/// `CosError::Filter` (keep them encoded). Use `decoded_strict` to detect damage.
@@ -214,26 +283,60 @@ impl Stream {
/// Like [`Stream::decoded`], for a stream whose decoded size has a tighter bound than
/// [`MAX_DECODED`]: decoding past `max` bytes is an error.
pub fn decoded_within(&self, max: usize) -> Result, CosError> {
- let chain = self.filters();
+ let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
+ pdfcraft_filters::decode_tolerant(chain, data, max).map(|(v, _)| (v, 0)).map_err(|e| (e, 0))
+ };
+ self.decode_chain(max, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
+ }
+
+ /// [`Stream::decoded_within`] for a caller charging an allowance: the filter error is kept
+ /// typed, so a caller can tell a spent limit from corrupt data, `max` bounds all filter
+ /// stages together, and the result gives the bytes they decoded (the work done, on success
+ /// and on failure).
+ pub(crate) fn decoded_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> {
+ let decode = |chain: &[(Filter, Params)], data: &[u8], max| {
+ pdfcraft_filters::decode_tolerant_counted(chain, data, max).map(|(v, _, spent)| (v, spent))
+ };
+ self.decode_chain(max, decode)
+ }
+
+ /// The stream decoded through its filters by `decode`, or copied when it has none, within
+ /// `max` and [`MAX_DECODED`].
+ fn decode_chain(
+ &self,
+ max: usize,
+ decode: impl FnOnce(&[(Filter, Params)], &[u8], usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)>,
+ ) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> {
+ let max = max.min(MAX_DECODED);
+ let chain = self.readable_filters().map_err(|e| (e, 0))?;
if chain.is_empty() {
- return self.raw_within(max);
+ return self
+ .raw_within_typed(max)
+ .map(|v| {
+ let n = v.len();
+ (v, n)
+ })
+ .map_err(|e| (e, 0));
}
- pdfcraft_filters::decode_tolerant(&chain, &self.raw, max.min(MAX_DECODED)).map(|(v, _)| v).map_err(|e| CosError::Filter(e.to_string()))
+ decode(&chain, &self.raw, max)
}
/// Decoded data, bounded by [`MAX_DECODED`]; any corruption is an error.
pub fn decoded_strict(&self) -> Result, CosError> {
- let chain = self.filters();
- if chain.is_empty() {
- return self.raw_within(MAX_DECODED);
- }
- pdfcraft_filters::decode(&chain, &self.raw, MAX_DECODED).map_err(|e| CosError::Filter(e.to_string()))
+ let decode = |chain: &[(Filter, Params)], data: &[u8], max| pdfcraft_filters::decode(chain, data, max).map(|v| (v, 0)).map_err(|e| (e, 0));
+ self.decode_chain(MAX_DECODED, decode).map(|(v, _)| v).map_err(|(e, _)| CosError::Filter(e.to_string()))
+ }
+
+ /// [`Stream::decoded_strict`] bounded by `max` and counted like
+ /// [`Stream::decoded_within_counted`].
+ pub(crate) fn decoded_strict_within_counted(&self, max: usize) -> Result<(Vec, usize), (pdfcraft_filters::FilterError, usize)> {
+ self.decode_chain(max, pdfcraft_filters::decode_counted)
}
/// Data after the first filter alone, bounded by `max`: the rest of the chain stays encoded.
/// Any corruption in that first filter is an error.
pub fn decoded_first_filter_strict(&self, max: usize) -> Result, CosError> {
- let mut chain = self.filters();
+ let mut chain = self.readable_filters().map_err(|e| CosError::Filter(e.to_string()))?;
chain.truncate(1);
if chain.is_empty() {
return self.raw_within(max);
@@ -242,11 +345,15 @@ impl Stream {
}
fn raw_within(&self, max: usize) -> Result, CosError> {
+ self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
+ }
+
+ fn raw_within_typed(&self, max: usize) -> Result, pdfcraft_filters::FilterError> {
let max = max.min(MAX_DECODED);
// An empty decoder chain still produces an owned output buffer: check its limit
// before cloning, including streams whose /Crypt filter was applied on load.
if self.raw.len() > max {
- return Err(CosError::Filter(pdfcraft_filters::FilterError::LimitExceeded(max).to_string()));
+ return Err(pdfcraft_filters::FilterError::LimitExceeded(max));
}
Ok(self.raw.to_vec())
}
@@ -382,6 +489,53 @@ impl From for Object {
mod tests {
use super::*;
+ #[test]
+ fn a_filter_that_is_not_a_direct_name_is_not_decoded_as_absent() {
+ let stream = |filter: Object| {
+ let mut d = Dict::new();
+ d.set(b"Filter".to_vec(), filter);
+ Stream::from_raw(d, b"48 69>".to_vec())
+ };
+ let hex = Object::Name(b"ASCIIHexDecode".to_vec());
+ assert_eq!(stream(hex.clone()).decoded_strict().ok(), Some(b"Hi".to_vec()));
+ assert_eq!(stream(Object::Array(vec![hex.clone()])).decoded().ok(), Some(b"Hi".to_vec()));
+ assert_eq!(stream(Object::Null).decoded_strict().ok(), Some(b"48 69>".to_vec()));
+ for filter in [
+ Object::Ref(ObjRef::new(5, 0)),
+ Object::Array(vec![Object::Ref(ObjRef::new(5, 0))]),
+ Object::Int(3),
+ Object::Array(vec![hex, Object::Int(1)]),
+ ] {
+ let s = stream(filter.clone());
+ assert!(s.decoded_strict().is_err(), "{filter:?}");
+ assert!(s.decoded().is_err(), "{filter:?}");
+ assert!(s.decoded_first_filter_strict(1 << 20).is_err(), "{filter:?}");
+ }
+ }
+
+ #[test]
+ fn parsing_many_distinct_keys_is_not_quadratic() {
+ let n = 20_000;
+ let body: String = (0..n).map(|i| format!("/K{i} {i} ")).collect();
+ let text = format!("<< {body}/K7 true >>");
+ KEY_COMPARES.with(|c| c.set(0));
+ let Ok(Object::Dict(d)) = crate::parser::Lexer::new(text.as_bytes(), 0).object() else { panic!("not a dict") };
+ let compares = KEY_COMPARES.with(|c| c.get());
+ assert!(compares < 8 * n, "{compares} key comparisons for {n} keys");
+ // Order and replace-in-place semantics stay those of a plain ordered dictionary.
+ assert_eq!(d.len(), n);
+ assert_eq!(d.iter().next().map(|(k, _)| k.as_slice()), Some(&b"K0"[..]));
+ assert_eq!(d.get(b"K7"), Some(&Object::Bool(true)));
+ assert_eq!(d.iter().nth(7).map(|(_, v)| v), Some(&Object::Bool(true)));
+ let mut d = d;
+ assert_eq!(d.remove(b"K3"), Some(Object::Int(3)));
+ assert_eq!(d.get(b"K4"), Some(&Object::Int(4)));
+ assert_eq!(d.iter().nth(3).map(|(k, _)| k.as_slice()), Some(&b"K4"[..]));
+ d.set(b"K3".to_vec(), Object::Null);
+ assert_eq!(d.iter().last().map(|(k, _)| k.as_slice()), Some(&b"K3"[..]));
+ assert!(d.get(b"K20000").is_none());
+ }
+
#[test]
fn text_strings_round_trip() {
for s in ["Plain ASCII", "Café — naïve", "日本語", "emoji 📄"] {
diff --git a/crates/cos/src/parser.rs b/crates/cos/src/parser.rs
index 045805596..1e9b45ad6 100644
--- a/crates/cos/src/parser.rs
+++ b/crates/cos/src/parser.rs
@@ -10,6 +10,9 @@ use crate::object::{Dict, ObjRef, Object, PdfString, Stream};
use crate::{Bytes, CosError};
const MAX_DEPTH: usize = 128;
+/// Objects one top-level [`Lexer::object`] call may build. Real arrays (page trees, parent
+/// trees, widths) stay far below; a decompressed object stream of `0 0 0 ...` does not.
+const MAX_NODES: usize = 1 << 22;
/// One of the six PDF whitespace characters (ISO 32000-2 §7.2.3, Table 1).
pub fn is_whitespace(b: u8) -> bool {
@@ -28,11 +31,13 @@ fn is_regular(b: u8) -> bool {
pub struct Lexer<'a> {
pub data: &'a [u8],
pub pos: usize,
+ /// Objects built by the current top-level [`Lexer::object`] call.
+ nodes: usize,
}
impl<'a> Lexer<'a> {
pub fn new(data: &'a [u8], pos: usize) -> Self {
- Self { data, pos: pos.min(data.len()) }
+ Self { data, pos: pos.min(data.len()), nodes: 0 }
}
fn peek(&self) -> Option {
@@ -84,13 +89,22 @@ impl<'a> Lexer<'a> {
/// Parse one direct object. References `N G R` are recognised.
pub fn object(&mut self) -> Result {
+ self.nodes = 0;
self.object_depth(0)
}
+ fn over_budget(&self) -> bool {
+ self.nodes > MAX_NODES
+ }
+
fn object_depth(&mut self, depth: usize) -> Result {
if depth > MAX_DEPTH {
return Err(self.err("nesting too deep"));
}
+ self.nodes = self.nodes.saturating_add(1);
+ if self.over_budget() {
+ return Err(self.err("object too large"));
+ }
self.skip_ws();
let Some(b) = self.peek() else { return Err(self.err("unexpected end of data")) };
match b {
@@ -125,6 +139,7 @@ impl<'a> Lexer<'a> {
let before = self.pos;
match self.object_depth(depth + 1) {
Ok(o) => items.push(o),
+ Err(e) if self.over_budget() => return Err(e),
// Tolerate junk inside arrays by skipping one byte.
Err(_) if self.pos == before => self.pos += 1,
Err(e) => return Err(e),
@@ -283,6 +298,9 @@ impl<'a> Lexer<'a> {
// Tolerate garbage between entries (seen in damaged files): skip a token.
let before = self.pos;
let _ = self.object_depth(depth + 1);
+ if self.over_budget() {
+ return Err(self.err("object too large"));
+ }
if self.pos == before {
self.pos += 1;
}
@@ -324,18 +342,8 @@ impl<'a> Lexer<'a> {
if s.contains('.') {
// Tolerate forms like "--5", "5-" or "1.2.3" by parsing the longest valid prefix.
let cleaned: String = s.trim_start_matches('+').replacen("--", "-", 1);
- let mut end = cleaned.len();
- while end > 0 {
- // Only cut at character boundaries: a token can hold multi-byte UTF-8 (fuzzing
- // found ".—", which panicked here).
- if cleaned.is_char_boundary(end)
- && let Ok(v) = cleaned[..end].parse::()
- {
- return Ok(Object::Real(v));
- }
- end -= 1;
- }
- return Ok(Object::Real(0.0));
+ let v = cleaned.get(..real_prefix_len(cleaned.as_bytes())).and_then(|p| parse_counted::(p).ok());
+ return Ok(Object::Real(v.unwrap_or(0.0)));
}
// Tolerate a doubled sign ("--280") like the real branch does.
let t = s.trim_start_matches('+');
@@ -350,6 +358,46 @@ impl<'a> Lexer<'a> {
}
}
+/// Length of the longest prefix of `s` that reads as a decimal number: a sign, digits with at
+/// most one `.`, and an exponent with digits. Zero when that prefix has no digit. Found in one
+/// pass so a long damaged token is converted once, not once per shorter prefix.
+fn real_prefix_len(s: &[u8]) -> usize {
+ let digits = |from: usize| s.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count());
+ let mut at = usize::from(matches!(s.first(), Some(b'+' | b'-')));
+ let int = digits(at);
+ at += int;
+ let mut frac = 0;
+ if s.get(at) == Some(&b'.') {
+ frac = digits(at + 1);
+ at += 1 + frac;
+ }
+ if int + frac == 0 {
+ return 0;
+ }
+ if matches!(s.get(at), Some(b'e' | b'E')) {
+ let sign = usize::from(matches!(s.get(at + 1), Some(b'+' | b'-')));
+ let exp = digits(at + 1 + sign);
+ if exp > 0 {
+ at += 1 + sign + exp;
+ }
+ }
+ at
+}
+
+#[cfg(test)]
+thread_local! {
+ /// Bytes searched for `endstream` on this thread.
+ pub(crate) static ENDSTREAM_SCAN: std::cell::Cell = const { std::cell::Cell::new(0) };
+ /// Bytes handed to number conversion on this thread.
+ static NUMBER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+fn parse_counted(s: &str) -> Result {
+ #[cfg(test)]
+ NUMBER_BYTES.with(|c| c.set(c.get() + s.len()));
+ s.parse()
+}
+
fn hexval(b: u8) -> Option {
match b {
b'0'..=b'9' => Some(b - b'0'),
@@ -362,17 +410,30 @@ fn hexval(b: u8) -> Option {
/// Parse an indirect object at `offset`: `N G obj [stream…endstream] endobj`.
/// `resolve_length` resolves an indirect `/Length`. Returns the object and its (num, gen).
pub fn parse_indirect(data: &[u8], offset: usize, resolve_length: &dyn Fn(ObjRef) -> Option) -> Result<(ObjRef, Object), CosError> {
- parse_indirect_in(data, None, offset, resolve_length)
+ parse_indirect_in(data, None, offset, resolve_length, None)
}
/// [`parse_indirect`] for a document's own bytes: stream data points into `data` instead of
/// being copied out of it.
+/// `endstreams` is the document's [`EndstreamIndex`] for `data`.
pub(crate) fn parse_indirect_shared(
data: &Arc>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option,
+ endstreams: &EndstreamIndex,
) -> Result<(ObjRef, Object), CosError> {
- parse_indirect_in(data, Some(data), offset, resolve_length)
+ parse_indirect_in(data, Some(data), offset, resolve_length, Some(endstreams))
+}
+
+/// Where `endstream` occurs in a document's bytes, found once, the first time a stream's
+/// /Length is wrong or missing. Searching the rest of the file for each such stream instead
+/// makes many of them ending at one late `endstream` quadratic to read.
+pub(crate) type EndstreamIndex = std::sync::OnceLock>;
+
+fn endstream_offsets(data: &[u8]) -> Vec {
+ #[cfg(test)]
+ ENDSTREAM_SCAN.with(|c| c.set(c.get() + data.len()));
+ data.windows(9).enumerate().filter(|(_, w)| *w == b"endstream").map(|(i, _)| i).collect()
}
fn parse_indirect_in(
@@ -380,6 +441,7 @@ fn parse_indirect_in(
shared: Option<&Arc>>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option,
+ endstreams: Option<&EndstreamIndex>,
) -> Result<(ObjRef, Object), CosError> {
let mut lx = Lexer::new(data, offset);
lx.skip_ws();
@@ -439,7 +501,11 @@ fn parse_indirect_in(
let len = match declared {
Some(l) if valid(l) => l as usize,
// Wrong or missing /Length: search for "endstream" (§7.3.8.1 recovery).
- _ => find_endstream(data, start).ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })? - start,
+ _ => {
+ find_endstream(data, start, endstreams)
+ .ok_or_else(|| CosError::Syntax { offset: start, detail: "stream without endstream".into() })?
+ - start
+ }
};
let range = start..start + len;
let raw = match shared {
@@ -455,9 +521,20 @@ fn parse_indirect_in(
}
/// Offset where stream data ends (before the EOL preceding "endstream").
-fn find_endstream(data: &[u8], from: usize) -> Option {
- let hay = data.get(from..)?;
- let at = hay.windows(9).position(|w| w == b"endstream")? + from;
+fn find_endstream(data: &[u8], from: usize, endstreams: Option<&EndstreamIndex>) -> Option {
+ let at = match endstreams {
+ Some(index) => {
+ let index = index.get_or_init(|| endstream_offsets(data));
+ index.get(index.partition_point(|&p| p < from)).copied()?
+ }
+ None => {
+ let hay = data.get(from..)?;
+ let found = hay.windows(9).position(|w| w == b"endstream");
+ #[cfg(test)]
+ ENDSTREAM_SCAN.with(|c| c.set(c.get() + found.map_or(hay.len(), |p| p + 9)));
+ found? + from
+ }
+ };
let mut end = at;
if end > from && data[end - 1] == b'\n' {
end -= 1;
@@ -483,6 +560,22 @@ mod tests {
assert_eq!(Lexer::new("1.5\u{2014}".as_bytes(), 0).object().unwrap(), Object::Real(1.5));
}
+ #[test]
+ fn a_damaged_real_is_converted_once() {
+ let token = format!("1.{}{}", "5".repeat(2_000), "x".repeat(20_000));
+ NUMBER_BYTES.with(|c| c.set(0));
+ let Ok(Object::Real(v)) = Lexer::new(token.as_bytes(), 0).object() else { panic!("not a real") };
+ let converted = NUMBER_BYTES.with(|c| c.get());
+ assert!(converted <= token.len(), "{converted} bytes converted for a {}-byte token", token.len());
+ assert!((v - 1.5555).abs() < 1e-3);
+ // The longest numeric prefix wins, as before.
+ for (t, want) in
+ [("1.2.3", 1.2), ("--5.", -5.0), ("+.5x", 0.5), ("5.-", 5.0), ("1.5e3", 1500.0), ("1.5e+", 1.5), ("-.x", 0.0), (".", 0.0), ("-inf.", 0.0)]
+ {
+ assert_eq!(Lexer::new(t.as_bytes(), 0).object().ok(), Some(Object::Real(want)), "{t}");
+ }
+ }
+
fn obj(s: &str) -> Object {
Lexer::new(s.as_bytes(), 0).object().unwrap_or_else(|e| panic!("{s}: {e}"))
}
@@ -598,4 +691,20 @@ mod tests {
let deep = "[".repeat(10_000);
assert!(Lexer::new(deep.as_bytes(), 0).object().is_err());
}
+
+ #[test]
+ fn one_object_has_a_node_budget() {
+ // A wide array (from a tiny compressed object stream) must not grow without bound.
+ let wide = |n: usize| format!("[{}]", "0 ".repeat(n));
+ let at_cap = Lexer::new(wide(MAX_NODES - 1).as_bytes(), 0).object();
+ assert!(matches!(at_cap, Ok(Object::Array(ref items)) if items.len() == MAX_NODES - 1));
+ assert!(Lexer::new(wide(MAX_NODES).as_bytes(), 0).object().is_err());
+ // The same inside a dictionary value and behind tolerated junk.
+ assert!(Lexer::new(format!("<< /A {} >>", wide(MAX_NODES)).as_bytes(), 0).object().is_err());
+ assert!(Lexer::new(format!("[[{}] ]", "0 ".repeat(MAX_NODES)).as_bytes(), 0).object().is_err());
+ // The budget is per top-level object: a lexer reused for many operands keeps working.
+ let two = wide(MAX_NODES - 1).repeat(2);
+ let mut lx = Lexer::new(two.as_bytes(), 0);
+ assert!(lx.object().is_ok() && lx.object().is_ok());
+ }
}
diff --git a/crates/cos/src/security.rs b/crates/cos/src/security.rs
index cd0d04a14..12d08b8dc 100644
--- a/crates/cos/src/security.rs
+++ b/crates/cos/src/security.rs
@@ -88,42 +88,104 @@ fn named_crypt_filter(dict: &Dict) -> Option> {
/// whose `/Contents` is never encrypted (ISO 32000-2 §7.6.2, §12.8.1): the signer writes a
/// placeholder, hashes the file around it and patches the value in place, so encrypting it
/// would make signing impossible. Identified as other readers do: by `/Type`, or by a string
-/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional).
+/// `/Contents` alongside an array `/ByteRange` (`/Type` is optional). A signature dictionary
+/// has no `/Subtype`; a dictionary with one (an annotation, whose `/Contents` is the comment
+/// text) or with another `/Type` is never exempt, whatever else it carries.
fn is_signature_dict(d: &Dict) -> bool {
- matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp"))
- || (matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))))
+ if d.get(b"Subtype").is_some() {
+ return false;
+ }
+ match d.get(b"Type") {
+ Some(_) => matches!(d.name(b"Type"), Some(b"Sig" | b"DocTimeStamp")),
+ None => matches!(d.get(b"Contents"), Some(Object::String(_))) && matches!(d.get(b"ByteRange"), Some(Object::Array(_))),
+ }
}
-/// Decrypt (`decrypt = true`) or encrypt every string and stream in an indirect object,
-/// except a signature dictionary's `/Contents` (see [`is_signature_dict`]).
-pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, decrypt: bool) -> Object {
+/// Remove the `/Crypt` entry from a stream's filter chain, with its decode parameters.
+fn drop_crypt_filter(dict: &mut Dict) {
+ let i = match dict.get(b"Filter") {
+ Some(Object::Array(a)) => a.iter().position(|n| n.as_name() == Some(b"Crypt")),
+ Some(Object::Name(n)) if n.as_slice() == b"Crypt" => Some(0),
+ _ => None,
+ };
+ let Some(i) = i else { return };
+ let mut filters = match dict.get(b"Filter") {
+ Some(Object::Array(a)) => a.clone(),
+ _ => Vec::new(),
+ };
+ if i < filters.len() {
+ filters.remove(i);
+ }
+ // A lone parameter dictionary belongs to the first filter (as `named_crypt_filter` reads it).
+ match dict.get(b"DecodeParms").cloned() {
+ Some(Object::Array(mut parms)) if i < parms.len() => {
+ parms.remove(i);
+ dict.set(b"DecodeParms".to_vec(), Object::Array(parms));
+ }
+ Some(Object::Dict(_)) if i == 0 => {
+ dict.remove(b"DecodeParms");
+ }
+ _ => {}
+ }
+ if filters.is_empty() {
+ dict.remove(b"Filter");
+ if matches!(dict.get(b"DecodeParms"), Some(Object::Array(p)) if p.is_empty()) {
+ dict.remove(b"DecodeParms");
+ }
+ } else {
+ dict.set(b"Filter".to_vec(), Object::Array(filters));
+ }
+}
+
+/// What [`transform`] does to an object.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Pass {
+ /// Decrypt, with the handler the document was opened with.
+ Decrypt,
+ /// Encrypt with the handler the document was opened with: streams keep their crypt filters.
+ Encrypt,
+ /// Encrypt with protection applied by `set_encryption`. A stream's crypt filter came from
+ /// the source's `/Encrypt`; unless the new one defines that name it is dropped, so the
+ /// stream follows the new policy instead of reading as `/Identity` and staying in clear.
+ EncryptReplacing,
+}
+
+/// Decrypt or encrypt every string and stream in an indirect object, except a signature
+/// dictionary's `/Contents` (see [`is_signature_dict`]).
+pub(crate) fn transform(h: &SecurityHandler, o: &Object, num: u32, generation: u16, pass: Pass) -> Object {
+ let decrypt = pass == Pass::Decrypt;
let strings = |s: &PdfString| {
let bytes = if decrypt { h.decrypt_string(num, generation, &s.bytes) } else { h.encrypt_string(num, generation, &s.bytes) };
PdfString { bytes, hex: s.hex || !decrypt }
};
- walk(o, &strings, h, num, generation, decrypt)
+ walk(o, &strings, h, num, generation, pass)
}
-fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, decrypt: bool) -> Object {
+fn walk(o: &Object, strings: &dyn Fn(&PdfString) -> PdfString, h: &SecurityHandler, num: u32, generation: u16, pass: Pass) -> Object {
+ let decrypt = pass == Pass::Decrypt;
match o {
Object::String(s) => Object::String(strings(s)),
- Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, decrypt)).collect()),
+ Object::Array(a) => Object::Array(a.iter().map(|x| walk(x, strings, h, num, generation, pass)).collect()),
Object::Dict(d) if is_signature_dict(d) => Object::Dict(
d.iter()
.map(|(k, v)| match (k.as_slice(), v) {
(b"Contents", Object::String(_)) => (k.clone(), v.clone()),
- _ => (k.clone(), walk(v, strings, h, num, generation, decrypt)),
+ _ => (k.clone(), walk(v, strings, h, num, generation, pass)),
})
.collect(),
),
- Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect()),
+ Object::Dict(d) => Object::Dict(d.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect()),
Object::Stream(s) => {
- let dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, decrypt))).collect();
+ let mut dict: Dict = s.dict.iter().map(|(k, v)| (k.clone(), walk(v, strings, h, num, generation, pass))).collect();
let ty = s.dict.name(b"Type");
if ty == Some(b"XRef") || (ty == Some(b"Metadata") && !h.encrypts_metadata()) {
return Object::Stream(Stream { dict, raw: s.raw.clone() });
}
- let named = named_crypt_filter(&s.dict);
+ let mut named = named_crypt_filter(&s.dict);
+ if pass == Pass::EncryptReplacing && named.as_deref().is_some_and(|n| !h.dict().crypt_filters.iter().any(|(f, _)| f == n)) {
+ drop_crypt_filter(&mut dict);
+ named = None;
+ }
let kind = match (&named, ty) {
(Some(n), _) => StreamKind::Named(n),
(None, Some(b"EmbeddedFile")) => StreamKind::EmbeddedFile,
diff --git a/crates/cos/src/writer.rs b/crates/cos/src/writer.rs
index 3db0566ff..b151399fe 100644
--- a/crates/cos/src/writer.rs
+++ b/crates/cos/src/writer.rs
@@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec) {
/// except the `/Encrypt` dictionary itself (`source_num` is its number in the source document).
fn prepared<'a>(doc: &Document, source_num: u32, num: u32, generation: u16, o: &'a Object) -> Cow<'a, Object> {
match doc.output_security() {
- (Some(h), encrypt_num) if Some(source_num) != encrypt_num => Cow::Owned(crate::security::transform(h, o, num, generation, false)),
+ (Some(h), encrypt_num) if Some(source_num) != encrypt_num => {
+ let pass = if doc.encryption_changed() { crate::security::Pass::EncryptReplacing } else { crate::security::Pass::Encrypt };
+ Cow::Owned(crate::security::transform(h, o, num, generation, pass))
+ }
_ => Cow::Borrowed(o),
}
}
diff --git a/crates/cos/tests/bounded_writer.rs b/crates/cos/tests/it/bounded_writer.rs
similarity index 100%
rename from crates/cos/tests/bounded_writer.rs
rename to crates/cos/tests/it/bounded_writer.rs
diff --git a/crates/cos/tests/corpus.rs b/crates/cos/tests/it/corpus.rs
similarity index 100%
rename from crates/cos/tests/corpus.rs
rename to crates/cos/tests/it/corpus.rs
diff --git a/crates/cos/tests/encryption.rs b/crates/cos/tests/it/encryption.rs
similarity index 78%
rename from crates/cos/tests/encryption.rs
rename to crates/cos/tests/it/encryption.rs
index e05161558..2c6aad244 100644
--- a/crates/cos/tests/encryption.rs
+++ b/crates/cos/tests/it/encryption.rs
@@ -2,6 +2,7 @@
//! with our reader and with an independent one (hayro-syntax's decryption). Also editing
//! encrypted documents incrementally, changing and removing passwords, and permissions.
+use std::io::Write;
use std::sync::Arc;
use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, Object, PdfString, SaveOptions, write_full, write_incremental};
@@ -9,11 +10,16 @@ use pdfcraft_cos::{Algorithm, Auth, CosError, Document, NewEncryption, ObjRef, O
const SECRET: &str = "BT /F1 18 Tf 20 100 Td (Secret page) Tj ET";
fn fixture() -> Vec {
+ fixture_with("")
+}
+
+/// The fixture with `extra` added to the content stream's dictionary.
+fn fixture_with(extra: &str) -> Vec {
let objs = [
"<< /Type /Catalog /Pages 2 0 R /Metadata 6 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 200] >>".to_string(),
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>".to_string(),
- format!("<< /Length {} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
+ format!("<< /Length {}{extra} >>\nstream\n{SECRET}\nendstream", SECRET.len()),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
"<< /Type /Metadata /Subtype /XML /Length 22 >>\nstream\nmeta \nendstream".replace("22", "26"),
"<< /Title (Quarterly numbers) >>".to_string(),
@@ -209,11 +215,10 @@ fn unencrypted_metadata_is_left_readable() {
#[test]
fn qpdf_agrees() {
let qpdf = pdfcraft_oracle::or_skip!(pdfcraft_oracle::qpdf());
- let dir = std::env::temp_dir().join(format!("pdfcraft-enc-{}", std::process::id()));
- std::fs::create_dir_all(&dir).unwrap();
+ let dir = private_temp_dir("pdfcraft-enc");
for alg in ALL {
let path = dir.join(format!("{alg:?}.pdf"));
- std::fs::write(&path, protect(alg, "user", "owner", -1)).unwrap();
+ std::fs::File::create_new(&path).and_then(|mut f| f.write_all(&protect(alg, "user", "owner", -1))).unwrap();
for (pw, expect) in [("user", "user password"), ("owner", "owner password")] {
let out = qpdf.command().arg(format!("--password={pw}")).arg("--show-encryption").arg(&path).output().unwrap();
let text = String::from_utf8_lossy(&out.stdout);
@@ -225,6 +230,35 @@ fn qpdf_agrees() {
let _ = std::fs::remove_dir_all(dir);
}
+/// A new directory for oracle files, private to this user: an unpredictable name (`RandomState`
+/// is seeded by the operating system), created by this call or not at all.
+fn private_temp_dir(prefix: &str) -> std::path::PathBuf {
+ use std::hash::{BuildHasher, Hasher};
+ let mut name = std::collections::hash_map::RandomState::new().build_hasher();
+ name.write_u32(std::process::id());
+ let dir = std::env::temp_dir().join(format!("{prefix}-{:016x}", name.finish()));
+ #[cfg(unix)]
+ let builder = {
+ let mut builder = std::fs::DirBuilder::new();
+ std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
+ builder
+ };
+ #[cfg(not(unix))]
+ let builder = std::fs::DirBuilder::new();
+ builder.create(&dir).unwrap();
+ dir
+}
+
+/// Oracle files go in a directory this test created: another local account cannot have
+/// prepared it (or symlinks inside it) in advance.
+#[test]
+fn oracle_files_go_in_a_fresh_directory() {
+ let (a, b) = (private_temp_dir("pdfcraft-enc-probe"), private_temp_dir("pdfcraft-enc-probe"));
+ assert_ne!(a, b);
+ assert_eq!(std::fs::read_dir(&a).unwrap().count(), 0);
+ let _ = (std::fs::remove_dir(a), std::fs::remove_dir(b));
+}
+
/// A signature dictionary's `/Contents` is never encrypted (§7.6.2): signers patch it in place
/// after hashing the file around it. Its other strings, and the `/Contents` of anything that
/// is not a signature dictionary, are encrypted as usual, and reading leaves it untouched.
@@ -279,6 +313,75 @@ fn signature_contents_stay_unencrypted() {
}
}
+/// Only a signature dictionary keeps its `/Contents` in clear. An annotation that carries a
+/// `/ByteRange` (or even `/Type /Sig`) is still an annotation, and its comment is encrypted.
+#[test]
+fn a_byte_range_does_not_exempt_an_annotation() {
+ for (ty, subtype) in [(Some("Annot"), "Text"), (None, "FreeText"), (Some("Sig"), "Text")] {
+ let mut doc = Document::open(Arc::new(fixture())).unwrap();
+ let mut note = pdfcraft_cos::Dict::new();
+ if let Some(ty) = ty {
+ note.set(b"Type".to_vec(), Object::name(ty));
+ }
+ note.set(b"Subtype".to_vec(), Object::name(subtype));
+ note.set(b"Contents".to_vec(), PdfString::literal(b"Private note".to_vec()));
+ note.set(b"ByteRange".to_vec(), Object::Array(Vec::new()));
+ let note = doc.add(Object::Dict(note));
+ doc.update_dict(doc.root().unwrap(), |c| c.set(b"Test".to_vec(), Object::Ref(note))).unwrap();
+ doc.set_encryption(&NewEncryption {
+ algorithm: Algorithm::Aes256,
+ user_password: "",
+ owner_password: "owner",
+ permissions: -1,
+ encrypt_metadata: true,
+ seed: [3; 32],
+ })
+ .unwrap();
+ let bytes = write_full(&doc, &SaveOptions { object_streams: false, ..SaveOptions::default() }).unwrap();
+ assert!(!String::from_utf8_lossy(&bytes).contains("Private note"), "{ty:?} {subtype}: comment encrypted");
+ let back = Document::open(Arc::new(bytes)).unwrap();
+ let note = back.get(back.root().unwrap()).as_dict().unwrap().reference(b"Test").unwrap();
+ assert_eq!(back.get(note).as_dict().unwrap().get(b"Contents").unwrap().as_string().unwrap().bytes, b"Private note");
+ }
+}
+
+/// Protecting a document puts every stream under the new handler. A stream that selects a
+/// crypt filter of its own loses the selector: the new `/Encrypt` defines only `/StdCF`, so an
+/// inherited name (or `/Identity`) would leave the stream in clear.
+#[test]
+fn protecting_drops_inherited_crypt_filter_selectors() {
+ for selector in [" /Filter /Crypt /DecodeParms << /Name /MyCF >>", " /Filter [/Crypt] /DecodeParms [<< /Name /Identity >>]", " /Filter /Crypt"] {
+ let mut doc = Document::open(Arc::new(fixture_with(selector))).unwrap();
+ assert_eq!(content(&doc), SECRET, "{selector}");
+ doc.set_encryption(&NewEncryption {
+ algorithm: Algorithm::Aes256,
+ user_password: "",
+ owner_password: "owner",
+ permissions: -1,
+ encrypt_metadata: true,
+ seed: [7; 32],
+ })
+ .unwrap();
+ let bytes = write_full(&doc, &SaveOptions::default()).unwrap();
+ assert!(!String::from_utf8_lossy(&bytes).contains("Secret page"), "{selector}: stream encrypted");
+ assert_eq!(content(&Document::open(Arc::new(bytes.clone())).unwrap()), SECRET, "{selector}");
+ assert!(hayro_syntax::Pdf::new(bytes).is_ok(), "{selector}");
+ }
+ // Under the handler the document was opened with, `/Identity` is the author's choice (a
+ // stream meant to stay readable) and is kept.
+ let mut doc = Document::open(Arc::new(protect(Algorithm::Aes128, "", "owner", -1))).unwrap();
+ let catalog = doc.get(doc.root().unwrap()).as_dict().cloned().unwrap();
+ let pages = doc.get(catalog.reference(b"Pages").unwrap()).as_dict().cloned().unwrap();
+ let page = doc.resolve(&pages.get(b"Kids").unwrap().as_array().unwrap()[0]).as_dict().cloned().unwrap();
+ let contents = page.reference(b"Contents").unwrap();
+ let Object::Stream(mut s) = doc.get(contents).as_ref().clone() else { panic!("no content stream") };
+ s.dict.set(b"Filter".to_vec(), Object::name("Crypt"));
+ doc.set(contents, Object::Stream(s));
+ let saved = write_incremental(&doc, &SaveOptions::default()).unwrap();
+ assert!(String::from_utf8_lossy(&saved).contains("Secret page"), "written as the stream asks");
+ assert_eq!(content(&Document::open(Arc::new(saved)).unwrap()), SECRET);
+}
+
#[test]
fn a_nonpositive_crypt_filter_length_reads_as_the_default_key_length() {
// A V4 crypt filter's /Length counts bytes (≤ 32) or bits. A zero or negative value is
diff --git a/crates/cos/tests/it/main.rs b/crates/cos/tests/it/main.rs
new file mode 100644
index 000000000..52cf05d6c
--- /dev/null
+++ b/crates/cos/tests/it/main.rs
@@ -0,0 +1,8 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod bounded_writer;
+mod corpus;
+mod encryption;
+mod proptests;
+mod shared_streams;
+mod writer;
diff --git a/crates/cos/tests/proptests.rs b/crates/cos/tests/it/proptests.rs
similarity index 100%
rename from crates/cos/tests/proptests.rs
rename to crates/cos/tests/it/proptests.rs
diff --git a/crates/cos/tests/shared_streams.rs b/crates/cos/tests/it/shared_streams.rs
similarity index 100%
rename from crates/cos/tests/shared_streams.rs
rename to crates/cos/tests/it/shared_streams.rs
diff --git a/crates/cos/tests/writer.rs b/crates/cos/tests/it/writer.rs
similarity index 100%
rename from crates/cos/tests/writer.rs
rename to crates/cos/tests/it/writer.rs
diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs
index 768da4d1f..115876d9c 100644
--- a/crates/create/src/extract.rs
+++ b/crates/create/src/extract.rs
@@ -359,6 +359,9 @@ impl Space {
}
}
+/// A lookup table holds at most 256 entries of 4 bytes; decoding stops well past that.
+const MAX_LOOKUP: usize = 64 << 10;
+
fn space(doc: &Document, cs: Option<&Object>) -> Result {
let Some(cs) = cs else { return Err("the image has no colour space".into()) };
let cs = doc.resolve(cs);
@@ -383,17 +386,26 @@ fn space(doc: &Document, cs: Option<&Object>) -> Result {
Some(b"CalGray") => Ok(Space::Gray),
Some(b"CalRGB") => Ok(Space::Rgb),
Some(b"Indexed" | b"I") => {
- let base = space(doc, a.get(1))?;
- if matches!(base, Space::Indexed(..)) {
+ // Checked before recursing: Indexed is the only space that recurses, so refusing
+ // an Indexed base here also ends a cyclic base reference.
+ let nested = match a.get(1).map(|b| doc.resolve(b)).as_deref() {
+ Some(Object::Array(b)) => matches!(b.first().and_then(Object::as_name), Some(b"Indexed" | b"I")),
+ Some(Object::Name(n)) => matches!(n.as_slice(), b"Indexed" | b"I"),
+ _ => false,
+ };
+ if nested {
return Err("nested indexed colour spaces aren't valid".into());
}
+ let base = space(doc, a.get(1))?;
let hival = match a.get(2).map(|value| doc.resolve(value)).as_deref() {
Some(Object::Int(value)) if (0..=255).contains(value) => *value as u8,
_ => return Err("an indexed colour space with an invalid hival".into()),
};
let table = match a.get(3).map(|t| doc.resolve(t)).as_deref() {
Some(Object::String(s)) => s.bytes.clone(),
- Some(Object::Stream(t)) => t.decoded().map_err(|e| e.to_string())?,
+ Some(Object::Stream(t)) => {
+ t.decoded_within(MAX_LOOKUP).map_err(|_| "an indexed colour space with an oversized or damaged lookup table".to_string())?
+ }
_ => return Err("an indexed colour space without a lookup table".into()),
};
Ok(Space::Indexed(Box::new(base), hival, table))
diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs
index 635610569..2007932f7 100644
--- a/crates/create/src/tests.rs
+++ b/crates/create/src/tests.rs
@@ -832,6 +832,15 @@ fn indexed_hival_resolves_indirect_values_and_stream_palettes() {
}
}
+#[test]
+fn indexed_lookup_stream_has_a_size_cap() {
+ // A lookup table needs 256 × 4 bytes at most; a 1 MiB one is refused, not decoded whole.
+ let mut palette = vec![255, 0, 0, 0, 0, 255];
+ palette.resize(1 << 20, 0);
+ let (doc, image) = indexed_export_fixture(Object::Int(1), palette, true, false, 8, vec![0, 1, 0, 1]);
+ assert!(crate::extract::image_file(&doc, image).unwrap_err().contains("lookup table"));
+}
+
#[test]
fn indexed_hival_boundary_values_keep_valid_palette_entries() {
let palette: Vec = (0..=255u8).flat_map(|i| [i, 255 - i, 17]).collect();
@@ -1181,3 +1190,19 @@ fn a_jpeg_with_an_exif_orientation_becomes_an_upright_page() {
let img = image_of(&doc, 1);
assert_eq!((img.int(b"Width"), img.int(b"Height")), (Some(60), Some(20)));
}
+
+#[test]
+fn a_cyclic_indexed_colour_space_is_refused() {
+ let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap();
+ // 5 0 obj [/Indexed 5 0 R 255 <00>]: the base is the indexed space itself.
+ let cs = doc.add(Object::Null);
+ doc.set(cs, Object::Array(vec![Object::name("Indexed"), Object::Ref(cs), Object::Int(255), Object::String(PdfString::literal(vec![0]))]));
+ with_image(&mut doc, |_| {
+ let mut dict = image_dict(1, 1, "DeviceGray", 8);
+ dict.set(b"ColorSpace".to_vec(), Object::Ref(cs));
+ Stream::from_raw(dict, vec![0])
+ });
+ let out = extract_images(&doc, &[0], 0);
+ assert!(out.images.is_empty());
+ assert_eq!(out.skipped.len(), 1, "{:?}", out.skipped);
+}
diff --git a/crates/crypt/src/lib.rs b/crates/crypt/src/lib.rs
index 82b598688..58e96e7d4 100644
--- a/crates/crypt/src/lib.rs
+++ b/crates/crypt/src/lib.rs
@@ -138,9 +138,51 @@ pub struct SecurityHandler {
key: Vec,
auth: Auth,
recovered_user: Option>,
+ methods: Methods,
+}
+
+/// The crypt filter methods of a handler, resolved once: a document controls both the number of
+/// filters and the number of strings, so a search of `/CF` per string would multiply them.
+#[derive(Clone, Debug)]
+struct Methods {
+ string: Method,
+ stream: Method,
+ embedded_file: Method,
+ named: std::collections::HashMap, Method>,
+}
+
+impl Methods {
+ fn new(dict: &EncryptDict) -> Self {
+ let mut named = std::collections::HashMap::with_capacity(dict.crypt_filters.len());
+ for (name, method) in &dict.crypt_filters {
+ #[cfg(test)]
+ tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
+ // The first of two filters with one name wins, as a search would find it.
+ named.entry(name.clone()).or_insert(*method);
+ }
+ let mut methods = Methods { string: Method::Identity, stream: Method::Identity, embedded_file: Method::Identity, named };
+ methods.string = methods.get(&dict.str_f);
+ methods.stream = methods.get(&dict.stm_f);
+ methods.embedded_file = methods.get(if dict.ef_f.is_empty() { &dict.stm_f } else { &dict.ef_f });
+ methods
+ }
+
+ fn get(&self, name: &[u8]) -> Method {
+ if name == b"Identity" || name.is_empty() {
+ return Method::Identity;
+ }
+ #[cfg(test)]
+ tests::FILTER_COMPARES.with(|c| c.set(c.get() + 1));
+ self.named.get(name).copied().unwrap_or(Method::Identity)
+ }
}
impl SecurityHandler {
+ fn new(dict: EncryptDict, key: Vec, auth: Auth, recovered_user: Option>) -> Self {
+ let methods = Methods::new(&dict);
+ Self { dict, key, auth, recovered_user, methods }
+ }
+
/// Authenticate with `password` (tried as owner password, then user password). `None`
/// tries the empty password, which opens documents that only have an owner password.
pub fn open(dict: EncryptDict, id0: &[u8], password: Option<&str>) -> Result {
@@ -177,7 +219,7 @@ impl SecurityHandler {
}
r => return Err(CryptError::Unsupported(format!("revision {r}"))),
};
- Ok(Self { dict, key, auth, recovered_user })
+ Ok(Self::new(dict, key, auth, recovered_user))
}
/// Upgrade to owner access with the owner (permissions) password, as when a restricted
@@ -235,26 +277,16 @@ impl SecurityHandler {
self.dict.encrypt_metadata
}
- fn method_named(&self, name: &[u8]) -> Method {
- if name == b"Identity" || name.is_empty() {
- return Method::Identity;
- }
- self.dict.crypt_filters.iter().find(|(n, _)| n == name).map(|(_, m)| *m).unwrap_or(Method::Identity)
- }
-
/// The method for strings or a kind of stream.
fn method(&self, stream: Option) -> Method {
if self.dict.v < 4 {
return Method::Rc4;
}
match stream {
- None => self.method_named(&self.dict.str_f),
- Some(StreamKind::Normal) => self.method_named(&self.dict.stm_f),
- Some(StreamKind::EmbeddedFile) => {
- let eff = if self.dict.ef_f.is_empty() { &self.dict.stm_f } else { &self.dict.ef_f };
- self.method_named(eff)
- }
- Some(StreamKind::Named(n)) => self.method_named(n),
+ None => self.methods.string,
+ Some(StreamKind::Normal) => self.methods.stream,
+ Some(StreamKind::EmbeddedFile) => self.methods.embedded_file,
+ Some(StreamKind::Named(n)) => self.methods.get(n),
}
}
@@ -642,7 +674,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result {
(d.v, d.r, d.length_bits) = (5, 6, 256);
@@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result = const { std::cell::Cell::new(0) };
+}
+
+#[test]
+fn crypt_filters_are_looked_up_once_per_handler() {
+ let plain = new(Algorithm::Aes128, "", "o", -1);
+ let mut d = plain.dict().clone();
+ let n = 5_000;
+ let mut filters: Vec<_> = (0..n).map(|i| (format!("F{i}").into_bytes(), Method::Identity)).collect();
+ filters.append(&mut d.crypt_filters);
+ d.crypt_filters = filters;
+ FILTER_COMPARES.with(|c| c.set(0));
+ let h = SecurityHandler::open(d, ID, None).unwrap();
+ let strings = 1_000;
+ let enc = plain.encrypt_string(7, 0, b"secret");
+ for _ in 0..strings {
+ assert_eq!(h.decrypt_string(7, 0, &enc), b"secret");
+ assert_eq!(h.decrypt_stream(7, 0, &plain.encrypt_stream(7, 0, b"s", StreamKind::Normal), StreamKind::Normal), b"s");
+ }
+ assert_eq!(h.decrypt_stream(7, 0, b"x", StreamKind::Named(b"F3")), b"x");
+ let compares = FILTER_COMPARES.with(|c| c.get());
+ assert!(compares <= 2 * n + 3 * strings, "{compares} filter name comparisons");
+}
+
#[test]
fn identity_crypt_filters_leave_data_alone() {
let mut d = new(Algorithm::Aes256, "", "o", -1).dict().clone();
diff --git a/crates/crypt/tests/it/main.rs b/crates/crypt/tests/it/main.rs
new file mode 100644
index 000000000..8b39bf195
--- /dev/null
+++ b/crates/crypt/tests/it/main.rs
@@ -0,0 +1,4 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod proptests;
+mod qpdf_literals;
diff --git a/crates/crypt/tests/proptests.rs b/crates/crypt/tests/it/proptests.rs
similarity index 100%
rename from crates/crypt/tests/proptests.rs
rename to crates/crypt/tests/it/proptests.rs
diff --git a/crates/crypt/tests/qpdf_literals.rs b/crates/crypt/tests/it/qpdf_literals.rs
similarity index 99%
rename from crates/crypt/tests/qpdf_literals.rs
rename to crates/crypt/tests/it/qpdf_literals.rs
index 8957063ac..f34464d98 100644
--- a/crates/crypt/tests/qpdf_literals.rs
+++ b/crates/crypt/tests/it/qpdf_literals.rs
@@ -278,7 +278,7 @@ fn metadata_flag_from_outside_changes_the_file_key_it_certifies() {
// `/EncryptMetadata` false folds four set bytes into a revision 4 key (Algorithm 2, step f) and
// changes what revision 6 stores in `/U`. qpdf wrote both variants of each, so an implementation
// that ignored the flag would give identical keys. Internally this is no longer untested -
- // `tests/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests
+ // `tests/it/proptests.rs` from #2358 varies the flag and asserts the keys differ - but internal tests
// still share our derivation, so what these rows add is a second implementation's own numbers.
for (enc_label, plain_label) in [("r4-aes128-meta-enc", "r4-aes128-meta-plain"), ("r6-aes256-meta-enc", "r6-aes256-meta-plain")] {
let a = CASES.iter().find(|c| c.label == enc_label).expect("encrypted-metadata fixture present");
diff --git a/crates/edit/src/background.rs b/crates/edit/src/background.rs
index d1d50e450..7d745c0c1 100644
--- a/crates/edit/src/background.rs
+++ b/crates/edit/src/background.rs
@@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result, EditE
let crop = p.crop(doc);
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let gstates = resources.get(b"ExtGState").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).unwrap_or_default();
- let streams = crate::text::content_streams(doc, &p.dict);
+ let streams = crate::text::content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs
index 75bb12f47..6f14271be 100644
--- a/crates/edit/src/images.rs
+++ b/crates/edit/src/images.rs
@@ -9,6 +9,7 @@ use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use std::sync::Arc;
use crate::EditError;
+use crate::text::content_streams;
/// An image drawn on a page.
#[derive(Clone, Debug, PartialEq)]
@@ -73,23 +74,12 @@ struct ClipPath {
even_odd: bool,
}
-fn streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> {
- let list: Vec = match page.get(b"Contents") {
- None => Vec::new(),
- Some(c) => match &*doc.resolve(c) {
- Object::Array(a) => a.clone(),
- _ => vec![c.clone()],
- },
- };
- list.into_iter()
- .map(|o| {
- let data = match &*doc.resolve(&o) {
- Object::Stream(s) => s.decoded().unwrap_or_default(),
- _ => Vec::new(),
- };
- (o, data)
- })
- .collect()
+/// Image draws one page's inventory lists at most: a form drawn many times from other forms
+/// would otherwise multiply a few hundred bytes into millions of records.
+pub(crate) const MAX_PAGE_IMAGES: usize = 100_000;
+
+fn too_many_images() -> EditError {
+ EditError::Invalid(format!("the page draws more than {MAX_PAGE_IMAGES} images"))
}
/// The page's streams joined into the one stream they are: an operator may span two of them.
@@ -237,7 +227,7 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr
let mut out = Vec::new();
// The streams are one stream in pieces, so the graphics state and operators carry over.
let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
- let pieces = joined(&streams(doc, &p.dict));
+ let pieces = joined(&content_streams(doc, &p.dict)?);
for (i, op) in pieces.parse().ops.iter().enumerate() {
state.apply(op)?;
let ctm = state.placement.matrix;
@@ -285,6 +275,9 @@ pub fn page_images(doc: &Document, page: usize) -> Result, EditEr
{
continue;
}
+ if out.len() >= MAX_PAGE_IMAGES {
+ return Err(too_many_images());
+ }
out.push(PageImage {
rect,
visible_rect: intersection(rect, state.placement.clip),
@@ -358,6 +351,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
if s.dict.name(b"Subtype") != Some(b"Image") {
continue;
}
+ if out.len() >= MAX_PAGE_IMAGES {
+ return Err(too_many_images());
+ }
out.push(PageImage {
rect: ctm.bbox([0.0, 0.0, 1.0, 1.0]),
visible_rect: intersection(ctm.bbox([0.0, 0.0, 1.0, 1.0]), st.placement.clip),
@@ -381,8 +377,9 @@ pub fn reading_images(doc: &Document, page: usize) -> Result, Edi
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut out = Vec::new();
let mut st = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new());
- let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::default() };
- let pieces = joined(&streams(doc, &p.dict));
+ let mut decode = pdfcraft_cos::DecodeBudget::page();
+ let pieces = joined(&crate::text::content_streams_within(doc, &p.dict, &mut decode)?);
+ let mut forms = Forms { path: Vec::new(), budget: crate::text::FormBudget::new(decode) };
walk(doc, &pieces.parse().ops, &res, &mut st, &|op| pieces.piece_of(op.span.start), &mut forms, &mut out)?;
Ok(out)
}
@@ -483,7 +480,7 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
let images = page_images(doc, page)?;
let img = images.get(index).cloned().ok_or_else(|| EditError::Invalid(format!("page {} has no image {}", page + 1, index + 1)))?;
let p = page_of(doc, page)?;
- let all = streams(doc, &p.dict);
+ let all = content_streams(doc, &p.dict)?;
let pieces = joined(&all);
let ops = pieces.parse().ops;
if !ops.get(img.op).is_some_and(|op| op.is("Do")) {
@@ -531,7 +528,7 @@ pub(crate) fn selectable_images(
excluded: &std::collections::BTreeSet,
) -> Result, EditError> {
let p = page_of(doc, page)?;
- let all = streams(doc, &p.dict);
+ let all = content_streams(doc, &p.dict)?;
Ok(page_images(doc, page)?
.into_iter()
.enumerate()
@@ -755,7 +752,7 @@ pub(crate) fn translate_images(doc: &mut Document, page: usize, indexes: &[usize
}
let images = page_images(doc, page)?;
let p = page_of(doc, page)?;
- let all = streams(doc, &p.dict);
+ let all = content_streams(doc, &p.dict)?;
let mut plans = std::collections::BTreeMap::new();
for index in indexes {
let image = images.get(*index).ok_or_else(|| EditError::Invalid("a selected image no longer exists".into()))?;
diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
index d3db00fe3..bef515f98 100644
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -17,7 +17,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-use pdfcraft_cos::{Dict, Document, Object, Stream};
+use pdfcraft_cos::{Dict, Document, ObjRef, Object, Stream};
use pdfcraft_fonts::{helvetica_width, literal, win_ansi};
#[derive(Debug, thiserror::Error, PartialEq)]
@@ -601,7 +601,7 @@ 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).
fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, content: Vec, behind: bool) -> Result<(), EditError> {
- place_tagged(doc, page, kind.tag(), content, behind)
+ place_tagged(doc, page, kind.tag(), content, behind).map(drop)
}
/// Put content on a page, tagged `tag` (see [`place`]). On top, whatever graphics state the
@@ -609,7 +609,8 @@ fn place(doc: &mut Document, page: &pdfcraft_model::Page, kind: MarkKind, conten
/// content appended after an earlier PdfCraft wrapper by another tool (#3084): the stretch of
/// content that leaves state set is wrapped in q/Q, once. Any known unfinished marked-content
/// sequences are closed at the end too, so hidden optional content cannot hide the new mark.
-fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result<(), EditError> {
+/// Returns the new content stream.
+fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec, behind: bool) -> Result {
let mut list = contents(doc, page)?;
// The behind-marks at the start: backgrounds in the order added, then watermarks.
let behind_kinds = [MarkKind::Background.tag(), MarkKind::Watermark.tag()];
@@ -625,7 +626,8 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
Isolation::Unknown { unclosed, marked } => (behind_marks < list.len()).then_some((behind_marks, unclosed, marked)),
}
};
- let mark = Object::Ref(doc.add(Object::Stream(Stream::flate(tagged(tag), &content))));
+ let mark_ref = doc.add(Object::Stream(Stream::flate(tagged(tag), &content)));
+ let mark = Object::Ref(mark_ref);
if behind {
// Right after the backgrounds already there, so a new background is painted over the
// older ones (#3591) and a watermark over all of them. Not after the watermarks: on a
@@ -646,7 +648,7 @@ fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, cont
list.push(mark);
}
doc.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(list)))?;
- Ok(())
+ Ok(mark_ref)
}
/// Put content tagged `tag` first inside the q/Q wrapper, so it applies to the page's original
@@ -664,7 +666,8 @@ fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Page, tag: &s
/// Draw `content` on top of a page (0-based) as permanent page content, with standard Helvetica
/// available as `/PCHelv`. The stream is tagged `tag` (not a mark kind, so Remove never takes
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
-pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result<(), EditError> {
+/// Returns the new content stream.
+pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec) -> Result {
let all = page_list(doc);
check(&[page], all.len())?;
add_resources(doc, &all[page], None, Some(&content))?;
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
index cf3860237..1395fb1c3 100644
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -6355,3 +6355,96 @@ fn reading_keeps_dotted_leader_entries_whole_and_in_order() {
]
);
}
+
+/// A one-page document from the page's /Contents and /Resources, built in code.
+fn page_with(build: impl FnOnce(&mut Document) -> (Object, Dict)) -> Document {
+ let mut doc = Document::new_empty();
+ let pages = doc.add(Object::Null);
+ let (contents, resources) = build(&mut doc);
+ let page: Dict = [
+ (b"Type".to_vec(), Object::Name(b"Page".to_vec())),
+ (b"Parent".to_vec(), Object::Ref(pages)),
+ (b"MediaBox".to_vec(), Object::Array([0, 0, 200, 300].map(Object::from).to_vec())),
+ (b"Contents".to_vec(), contents),
+ (b"Resources".to_vec(), Object::Dict(resources)),
+ ]
+ .into_iter()
+ .collect();
+ let page = doc.add(page);
+ let tree: Dict = [
+ (b"Type".to_vec(), Object::Name(b"Pages".to_vec())),
+ (b"Kids".to_vec(), Object::Array(vec![Object::Ref(page)])),
+ (b"Count".to_vec(), Object::Int(1)),
+ ]
+ .into_iter()
+ .collect();
+ doc.set(pages, Object::Dict(tree));
+ let catalog: Dict = [(b"Type".to_vec(), Object::Name(b"Catalog".to_vec())), (b"Pages".to_vec(), Object::Ref(pages))].into_iter().collect();
+ let catalog = doc.add(catalog);
+ doc.trailer_mut().set(b"Root".to_vec(), Object::Ref(catalog));
+ doc
+}
+
+#[test]
+fn repeated_content_references_share_one_decode_budget() {
+ // One 1 MiB stream (a kilobyte on disk) named 300 times by the page's /Contents.
+ let doc = page_with(|doc| {
+ let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
+ (Object::Array(vec![Object::Ref(spaces); 300]), Dict::new())
+ });
+ match text_lines(&doc, 0) {
+ Ok(lines) => panic!("read {} lines from more content than a page may decode", lines.len()),
+ Err(e) => assert!(matches!(e, EditError::Invalid(_)), "{e}"),
+ }
+}
+
+#[test]
+fn reading_images_stops_at_the_page_record_limit() {
+ // 20 draws of a form drawing a form 20 times that draws an image 20 times: 8,000 images
+ // per level-two form, 160,000 in all, from a few hundred bytes.
+ let doc = page_with(|doc| {
+ let image: Dict = [
+ (b"Subtype".to_vec(), Object::Name(b"Image".to_vec())),
+ (b"Width".to_vec(), Object::Int(1)),
+ (b"Height".to_vec(), Object::Int(1)),
+ (b"ColorSpace".to_vec(), Object::Name(b"DeviceGray".to_vec())),
+ (b"BitsPerComponent".to_vec(), Object::Int(8)),
+ ]
+ .into_iter()
+ .collect();
+ let mut inner = doc.add(Stream::from_raw(image, vec![0]));
+ let draws = "/X Do ".repeat(20);
+ let resources = |inner: pdfcraft_cos::ObjRef| -> Dict {
+ let xobjects: Dict = [(b"X".to_vec(), Object::Ref(inner))].into_iter().collect();
+ [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect()
+ };
+ for _ in 0..3 {
+ let mut d = resources(inner);
+ d = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec())), (b"Resources".to_vec(), Object::Dict(d))].into_iter().collect();
+ inner = doc.add(Stream::from_raw(d, draws.clone().into_bytes()));
+ }
+ let page = doc.add(Stream::from_raw(Dict::new(), draws.into_bytes()));
+ (Object::Ref(page), resources(inner))
+ });
+ if let Ok(images) = crate::reading_images(&doc, 0) {
+ assert!(images.len() <= crate::images::MAX_PAGE_IMAGES, "{} images", images.len());
+ }
+}
+
+#[test]
+fn reading_spends_one_decode_budget_on_content_and_forms() {
+ // 130 MiB of content and a 130 MiB form (a kilobyte each on disk): each fits a page's budget,
+ // together they don't, so reading leaves the form out and says so.
+ let doc = page_with(|doc| {
+ let mut body = b"%".to_vec();
+ body.resize(130 << 20, b'x');
+ let form: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
+ let form = doc.add(Stream::flate(form, &body));
+ let mut content = b"/X Do\n%".to_vec();
+ content.resize(130 << 20, b'x');
+ let content = doc.add(Stream::flate(Dict::new(), &content));
+ let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
+ (Object::Ref(content), [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect())
+ });
+ assert!(crate::text::reading_blocks_checked(&doc, 0).unwrap().truncated);
+}
diff --git a/crates/edit/src/text.rs b/crates/edit/src/text.rs
index 93a5eec99..12ed6a207 100644
--- a/crates/edit/src/text.rs
+++ b/crates/edit/src/text.rs
@@ -14,7 +14,7 @@ use std::collections::HashMap;
use std::rc::Rc;
use pdfcraft_content::{Matrix, Op, Pieces, parse, serialize_ops};
-use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
+use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, PdfString, Stream};
use pdfcraft_fonts::pdf::Metrics;
use pdfcraft_fonts::{CraftFont, GlyphError, japanese_glyph_from};
@@ -113,10 +113,12 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result budget {
continue;
}
@@ -615,7 +617,16 @@ struct Ts {
unreadable: Option>,
}
-pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec)> {
+/// The page's content streams as listed, each with its decoded data (empty when it doesn't
+/// decode), within one page's [`DecodeBudget`]. An error when the content runs past it: reading
+/// or rewriting a page from part of its content would lose the rest.
+pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Result)>, EditError> {
+ content_streams_within(doc, page, &mut DecodeBudget::page())
+}
+
+/// [`content_streams`], decoded against `budget`, which a walk of the page's forms then goes on
+/// spending.
+pub(crate) fn content_streams_within(doc: &Document, page: &Dict, budget: &mut DecodeBudget) -> Result)>, EditError> {
let list: Vec = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -623,15 +634,19 @@ pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec vec![c.clone()],
},
};
- list.into_iter()
- .map(|o| {
- let data = match &*doc.resolve(&o) {
- Object::Stream(s) => s.decoded().unwrap_or_default(),
- _ => Vec::new(),
- };
- (o, data)
- })
- .collect()
+ let mut out = Vec::with_capacity(list.len());
+ for o in list {
+ let data = match &*doc.resolve(&o) {
+ Object::Stream(s) => match budget.decode(s) {
+ Ok(data) => data,
+ Err(e) if budget.remaining() == 0 => return Err(EditError::Invalid(format!("the page content is too large to read: {e}"))),
+ Err(_) => Vec::new(),
+ },
+ _ => Vec::new(),
+ };
+ out.push((o, data));
+ }
+ Ok(out)
}
/// Rewrite a page's content (its streams joined, see [`Pieces`]): `edit(i)` gives the operators
@@ -1284,7 +1299,7 @@ pub fn text_lines(doc: &Document, page: usize) -> Result, EditErro
let mut carry = Carry::new();
// The streams are one stream in pieces (§7.8.2), split between any two tokens: parse them
// joined, so an operator whose operands end one piece still has them.
- let pieces = Pieces::join(&content_streams(doc, &p.dict).into_iter().map(|(_, d)| d).collect::>());
+ let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::>());
let ops = pieces.parse().ops;
let properties = res.get(b"Properties").and_then(|o| doc.dict(o)).unwrap_or_default();
let shown = interpret(doc, &ops, &res, &mut cache, &mut carry, None, Some(&properties))?;
@@ -1490,15 +1505,23 @@ pub const MAX_FORM_DEPTH: usize = 50;
pub const MAX_FORM_VISITS: usize = 50_000;
/// One page's walk through the form XObjects it draws: how many it entered (the page's
-/// [`MAX_FORM_VISITS`]), and whether a limit left a form unread.
-#[derive(Default)]
+/// [`MAX_FORM_VISITS`]), the bytes the page's content and forms may still decode to, and whether
+/// a limit left a form unread.
pub(crate) struct FormBudget {
visits: usize,
- /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`] or comes past
- /// [`MAX_FORM_VISITS`]: what it draws is missing from the walk.
+ decode: DecodeBudget,
+ /// A form was left unread because it nests deeper than [`MAX_FORM_DEPTH`], comes past
+ /// [`MAX_FORM_VISITS`] or past the decode budget: what it draws is missing from the walk.
pub(crate) truncated: bool,
}
+impl FormBudget {
+ /// The walk of a page's forms, spending what `decode` has left after the page's content.
+ pub(crate) fn new(decode: DecodeBudget) -> Self {
+ Self { visits: 0, decode, truncated: false }
+ }
+}
+
/// A form XObject a page draws: its object, content, resources and its matrix composed with the
/// CTM at the `Do`.
pub(crate) struct FormCall {
@@ -1519,7 +1542,7 @@ pub(crate) fn form_call(
path: &[pdfcraft_cos::ObjRef],
budget: &mut FormBudget,
) -> Option {
- let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS;
+ let limited = path.len() >= MAX_FORM_DEPTH || budget.visits >= MAX_FORM_VISITS || budget.decode.remaining() == 0;
// Once a limit has cut the page short, refusing the rest costs nothing more.
if limited && budget.truncated {
return None;
@@ -1546,13 +1569,16 @@ pub(crate) fn form_call(
});
// 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 data = match budget.decode.decode(s) {
+ Ok(data) => data,
+ Err(_) if budget.decode.remaining() == 0 => {
+ budget.truncated = true;
+ return None;
+ }
+ Err(_) => Vec::new(),
+ };
budget.visits = budget.visits.saturating_add(1);
- Some(FormCall {
- obj,
- data: s.decoded().unwrap_or_default(),
- resources: own.unwrap_or_else(|| resources.clone()),
- ctm: m.map_or(ctm, |m| Matrix(m).then(&ctm)),
- })
+ Some(FormCall { obj, data, resources: own.unwrap_or_else(|| resources.clone()), 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
@@ -1612,8 +1638,9 @@ fn reading_lines(doc: &Document, page: usize) -> Result<(Vec, bool), E
}
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
- let streams = content_streams(doc, &p.dict);
- let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::default() };
+ let mut decode = DecodeBudget::page();
+ let streams = content_streams_within(doc, &p.dict, &mut decode)?;
+ let mut w = Walk { doc, lines: Vec::new(), next_stream: streams.len(), path: Vec::new(), budget: FormBudget::new(decode) };
let mut carry = Carry::new();
// Read the page's streams as the one stream they are, in one pass, as rendering and editing
// do: a comment, an operator's operands or a marked-content sequence (its /ActualText too)
@@ -2235,7 +2262,7 @@ pub(crate) fn replace_line_with_limit(doc: &mut Document, page: usize, line: usi
let p = page_dict(doc, page)?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
let first = target.ops.first().copied().filter(|i| *i < ops.len()).ok_or_else(|| EditError::Invalid("the page's content changed".into()))?;
@@ -2440,7 +2467,7 @@ pub fn clear_block(doc: &mut Document, page: usize, block: usize) -> Result<(),
drops.extend(target.lines.iter().filter_map(|i| lines.get(*i)).flat_map(|l| l.ops.iter().copied()));
let p = page_dict(doc, page)?;
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
let shown = interpret(doc, &ops, &res, &mut HashMap::new(), &mut Carry::new(), None, None)?;
@@ -2486,14 +2513,17 @@ pub fn reading_blocks(doc: &Document, page: usize) -> Result, Edi
pub struct ReadingBlocks {
/// The paragraphs, as [`reading_blocks`] returns them.
pub blocks: Vec,
- /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], or more of them than
- /// [`MAX_FORM_VISITS`]. Reading leaves those out to stay bounded on hostile files, so their
- /// text is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
+ /// The page draws forms nested deeper than [`MAX_FORM_DEPTH`], more of them than
+ /// [`MAX_FORM_VISITS`], or forms that run past what the page's content left of its decode
+ /// budget. Reading leaves those forms out to stay bounded on hostile files, so their text
+ /// is missing from `blocks`; [`crate::reading_images`] walks the same forms and misses
/// their images too.
pub truncated: bool,
}
-/// [`reading_blocks`], saying whether the limits that bound reading left text out.
+/// [`reading_blocks`], saying whether the limits that bound reading left text out. A page
+/// whose `/Contents` alone runs past its decode budget is an [`EditError::Invalid`], not a
+/// truncated result.
pub fn reading_blocks_checked(doc: &Document, page: usize) -> Result {
let (lines, truncated) = reading_lines(doc, page)?;
Ok(ReadingBlocks { blocks: regions::group_reading_blocks(&lines), truncated })
@@ -2661,7 +2691,7 @@ fn rewrite_block_from(
{
return rewrite_styled_block(doc, page, &b, &lines, text, style);
}
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
// Explicit line breaks stay (#1443); within a line, runs of white space become one space.
@@ -3044,7 +3074,7 @@ pub(crate) fn selectable_blocks(
excluded: &std::collections::BTreeSet,
) -> Result, EditError> {
let p = page_dict(doc, page)?;
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
Ok(group_blocks(&lines)
.into_iter()
@@ -3133,7 +3163,7 @@ pub(crate) fn translate_blocks(doc: &mut Document, page: usize, indexes: &[usize
return Ok(());
}
let p = page_dict(doc, page)?;
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let lines = text_lines(doc, page)?;
let blocks = group_blocks(&lines);
let mut selected = std::collections::BTreeSet::new();
diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs
index 928280d5d..a77559494 100644
--- a/crates/edit/src/text_regions.rs
+++ b/crates/edit/src/text_regions.rs
@@ -772,7 +772,7 @@ pub(super) fn rewrite_styled_block(
row_ranges.push(row_start..row_start + row.len());
rows.push(row);
}
- let streams = content_streams(doc, &p.dict);
+ let streams = content_streams(doc, &p.dict)?;
let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>());
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and
diff --git a/crates/engine/src/actions.rs b/crates/engine/src/actions.rs
index f679cf87c..f7d11d47e 100644
--- a/crates/engine/src/actions.rs
+++ b/crates/engine/src/actions.rs
@@ -191,9 +191,13 @@ fn apply_step(s: &mut Session, id: DocId, step: &Step, log: &mut Vec) ->
return Ok(None);
}
};
- // Steps that find nothing to do (no comments to flatten, …) aren't failures.
+ // Steps that find nothing to do (no comments to flatten, …) aren't failures. A removal step
+ // that didn't remove is: the file would be passed on as cleaned with the data still in it.
+ let removes = matches!(step, Step::RemoveHiddenInformation | Step::Sanitize);
match s.apply(id, edit) {
Ok(()) => log.push(step.label().to_string()),
+ Err(EditError::Redact(pdfcraft_redact::RedactError::NothingToApply)) if removes => log.push(format!("{}: nothing to remove", step.label())),
+ Err(e) if removes => return Err(e),
Err(e) => log.push(format!("{}: {e}", step.label())),
}
Ok(None)
diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs
index 50508e20e..c23db91f1 100644
--- a/crates/engine/src/compare.rs
+++ b/crates/engine/src/compare.rs
@@ -46,10 +46,13 @@ impl crate::Document {
}
impl Session {
- /// Compare files: the text differences from document `old` to document `new`.
+ /// Compare files: the text differences from document `old` to document `new`. The result
+ /// carries both documents' text, so both must allow copying it.
pub fn compare(&self, old: DocId, new: DocId) -> Result {
let a = self.get(old).ok_or(EditError::NoDocument)?;
let b = self.get(new).ok_or(EditError::NoDocument)?;
+ a.check_copying()?;
+ b.check_copying()?;
Ok(pdfcraft_compare::compare(&a.words(), &b.words()))
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 7811faab1..bde8f0960 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -232,6 +232,10 @@ pub struct DocId(pub u64);
/// Undo/redo depth. Snapshots share unchanged data, so this is memory-cheap.
const MAX_UNDO: usize = 100;
+/// The highest resolution a document that allows only low-resolution printing (Table 22, bit 12
+/// clear) is printed at.
+const LOW_RESOLUTION_PRINT_DPI: f64 = 150.0;
+
/// The passwords that go with one state of a document (protection can change them).
#[derive(Clone, Debug, Default, PartialEq)]
struct Keys {
@@ -981,16 +985,17 @@ fn recovered_render_password(cos: &pdfcraft_cos::Document) -> Option {
///
/// Redaction decides what lies under a mark from the positions its own interpreter computes, and
/// its verify pass reuses them. Where they differ from the renderer's (a standard font without
-/// /Widths, whose advances the interpreter approximates; a CMap, or a font set outside `Tf`,
+/// /Widths, whose advances the interpreter approximates; a CMap
/// that it reads differently), a mark drawn over the text as displayed lies over glyphs the
/// interpreter believes are elsewhere: they stay in the file and nothing notices. The renderer is
/// what the user saw when placing the marks, so it is asked. `after` is written as a save would
-/// write it, without the marks' own overlay (the box and its label are drawn on purpose), its
+/// write it, without the marks' own `overlays` (the box and its label are drawn on purpose), its
/// text is extracted, and a glyph still mostly inside a mark fails the edit: the caller keeps
/// the document as it was.
fn shown_under_redactions(
after: &pdfcraft_cos::Document,
marks: &[pdfcraft_redact::Mark],
+ overlays: &[pdfcraft_cos::ObjRef],
pages: &[pdfcraft_render::PageInfo],
password: Option<&str>,
) -> Result<(), EditError> {
@@ -1007,8 +1012,7 @@ fn shown_under_redactions(
for &pi in &marked {
let Some(page) = model.get(pi) else { continue };
let Some(Object::Array(list)) = page.dict.get(b"Contents").map(|c| (*shown.resolve(c)).clone()) else { continue };
- let kept: Vec =
- list.into_iter().filter(|o| shown.resolve(o).as_dict().and_then(|d| d.name(b"PCMark")) != Some(b"Redaction")).collect();
+ let kept: Vec = list.into_iter().filter(|o| !o.as_ref().is_some_and(|r| overlays.contains(&r))).collect();
shown.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), Object::Array(kept))).map_err(|e| EditError::Write(e.to_string()))?;
}
let bytes = write_incremental(&shown, &SaveOptions::default()).map_err(|e| EditError::Write(e.to_string()))?;
@@ -2352,6 +2356,9 @@ struct EditCtx {
/// Fields the edit renamed to keep imported names separate (old → new), for the repair
/// log and the undo label.
renamed_fields: Vec<(String, String)>,
+ /// The overlay streams the edit's applied redactions added, which alone the renderer's check
+ /// leaves out.
+ redaction_overlays: Vec,
}
impl EditCtx {
@@ -2378,6 +2385,7 @@ impl EditCtx {
seed,
count: 0,
renamed_fields: Vec::new(),
+ redaction_overlays: Vec::new(),
}
}
@@ -2742,7 +2750,7 @@ fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) ->
pdfcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(pdfcraft_edit::AddedImage { image, ..old }))?;
}
Edit::ApplyRedactions { pages } => {
- pdfcraft_redact::apply(doc, pages.as_deref())?;
+ cx.redaction_overlays = pdfcraft_redact::apply(doc, pages.as_deref())?.overlays;
}
Edit::ClearRedactions => {
pdfcraft_redact::clear_marks(doc, None)?;
@@ -3804,6 +3812,7 @@ impl Session {
cx.js = Some(js::JsRunner::new(&next, &name));
}
cx.renamed_fields.clear();
+ cx.redaction_overlays.clear();
// `next` is a copy: if the edit fails or crashes, the document is unchanged.
guard(|| run_edit(&mut next, &edit, &mut cx))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
@@ -3812,7 +3821,7 @@ impl Session {
if let Edit::ApplyRedactions { pages } = &edit {
let marks: Vec =
pdfcraft_redact::marks(&editor.cos).into_iter().filter(|m| pages.as_ref().is_none_or(|p| p.contains(&m.page))).collect();
- guard(|| shown_under_redactions(&next, &marks, &doc.info.pages, doc.password.as_deref()))
+ guard(|| shown_under_redactions(&next, &marks, &cx.redaction_overlays, &doc.info.pages, doc.password.as_deref()))
.unwrap_or_else(|m| Err(EditError::Invalid(format!("{} failed unexpectedly ({m}); the document was not changed", edit.label()))))?;
}
// Renamed fields are said, not slipped in: a repair-log note (with the caveat that
@@ -4532,17 +4541,22 @@ impl Session {
if !doc.allows_printing() {
return Err(EditError::NotPermitted("printing"));
}
+ // Table 22, bit 12 clear: only a low-level representation may be printed, so the pages
+ // always go as pictures, at no more than the low resolution.
+ let low_res = doc.permissions().is_some_and(|p| !p.print_high_quality()).then(|| print::Raster {
+ dpi: settings.raster.map_or(LOW_RESOLUTION_PRINT_DPI, |r| r.dpi.min(LOW_RESOLUTION_PRINT_DPI)),
+ grayscale: settings.raster.is_some_and(|r| r.grayscale),
+ });
// Print as image: the pages used become pictures first, then the picture document is
// imposed in their place (its page k is the k-th distinct source page used).
- if let Some(raster) = &settings.raster {
+ if let Some(raster) = low_res.as_ref().or(settings.raster.as_ref()) {
let mut distinct: Vec = Vec::new();
for &p in &settings.pages {
if !distinct.contains(&p) {
distinct.push(p);
}
}
- let hide_comments = matches!(settings.content, print::Content::Document | print::Content::FormFieldsOnly);
- let bytes = print_raster::rasterize(doc, &distinct, raster, hide_comments).map_err(EditError::Print)?;
+ let bytes = print_raster::rasterize(doc, &distinct, raster, settings.content).map_err(EditError::Print)?;
let pictures = pdfcraft_cos::Document::open(Arc::new(bytes)).map_err(|e| EditError::Print(e.to_string()))?;
let remapped = print::Settings {
pages: settings.pages.iter().map(|p| distinct.iter().position(|d| d == p).unwrap_or(0)).collect(),
diff --git a/crates/engine/src/print_raster.rs b/crates/engine/src/print_raster.rs
index 117c8e14c..4ed589586 100644
--- a/crates/engine/src/print_raster.rs
+++ b/crates/engine/src/print_raster.rs
@@ -1,25 +1,33 @@
//! Print as image (Acrobat's Advanced Print Setup ▸ Print As Image): every page
//! sent to the printer as a picture at a chosen resolution, for printers that skip text,
//! shading or transparency. The pages are rendered the way the viewer shows them (reading
-//! colours off, the print's own colours), made into an image-only document, and that document
-//! is imposed on the paper like any other. The print is no longer searchable text.
+//! colours off, the print's own colours) with only what the print's content choice keeps, made
+//! into an image-only document, and that document is imposed on the paper like any other. The
+//! print is no longer searchable text.
+use pdfcraft_cos::{SaveOptions, write_incremental};
use pdfcraft_create::ImageResolution;
-use pdfcraft_print::Raster;
+use pdfcraft_print::{Content, Raster};
use pdfcraft_render::{PRINT_TAG, PageRenderer, RenderRequest};
-use crate::Document;
use crate::export::encode_png;
+use crate::{Document, guard};
/// `doc`'s pages `pages` (0-based, each once, in the order given) as an image-only PDF, one
-/// page per entry, the size of the source page as displayed. Pages too large for the renderer
-/// at `raster.dpi` render at the largest resolution it allows.
-pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, hide_comments: bool) -> Result, String> {
+/// page per entry, the size of the source page as displayed, drawing what `content` prints.
+/// Pages too large for the renderer at `raster.dpi` render at the largest resolution it allows.
+pub fn rasterize(doc: &Document, pages: &[usize], raster: &Raster, content: Content) -> Result, String> {
let dpi = raster.dpi.clamp(72.0, 600.0);
let src = doc.export_source();
let mut config = src.config.clone();
- config.hide_comments = hide_comments;
- let mut renderer = PageRenderer::new(src.bytes, config);
+ // The copy below holds only what prints, so the viewer's own comment hiding plays no part.
+ config.hide_comments = false;
+ let printed = guard(|| -> Result, String> {
+ let mut cos = pdfcraft_cos::Document::open_with_password(src.bytes.clone(), config.password.as_deref()).map_err(|e| e.to_string())?;
+ pdfcraft_print::keep_printed(&mut cos, content);
+ write_incremental(&cos, &SaveOptions::default()).map_err(|e| e.to_string())
+ })??;
+ let mut renderer = PageRenderer::new(std::sync::Arc::new(printed), config);
let mut images: Vec<(String, Vec)> = Vec::with_capacity(pages.len());
let mut used: Option = None;
for &page in pages {
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index d8a680880..ed9e22c6b 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -1311,6 +1311,12 @@ fn copy_restricted_documents_refuse_extraction_unless_opened_by_the_owner() {
let oid = s.open("r.pdf", None, bytes, Some("owner")).unwrap();
assert!(s.get(oid).unwrap().allows_copying());
assert!(extracting.iter().all(|c| enabled(&s, oid, c)));
+ // Comparing reads both documents' text out, into the result, the report and the comments.
+ let refused = || Err(EditError::NotPermitted("copying text and images"));
+ assert_eq!((s.compare(id, oid).map(|_| ()), s.compare(oid, id).map(|_| ())), (refused(), refused()));
+ assert_eq!(s.compare_report(oid, id).map(|_| ()), refused());
+ assert!(s.compare(oid, oid).is_ok());
+ assert_eq!(s.mark_differences(id, oid).map(|_| ()), refused());
// Unencrypted documents allow everything.
let (s, plain) = session_with(1);
assert!(s.get(plain).unwrap().check_copying().is_ok() && extracting.iter().all(|c| enabled(&s, plain, c)));
@@ -1334,6 +1340,115 @@ fn print_restricted_documents_disable_print_unless_opened_by_the_owner() {
assert!(enabled(&s, low, "print.dialog"), "low-resolution printing is printing");
}
+/// Table 22, bit 12 clear: printing is limited to a low-level representation. Whatever the
+/// settings ask for, the print-ready PDF carries each page as a picture of at most 150 dpi, with
+/// no text or vector artwork left; the owner password lifts the limit.
+#[test]
+fn low_resolution_printing_prints_pictures_of_at_most_150_dpi() {
+ let bytes = protected("", "owner", -1 ^ (1 << 11));
+ let mut s = Session::new();
+ let low = s.open("low.pdf", None, bytes.clone(), None).unwrap();
+ let width = f64::from(s.get(low).unwrap().info.pages[0].width);
+ let actual = print::Settings { pages: vec![0], layout: print::Layout::Size(print::SizeMode::Actual), ..Default::default() };
+ for raster in [None, Some(print::Raster { dpi: 600.0, grayscale: false })] {
+ let printed = s.print_pdf(low, &print::Settings { raster, ..actual.clone() }).unwrap();
+ let mut s2 = Session::new();
+ let id2 = s2.open("print.pdf", None, Arc::new(printed.clone()), None).unwrap();
+ assert!(page_texts(&s2, id2).iter().all(|t| t.trim().is_empty()), "{raster:?}: no text is left");
+ let doc = pdfcraft_cos::Document::open(Arc::new(printed)).unwrap();
+ let widths: Vec = doc
+ .scan_objects()
+ .filter_map(|(_, o)| match &*o {
+ pdfcraft_cos::Object::Stream(st) if st.dict.name(b"Subtype") == Some(b"Image") => st.dict.int(b"Width"),
+ _ => None,
+ })
+ .collect();
+ assert!(!widths.is_empty() && widths.iter().all(|&w| w as f64 <= width * 150.0 / 72.0 + 1.0), "{raster:?}: {widths:?} px for {width} pt");
+ }
+ let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap();
+ let printed = s.print_pdf(owner, &actual).unwrap();
+ let mut s2 = Session::new();
+ let id2 = s2.open("print.pdf", None, Arc::new(printed), None).unwrap();
+ assert!(page_texts(&s2, id2).iter().any(|t| !t.trim().is_empty()), "the owner prints the pages themselves");
+}
+
+/// A 100 × 400 pt page with one band per kind of thing that prints, top to bottom: page content
+/// (blue), a filled text field (green), a stamp (red) and a square comment (yellow).
+fn print_content_pdf() -> Vec {
+ let content = "0 0 1 rg 0 300 100 100 re f";
+ let ap = |colour: &str| {
+ let ops = format!("{colour} rg 0 0 100 100 re f");
+ format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >> stream\n{ops}\nendstream", ops.len())
+ };
+ format!(
+ "%PDF-1.7\n\
+ 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [5 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 400] /Contents 4 0 R /Annots [5 0 R 6 0 R 7 0 R] >> endobj\n\
+ 4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n\
+ 5 0 obj << /Type /Annot /Subtype /Widget /FT /Tx /T (Name) /V (Filled) /F 4 /P 3 0 R /Rect [0 200 100 300] /AP << /N 8 0 R >> >> endobj\n\
+ 6 0 obj << /Type /Annot /Subtype /Stamp /F 4 /P 3 0 R /Rect [0 100 100 200] /AP << /N 9 0 R >> >> endobj\n\
+ 7 0 obj << /Type /Annot /Subtype /Square /F 4 /P 3 0 R /Rect [0 0 100 100] /AP << /N 10 0 R >> >> endobj\n\
+ 8 0 obj {} endobj\n\
+ 9 0 obj {} endobj\n\
+ 10 0 obj {} endobj\n\
+ trailer << /Root 1 0 R >>\n%%EOF",
+ content.len(),
+ ap("0 1 0"),
+ ap("1 0 0"),
+ ap("1 1 0"),
+ )
+ .into_bytes()
+}
+
+/// Low-resolution printing prints what the chosen content prints when the pages themselves are
+/// printed: form fields only leaves out the page content and every comment, the document alone
+/// leaves out the comments, and the document with stamps keeps only the stamps.
+#[test]
+fn low_resolution_printing_prints_only_the_chosen_content() {
+ let mut cos = pdfcraft_cos::Document::open(Arc::new(print_content_pdf())).unwrap();
+ cos.set_encryption(&pdfcraft_cos::NewEncryption {
+ algorithm: pdfcraft_cos::Algorithm::Aes256,
+ user_password: "",
+ owner_password: "owner",
+ permissions: -1 ^ (1 << 11),
+ encrypt_metadata: true,
+ seed: [7; 32],
+ })
+ .unwrap();
+ let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
+ let mut s = Session::new();
+ let low = s.open("low.pdf", None, bytes.clone(), None).unwrap();
+ let owner = s.open("low.pdf", None, bytes, Some("owner")).unwrap();
+ // Each band's centre, in pixels from the top of the sheet at 72 dpi, and its colour.
+ let bands = [("content", 50, [0, 0, 255]), ("field", 150, [0, 255, 0]), ("stamp", 250, [255, 0, 0]), ("comment", 350, [255, 255, 0])];
+ let all: &[&str] = &["content", "field", "stamp", "comment"];
+ for (content, shown) in [
+ (print::Content::DocumentAndMarkups, all),
+ (print::Content::Document, &["content", "field"][..]),
+ (print::Content::DocumentAndStamps, &["content", "field", "stamp"][..]),
+ (print::Content::FormFieldsOnly, &["field"][..]),
+ ] {
+ let settings = print::Settings {
+ pages: vec![0],
+ paper: (100.0, 400.0),
+ layout: print::Layout::Size(print::SizeMode::Actual),
+ content,
+ ..Default::default()
+ };
+ for (id, how) in [(owner, "pages"), (low, "pictures")] {
+ let (w, h, rgba) = printed_pixels(s.print_pdf(id, &settings).unwrap());
+ assert_eq!((w, h), (100, 400), "{content:?} as {how}");
+ for (band, y, colour) in bands {
+ let px = rgba.get((y * w as usize + 50) * 4..).and_then(|p| p.get(..3)).unwrap();
+ let near = |c: [u8; 3]| px.iter().zip(c).all(|(&a, b)| a.abs_diff(b) < 40);
+ let expected = shown.contains(&band);
+ assert!(if expected { near(colour) } else { near([255; 3]) }, "{content:?} as {how}: {band} drawn {expected} expected, pixel {px:?}");
+ }
+ }
+ }
+}
+
#[test]
fn the_permissions_password_unlocks_an_open_document() {
// Only a permissions password (#995, #1077): opens without asking. Changes are allowed,
@@ -2138,12 +2253,17 @@ fn setting_the_calculation_order_recalculates_and_undoes() {
/// One 612×300 page with the content stream `body`. Object 4 is Helvetica; `resources` is what
/// the page's /Resources holds.
fn one_line_page(resources: &str, body: &str) -> Vec {
+ one_line_page_with(resources, "", body)
+}
+
+/// [`one_line_page`] with `stream_entries` added to the content stream's dictionary.
+fn one_line_page_with(resources: &str, stream_entries: &str, body: &str) -> Vec {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 300] >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /Resources << {resources} >> /Contents 5 0 R >>"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_string(),
- format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
+ format!("<< /Length {} {stream_entries} >>\nstream\n{body}\nendstream", body.len()),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
@@ -2174,31 +2294,76 @@ fn mark_shown_text(s: &mut Session, id: DocId, needle: &str) {
s.apply(id, mark).unwrap();
}
-/// Redaction removes text by the positions its own interpreter works out. Here the font and size
-/// come from an ExtGState /Font entry (`gs`), which the renderer honours and the interpreter does
-/// not model, so it places no glyph under the mark the user drew over the word as displayed: it
-/// used to report success and leave the word in the file. The renderer is asked now, and the edit
-/// is refused.
+/// Redaction removes text by the positions its own interpreter works out, and the renderer has
+/// the last word: where the two disagree, a page that still shows a glyph mostly under a mark
+/// fails the edit. The check is run here on a page nothing was removed from, as if the
+/// interpreter had placed the word elsewhere. Only the overlay streams an apply added are left
+/// out: a content stream of the input labelled like one (`/PCMark /Redaction`) is checked too.
#[test]
-fn redaction_is_refused_when_the_page_still_shows_text_under_a_mark() {
+fn the_renderer_check_refuses_text_still_shown_under_a_mark() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let page = one_line_page_with("/Font << /F1 4 0 R >>", "/PCMark /Redaction", "BT /F1 16 Tf 20 150 Td (Public words then SECRET42) Tj ET");
+ let id = s.open("shown.pdf", None, Arc::new(page), None).unwrap();
+ mark_shown_text(&mut s, id, "SECRET42");
+ let doc = s.get(id).unwrap();
+ let mut cos = doc.editor.as_ref().unwrap().cos.clone();
+ let marks = pdfcraft_redact::marks(&cos);
+ let refused = |cos: &pdfcraft_cos::Document| {
+ let shown = shown_under_redactions(cos, &marks, &[], &doc.info.pages, None);
+ matches!(shown, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0)
+ };
+ assert!(refused(&cos));
+ // The page's contents as an array, as they are once an apply has added its overlay.
+ let page = pdfcraft_model::pages(&cos).swap_remove(0);
+ let contents = page.dict.get(b"Contents").cloned().unwrap();
+ cos.update_dict(page.obj, |d| d.set(b"Contents".to_vec(), pdfcraft_cos::Object::Array(vec![contents]))).unwrap();
+ assert!(refused(&cos), "the labelled input stream was not checked");
+}
+
+/// Inline image data `7FEI 1 0 0 1 200 0 cm` holds an `EI` the renderer ends the image at, so it
+/// runs the `cm` and shows SECRET 200 pt to the right. Redaction reads the image the same way, so
+/// a mark drawn over the word as shown takes it out of the file.
+#[test]
+fn text_after_an_inline_image_is_redacted_where_the_page_shows_it() {
+ let mut s = Session::new().with_clock(|| 1_700_000_000);
+ let page = one_line_page(
+ "/Font << /F1 4 0 R >>",
+ "BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 7FEI 1 0 0 1 200 0 cm EI BT /F1 10 Tf 10 100 Td (SECRET) Tj ET",
+ );
+ let id = s.open("inline.pdf", None, Arc::new(page), None).unwrap();
+ mark_shown_text(&mut s, id, "SECRET");
+ s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
+ assert!(!page_texts(&s, id).concat().contains("SECRET"));
+ let saved = s.save_bytes(id).unwrap();
+ let held = |d: &[u8]| d.windows(6).any(|w| w == b"SECRET");
+ assert!(!held(&saved), "the saved file holds the redacted word");
+ let reopened = pdfcraft_cos::Document::open(saved).unwrap();
+ assert!(
+ !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
+ "a saved stream holds the redacted word"
+ );
+}
+
+/// A word whose font and size an ExtGState /Font entry (`gs`) sets is placed where the renderer
+/// draws it, so a mark drawn over it as displayed takes it out of the file.
+#[test]
+fn a_word_in_a_font_set_by_an_extgstate_is_redacted() {
let line = "Public words then SECRET42 and more";
let mut s = Session::new().with_clock(|| 1_700_000_000);
let by_gs = one_line_page("/ExtGState << /GS1 << /Type /ExtGState /Font [4 0 R 16] >> >>", &format!("BT /GS1 gs 20 150 Td ({line}) Tj ET"));
let id = s.open("gs.pdf", None, Arc::new(by_gs), None).unwrap();
mark_shown_text(&mut s, id, "SECRET42");
- let refused = s.apply(id, Edit::ApplyRedactions { pages: None });
- assert!(matches!(refused, Err(EditError::Redact(pdfcraft_redact::RedactError::Residue(n))) if n > 0), "{refused:?}");
- // Nothing was changed: the mark is still pending and the page still shows the line.
- assert_eq!(s.get(id).unwrap().redaction_marks(), 1);
- assert_eq!(page_texts(&s, id), [line]);
-
- // The same line with its font set by `Tf`: the mark is applied and the word is gone.
- let by_tf = one_line_page("/Font << /F1 4 0 R >>", &format!("BT /F1 16 Tf 20 150 Td ({line}) Tj ET"));
- let id = s.open("tf.pdf", None, Arc::new(by_tf), None).unwrap();
- mark_shown_text(&mut s, id, "SECRET42");
s.apply(id, Edit::ApplyRedactions { pages: None }).unwrap();
let shown = page_texts(&s, id).swap_remove(0);
assert!(shown.starts_with("Public words then") && shown.ends_with("and more") && !shown.contains("SECRET42"), "{shown}");
+ let saved = s.save_bytes(id).unwrap();
+ let held = |d: &[u8]| d.windows(8).any(|w| w == b"SECRET42");
+ assert!(!held(&saved), "the saved file holds the redacted word");
+ let reopened = pdfcraft_cos::Document::open(saved).unwrap();
+ assert!(
+ !reopened.scan_objects().any(|(_, o)| matches!(&*o, pdfcraft_cos::Object::Stream(st) if st.decoded().is_ok_and(|d| held(&d)))),
+ "a saved stream holds the redacted word"
+ );
}
#[test]
@@ -4236,6 +4401,24 @@ fn actions_run_their_steps_on_files() {
assert!(actions::run_on(&custom, "bad.pdf", Arc::new(b"nope".to_vec()), |_, _| {}).is_err());
}
+/// A removal step that fails (here: a signed file can't be rewritten without its hidden data)
+/// fails the action, so the file is not passed on as cleaned.
+#[test]
+fn actions_fail_when_hidden_information_could_not_be_removed() {
+ let p12 = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../sign/tests/data/ec-p256.p12")).unwrap();
+ let digital_id = sign::pkcs12::open(&p12, "test").unwrap();
+ let mut s = Session::new().with_clock(|| 1_800_000_000);
+ let id = s.open("fixture.pdf", None, Arc::new(fixture(2)), None).unwrap();
+ s.apply(id, Edit::SetInfo { key: "Author".into(), value: "Private Name".into() }).unwrap();
+ let saved = s.save_bytes(id).unwrap();
+ s.mark_saved(id, saved, None).unwrap();
+ let signed = s.sign(id, &digital_id, SignOptions { page: 1, ..SignOptions::default() }).unwrap();
+ let built = actions::builtin();
+ let sanitize = built.iter().find(|a| a.name == "Sanitize Documents").unwrap();
+ let e = actions::run_on(sanitize, "signed.pdf", signed, |_, _| {}).map(|r| r.log).unwrap_err();
+ assert!(e.starts_with("Sanitize document:"), "{e}");
+}
+
#[test]
fn exporting_office_files_keeps_images() {
let mut s = Session::new();
diff --git a/crates/engine/tests/document_fonts.rs b/crates/engine/tests/it/document_fonts.rs
similarity index 99%
rename from crates/engine/tests/document_fonts.rs
rename to crates/engine/tests/it/document_fonts.rs
index ebeff24ae..e4212f4a3 100644
--- a/crates/engine/tests/document_fonts.rs
+++ b/crates/engine/tests/it/document_fonts.rs
@@ -1,7 +1,5 @@
//! Document font selection and contextual glyph save/reopen acceptance on synthetic PDFs.
-mod support {
- pub mod font_fixture;
-}
+use crate::support;
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
use pdfcraft_engine::{BlockStyle, Edit, Session};
use pdfcraft_render::{PageRenderer, RenderRequest};
diff --git a/crates/engine/tests/form_rotation.rs b/crates/engine/tests/it/form_rotation.rs
similarity index 100%
rename from crates/engine/tests/form_rotation.rs
rename to crates/engine/tests/it/form_rotation.rs
diff --git a/crates/engine/tests/it/main.rs b/crates/engine/tests/it/main.rs
new file mode 100644
index 000000000..15e1a8548
--- /dev/null
+++ b/crates/engine/tests/it/main.rs
@@ -0,0 +1,12 @@
+//! The crate's integration tests, linked into one test binary.
+
+mod support {
+ pub mod font_fixture;
+}
+
+mod document_fonts;
+mod form_rotation;
+mod marked_content_isolation;
+mod ocr_edit;
+mod private_image_resize;
+mod private_text_glyphs;
diff --git a/crates/engine/tests/marked_content_isolation.rs b/crates/engine/tests/it/marked_content_isolation.rs
similarity index 100%
rename from crates/engine/tests/marked_content_isolation.rs
rename to crates/engine/tests/it/marked_content_isolation.rs
diff --git a/crates/engine/tests/ocr_edit.rs b/crates/engine/tests/it/ocr_edit.rs
similarity index 100%
rename from crates/engine/tests/ocr_edit.rs
rename to crates/engine/tests/it/ocr_edit.rs
diff --git a/crates/engine/tests/private_image_resize.rs b/crates/engine/tests/it/private_image_resize.rs
similarity index 100%
rename from crates/engine/tests/private_image_resize.rs
rename to crates/engine/tests/it/private_image_resize.rs
diff --git a/crates/engine/tests/private_text_glyphs.rs b/crates/engine/tests/it/private_text_glyphs.rs
similarity index 100%
rename from crates/engine/tests/private_text_glyphs.rs
rename to crates/engine/tests/it/private_text_glyphs.rs
diff --git a/crates/engine/tests/support/font_fixture.rs b/crates/engine/tests/it/support/font_fixture.rs
similarity index 100%
rename from crates/engine/tests/support/font_fixture.rs
rename to crates/engine/tests/it/support/font_fixture.rs
diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs
index f3030ad58..8c3999712 100644
--- a/crates/export/src/lib.rs
+++ b/crates/export/src/lib.rs
@@ -8,6 +8,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+use std::collections::HashMap;
+
mod pptx;
mod zip;
@@ -85,14 +87,33 @@ enum Item<'a> {
PageBreak,
}
+#[cfg(test)]
+thread_local! {
+ /// Entries read to find a font size's or a colour's slot, for the tests that keep it constant.
+ static LOOKUP_STEPS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Counts `n` entries read by a lookup (tests only).
+#[inline]
+fn count_lookup_steps(_n: usize) {
+ #[cfg(test)]
+ LOOKUP_STEPS.with(|c| c.set(c.get().saturating_add(_n)));
+}
+
/// The body text size: the size most of the document's characters are set in.
fn body_size(pages: &[Page]) -> f64 {
+ // Sizes in first-seen order (which breaks ties), and where each one is in it.
let mut by: Vec<(i64, usize)> = Vec::new();
+ let mut slot: HashMap = HashMap::new();
for b in pages.iter().flat_map(|p| &p.blocks) {
let k = (b.size * 2.0).round() as i64;
- match by.iter_mut().find(|e| e.0 == k) {
- Some(e) => e.1 += b.text.len(),
- None => by.push((k, b.text.len())),
+ count_lookup_steps(1);
+ let i = *slot.entry(k).or_insert_with(|| {
+ by.push((k, 0));
+ by.len() - 1
+ });
+ if let Some(e) = by.get_mut(i) {
+ e.1 = e.1.saturating_add(b.text.len());
}
}
by.into_iter().max_by_key(|e| e.1).map_or(11.0, |e| e.0 as f64 / 2.0)
@@ -831,9 +852,12 @@ pub fn rtf(pages: &[Page]) -> String {
let items = items(pages);
// The colour table: entry 0 is the default (auto) colour, then each non-black colour once.
let mut colors: Vec<[u8; 3]> = Vec::new();
+ let mut slot: HashMap<[u8; 3], usize> = HashMap::new();
let mut note = |c: [f64; 3]| {
let c = rgb8(c);
- if c != [0, 0, 0] && !colors.contains(&c) {
+ count_lookup_steps(1);
+ if c != [0, 0, 0] && !slot.contains_key(&c) {
+ slot.insert(c, colors.len());
colors.push(c);
}
};
@@ -847,7 +871,8 @@ pub fn rtf(pages: &[Page]) -> String {
// `\cfN` for a colour (nothing for black, which keeps the default).
let cf = |c: [f64; 3]| {
let c = rgb8(c);
- colors.iter().position(|e| *e == c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
+ count_lookup_steps(1);
+ slot.get(&c).map_or(String::new(), |i| format!("\\cf{}", i + 1))
};
let mut s = String::from("{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Helvetica;}}\n");
if !colors.is_empty() {
@@ -911,6 +936,46 @@ pub fn rtf(pages: &[Page]) -> String {
mod tests {
use super::*;
+ /// A page of `n` one-word blocks, each in its own size and colour.
+ fn varied_page(n: usize) -> Page {
+ let blocks = (0..n)
+ .map(|i| Block {
+ text: "word".into(),
+ rect: [72.0, i as f64, 100.0, i as f64 + 1.0],
+ size: 4.0 + i as f64 * 0.5,
+ bold: false,
+ italic: false,
+ color: [(i % 256) as f64 / 255.0, (i / 256 % 256) as f64 / 255.0, 0.5],
+ rotation: 0.0,
+ })
+ .collect();
+ Page { width: 612.0, height: 792.0, blocks, images: Vec::new(), background: None }
+ }
+
+ fn lookup_steps(f: impl FnOnce()) -> usize {
+ LOOKUP_STEPS.with(|c| c.set(0));
+ f();
+ LOOKUP_STEPS.with(|c| c.get())
+ }
+
+ #[test]
+ fn many_distinct_sizes_are_counted_in_constant_steps_each() {
+ let pages = [varied_page(3000)];
+ let steps = lookup_steps(|| {
+ body_size(&pages);
+ });
+ assert!(steps <= 2 * 3000, "{steps} steps");
+ }
+
+ #[test]
+ fn many_distinct_colours_take_constant_steps_each() {
+ let pages = [varied_page(3000)];
+ let mut out = String::new();
+ let steps = lookup_steps(|| out = rtf(&pages));
+ assert!(steps <= 4 * 3000, "{steps} steps");
+ assert!(out.contains("\\cf3000 ") || out.contains("\\cf3000\\"), "the last colour has the last slot");
+ }
+
fn page() -> Page {
let b = |t: &str, y: f64, size: f64, bold: bool| Block {
text: t.into(),
diff --git a/crates/filters/README.md b/crates/filters/README.md
index df64e4b7c..959180d87 100644
--- a/crates/filters/README.md
+++ b/crates/filters/README.md
@@ -17,14 +17,17 @@ pub struct Params { predictor, colors, bits_per_component, columns, early_change
pub enum FilterError { Corrupt { filter, detail }, LimitExceeded(usize), Unsupported(String) }
decode(chain, data, max_output) -> Result, FilterError>
+decode_counted(chain, data, max_output) -> Result<(Vec, usize /*decoded*/), (FilterError, usize /*decoded*/)>
decode_tolerant(chain, data, max_output) -> Result<(Vec, bool /*partial*/), FilterError>
+decode_tolerant_counted(chain, data, max_output) -> Result<(Vec, bool /*partial*/, usize /*decoded*/), (FilterError, usize /*decoded*/)>
encode(filter, params, data) -> Result, FilterError>
encode_flate(data) -> Vec // zlib, level 6
```
- `chain` is applied first to last, as in a `/Filter` array.
-- Image codecs (DCT, JPX, JBIG2, CCITT), `Crypt` and unknown filters anywhere in a chain give `Unsupported`: callers keep such data encoded. Decryption belongs to `crypt`.
+- 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` 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
diff --git a/crates/filters/src/lib.rs b/crates/filters/src/lib.rs
index 9a0124d85..22a78aefd 100644
--- a/crates/filters/src/lib.rs
+++ b/crates/filters/src/lib.rs
@@ -137,52 +137,95 @@ pub(crate) type Step = Result, Failure>;
/// 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). `max_output` bounds
-/// every intermediate buffer.
+/// [`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, FilterError> {
- run(chain, data, max_output, true).map(|(v, _)| v)
+ run(chain, data, max_output, true, false).map(|(v, _, _)| v).map_err(|(e, _)| e)
+}
+
+/// Like [`decode`] for a caller charging an allowance: `max_output` bounds the bytes all stages
+/// decode together, each stage getting what the earlier ones left, and the result gives those
+/// 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, usize), (FilterError, usize)> {
+ run(chain, data, max_output, true, 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, bool), FilterError> {
- run(chain, data, max_output, false)
+ run(chain, data, max_output, false, false).map(|(v, partial, _)| (v, partial)).map_err(|(e, _)| e)
}
-fn run(chain: &[(Filter, Params)], data: &[u8], max: usize, strict: bool) -> Result<(Vec, bool), FilterError> {
+/// [`decode_tolerant`] bounded and counted as [`decode_counted`] is.
+pub fn decode_tolerant_counted(chain: &[(Filter, Params)], data: &[u8], max_output: usize) -> Result<(Vec, bool, usize), (FilterError, usize)> {
+ run(chain, data, max_output, false, true)
+}
+
+/// 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, bool, usize), (FilterError, usize)> {
+ if chain.len() > MAX_CHAIN {
+ return Err((FilterError::Unsupported(format!("a chain of {} filters", chain.len())), 0));
+ }
if let Some((f, _)) = chain.iter().find(|(f, _)| !f.is_decodable()) {
- return Err(FilterError::Unsupported(f.name().to_owned()));
+ return Err((FilterError::Unsupported(f.name().to_owned()), 0));
}
let mut cur: Cow<[u8]> = Cow::Borrowed(data);
let mut partial = false;
+ let mut spent = 0usize;
for (filter, params) in chain {
- cur = Cow::Owned(match decode_one(filter, params, &cur, max) {
+ #[cfg(test)]
+ STAGE_BYTES.with(|c| c.set(c.get() + cur.len()));
+ let limit = if cumulative { max.saturating_sub(spent) } else { max };
+ 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 => {
partial = true;
out
}
- Err(f) => return Err(f.error),
+ Err(Failure { error: FilterError::LimitExceeded(_), .. }) => return Err((FilterError::LimitExceeded(max), spent)),
+ Err(f) => return Err((f.error, spent)),
});
}
- Ok((cur.into_owned(), partial))
+ Ok((cur.into_owned(), partial, spent))
}
-fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> Step {
+/// One stage's result and the bytes its decoder produced (before any predictor).
+fn decode_one(filter: &Filter, params: &Params, data: &[u8], max: usize) -> (Step, usize) {
match filter {
Filter::Flate => with_predictor(flate::decode(data, max), params, "FlateDecode"),
Filter::Lzw => with_predictor(lzw::decode(data, params.early_change != 0, max), params, "LZWDecode"),
- Filter::AsciiHex => asciihex::decode(data, max),
- Filter::Ascii85 => ascii85::decode(data, max),
- Filter::RunLength => runlength::decode(data, max),
- other => Err(Failure { error: FilterError::Unsupported(other.name().to_owned()), partial: Vec::new() }),
+ 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),
}
}
-/// Applies the predictor to a decoder's result, including to partial output on failure.
-fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
- match step {
+/// A step with the bytes it produced, its output or its partial output.
+fn counted(step: Step) -> (Step, usize) {
+ let n = match &step {
+ Ok(v) => v.len(),
+ Err(f) => f.partial.len(),
+ };
+ (step, n)
+}
+
+/// Applies the predictor to a decoder's result, including to partial output on failure. The
+/// count is the decoder's output: a PNG predictor drops a tag byte per row, but the decoder
+/// produced it.
+fn with_predictor(step: Step, params: &Params, name: &'static str) -> (Step, usize) {
+ let (step, n) = counted(step);
+ let step = match step {
Ok(v) => predictor::decode(params, v, name),
Err(Failure { error, partial }) => {
let partial = match predictor::decode(params, partial, name) {
@@ -191,7 +234,8 @@ fn with_predictor(step: Step, params: &Params, name: &'static str) -> Step {
};
Err(Failure { error, partial })
}
- }
+ };
+ (step, n)
}
/// Encodes `data` with `filter`. Supported: Flate (zlib, level 6), LZW, ASCIIHex,
@@ -218,8 +262,27 @@ pub(crate) fn is_pdf_whitespace(b: u8) -> bool {
matches!(b, 0 | 9 | 10 | 12 | 13 | 32)
}
+#[cfg(test)]
+thread_local! {
+ /// Bytes handed to decoding stages on this thread.
+ static STAGE_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
#[cfg(test)]
mod tests {
+ #[test]
+ fn a_long_filter_chain_is_refused_before_decoding() {
+ // `FF FF` run-length decodes to itself, so every stage of the chain repeats the work.
+ let data = vec![0xFF; 4096];
+ let chain = |n: usize| vec![(super::Filter::RunLength, super::Params::default()); n];
+ super::STAGE_BYTES.with(|c| c.set(0));
+ let long = super::decode(&chain(1000), &data, 1 << 20);
+ let read = super::STAGE_BYTES.with(|c| c.get());
+ assert!(read <= 16 * data.len(), "{read} bytes decoded through the chain");
+ assert!(matches!(long, Err(super::FilterError::Unsupported(_))), "{long:?}");
+ assert_eq!(super::decode(&chain(super::MAX_CHAIN), &data, 1 << 20).ok(), Some(data));
+ }
+
use super::*;
#[test]
@@ -343,4 +406,35 @@ mod tests {
assert!(partial);
assert_eq!(&data[..out.len()], &out[..]);
}
+
+ /// A counted decode bounds what all stages decode together: Flate inflating 1 MiB of hex
+ /// that ASCIIHex halves is 1.5 MiB of work, past a 1.25 MiB allowance. A plain decode keeps
+ /// the limit per stage.
+ #[test]
+ fn a_counted_decode_bounds_all_stages_together() {
+ let hex: Vec = std::iter::repeat_n(b"ab".as_slice(), 1 << 19).flatten().copied().collect();
+ let data = encode_flate(&hex);
+ let chain = [(Filter::Flate, Params::default()), (Filter::AsciiHex, Params::default())];
+ let max = (1 << 20) + (1 << 18);
+ 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)));
+ }
+
+ /// A PNG predictor drops a tag byte per row, but Flate produced it: the count includes it,
+ /// also when the predictor fails on a bad tag.
+ #[test]
+ fn a_counted_decode_counts_output_before_the_predictor() {
+ let params = Params { predictor: 12, ..Params::default() };
+ let rows = vec![7u8; 1 << 16];
+ let data = encode(&Filter::Flate, ¶ms, &rows).unwrap();
+ let chain = [(Filter::Flate, params.clone())];
+ let (out, spent) = decode_counted(&chain, &data, usize::MAX).unwrap();
+ assert!(out == rows);
+ assert_eq!(spent, 2 << 16);
+ let bad: Vec = std::iter::repeat_n([0u8, 7], 1 << 15).flatten().chain([9, 7]).collect();
+ let (e, spent) = decode_counted(&chain, &encode_flate(&bad), usize::MAX).unwrap_err();
+ assert!(matches!(e, FilterError::Corrupt { .. }), "{e}");
+ assert_eq!(spent, bad.len());
+ }
}
diff --git a/crates/fonts/src/embedding.rs b/crates/fonts/src/embedding.rs
index a5bb011e0..2b7b4e17e 100644
--- a/crates/fonts/src/embedding.rs
+++ b/crates/fonts/src/embedding.rs
@@ -1,9 +1,10 @@
//! Unicode embedding of an explicitly supplied, embeddable TrueType face. Font files are
//! document inputs or the optional craft-fonts build input, never system-interface fallbacks.
+use std::collections::HashMap;
use std::fmt::Write as _;
-use pdfcraft_cos::{Dict, Document, Object, PdfString, Stream};
+use pdfcraft_cos::{Dict, Document, ObjRef, Object, PdfString, Stream};
use skrifa::{
FontRef, MetadataProvider,
instance::{LocationRef, Size},
@@ -173,7 +174,8 @@ pub fn embed_mapping(
return Err("the font family name is invalid".into());
}
let glyph_map = if cff { cid_encoding.as_bytes() } else { gids.as_slice() };
- if let Some(resource) = same_resource(doc, fonts, &name, bytes, glyph_map, cmap.as_bytes()) {
+ let mut programs = ProgramMatch::new(bytes);
+ if let Some(resource) = same_resource(doc, fonts, &name, &mut programs, glyph_map, cmap.as_bytes()) {
return Ok(resource);
}
let mut descriptor = Dict::new();
@@ -193,12 +195,7 @@ pub fn embed_mapping(
);
// Reuse the program another resource of this page already has, else one registered on another
// page; source subset programs stay untouched.
- let existing = written(doc, fonts).find(|w| w.has_program(doc, bytes)).map(|w| w.program).or_else(|| {
- doc.scan_objects().take(8192).find_map(|(r, o)| match o.as_ref() {
- Object::Stream(s) if is_program(doc, s, bytes) => Some(Object::Ref(r)),
- _ => None,
- })
- });
+ let existing = existing_program(doc, fonts, &mut programs);
let program = existing.unwrap_or_else(|| {
let mut d = Dict::new();
d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
@@ -246,12 +243,64 @@ pub fn embed_mapping(
Ok(resource)
}
-/// The decoded data of the stream `object` refers to.
-fn stream_data(doc: &Document, object: Option<&Object>) -> Option> {
- match doc.resolve(object?).as_ref() {
- Object::Stream(s) => s.decoded_within(17 << 20).ok(),
- _ => None,
+/// Whether the stream `object` refers to decodes to `expected`.
+fn stream_is(doc: &Document, object: Option<&Object>, expected: &[u8]) -> bool {
+ object.is_some_and(|o| match doc.resolve(o).as_ref() {
+ Object::Stream(s) => s.decoded_within(expected.len()).is_ok_and(|d| d == expected),
+ _ => false,
+ })
+}
+
+/// Full programs one embedding decodes at most while looking for its own. A real match passes
+/// the cheap checks and decodes once; many candidates that pass them come from a hostile file.
+const MAX_PROGRAM_DECODES: usize = 16;
+
+/// Recognises the full program written for `bytes`: each candidate stream decodes at most once
+/// and within `bytes.len()`, and at most [`MAX_PROGRAM_DECODES`] decode in all.
+struct ProgramMatch<'a> {
+ bytes: &'a [u8],
+ known: HashMap,
+ decodes: usize,
+}
+
+impl<'a> ProgramMatch<'a> {
+ fn new(bytes: &'a [u8]) -> Self {
+ Self { bytes, known: HashMap::new(), decodes: 0 }
}
+
+ /// Whether `r`, holding `object`, is the program.
+ fn is(&mut self, doc: &Document, r: ObjRef, object: &Object) -> bool {
+ if let Some(&known) = self.known.get(&r) {
+ return known;
+ }
+ let found = match object {
+ Object::Stream(s)
+ if s.dict.get(b"PCFullFont").is_some()
+ && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(self.bytes.len()).ok()
+ && self.decodes < MAX_PROGRAM_DECODES =>
+ {
+ self.decodes = self.decodes.saturating_add(1);
+ s.decoded_within(self.bytes.len()).is_ok_and(|data| data == self.bytes)
+ }
+ _ => false,
+ };
+ self.known.insert(r, found);
+ found
+ }
+
+ /// Whether the resource's program is this one.
+ fn written(&mut self, doc: &Document, w: &Written) -> bool {
+ w.program.as_ref().is_some_and(|r| self.is(doc, r, &doc.get(r)))
+ }
+}
+
+/// The program for `programs.bytes` that this page's `PCFull` resources, or else the first
+/// objects of the document, already have.
+fn existing_program(doc: &Document, fonts: &Dict, programs: &mut ProgramMatch) -> Option {
+ written(doc, fonts)
+ .find(|w| programs.written(doc, w))
+ .map(|w| w.program)
+ .or_else(|| doc.scan_objects().take(8192).find_map(|(r, o)| programs.is(doc, r, &o).then_some(Object::Ref(r))))
}
/// A `PCFull` resource that [`embed_mapping`] wrote.
@@ -263,21 +312,6 @@ struct Written {
program: Object,
}
-impl Written {
- /// Whether its program is `bytes`.
- fn has_program(&self, doc: &Document, bytes: &[u8]) -> bool {
- matches!(doc.resolve(&self.program).as_ref(), Object::Stream(s) if is_program(doc, s, bytes))
- }
-}
-
-/// Whether `s` is a full program written for `bytes`, compressed or not. The length it was
-/// written with is compared before decoding.
-fn is_program(doc: &Document, s: &Stream, bytes: &[u8]) -> bool {
- s.dict.get(b"PCFullFont").is_some()
- && s.dict.get(b"Length1").and_then(|l| doc.resolve(l).as_int()) == i64::try_from(bytes.len()).ok()
- && s.decoded_within(17 << 20).is_ok_and(|data| data == bytes)
-}
-
/// The `PCFull` resources in `fonts`, in order.
fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator- + 'a {
fonts.iter().filter(|(key, _)| key.starts_with(b"PCFull")).take(256).filter_map(move |(key, value)| {
@@ -291,14 +325,14 @@ fn written<'a>(doc: &'a Document, fonts: &'a Dict) -> impl Iterator
- Option
{
+fn same_resource(doc: &Document, fonts: &Dict, name: &str, programs: &mut ProgramMatch, glyph_map: &[u8], unicode: &[u8]) -> Option {
written(doc, fonts)
.find(|w| {
let glyphs = w.cid.get(b"CIDToGIDMap").or_else(|| w.top.get(b"Encoding"));
w.top.name(b"BaseFont") == Some(name.as_bytes())
- && stream_data(doc, w.top.get(b"ToUnicode")).is_some_and(|d| d == unicode)
- && stream_data(doc, glyphs).is_some_and(|d| d == glyph_map)
- && w.has_program(doc, bytes)
+ && stream_is(doc, w.top.get(b"ToUnicode"), unicode)
+ && stream_is(doc, glyphs, glyph_map)
+ && programs.written(doc, w)
})
.map(|w| w.resource)
}
@@ -385,6 +419,48 @@ mod tests {
assert_eq!(programs(&doc), 2);
}
+ #[test]
+ fn the_program_search_decodes_each_candidate_once_and_few_in_all() {
+ let bytes = b"the program".as_slice();
+ let decoy = |doc: &mut Document| {
+ let mut d = Dict::new();
+ d.set(b"PCFullFont".to_vec(), Object::Bool(true));
+ d.set(b"Length1".to_vec(), Object::Int(bytes.len() as i64));
+ doc.add(Object::Stream(Stream::flate(d, &[0; 4096])))
+ };
+ let resource = |doc: &mut Document, program: ObjRef| {
+ let mut descriptor = Dict::new();
+ descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
+ let mut cid = Dict::new();
+ cid.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
+ let mut top = Dict::new();
+ top.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(cid)]));
+ doc.add(Object::Dict(top))
+ };
+ // 200 resources sharing one decoy program decode it once.
+ let mut doc = Document::new_empty();
+ let shared = decoy(&mut doc);
+ let mut fonts = Dict::new();
+ for i in 0..200 {
+ let r = resource(&mut doc, shared);
+ fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
+ }
+ let mut programs = ProgramMatch::new(bytes);
+ assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
+ assert_eq!(programs.decodes, 1);
+ // 100 distinct decoys decode a bounded number of times.
+ let mut doc = Document::new_empty();
+ let mut fonts = Dict::new();
+ for i in 0..100 {
+ let program = decoy(&mut doc);
+ let r = resource(&mut doc, program);
+ fonts.set(format!("PCFull{i}").into_bytes(), Object::Ref(r));
+ }
+ let mut programs = ProgramMatch::new(bytes);
+ assert_eq!(existing_program(&doc, &fonts, &mut programs), None);
+ assert_eq!(programs.decodes, MAX_PROGRAM_DECODES);
+ }
+
#[test]
fn the_full_program_is_written_compressed_and_another_page_reuses_it() {
let bytes = include_bytes!("../../../assets/fonts/Inter-Regular.ttf");
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index ec103ed5f..37a2ea74d 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -88,17 +88,28 @@ pub fn wrap(text: &str, size: f64, width: f64) -> Vec {
}
/// [`wrap`], measuring with `measure` (for example [`cjk_width`]).
+///
+/// A line measures as the sum of its words, the spaces between them and the characters of a broken
+/// word, each measured once, so the work stays linear in `text` however much fits on a line.
pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64) -> f64) -> Vec {
let mut lines = Vec::new();
+ let space = measure(" ", size);
+ let mut buf = [0u8; 4];
// A CRLF pair is one break, a lone CR or LF is one break each.
for para in text.replace("\r\n", "\n").split(['\n', '\r']) {
let mut line = String::new();
+ let mut line_width = 0.0;
// No word is on the line yet; an empty word (a leading space) still starts it.
let mut fresh = true;
for word in para.split(' ') {
- let candidate = if fresh { word.to_string() } else { format!("{line} {word}") };
- if measure(&candidate, size) <= width || fresh && measure(word, size) <= width {
- line = candidate;
+ let word_width = measure(word, size);
+ let candidate = if fresh { word_width } else { line_width + space + word_width };
+ if candidate <= width {
+ if !fresh {
+ line.push(' ');
+ }
+ line.push_str(word);
+ line_width = candidate;
fresh = false;
continue;
}
@@ -108,11 +119,15 @@ pub fn wrap_with(text: &str, size: f64, width: f64, measure: impl Fn(&str, f64)
} else {
lines.push(std::mem::take(&mut line));
}
+ line_width = 0.0;
for ch in word.chars() {
- if !line.is_empty() && measure(&format!("{line}{ch}"), size) > width {
+ let ch_width = measure(ch.encode_utf8(&mut buf), size);
+ if !line.is_empty() && line_width + ch_width > width {
lines.push(std::mem::take(&mut line));
+ line_width = 0.0;
}
line.push(ch);
+ line_width += ch_width;
}
fresh = line.is_empty();
}
@@ -442,4 +457,20 @@ mod tests {
let lines = wrap_with("日本語のテキストは文字単位で折り返されます", 10.0, 45.0, cjk_width);
assert!(lines.len() > 3 && lines.iter().all(|l| cjk_width(l, 10.0) <= 45.0), "{lines:?}");
}
+
+ /// Wrapping measures each word and character a bounded number of times, however many fit on a line.
+ #[test]
+ fn wrapping_measures_each_word_a_few_times() {
+ let measured = std::cell::Cell::new(0usize);
+ let measure = |s: &str, size: f64| {
+ measured.set(measured.get() + s.len());
+ helvetica_width(s, size)
+ };
+ let text = format!("{} {}", "word ".repeat(5_000), "x".repeat(5_000));
+ let lines = wrap_with(&text, 1.0, 13_000.0, measure);
+ assert_eq!(lines.len(), 2, "the words fit on one line, the long word on another");
+ assert!(measured.get() <= 4 * text.len(), "{} bytes measured", measured.get());
+ let narrow = wrap_with(&text, 10.0, 100.0, helvetica_width);
+ assert!(narrow.iter().all(|l| helvetica_width(l, 10.0) <= 100.0), "{narrow:?}");
+ }
}
diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs
index 0aaed6939..6e7ac7a8c 100644
--- a/crates/fonts/src/pdf.rs
+++ b/crates/fonts/src/pdf.rs
@@ -14,7 +14,7 @@
use std::borrow::Cow;
use std::collections::{BinaryHeap, HashMap, HashSet};
-use pdfcraft_cos::{Dict, Document, Object};
+use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
/// How the bytes of a string map to codes.
#[derive(Clone, Debug, PartialEq)]
@@ -33,9 +33,9 @@ pub struct Metrics {
widths: Vec,
/// Composite fonts: CID → width, and CID ranges with one width.
cid_widths: HashMap,
- cid_ranges: Vec<(u32, u32, f64)>,
+ cid_ranges: Spans,
/// Code → CID for embedded non-identity CMaps (`cidrange` / `cidchar`).
- cid_map: Vec<(u32, u32, u32)>,
+ cid_map: Spans,
default: f64,
/// Composite fonts with an explicit `/DW`: `/W` may leave out the glyphs that have that
/// width (Chrome and Skia do), so a CID missing from `/W` is not a missing glyph.
@@ -92,6 +92,15 @@ fn legacy_indic_font(base_font: &str) -> bool {
/// A source PDF's sfnt program for an in-memory editor. Never a bundled font or an export
/// fallback. Type 1/Type 3 programs cannot be consumed by an sfnt text renderer.
pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> {
+ embedded_sfnt_within(doc, font, &mut DecodeBudget::new(2 * MAX_SFNT, 0))
+}
+
+/// The largest embedded sfnt program read for the editor.
+const MAX_SFNT: usize = 16 << 20;
+
+/// [`embedded_sfnt`], decoding against `budget`, which an enumeration of a page's fonts shares:
+/// each program decoded is charged whether or not it turns out to be usable.
+pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudget) -> Option> {
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|o| doc.resolve(o).as_array().and_then(|a| a.first()).cloned()).as_ref());
let descriptor = dict(doc, descendant.as_ref().unwrap_or(font).get(b"FontDescriptor"))?;
@@ -99,7 +108,7 @@ pub fn embedded_sfnt(doc: &Document, font: &Dict) -> Option> {
let Some(object) = descriptor.get(key) else { continue };
let object = doc.resolve(object);
let Object::Stream(stream) = object.as_ref() else { continue };
- let Ok(mut bytes) = stream.decoded_within(16 << 20) else { continue };
+ let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue };
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
continue;
@@ -275,6 +284,9 @@ const MAX_CMAP_ENTRIES: usize = 1 << 20;
/// Most codespace or CID ranges kept from a CMap; Adobe's largest published CMaps have a few
/// thousand.
const MAX_CMAP_RANGES: usize = 1 << 16;
+/// Largest decoded Encoding or ToUnicode CMap stream read; real CMaps stay well under it, and a larger
+/// one is not decoded at all.
+const MAX_CMAP_BYTES: usize = 16 << 20;
/// Code → Unicode of a font. Explicit mappings and sequential ranges are kept as declared, so a range
/// costs one record however many codes it covers; where two overlap, the one declared later wins.
@@ -598,8 +610,8 @@ impl Metrics {
first: 0,
widths: Vec::new(),
cid_widths: HashMap::new(),
- cid_ranges: Vec::new(),
- cid_map: Vec::new(),
+ cid_ranges: Spans::default(),
+ cid_map: Spans::default(),
default: 500.0,
explicit_default: false,
std14: Some(crate::Std14::Helvetica),
@@ -679,13 +691,13 @@ impl Metrics {
m.codes = Codes::Two;
if let Some(e) = font.get(b"Encoding").map(|e| doc.resolve(e))
&& let Object::Stream(s) = &*e
- && let Ok(data) = s.decoded()
+ && let Ok(data) = s.decoded_within(MAX_CMAP_BYTES)
{
let (spaces, cids) = parse_cmap(&data);
if !spaces.is_empty() {
m.codes = Codes::Ranges(spaces);
}
- m.cid_map = cids;
+ m.cid_map = Spans::new(cids);
}
let desc = font
.get(b"DescendantFonts")
@@ -698,6 +710,7 @@ impl Metrics {
m.explicit_default = true;
}
if let Some(w) = desc.get(b"W").map(|w| doc.resolve(w)).and_then(|w| w.as_array().cloned()) {
+ let mut ranges = Vec::new();
let mut i = 0;
while i < w.len() {
let Some(c0) = doc.resolve(&w[i]).as_f64().map(|v| v as u32) else { break };
@@ -713,14 +726,17 @@ impl Metrics {
i += 2;
}
Some(c1) => {
- if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64())) {
- m.cid_ranges.push((c0, c1 as u32, v));
+ if let (Some(c1), Some(v)) = (c1.as_f64(), w.get(i + 2).and_then(|x| doc.resolve(x).as_f64()))
+ && ranges.len() < MAX_CMAP_ENTRIES
+ {
+ ranges.push((c0, c1 as u32, v));
}
i += 3;
}
None => break,
}
}
+ m.cid_ranges = Spans::new(ranges);
}
descriptor = dict(doc, desc.get(b"FontDescriptor"));
} else {
@@ -791,14 +807,14 @@ impl Metrics {
if self.cid_map.is_empty() {
return code;
}
- self.cid_map.iter().find(|(lo, hi, _)| code >= *lo && code <= *hi).map_or(0, |(lo, _, c)| c.saturating_add(code - lo))
+ self.cid_map.find(code).map_or(0, |(lo, _, c)| c.saturating_add(code.saturating_sub(*lo)))
}
/// The advance of `code` in text space per unit of font size.
pub fn width(&self, code: u32) -> f64 {
if self.composite {
let cid = self.cid(code);
- let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.iter().find(|(a, b, _)| cid >= *a && cid <= *b).map(|r| r.2));
+ let w = self.cid_widths.get(&cid).copied().or_else(|| self.cid_ranges.find(cid).map(|r| r.2));
return w.unwrap_or(self.default) * self.scale;
}
if let Some(w) = code.checked_sub(self.first).and_then(|i| self.widths.get(i as usize)) {
@@ -858,7 +874,7 @@ impl Metrics {
// A CID /W leaves out takes /DW, and Skia (Chrome) leaves out every CID of that width: for
// those, only the subset's program says whether the glyph is there.
return self.cid_widths.contains_key(&cid)
- || self.cid_ranges.iter().any(|(a, b, _)| cid >= *a && cid <= *b)
+ || self.cid_ranges.find(cid).is_some()
|| !self.subset
|| self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1)
// No readable TrueType program to ask (e.g. CFF or missing): an explicit /DW
@@ -905,9 +921,13 @@ impl Metrics {
}
}
-/// The UTF-16 code units of CMap hex bytes.
+/// Most UTF-16 units kept from one ToUnicode destination: the CMap format allows destinations of up
+/// to 512 bytes, and every glyph a sequential range covers builds its text from the whole of one.
+const MAX_DESTINATION_UNITS: usize = 256;
+
+/// The UTF-16 code units of CMap hex bytes, at most [`MAX_DESTINATION_UNITS`] of them.
fn code_units(b: &[u8]) -> Vec {
- b.chunks(2).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
+ b.chunks(2).take(MAX_DESTINATION_UNITS).map(|c| u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])).collect()
}
/// UTF-16BE (with surrogates) from CMap hex bytes.
@@ -954,16 +974,69 @@ fn add_range(out: &mut Unicode, budget: &mut usize, seq: &mut u32, lo: u32, hi:
true
}
+#[cfg(test)]
+thread_local! {
+ /// Code span records read by CID and width lookups, for the tests that keep a lookup logarithmic.
+ static SPAN_READS: std::cell::Cell = const { std::cell::Cell::new(0) };
+}
+
+/// Counts one span record read (tests only).
+#[inline]
+fn count_span_reads() {
+ #[cfg(test)]
+ SPAN_READS.with(|c| c.set(c.get().saturating_add(1)));
+}
+
+/// Code spans `(lo, hi, value)` in declaration order, where the first-declared span covering a code
+/// wins, indexed so that a lookup is one binary search.
+#[derive(Clone, Debug, Default, PartialEq)]
+struct Spans {
+ spans: Vec<(u32, u32, T)>,
+ /// The span covering each stretch of codes; see [`stretches`].
+ cover: Vec<(u64, Option)>,
+}
+
+impl Spans {
+ fn new(spans: Vec<(u32, u32, T)>) -> Self {
+ let bounds: Vec<(u32, u32)> = spans.iter().map(|s| (s.0, s.1)).collect();
+ Spans { cover: stretches(&bounds, false), spans }
+ }
+
+ fn is_empty(&self) -> bool {
+ self.spans.is_empty()
+ }
+
+ /// The first-declared span covering `code`.
+ fn find(&self, code: u32) -> Option<&(u32, u32, T)> {
+ let step = self.cover.partition_point(|&(start, _)| {
+ count_span_reads();
+ start <= u64::from(code)
+ });
+ let &(_, record) = self.cover.get(step.checked_sub(1)?)?;
+ self.spans.get(record?)
+ }
+}
+
/// For each stretch of codes, from its first code on, the latest-declared range covering it (`None`
/// where no range does), sorted by code; a lookup is one binary search over it.
fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> {
+ let spans: Vec<(u32, u32)> = ranges.iter().map(|r| (r.lo, r.hi)).collect();
+ stretches(&spans, true)
+}
+
+/// For each stretch of codes, from its first code on, the span `(lo, hi)` covering it (`None` where
+/// none does), sorted by code: the latest-declared one when `later_wins`, else the first-declared.
+/// At most `u32::MAX` spans are indexed.
+fn stretches(spans: &[(u32, u32)], later_wins: bool) -> Vec<(u64, Option)> {
+ // Heap keys, largest for the winning record; the mapping is its own inverse.
+ let key = |record: u32| if later_wins { record } else { !record };
// Openings (first code, record) and closings (one past the last code), each in code order. The cover
// can only change at one of their codes, so the sweep visits those codes in order.
- let mut opens: Vec<(u32, u32)> = ranges.iter().enumerate().map(|(i, r)| (r.lo, i as u32)).collect();
+ let mut opens: Vec<(u32, u32)> = spans.iter().zip(0..u32::MAX).map(|(&(lo, _), i)| (lo, i)).collect();
opens.sort_unstable();
- let mut closes: Vec = ranges.iter().map(|r| u64::from(r.hi) + 1).collect();
+ let mut closes: Vec = spans.iter().take(opens.len()).map(|&(_, hi)| u64::from(hi) + 1).collect();
closes.sort_unstable();
- // Records that have opened and not yet closed; the top is the latest declared one.
+ // Keys of the records that have opened and not yet closed; the top is the winning one.
let mut open: BinaryHeap = BinaryHeap::new();
let mut cover: Vec<(u64, Option)> = Vec::new();
let (mut next_open, mut next_close) = (0, 0);
@@ -977,16 +1050,16 @@ fn cover_index(ranges: &[BfRange]) -> Vec<(u64, Option)> {
while let Some(&(lo, record)) = opens.get(next_open)
&& u64::from(lo) <= p
{
- open.push(record);
+ open.push(key(record));
next_open += 1;
}
while closes.get(next_close).is_some_and(|&end| end <= p) {
next_close += 1;
}
- while open.peek().is_some_and(|&top| ranges.get(top as usize).is_none_or(|r| u64::from(r.hi) < p)) {
+ while open.peek().is_some_and(|&top| spans.get(key(top) as usize).is_none_or(|&(_, hi)| u64::from(hi) < p)) {
open.pop();
}
- let winner = open.peek().map(|&top| top as usize);
+ let winner = open.peek().map(|&top| key(top) as usize);
if cover.last().map(|&(_, w)| w) != Some(winner) {
cover.push((p, winner));
}
@@ -1220,7 +1293,7 @@ fn unicode_map(doc: &Document, font: &Dict, composite: bool) -> Unicode {
}
match font.get(b"ToUnicode").map(|t| doc.resolve(t)).as_deref() {
Some(Object::Stream(s)) => {
- if let Ok(data) = s.decoded() {
+ if let Ok(data) = s.decoded_within(MAX_CMAP_BYTES) {
parse_to_unicode(&data, &mut out);
}
}
@@ -1262,6 +1335,88 @@ mod tests {
use super::*;
+ /// Span records read while `f` runs.
+ fn span_reads(f: impl FnOnce()) -> usize {
+ SPAN_READS.with(|c| c.set(0));
+ f();
+ SPAN_READS.with(|c| c.get())
+ }
+
+ /// A composite font with `encoding` as its CMap, `w` as its widths and `to_unicode`.
+ fn composite(doc: &mut Document, encoding: &str, w: Vec, to_unicode: &str) -> Dict {
+ let mut desc = Dict::new();
+ desc.set(b"Subtype".to_vec(), Object::name("CIDFontType2"));
+ desc.set(b"W".to_vec(), Object::Array(w));
+ let enc = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), encoding.as_bytes()))));
+ let tu = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode.as_bytes()))));
+ font(
+ doc,
+ vec![
+ ("Subtype", Object::name("Type0")),
+ ("Encoding", enc),
+ ("DescendantFonts", Object::Array(vec![Object::Dict(desc)])),
+ ("ToUnicode", tu),
+ ],
+ )
+ }
+
+ /// Many `/W` ranges that leave a code out cost a glyph of it a binary search, not a scan of them all.
+ #[test]
+ fn widths_of_a_code_no_range_covers_take_few_steps() {
+ let mut doc = Document::new_empty();
+ let w: Vec = (1..=10_000i64).flat_map(|k| [Object::Int(2 * k), Object::Int(2 * k), Object::Int(500)]).collect();
+ let f = composite(&mut doc, "", w, "");
+ let m = Metrics::from_dict(&doc, &f);
+ assert_eq!(m.width(4), 0.5);
+ let reads = span_reads(|| {
+ for _ in 0..1000 {
+ assert_eq!(m.width(3), 1.0);
+ }
+ });
+ assert!(reads <= 1000 * 64, "{reads} span reads");
+ }
+
+ /// Where spans overlap, the first-declared one wins, as a scan in declaration order finds it.
+ #[test]
+ fn overlapping_spans_resolve_to_the_first_declared() {
+ let spans = Spans::new(vec![(0, 10, 'a'), (5, 20, 'b'), (3, 3, 'c'), (30, 25, 'd'), (u32::MAX, u32::MAX, 'e')]);
+ let found = |code| spans.find(code).map(|s| s.2);
+ let want = [(0, Some('a')), (3, Some('a')), (10, Some('a')), (11, Some('b')), (20, Some('b')), (21, None), (27, None), (u32::MAX, Some('e'))];
+ for (code, value) in want {
+ assert_eq!(found(code), value, "code {code}");
+ }
+ }
+
+ /// Checking the glyph of every explicit ToUnicode entry looks its code up once in the CID map.
+ #[test]
+ fn many_unicode_entries_look_up_their_cids_in_few_steps() {
+ let n = 2000;
+ let cidchars: String = (0..n).map(|k| format!("<{:04X}> {} ", 0x8000 + k, k + 1)).collect();
+ let encoding = format!("1 begincodespacerange <0000> endcodespacerange {n} begincidchar {cidchars} endcidchar");
+ let bfchars: String = (0..n).map(|k| format!("<{:04X}> <{:04X}> ", 0x1000 + k, 0x4E00 + k)).collect();
+ let to_unicode = format!("{n} beginbfchar {bfchars} endbfchar");
+ let mut doc = Document::new_empty();
+ let f = composite(&mut doc, &encoding, Vec::new(), &to_unicode);
+ let reads = span_reads(|| {
+ Metrics::from_dict(&doc, &f);
+ });
+ assert!(reads <= n * 64, "{reads} span reads");
+ }
+
+ /// Every glyph of a sequential range builds its text from the destination, so a long one is cut
+ /// to the CMap limit once, when it is read.
+ #[test]
+ fn unicode_destinations_are_cut_to_the_cmap_limit() {
+ let long = "0041".repeat(4096);
+ let cmap = format!("2 beginbfrange <0010> <0020> <{long}> <0030> <0030> [<{long}>] endbfrange 1 beginbfchar <0040> <{long}> endbfchar");
+ let mut u = Unicode::default();
+ parse_to_unicode(cmap.as_bytes(), &mut u);
+ for code in [0x10, 0x20, 0x30, 0x40] {
+ let text = u.text(code).unwrap_or_default();
+ assert!(!text.is_empty() && text.len() <= MAX_DESTINATION_UNITS * 3, "code {code:#x}: {} bytes", text.len());
+ }
+ }
+
/// A font whose ranges cover a few low codes keeps tables that size, not a fixed 64k-entry one.
#[test]
fn low_code_tables_end_with_the_last_covered_code() {
@@ -1496,6 +1651,38 @@ mod tests {
assert_eq!(m.encode("abc"), None);
}
+ #[test]
+ fn cmap_streams_decode_within_a_size_cap() {
+ // A valid CMap padded past the cap (a small Flate stream of whitespace) is not decoded whole.
+ let to_unicode = b"1 begincodespacerange <0000> endcodespacerange 1 beginbfrange <0024> <0026> <0041> endbfrange";
+ let encoding = b"1 begincodespacerange <0000> endcodespacerange 1 begincidrange <0000>