Merge remote-tracking branch 'upstream/main' into fix/xfa-edit-picture-alternatives

# Conflicts:
#	crates/xfa/src/tests.rs
This commit is contained in:
Eric Kaden
2026-10-22 21:54:56 -04:00
267 changed files with 7880 additions and 1608 deletions
+6 -6
View File
@@ -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 |
+15 -15
View File
@@ -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"
Generated
+63 -37
View File
@@ -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",
]
+34 -34
View File
@@ -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"
+4
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.pdfcraft.png" width="128" height="128" alt="PdfCraft icon: an engraved lion on a teal tile">
</p>
<h1 align="center">PdfCraft</h1>
<p align="center">
+2
View File
@@ -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.
+1 -1
View File
@@ -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]
+24
View File
@@ -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;
+1 -1
View File
@@ -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(());
+21
View File
@@ -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;
@@ -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 {
+10 -10
View File
@@ -401,12 +401,12 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
</div>
<h3>The families in this specimen</h3>
<div class="families">
<div><span style="font-family:'PC Playfair'">Ag</span><b>Playfair Display</b>Display</div>
<div><span style="font-family:'PC Garamond'">Ag</span><b>EB Garamond</b>Body text</div>
<div><span style="font-family:'PC Inter';font-weight:500">Ag</span><b>Inter</b>Labels</div>
<div><span style="font-family:'PC Mono'">Ag</span><b>JetBrains Mono</b>Code</div>
<div><span style="font-family:'PC Vibes'">Ag</span><b>Great Vibes</b>Script</div>
<div><span style="font-family:'PC Math'">∑x</span><b>STIX Two Math</b>Mathematics</div>
<div><span style="font-family:'PC Playfair', serif">Ag</span><b>Playfair Display</b>Display</div>
<div><span style="font-family:'PC Garamond', serif">Ag</span><b>EB Garamond</b>Body text</div>
<div><span style="font-family:'PC Inter', sans-serif;font-weight:500">Ag</span><b>Inter</b>Labels</div>
<div><span style="font-family:'PC Mono', monospace">Ag</span><b>JetBrains Mono</b>Code</div>
<div><span style="font-family:'PC Vibes', cursive">Ag</span><b>Great Vibes</b>Script</div>
<div><span style="font-family:'PC Math', math">∑x</span><b>STIX Two Math</b>Mathematics</div>
</div>
<div class="folio"><span>Foreword</span><b>ii</b></div>
</section>
@@ -449,7 +449,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
<tr><td class="tag">onum</td><td class="lbl">Old-style figures</td><td class="off" style="font-feature-settings:'lnum' 1">Since 1885: 3,467,920</td><td class="on" style="font-feature-settings:'onum' 1">Since 1885: 3,467,920</td></tr>
<tr><td class="tag">frac</td><td class="lbl">Fractions</td><td class="off" style="font-family:var(--ui)">1/2 cup, 3/4 inch, 7/8</td><td class="on" style="font-family:var(--ui);font-feature-settings:'frac' 1">1/2 cup, 3/4 inch, 7/8</td></tr>
<tr><td class="tag">swsh</td><td class="lbl">Swash capitals</td><td class="off" style="font-style:italic">The Quiet River</td><td class="on" style="font-style:italic;font-feature-settings:'swsh' 1">The Quiet River</td></tr>
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter'">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter';font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
<tr><td class="tag">ss01</td><td class="lbl">Stylistic set 1</td><td class="off" style="font-family:'PC Inter', sans-serif">a 1 4 6 9 G ?</td><td class="on" style="font-family:'PC Inter', sans-serif;font-feature-settings:'ss01' 1">a 1 4 6 9 G ?</td></tr>
<tr><td class="tag">kern</td><td class="lbl">Kerning</td><td class="off" style="font-kerning:none;font-family:var(--sans)">AVATAR Type Wave</td><td class="on" style="font-kerning:normal;font-family:var(--sans)">AVATAR Type Wave</td></tr>
<tr><td class="tag">tnum</td><td class="lbl">Tabular figures</td><td class="off" style="font-family:var(--ui);font-feature-settings:'pnum' 1">1111 / 8888</td><td class="on" style="font-family:var(--ui);font-feature-settings:'tnum' 1">1111 / 8888</td></tr>
</table>
@@ -519,9 +519,9 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
<p class="dek">A short greeting &mdash; roughly, &ldquo;every letter deserves a good home&rdquo; &mdash; in writing systems that need bidirectional layout, contextual shaping, reordering and vertical setting.</p>
<div style="display:grid;grid-template-columns:1fr 1in;gap:16pt">
<div class="scripts">
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL &middot; joining</i></div><div class="tx" style="font-family:'PC Amiri'">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
<div class="sc" lang="ar" dir="rtl"><div class="nm" dir="ltr">Arabic <i>RTL &middot; joining</i></div><div class="tx" style="font-family:'PC Amiri', serif">لكل حرف بيت</div><div class="en" dir="ltr">Amiri</div></div>
<div class="sc" lang="he" dir="rtl"><div class="nm" dir="ltr">Hebrew <i>RTL</i></div><div class="tx" style="font-family:'PC Hebrew',sans-serif">לכל אות יש בית טוב</div><div class="en" dir="ltr">Noto Sans Hebrew</div></div>
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari'">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
<div class="sc" lang="hi"><div class="nm">Devanagari <i>conjuncts</i></div><div class="tx" style="font-family:'PC Devanagari', sans-serif">हर अक्षर का घर</div><div class="en">Noto Sans Devanagari</div></div>
<div class="sc" lang="th"><div class="nm">Thai <i>no spaces</i></div><div class="tx" style="font-family:'PC Thai',sans-serif">ทุกอักษรมีบ้าน</div><div class="en">Noto Sans Thai</div></div>
<div class="sc" lang="el"><div class="nm">Greek <i>polytonic</i></div><div class="tx" style="font-family:'PC Garamond',serif">Κάθε γράμμα ἔχει σπίτι</div><div class="en">EB Garamond</div></div>
<div class="sc" lang="ru"><div class="nm">Cyrillic</div><div class="tx" style="font-family:'PC Garamond',serif">Каждой букве — дом</div><div class="en">EB Garamond</div></div>
@@ -541,7 +541,7 @@ a.box { position: absolute; display: block; color: transparent; font-size: 1pt;
</div>
<h3>Mixed direction in one line</h3>
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri'">كتاب</span> means &ldquo;book&rdquo;, and <span lang="he" style="font-family:'PC Hebrew'">ספר</span> does too &mdash; the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri'">٣٤٥</span> and 345.</p>
<p style="font-size:12pt;margin:0">The word <span lang="ar" style="font-family:'PC Amiri', serif">كتاب</span> means &ldquo;book&rdquo;, and <span lang="he" style="font-family:'PC Hebrew', serif">ספר</span> does too &mdash; the bidi algorithm keeps each run in its own direction while the sentence stays left-to-right, even across numbers like <span lang="ar" style="font-family:'PC Amiri', serif">٣٤٥</span> and 345.</p>
<h3>Colour emoji</h3>
<div class="emoji">📄🖋️🎨📐🔍🌍🧮✅🦀📎</div>
+113 -33
View File
@@ -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<i
}
pub(crate) fn page_content(doc: &Document, page: &Dict) -> Vec<u8> {
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<u8> {
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<i64>) -> 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<i64>,
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<Mark>, matching: &mut usize, out: &mut HashMap<i64, [f64; 4]>) {
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<i64>) -> HashMap<i64, [f64; 4]> {
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<bool> = Vec::new();
let mut bbox = None;
// The open marked-content sequences, and how many of them are one of `ids`.
let mut marks: Vec<Mark> = 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<i64>) -> 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<i64>) -> 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<i64>) -> 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<Figure> {
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<ObjRef, usize> = pages.iter().enumerate().map(|(i, p)| (p.obj, i)).collect();
elements(doc)
let figures: Vec<(Element, Option<usize>)> = 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<usize, HashSet<i64>> = HashMap::new();
for (e, page) in &figures {
if let Some(p) = page {
wanted.entry(*p).or_default().extend(mcids(&e.dict));
}
}
let bounds: HashMap<usize, HashMap<i64, [f64; 4]>> =
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<i64> = 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<Option<(Stream, Vec<u8>)>> = 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")
+26 -24
View File
@@ -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<usize, usize>,
/// Page → fonts used.
pub fonts: BTreeMap<usize, BTreeSet<ObjRef>>,
/// Pages read only in part: content or forms past the page's decode budget are missing.
pub incomplete: BTreeSet<usize>,
}
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<u8> {
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<ObjRef>,
/// 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);
}
+19 -14
View File
@@ -182,6 +182,7 @@ pub(crate) fn mcid_texts(doc: &Document, page: &Dict) -> HashMap<i64, String> {
let res = sub(doc, page, b"Resources");
let (fonts, props) = (sub(doc, &res, b"Font"), sub(doc, &res, b"Properties"));
let mut metrics: HashMap<Vec<u8>, Metrics> = HashMap::new();
// The id each open marked-content sequence is in: its own, else the enclosing one's.
let mut stack: Vec<Option<i64>> = Vec::new();
let mut out: HashMap<i64, String> = 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<i64, String> {
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<i64, String> {
};
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);
+28
View File
@@ -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<usize> = const { std::cell::Cell::new(0) };
/// Page content passes made to find figure bounds.
static BOUNDS_PASSES: std::cell::Cell<usize> = 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);
+133
View File
@@ -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<String> = (0..n).map(|i| format!("/Figure << /MCID {i} >> BDC {i} 0 1 1 re f EMC")).collect();
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 6 + i)).collect();
let mut objs: Vec<String> = 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<String> = 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<String> = 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");
}
+6
View File
@@ -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);
+28 -5
View File
@@ -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<f64> {
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<usize> {
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<LinkItem> {
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<LinkItem> {
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),
});
}
+63
View File
@@ -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");
}
+13 -5
View File
@@ -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<FilledRect>,
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<Vec<FilledRect>, 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<Object> = match page.get(b"Contents") {
@@ -314,15 +321,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, 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)?;
+2
View File
@@ -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)]
+33
View File
@@ -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)));
}
+1 -1
View File
@@ -128,7 +128,7 @@ claude mcp add pdfcraft -- pdfcraft-cli mcp --root <dir>
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
+6 -2
View File
@@ -626,13 +626,17 @@ impl Automation {
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
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<Value> = 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::<usize>(), "pages": list }))
+10 -3
View File
@@ -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<Value> = 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::<String>() == 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() {
+2 -2
View File
@@ -1172,7 +1172,7 @@ pub fn tools() -> Vec<ToolDef> {
)),
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<ToolDef> {
.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 },
@@ -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
+9
View File
@@ -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;
+3
View File
@@ -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 }
+7 -4
View File
@@ -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.
+126 -26
View File
@@ -60,6 +60,10 @@ pub struct Parsed {
pub ops: Vec<Op>,
/// 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<usize>, 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<u8>, 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<u8>)>,
/// 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<u8>, 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<Item = (std::ops::Range<usize>, Vec<u8>)>) -> Vec<Vec<u8>> {
let mut out: Vec<Vec<u8>> = (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<Vec<u8>>, 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());
+97 -7
View File
@@ -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<usize> = 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<usize> = const { std::cell::Cell::new(0) };
}
#[test]
fn splicing_visits_each_piece_a_few_times() {
let n = 5_000;
let pieces: Vec<String> = (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::<Vec<_>>(), [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<Vec<u8>> {
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<u8> = (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<Vec<u8>> = 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));
}
}
+14 -1
View File
@@ -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<String> = 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();
+220
View File
@@ -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<u8>, 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<ObjRef>,
}
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Result<Vec<u8>, 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<u8> = 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);
}
}
+157 -14
View File
@@ -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<Vec<u8>>,
/// Where `endstream` occurs in `data`; see [`EndstreamIndex`].
endstreams: Arc<EndstreamIndex>,
source_identity: Arc<()>,
entries: Arc<XrefIndex>,
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<usize> = 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<Vec<u8>>, 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<i64, Option<BTreeMap<u32, XrefEntry>>> = 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 &sect {
// 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<u32>,
log: &mut Vec<String>,
) -> 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<String> {
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())))
}
+2
View File
@@ -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};
+170 -16
View File
@@ -76,10 +76,37 @@ impl PdfString {
/// A name object (without the leading `/`, with `#xx` escapes already decoded).
pub type Name = Vec<u8>;
#[cfg(test)]
thread_local! {
/// Key comparisons and index lookups made by [`Dict`] on this thread.
static KEY_COMPARES: std::cell::Cell<usize> = 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<Box<std::collections::HashMap<Name, usize>>>,
}
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<usize> {
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<Name>, value: impl Into<Object>) {
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<Object> {
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<Vec<(Filter, Params)>, 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<Vec<u8>, 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<u8>, 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<u8>, usize), (pdfcraft_filters::FilterError, usize)>,
) -> Result<(Vec<u8>, 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<Vec<u8>, 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<u8>, 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<Vec<u8>, 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<Vec<u8>, CosError> {
self.raw_within_typed(max).map_err(|e| CosError::Filter(e.to_string()))
}
fn raw_within_typed(&self, max: usize) -> Result<Vec<u8>, 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<Stream> 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 📄"] {
+128 -19
View File
@@ -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<u8> {
@@ -84,13 +89,22 @@ impl<'a> Lexer<'a> {
/// Parse one direct object. References `N G R` are recognised.
pub fn object(&mut self) -> Result<Object, CosError> {
self.nodes = 0;
self.object_depth(0)
}
fn over_budget(&self) -> bool {
self.nodes > MAX_NODES
}
fn object_depth(&mut self, depth: usize) -> Result<Object, CosError> {
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::<f64>()
{
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::<f64>(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<usize> = const { std::cell::Cell::new(0) };
/// Bytes handed to number conversion on this thread.
static NUMBER_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn parse_counted<T: std::str::FromStr>(s: &str) -> Result<T, T::Err> {
#[cfg(test)]
NUMBER_BYTES.with(|c| c.set(c.get() + s.len()));
s.parse()
}
fn hexval(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
@@ -362,17 +410,30 @@ fn hexval(b: u8) -> Option<u8> {
/// Parse an indirect object at `offset`: `N G obj <object> [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<i64>) -> 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<Vec<u8>>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
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<Vec<usize>>;
fn endstream_offsets(data: &[u8]) -> Vec<usize> {
#[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<Vec<u8>>>,
offset: usize,
resolve_length: &dyn Fn(ObjRef) -> Option<i64>,
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<usize> {
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<usize> {
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());
}
}
+75 -13
View File
@@ -88,42 +88,104 @@ fn named_crypt_filter(dict: &Dict) -> Option<Vec<u8>> {
/// 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,
+4 -1
View File
@@ -132,7 +132,10 @@ fn write_stream(s: &Stream, out: &mut Vec<u8>) {
/// 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),
}
}
@@ -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<u8> {
fixture_with("")
}
/// The fixture with `extra` added to the content stream's dictionary.
fn fixture_with(extra: &str) -> Vec<u8> {
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\n<x:xmpmeta>meta</x:xmpmeta>\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
+8
View File
@@ -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;
+15 -3
View File
@@ -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<Space, String> {
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<Space, String> {
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))
+25
View File
@@ -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<u8> = (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);
}
+49 -17
View File
@@ -138,9 +138,51 @@ pub struct SecurityHandler {
key: Vec<u8>,
auth: Auth,
recovered_user: Option<Vec<u8>>,
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<Vec<u8>, 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<u8>, auth: Auth, recovered_user: Option<Vec<u8>>) -> 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<Self, CryptError> {
@@ -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<StreamKind>) -> 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<SecurityHandler, Cry
d.o = o;
let key = file_key_r4(&d, id0, &user);
d.u = u_value_r4(&d, id0, &key);
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: Some(user) })
Ok(SecurityHandler::new(d, key, Auth::Owner, Some(user)))
}
Algorithm::Aes256 => {
(d.v, d.r, d.length_bits) = (5, 6, 256);
@@ -671,7 +703,7 @@ pub fn create(params: &NewEncryption, id0: &[u8]) -> Result<SecurityHandler, Cry
perms[9..12].copy_from_slice(b"adb");
perms[12..16].copy_from_slice(&expand(&params.seed, b"perms", 4));
d.perms = aes_cbc_encrypt(&key, &[0; 16], &perms, false);
Ok(SecurityHandler { dict: d, key, auth: Auth::Owner, recovered_user: None })
Ok(SecurityHandler::new(d, key, Auth::Owner, None))
}
}
}
+26
View File
@@ -121,6 +121,32 @@ fn strings_and_streams_round_trip_and_are_scrambled() {
}
}
thread_local! {
/// Crypt filter names compared or looked up, on this thread.
pub(crate) static FILTER_COMPARES: std::cell::Cell<usize> = 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();
+4
View File
@@ -0,0 +1,4 @@
//! The crate's integration tests, linked into one test binary.
mod proptests;
mod qpdf_literals;
@@ -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");
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn page_background(doc: &Document, page: usize) -> Result<Option<Rgb>, 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::<Vec<_>>());
let ops = pieces.parse().ops;
+20 -23
View File
@@ -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<u8>)> {
let list: Vec<Object> = 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<Vec<PageImage>, 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<Vec<PageImage>, 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<Vec<PageImage>, 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<Vec<PageImage>, 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<ObjRef>,
) -> Result<Vec<(usize, PageImage)>, 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()))?;
+9 -6
View File
@@ -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<u8>, 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<u8>, behind: bool) -> Result<(), EditError> {
/// Returns the new content stream.
fn place_tagged(doc: &mut Document, page: &pdfcraft_model::Page, tag: &str, content: Vec<u8>, behind: bool) -> Result<ObjRef, EditError> {
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<u8>) -> Result<(), EditError> {
/// Returns the new content stream.
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
let all = page_list(doc);
check(&[page], all.len())?;
add_resources(doc, &all[page], None, Some(&content))?;
+93
View File
@@ -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);
}
+65 -35
View File
@@ -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<Vec<TextEditorFo
let resources = p.dict.get(b"Resources").and_then(|o| doc.dict(o)).unwrap_or_default();
let fonts = resources.get(b"Font").and_then(|o| doc.dict(o)).unwrap_or_default();
let mut budget = 32_usize << 20;
// Every program decoded counts, usable or not, so aliases of one program can't repeat the work.
let mut decode = DecodeBudget::new(2 * budget, 0);
let mut result = Vec::new();
for (name, font) in fonts.iter().take(128) {
let Some(font) = doc.dict(font) else { continue };
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font) else { continue };
let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt_within(doc, &font, &mut decode) else { continue };
if bytes.len() > budget {
continue;
}
@@ -615,7 +617,16 @@ struct Ts {
unreadable: Option<Rc<str>>,
}
pub(crate) fn content_streams(doc: &Document, page: &Dict) -> Vec<(Object, Vec<u8>)> {
/// 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<Vec<(Object, Vec<u8>)>, 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<Vec<(Object, Vec<u8>)>, EditError> {
let list: Vec<Object> = 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<u
_ => 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<Vec<TextLine>, 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::<Vec<_>>());
let pieces = Pieces::join(&content_streams(doc, &p.dict)?.into_iter().map(|(_, d)| d).collect::<Vec<_>>());
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<FormCall> {
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<TextLine>, 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::<Vec<_>>());
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::<Vec<_>>());
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<Vec<TextBlock>, Edi
pub struct ReadingBlocks {
/// The paragraphs, as [`reading_blocks`] returns them.
pub blocks: Vec<TextBlock>,
/// 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<ReadingBlocks, EditError> {
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::<Vec<_>>());
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<pdfcraft_cos::ObjRef>,
) -> Result<Vec<(usize, TextBlock)>, 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();
+1 -1
View File
@@ -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::<Vec<_>>());
let ops = pieces.parse().ops;
// A row the edit doesn't reach keeps its source operators, so its kerning, TJ word gaps and

Some files were not shown because too many files have changed in this diff Show More