mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
PDFR: share parsing while retaining audited EPS fixes
This commit is contained in:
1 parent
549e9d8251
commit
a954ba4fa4
15 files changed
+773
-182
No files matched your search
Generated
+147
@@ -747,6 +747,12 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cast"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -962,6 +968,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "craft-pdf"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||||
|
dependencies = [
|
||||||
|
"hayro-syntax",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-ui"
|
name = "craft-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1478,6 +1493,7 @@ dependencies = [
|
|||||||
name = "effectcraft-pdf"
|
name = "effectcraft-pdf"
|
||||||
version = "0.9.2"
|
version = "0.9.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"craft-pdf",
|
||||||
"effectcraft-raster",
|
"effectcraft-raster",
|
||||||
"effectcraft-svg",
|
"effectcraft-svg",
|
||||||
"effectcraft-text",
|
"effectcraft-text",
|
||||||
@@ -1485,6 +1501,7 @@ dependencies = [
|
|||||||
"kurbo",
|
"kurbo",
|
||||||
"miniz_oxide 0.8.9",
|
"miniz_oxide 0.8.9",
|
||||||
"skrifa",
|
"skrifa",
|
||||||
|
"wasm-bindgen-test",
|
||||||
"zune-jpeg",
|
"zune-jpeg",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2678,6 +2695,40 @@ dependencies = [
|
|||||||
"foldhash 0.2.0",
|
"foldhash 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-ccitt"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-jbig2"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||||
|
dependencies = [
|
||||||
|
"hayro-ccitt",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-jpeg2000"
|
||||||
|
version = "0.3.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hayro-syntax"
|
||||||
|
version = "0.7.2"
|
||||||
|
source = "git+https://github.com/storytold/craft-libs?rev=89dcbb3060377a26c99a6d80caa16084366704c4#89dcbb3060377a26c99a6d80caa16084366704c4"
|
||||||
|
dependencies = [
|
||||||
|
"hayro-jbig2",
|
||||||
|
"hayro-jpeg2000",
|
||||||
|
"rustc-hash 2.1.3",
|
||||||
|
"smallvec",
|
||||||
|
"stringprep",
|
||||||
|
"zune-jpeg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -3167,6 +3218,16 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "minicov"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"walkdir",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3311,6 +3372,15 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.5.1"
|
version = "0.5.1"
|
||||||
@@ -3759,6 +3829,12 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
|
checksum = "cfe21416a02c693fb9f980befcb230ecc70b0b3d1cc4abf88b9675c4c1457f0c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "oorandom"
|
||||||
|
version = "11.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orbclient"
|
name = "orbclient"
|
||||||
version = "0.3.55"
|
version = "0.3.55"
|
||||||
@@ -4773,6 +4849,17 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "stringprep"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-bidi",
|
||||||
|
"unicode-normalization",
|
||||||
|
"unicode-properties",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strum"
|
name = "strum"
|
||||||
version = "0.28.0"
|
version = "0.28.0"
|
||||||
@@ -5156,6 +5243,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tinyvec"
|
||||||
|
version = "1.13.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.1"
|
version = "1.53.1"
|
||||||
@@ -5298,6 +5391,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-normalization"
|
||||||
|
version = "0.1.25"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
|
||||||
|
dependencies = [
|
||||||
|
"tinyvec",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-properties"
|
||||||
|
version = "0.1.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.13.3"
|
version = "1.13.3"
|
||||||
@@ -5462,6 +5570,45 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-test"
|
||||||
|
version = "0.3.79"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"cast",
|
||||||
|
"js-sys",
|
||||||
|
"libm",
|
||||||
|
"minicov",
|
||||||
|
"nu-ansi-term",
|
||||||
|
"num-traits",
|
||||||
|
"oorandom",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"wasm-bindgen-test-macro",
|
||||||
|
"wasm-bindgen-test-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-test-macro"
|
||||||
|
version = "0.3.79"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.6",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-test-shared"
|
||||||
|
version = "0.2.129"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-encoder"
|
name = "wasm-encoder"
|
||||||
version = "0.261.0"
|
version = "0.261.0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ rust-version = "1.95"
|
|||||||
repository = "https://github.com/storytold/effectcraft"
|
repository = "https://github.com/storytold/effectcraft"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
|
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.1.0", default-features = false, features = ["read"] }
|
||||||
# internal
|
# internal
|
||||||
effectcraft-time = { path = "crates/time" }
|
effectcraft-time = { path = "crates/time" }
|
||||||
effectcraft-geom = { path = "crates/geom" }
|
effectcraft-geom = { path = "crates/geom" }
|
||||||
@@ -204,3 +205,6 @@ opt-level = 3
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "89dcbb3060377a26c99a6d80caa16084366704c4", version = "=0.7.2" }
|
||||||
@@ -15,3 +15,8 @@ list (author, source, license) is in ATTRIBUTION.md.
|
|||||||
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
Inter (assets/fonts/Inter-*.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-Inter.txt.
|
||||||
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
Noto Serif (assets/fonts/NotoSerif-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||||
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
JetBrains Mono (assets/fonts/JetBrainsMono-Regular.ttf) — SIL Open Font License 1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||||
|
|
||||||
|
The optional PDF document reader uses craft-pdf and Hayro-syntax 0.7.2, MIT OR Apache-2.0.
|
||||||
|
The shared patched syntax source is centralized in storytold/craft-libs, transferred from
|
||||||
|
PdfCraft revision 9ed3abe4907a127d36b12fbfeee71d7e26248da1. This parser-only use embeds no
|
||||||
|
Hayro fonts, CMaps or image codecs; EffectCraft keeps its existing fonts and drawing filters.
|
||||||
@@ -154,6 +154,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
|||||||
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
| 2026-10-12 | patch | Roto Brush span validation after trimming; command regressions for both layer edges |
|
||||||
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
| 2026-10-10 | minor | Hours to ≈ 95% for each audience |
|
||||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
| 2026-10-10 | minor | Headline adds mainstream practitioner (≈ 47%) and essentials user (≈ 52%) |
|
||||||
|
| 2026-10-12 | minor | Shared PDF object/xref parsing with bounded app-owned conversion and preserved stream budgets; drawing, EPS and image/font policy remain unchanged |
|
||||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
| 2026-10-10 | minor | Stage checked against the new core-workflow gate (all six pass; still alpha) |
|
||||||
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
| 2026-10-10 | major | Rewritten to the shared progress-docs standard: stage, two numbers, dimensions, features, languages, upcoming, progress log; milestones moved to docs/roadmap.md |
|
||||||
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
| 2026-10-05 | major | Two numbers (breadth vs real use) and workstreams G1–G9 |
|
||||||
@@ -10,6 +10,7 @@ rust-version.workspace = true
|
|||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
craft-pdf.workspace = true
|
||||||
effectcraft-svg = { workspace = true }
|
effectcraft-svg = { workspace = true }
|
||||||
kurbo = { workspace = true }
|
kurbo = { workspace = true }
|
||||||
effectcraft-text = { workspace = true }
|
effectcraft-text = { workspace = true }
|
||||||
@@ -23,3 +24,6 @@ zune-jpeg = "0.5"
|
|||||||
# Encodes JPEG fixtures for the DCT image test.
|
# Encodes JPEG fixtures for the DCT image test.
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
effectcraft-raster = { workspace = true }
|
effectcraft-raster = { workspace = true }
|
||||||
|
|
||||||
|
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||||
|
wasm-bindgen-test = "0.3"
|
||||||
+39
-9
@@ -14,20 +14,23 @@ Charstring Format) for CFF font programs; **Adobe Type 1 Font Format** (version
|
|||||||
font programs; the **PostScript Language Reference, third edition** and the **Encapsulated
|
font programs; the **PostScript Language Reference, third edition** and the **Encapsulated
|
||||||
PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6**
|
PostScript File Format Specification 3.0** for EPS; **ITU-T T.4** (07/2003) and **ITU-T T.6**
|
||||||
(11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT)
|
(11/1988) for CCITT fax images. TrueType font programs are read through skrifa and JPEG (DCT)
|
||||||
images through zune-jpeg (both permissively licensed, pure Rust). No third-party
|
images through zune-jpeg (both permissively licensed, pure Rust). PDF document/object loading uses the shared `craft-pdf` parser described below. The retained
|
||||||
PDF/PostScript/font/codec code was read; test files (including the CFF and Type 1 fonts and the
|
drawing, PostScript, font and filter implementations were developed from the public
|
||||||
CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
|
specifications without copying third-party implementations. Test files (including the CFF
|
||||||
|
and Type 1 fonts and the CCITT data) are generated in code ([`write`](src/write.rs) and the tests). One CCITT fixture was
|
||||||
produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like
|
produced once by libtiff's `tiffcp` (an external encoder used only as a fixture generator, like
|
||||||
ffmpeg elsewhere) to check the code tables against an independent implementation.
|
ffmpeg elsewhere) to check the code tables against an independent implementation.
|
||||||
|
|
||||||
## What is read
|
## What is read
|
||||||
|
|
||||||
- **Structure:** indirect objects found by scanning (damaged or incremental cross-reference
|
- **Structure:** `craft-pdf` resolves cross-reference tables/streams and indirect objects into
|
||||||
sections do not matter), object streams, the page tree with inherited resources, MediaBox /
|
the app's existing object model. References retain object and generation numbers. A bounded
|
||||||
CropBox and `/Rotate`. Filters: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
compatibility repair recovers referenced objects from object streams when damaged xrefs omit
|
||||||
RunLength, CCITTFax (Group 3 one- and two-dimensional, Group 4; `K`, `Columns`, `Rows`,
|
them; every recovery or unresolved reference is reported in `Doc::skipped` (up to 64 messages).
|
||||||
`EndOfLine`, `EncodedByteAlign`, `EndOfBlock`, `BlackIs1`; damaged data keeps the rows
|
The existing page tree preserves inherited resources, MediaBox / CropBox and `/Rotate`.
|
||||||
decoded before the damage). Encrypted files are refused.
|
Content/image filters remain app-owned: Flate (with PNG predictors), LZW, ASCIIHex, ASCII85,
|
||||||
|
RunLength and CCITTFax. Encrypted files remain refused; `page_count` returns zero when the
|
||||||
|
reader refuses a document.
|
||||||
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
|
- **Content:** `q`/`Q`, `cm`, line width / cap / join / miter / dash, ExtGState (`CA`, `ca`, `LW`,
|
||||||
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
|
`LC`, `LJ`, `ML`, `D`), all path construction and painting operators (non-zero and even-odd),
|
||||||
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
|
clipping (`W`, `W*`) as clipping groups, colour in DeviceGray / RGB / CMYK, ICCBased (by
|
||||||
@@ -129,3 +132,30 @@ ffmpeg elsewhere) to check the code tables against an independent implementation
|
|||||||
layer; the shapes drawn between images go to shape layers of their own (`<name> Outlines
|
layer; the shapes drawn between images go to shape layers of their own (`<name> Outlines
|
||||||
<n>`), so the layer stack keeps the document's paint order. Soft masks, and the clips and
|
<n>`), so the layer stack keeps the document's paint order. Soft masks, and the clips and
|
||||||
masks of images, are not converted (the command's `skipped` list says so).
|
masks of images, are not converted (the command's `skipped` list says so).
|
||||||
|
|
||||||
|
## Shared parser setup and bounds
|
||||||
|
|
||||||
|
The workspace pins `craft-pdf` to `craft-libs` Git revision
|
||||||
|
`89dcbb3060377a26c99a6d80caa16084366704c4`, with `default-features = false` and only the explicit `read`
|
||||||
|
feature. This excludes the backend's bundled fonts and Adobe CMaps and preserves EffectCraft's
|
||||||
|
font and asset policies. The shared parser does not change the supported drawing operators,
|
||||||
|
image codecs, continuously rasterized SVG tree, editable shapes, or EPS interpreter.
|
||||||
|
|
||||||
|
The root Cargo patch pins Hayro-syntax to the same `storytold/craft-libs` revision. `cargo xtask pdf-deps` checks this graph and refuses
|
||||||
|
an enabled Hayro interpreter, rasterizer or CMap dependency. The shared revision must be
|
||||||
|
published before a fresh app checkout can resolve it; no sibling checkout is required.
|
||||||
|
|
||||||
|
Input is limited to 512 MiB. The object adapter admits at most 100,000 document/referenced
|
||||||
|
objects, 1,000,000 converted values and 64 inline nesting levels, with a 512 MiB retained
|
||||||
|
object-data budget. References are queued once rather than recursively expanded. Native
|
||||||
|
unwinding panics during shared loading or lazy object conversion become an error and discard
|
||||||
|
partial state. These limits are not an overall process-memory or execution-time guarantee;
|
||||||
|
WASM aborts and process-wide allocation failures cannot be caught. Missing or repaired objects
|
||||||
|
are reported rather than silently presented as a complete import.
|
||||||
|
|
||||||
|
The shared parser receives EffectCraft's existing ordinary object/xref stream limit at open:
|
||||||
|
256 MiB on 64-bit targets and 64 MiB on 32-bit targets, including WASM. Immutable document
|
||||||
|
limits apply during normal parsing, initial xrefs, fallback and repair. Malformed object-stream
|
||||||
|
compatibility recovery also obeys that limit and the adapter's remaining retained-data budget.
|
||||||
|
Content/image filter decoding still uses EffectCraft's existing bounded filter chain; the
|
||||||
|
retained-data cap is not a bound on every parser cache or temporary allocation.
|
||||||
@@ -19,6 +19,7 @@ mod image;
|
|||||||
mod object;
|
mod object;
|
||||||
mod page;
|
mod page;
|
||||||
mod ps;
|
mod ps;
|
||||||
|
mod read;
|
||||||
mod shading;
|
mod shading;
|
||||||
mod type1;
|
mod type1;
|
||||||
pub mod write;
|
pub mod write;
|
||||||
@@ -33,6 +34,7 @@ pub enum Error {
|
|||||||
NoPages,
|
NoPages,
|
||||||
PageOutOfRange(usize),
|
PageOutOfRange(usize),
|
||||||
NoBoundingBox,
|
NoBoundingBox,
|
||||||
|
Read(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for Error {
|
impl std::fmt::Display for Error {
|
||||||
@@ -41,8 +43,9 @@ impl std::fmt::Display for Error {
|
|||||||
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
|
Error::NotVector => write!(f, "not a PDF, Illustrator or EPS file"),
|
||||||
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
|
Error::Encrypted => write!(f, "encrypted PDF files are not supported"),
|
||||||
Error::NoPages => write!(f, "the PDF has no pages"),
|
Error::NoPages => write!(f, "the PDF has no pages"),
|
||||||
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n + 1),
|
Error::PageOutOfRange(n) => write!(f, "the PDF has no page {}", n.saturating_add(1)),
|
||||||
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
|
Error::NoBoundingBox => write!(f, "the EPS file has no %%BoundingBox"),
|
||||||
|
Error::Read(message) => f.write_str(message),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +102,7 @@ pub fn parse_page(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
|||||||
/// Number of pages (1 for EPS).
|
/// Number of pages (1 for EPS).
|
||||||
pub fn page_count(bytes: &[u8]) -> usize {
|
pub fn page_count(bytes: &[u8]) -> usize {
|
||||||
match sniff(bytes) {
|
match sniff(bytes) {
|
||||||
Some(Format::Pdf) => page::pages(&object::File::parse(bytes)).len(),
|
Some(Format::Pdf) => object::File::parse(bytes).map(|file| page::pages(&file).len()).unwrap_or(0),
|
||||||
Some(Format::Eps) => 1,
|
Some(Format::Eps) => 1,
|
||||||
None => 0,
|
None => 0,
|
||||||
}
|
}
|
||||||
@@ -140,10 +143,7 @@ fn finish(nodes: Vec<Node>, flags: Vec<bool>, skipped: Vec<String>, width: f64,
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
||||||
let file = object::File::parse(bytes);
|
let file = object::File::parse(bytes)?;
|
||||||
if file.encrypted() {
|
|
||||||
return Err(Error::Encrypted);
|
|
||||||
}
|
|
||||||
let pages = page::pages(&file);
|
let pages = page::pages(&file);
|
||||||
if pages.is_empty() {
|
if pages.is_empty() {
|
||||||
return Err(Error::NoPages);
|
return Err(Error::NoPages);
|
||||||
@@ -160,6 +160,7 @@ fn parse_pdf(bytes: &[u8], index: usize) -> Result<Doc, Error> {
|
|||||||
};
|
};
|
||||||
let content = page::page_content(&file, pg);
|
let content = page::page_content(&file, pg);
|
||||||
let mut it = page::Interp::new(&file, pg.bbox);
|
let mut it = page::Interp::new(&file, pg.bbox);
|
||||||
|
it.b.skipped.extend(file.repairs.iter().cloned());
|
||||||
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
|
it.run(&content, &pg.resources, Affine::IDENTITY, 0);
|
||||||
let (nodes, flags, skipped) = it.b.finish();
|
let (nodes, flags, skipped) = it.b.finish();
|
||||||
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
|
Ok(finish(nodes, flags, skipped, dw.max(1.0), dh.max(1.0), to_doc))
|
||||||
@@ -201,4 +202,6 @@ mod tests_groups;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests_import;
|
mod tests_import;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
mod tests_reader;
|
||||||
|
#[cfg(test)]
|
||||||
mod tests_shading;
|
mod tests_shading;
|
||||||
+14
-159
@@ -1,9 +1,6 @@
|
|||||||
//! PDF objects (ISO 32000-1 §7.2–7.5): the lexer, the object model, stream filters and the
|
//! Local object model, content-stream lexer and stream filters. Document objects are read
|
||||||
//! indirect-object table.
|
//! through the shared parser in `read`; the local lexer remains for content operators and
|
||||||
//!
|
//! PostScript-related data.
|
||||||
//! The table is built by scanning the file for `n g obj` headers (later definitions win, as
|
|
||||||
//! incremental updates do) and by unpacking object streams, so damaged or rewritten
|
|
||||||
//! cross-reference sections do not matter.
|
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -191,7 +188,7 @@ impl<'a> Lexer<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `n g R` after a number inside arrays and dictionaries.
|
/// `n g R` after a number inside arrays and dictionaries.
|
||||||
fn maybe_ref(&mut self, v: Obj) -> Obj {
|
pub(super) fn maybe_ref(&mut self, v: Obj) -> Obj {
|
||||||
let Obj::Num(n) = v else { return v };
|
let Obj::Num(n) = v else { return v };
|
||||||
if n.fract() != 0.0 || n < 0.0 {
|
if n.fract() != 0.0 || n < 0.0 {
|
||||||
return v;
|
return v;
|
||||||
@@ -312,11 +309,10 @@ pub fn parse_num(w: &[u8]) -> Option<f64> {
|
|||||||
/// growing buffer can reserve about twice the limit.
|
/// growing buffer can reserve about twice the limit.
|
||||||
const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 };
|
const MAX_INFLATED: usize = if cfg!(target_pointer_width = "32") { 256 << 20 } else { 1 << 30 };
|
||||||
|
|
||||||
/// The tighter bound for object streams, which [`File::parse`] decodes eagerly (all of them,
|
/// The bound for ordinary object/xref streams and malformed object-stream recovery.
|
||||||
/// before any page is chosen). They hold only other objects' dictionaries, arrays, numbers and
|
/// The shared parser receives this immutable limit when the document is opened;
|
||||||
/// strings, so real ones are far smaller; PdfCraft uses 256 MiB for the object and
|
/// 32-bit targets retain the app's quarter-sized limit.
|
||||||
/// cross-reference streams it decodes while loading. A quarter on 32-bit targets, as above.
|
pub(crate) const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||||
const MAX_OBJECT_STREAM: usize = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
|
||||||
|
|
||||||
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
|
/// Decode a stream's data through its `/Filter` chain. Unknown filters (images: DCT, JPX, CCITT,
|
||||||
/// JBIG2) give `None`.
|
/// JBIG2) give `None`.
|
||||||
@@ -624,151 +620,14 @@ fn predict(data: &[u8], p: &Dict, max: usize) -> Option<Vec<u8>> {
|
|||||||
|
|
||||||
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
|
/// Every indirect object of a PDF file, plus the trailer dictionaries found.
|
||||||
pub struct File {
|
pub struct File {
|
||||||
pub objects: HashMap<u32, Obj>,
|
pub objects: HashMap<(u32, u16), Obj>,
|
||||||
pub trailers: Vec<Dict>,
|
pub trailers: Vec<Dict>,
|
||||||
}
|
pub repairs: Vec<String>,
|
||||||
|
|
||||||
fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
|
|
||||||
if from >= hay.len() || needle.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
hay[from..].windows(needle.len()).position(|w| w == needle).map(|p| p + from)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl File {
|
impl File {
|
||||||
pub fn parse(data: &[u8]) -> File {
|
pub fn parse(data: &[u8]) -> Result<File, crate::Error> {
|
||||||
let mut f = File { objects: HashMap::new(), trailers: vec![] };
|
crate::read::open(data)
|
||||||
let mut i = 0;
|
|
||||||
while let Some(p) = find(data, b"obj", i) {
|
|
||||||
i = p + 3;
|
|
||||||
if p >= 3 && &data[p - 3..p] == b"end" {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if data.get(p + 3).is_some_and(|c| !is_white(*c) && !is_delim(*c)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Walk back over "num gen ".
|
|
||||||
let mut q = p;
|
|
||||||
let back_ws = |q: &mut usize| {
|
|
||||||
let s = *q;
|
|
||||||
while *q > 0 && is_white(data[*q - 1]) {
|
|
||||||
*q -= 1;
|
|
||||||
}
|
|
||||||
*q < s
|
|
||||||
};
|
|
||||||
let back_digits = |q: &mut usize| {
|
|
||||||
let e = *q;
|
|
||||||
while *q > 0 && data[*q - 1].is_ascii_digit() {
|
|
||||||
*q -= 1;
|
|
||||||
}
|
|
||||||
(*q < e).then(|| std::str::from_utf8(&data[*q..e]).ok()?.parse::<u32>().ok()).flatten()
|
|
||||||
};
|
|
||||||
if !back_ws(&mut q) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(_gen) = back_digits(&mut q) else { continue };
|
|
||||||
if !back_ws(&mut q) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Some(num) = back_digits(&mut q) else { continue };
|
|
||||||
if q > 0 && !is_white(data[q - 1]) && !is_delim(data[q - 1]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut lx = Lexer::new(data, p + 3);
|
|
||||||
let Some(obj) = lx.next() else { continue };
|
|
||||||
let obj = lx.maybe_ref(obj);
|
|
||||||
let obj = match obj {
|
|
||||||
Obj::Dict(d) => {
|
|
||||||
let save = lx.pos;
|
|
||||||
lx.skip_ws();
|
|
||||||
if data.get(lx.pos..).is_some_and(|d| d.starts_with(b"stream")) {
|
|
||||||
let mut s = lx.pos + 6;
|
|
||||||
if data.get(s) == Some(&b'\r') {
|
|
||||||
s += 1;
|
|
||||||
}
|
|
||||||
if data.get(s) == Some(&b'\n') {
|
|
||||||
s += 1;
|
|
||||||
}
|
|
||||||
let declared = d.get("Length").and_then(Obj::num).map(|l| l as usize);
|
|
||||||
let s = s.min(data.len());
|
|
||||||
let end = match declared.filter(|l| {
|
|
||||||
s.checked_add(*l).is_some_and(|e| e <= data.len()) && {
|
|
||||||
let mut k = s + l;
|
|
||||||
while k < data.len() && is_white(data[k]) {
|
|
||||||
k += 1;
|
|
||||||
}
|
|
||||||
data[k..].starts_with(b"endstream")
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
Some(l) => s + l,
|
|
||||||
None => {
|
|
||||||
let mut e = find(data, b"endstream", s).unwrap_or(data.len());
|
|
||||||
if e > s && data[e - 1] == b'\n' {
|
|
||||||
e -= 1;
|
|
||||||
}
|
|
||||||
if e > s && data[e - 1] == b'\r' {
|
|
||||||
e -= 1;
|
|
||||||
}
|
|
||||||
e
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let end = end.max(s);
|
|
||||||
i = end;
|
|
||||||
Obj::Stream(d, data[s..end].to_vec())
|
|
||||||
} else {
|
|
||||||
lx.pos = save;
|
|
||||||
Obj::Dict(d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
o => o,
|
|
||||||
};
|
|
||||||
if let Obj::Stream(d, _) = &obj
|
|
||||||
&& d.get("Type").and_then(Obj::name) == Some("XRef")
|
|
||||||
{
|
|
||||||
f.trailers.push(d.clone());
|
|
||||||
}
|
|
||||||
f.objects.insert(num, obj);
|
|
||||||
}
|
|
||||||
// Trailer dictionaries of classic cross-reference sections.
|
|
||||||
let mut i = 0;
|
|
||||||
while let Some(p) = find(data, b"trailer", i) {
|
|
||||||
i = p + 7;
|
|
||||||
let mut lx = Lexer::new(data, i);
|
|
||||||
if let Some(Obj::Dict(d)) = lx.next() {
|
|
||||||
f.trailers.push(d);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Object streams.
|
|
||||||
let streams: Vec<(Dict, Vec<u8>)> = f
|
|
||||||
.objects
|
|
||||||
.values()
|
|
||||||
.filter_map(|o| match o {
|
|
||||||
Obj::Stream(d, raw) if d.get("Type").and_then(Obj::name) == Some("ObjStm") => Some((d.clone(), raw.clone())),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
for (d, raw) in streams {
|
|
||||||
let Some(dec) = decode_stream_within(&f, &d, &raw, MAX_OBJECT_STREAM) else { continue };
|
|
||||||
let n = d.get("N").and_then(Obj::num).unwrap_or(0.0) as usize;
|
|
||||||
let first = d.get("First").and_then(Obj::num).unwrap_or(0.0) as usize;
|
|
||||||
let mut lx = Lexer::new(&dec, 0);
|
|
||||||
let mut heads = vec![];
|
|
||||||
for _ in 0..n.min(100_000) {
|
|
||||||
let (Some(Obj::Num(num)), Some(Obj::Num(off))) = (lx.next(), lx.next()) else { break };
|
|
||||||
heads.push((num as u32, off as usize));
|
|
||||||
}
|
|
||||||
for (num, off) in heads {
|
|
||||||
if f.objects.contains_key(&num) || first + off >= dec.len() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut lx = Lexer::new(&dec, first + off);
|
|
||||||
if let Some(o) = lx.next() {
|
|
||||||
let o = lx.maybe_ref(o);
|
|
||||||
f.objects.insert(num, o);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow references (bounded).
|
/// Follow references (bounded).
|
||||||
@@ -776,7 +635,7 @@ impl File {
|
|||||||
let mut o = o;
|
let mut o = o;
|
||||||
for _ in 0..32 {
|
for _ in 0..32 {
|
||||||
match o {
|
match o {
|
||||||
Obj::Ref(n, _) => match self.objects.get(n) {
|
Obj::Ref(n, g) => match self.objects.get(&(*n, *g)) {
|
||||||
Some(x) => o = x,
|
Some(x) => o = x,
|
||||||
None => return &Obj::Null,
|
None => return &Obj::Null,
|
||||||
},
|
},
|
||||||
@@ -810,13 +669,9 @@ impl File {
|
|||||||
return Some(d);
|
return Some(d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let mut cats: Vec<(&u32, &Obj)> =
|
let mut cats: Vec<(&(u32, u16), &Obj)> =
|
||||||
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
|
self.objects.iter().filter(|(_, o)| matches!(o, Obj::Dict(d) if d.get("Type").and_then(Obj::name) == Some("Catalog"))).collect();
|
||||||
cats.sort_by_key(|(n, _)| **n);
|
cats.sort_by_key(|(n, _)| **n);
|
||||||
cats.last().and_then(|(_, o)| o.dict())
|
cats.last().and_then(|(_, o)| o.dict())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn encrypted(&self) -> bool {
|
|
||||||
self.trailers.iter().any(|t| t.contains_key("Encrypt"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
//! Adapter from the shared PDF syntax API to the existing drawing interpreter's object model.
|
||||||
|
//! Only reachable objects are retained, references keep their generation, and native parser
|
||||||
|
//! panics (including lazy object reads) discard the entire partially populated adapter.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use craft_pdf::syntax::object::{Dict as PdfDict, MaybeRef, Name, ObjRef, Object, ObjectIdentifier, Stream};
|
||||||
|
use craft_pdf::syntax::xref::XRef;
|
||||||
|
|
||||||
|
use crate::Error;
|
||||||
|
use crate::object::{Dict, File, Lexer, Obj};
|
||||||
|
|
||||||
|
const MAX_OBJECTS: usize = 100_000;
|
||||||
|
const MAX_VALUES: usize = 1_000_000;
|
||||||
|
const MAX_DEPTH: usize = 64;
|
||||||
|
const MAX_BYTES: usize = 512 * 1024 * 1024;
|
||||||
|
|
||||||
|
fn limit(what: &str) -> Error {
|
||||||
|
Error::Read(format!("PDF exceeds the {what} import limit"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allocation() -> Error {
|
||||||
|
Error::Read("PDF object storage could not be allocated".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_options() -> craft_pdf::OpenOptions {
|
||||||
|
craft_pdf::OpenOptions {
|
||||||
|
decode_limits: craft_pdf::syntax::DecodeLimits::default().with_max_decoded_stream(crate::object::MAX_OBJECT_STREAM),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn open(bytes: &[u8]) -> Result<File, Error> {
|
||||||
|
craft_pdf::check_input_size_with(bytes.len(), &open_options()).map_err(|e| Error::Read(e.to_string()))?;
|
||||||
|
catch_unwind(AssertUnwindSafe(|| open_inner(bytes))).map_err(|_| Error::Read("PDF parser failed internally".into()))?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_inner(bytes: &[u8]) -> Result<File, Error> {
|
||||||
|
// Preserve the app's refusal of encrypted PDFs, including empty-user-password files.
|
||||||
|
// Classic trailer dictionaries use the existing lexer; xref-stream dictionaries below
|
||||||
|
// are read by the shared parser. This does not parse document objects a second time.
|
||||||
|
let mut markers = 0;
|
||||||
|
for (offset, word) in bytes.windows(7).enumerate() {
|
||||||
|
if word == b"trailer" {
|
||||||
|
markers += 1;
|
||||||
|
if markers > MAX_OBJECTS {
|
||||||
|
return Err(limit("trailer count"));
|
||||||
|
}
|
||||||
|
if let Some(Obj::Dict(dict)) = Lexer::new(bytes, offset + 7).next()
|
||||||
|
&& dict.contains_key("Encrypt")
|
||||||
|
{
|
||||||
|
return Err(Error::Encrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let pdf = craft_pdf::open_slice_with_options(bytes, None, &open_options()).map_err(|error| match error {
|
||||||
|
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Decryption(_)) => Error::Encrypted,
|
||||||
|
craft_pdf::OpenError::Load(craft_pdf::syntax::LoadPdfError::Invalid) => Error::NoPages,
|
||||||
|
other => Error::Read(other.to_string()),
|
||||||
|
})?;
|
||||||
|
let mut object_streams = Vec::new();
|
||||||
|
for (index, object) in pdf.objects().into_iter().enumerate() {
|
||||||
|
if index >= MAX_OBJECTS {
|
||||||
|
return Err(limit("100000 objects"));
|
||||||
|
}
|
||||||
|
if let Object::Stream(stream) = object {
|
||||||
|
let dict = stream.dict();
|
||||||
|
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"XRef") && dict.contains_key(b"Encrypt") {
|
||||||
|
return Err(Error::Encrypted);
|
||||||
|
}
|
||||||
|
if dict.get::<Name<'_>>(b"Type").is_some_and(|name| name.as_ref() == b"ObjStm") {
|
||||||
|
object_streams.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
object_streams.push(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut reader = Reader {
|
||||||
|
xref: pdf.xref(),
|
||||||
|
pending: VecDeque::new(),
|
||||||
|
seen: HashSet::new(),
|
||||||
|
values_left: MAX_VALUES,
|
||||||
|
bytes_left: MAX_BYTES,
|
||||||
|
object_streams,
|
||||||
|
recovery: None,
|
||||||
|
};
|
||||||
|
let root = pdf.xref().root_id();
|
||||||
|
let root = reader.reference(ObjRef::new(root.obj_number, root.gen_number))?;
|
||||||
|
let mut file = File { objects: HashMap::new(), trailers: vec![Dict::from([("Root".into(), root)])], repairs: Vec::new() };
|
||||||
|
while let Some(id) = reader.pending.pop_front() {
|
||||||
|
let Some(key) = key(id) else { continue };
|
||||||
|
let object = match reader.xref.get::<Object<'_>>(id) {
|
||||||
|
Some(value) => reader.object(value, 0)?,
|
||||||
|
None => {
|
||||||
|
let recovered = reader.recover(id)?;
|
||||||
|
if file.repairs.len() < 64 {
|
||||||
|
let action = if recovered.is_some() { "recovered PDF object from an object stream" } else { "unresolved PDF object" };
|
||||||
|
file.repairs.push(format!("{action}: {} {}", id.obj_number, id.gen_number));
|
||||||
|
}
|
||||||
|
recovered.unwrap_or(Obj::Null)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
file.objects.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
file.objects.insert(key, object);
|
||||||
|
}
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key(id: ObjectIdentifier) -> Option<(u32, u16)> {
|
||||||
|
Some((u32::try_from(id.obj_number).ok()?, u16::try_from(id.gen_number).ok()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Reader<'a> {
|
||||||
|
xref: &'a XRef,
|
||||||
|
pending: VecDeque<ObjectIdentifier>,
|
||||||
|
seen: HashSet<ObjectIdentifier>,
|
||||||
|
values_left: usize,
|
||||||
|
bytes_left: usize,
|
||||||
|
object_streams: Vec<Stream<'a>>,
|
||||||
|
recovery: Option<HashMap<u32, Option<Recovered>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Recovered {
|
||||||
|
bytes: Arc<Vec<u8>>,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Reader<'_> {
|
||||||
|
fn value(&mut self) -> Result<(), Error> {
|
||||||
|
self.values_left = self.values_left.checked_sub(1).ok_or_else(|| limit("1000000 values"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes(&mut self, bytes: &[u8]) -> Result<Vec<u8>, Error> {
|
||||||
|
self.bytes_left = self.bytes_left.checked_sub(bytes.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
out.try_reserve_exact(bytes.len()).map_err(|_| allocation())?;
|
||||||
|
out.extend_from_slice(bytes);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The previous importer could recover compressed objects incorrectly marked free in
|
||||||
|
/// an otherwise readable xref. Retain that narrow repair only for unresolved references,
|
||||||
|
/// with a warning, bounded decoding and the existing content/object lexer.
|
||||||
|
fn recover(&mut self, id: ObjectIdentifier) -> Result<Option<Obj>, Error> {
|
||||||
|
if id.gen_number != 0 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if self.recovery.is_none() {
|
||||||
|
let mut recovered: HashMap<u32, Option<Recovered>> = HashMap::new();
|
||||||
|
for stream in std::mem::take(&mut self.object_streams) {
|
||||||
|
let Some(count) = stream.dict().get::<u32>(b"N") else { continue };
|
||||||
|
let Some(first) = stream.dict().get::<u32>(b"First") else { continue };
|
||||||
|
if count as usize > MAX_OBJECTS.saturating_sub(recovered.len()) {
|
||||||
|
return Err(limit("100000 recovered objects"));
|
||||||
|
}
|
||||||
|
let decoded = match stream.decoded_within(self.bytes_left.min(crate::object::MAX_OBJECT_STREAM)) {
|
||||||
|
Ok(data) => data,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
self.bytes_left = self.bytes_left.checked_sub(decoded.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||||
|
let decoded = Arc::new(decoded.into_owned());
|
||||||
|
let mut lexer = Lexer::new(&decoded, 0);
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for _ in 0..count {
|
||||||
|
self.value()?;
|
||||||
|
let (Some(Obj::Num(number)), Some(Obj::Num(offset))) = (lexer.next(), lexer.next()) else { break };
|
||||||
|
if !number.is_finite() || number.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&number) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !offset.is_finite() || offset.fract() != 0.0 || offset < 0.0 || offset > decoded.len() as f64 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(start) = (first as usize).checked_add(offset as usize).filter(|start| *start < decoded.len()) else { continue };
|
||||||
|
offsets.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
offsets.push((start, number as u32));
|
||||||
|
}
|
||||||
|
offsets.sort_unstable();
|
||||||
|
for (index, &(start, number)) in offsets.iter().enumerate() {
|
||||||
|
let end = offsets.get(index + 1).map_or(decoded.len(), |(next, _)| *next);
|
||||||
|
recovered.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
recovered.entry(number).and_modify(|entry| *entry = None).or_insert_with(|| Some(Recovered { bytes: Arc::clone(&decoded), start, end }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.recovery = Some(recovered);
|
||||||
|
}
|
||||||
|
let Some(number) = u32::try_from(id.obj_number).ok() else { return Ok(None) };
|
||||||
|
let Some(location) = self.recovery.as_ref().and_then(|r| r.get(&number)) else { return Ok(None) };
|
||||||
|
let Some(location) = location else { return Err(Error::Read(format!("PDF has ambiguous recovered object {number}"))) };
|
||||||
|
let bytes = Arc::clone(&location.bytes);
|
||||||
|
let Some(bytes) = bytes.get(location.start..location.end) else { return Ok(None) };
|
||||||
|
let mut lexer = Lexer::new(bytes, 0);
|
||||||
|
let Some(object) = lexer.next() else { return Ok(None) };
|
||||||
|
let object = lexer.maybe_ref(object);
|
||||||
|
self.register_recovered(&object, 0)?;
|
||||||
|
Ok(Some(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_recovered(&mut self, object: &Obj, depth: usize) -> Result<(), Error> {
|
||||||
|
self.value()?;
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(limit("64 nested objects"));
|
||||||
|
}
|
||||||
|
let bytes = match object {
|
||||||
|
Obj::Name(text) | Obj::Op(text) => text.len(),
|
||||||
|
Obj::Str(bytes) => bytes.len(),
|
||||||
|
Obj::Array(values) => {
|
||||||
|
for value in values {
|
||||||
|
self.register_recovered(value, depth + 1)?;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Obj::Dict(dict) | Obj::Stream(dict, _) => {
|
||||||
|
for (name, value) in dict {
|
||||||
|
self.bytes_left = self.bytes_left.checked_sub(name.len()).ok_or_else(|| limit("512 MiB object data"))?;
|
||||||
|
self.register_recovered(value, depth + 1)?;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Obj::Ref(number, generation) => {
|
||||||
|
if let Ok(number) = i32::try_from(*number) {
|
||||||
|
self.reference(ObjRef::new(number, i32::from(*generation)))?;
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
self.bytes_left = self.bytes_left.checked_sub(bytes).ok_or_else(|| limit("512 MiB object data"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name(&mut self, bytes: &[u8]) -> Result<String, Error> {
|
||||||
|
// Lossy UTF-8 expansion is at most three output bytes per source byte.
|
||||||
|
let bound = bytes.len().checked_mul(3).ok_or_else(|| limit("name length"))?;
|
||||||
|
self.bytes_left = self.bytes_left.checked_sub(bound).ok_or_else(|| limit("512 MiB object data"))?;
|
||||||
|
Ok(String::from_utf8_lossy(bytes).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reference(&mut self, reference: ObjRef) -> Result<Obj, Error> {
|
||||||
|
self.value()?;
|
||||||
|
let id = ObjectIdentifier::new(reference.obj_number, reference.gen_number);
|
||||||
|
let Some((number, generation)) = key(id) else { return Ok(Obj::Null) };
|
||||||
|
if !self.seen.contains(&id) {
|
||||||
|
if self.seen.len() >= MAX_OBJECTS {
|
||||||
|
return Err(limit("100000 referenced objects"));
|
||||||
|
}
|
||||||
|
self.seen.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
self.pending.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
self.seen.insert(id);
|
||||||
|
self.pending.push_back(id);
|
||||||
|
}
|
||||||
|
Ok(Obj::Ref(number, generation))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe(&mut self, object: MaybeRef<Object<'_>>, depth: usize) -> Result<Obj, Error> {
|
||||||
|
match object {
|
||||||
|
MaybeRef::Ref(reference) => self.reference(reference),
|
||||||
|
MaybeRef::NotRef(object) => self.object(object, depth),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dictionary(&mut self, dict: &PdfDict<'_>, depth: usize) -> Result<Dict, Error> {
|
||||||
|
let mut out = Dict::new();
|
||||||
|
for (name, object) in dict.entries() {
|
||||||
|
let name = self.name(name.as_ref())?;
|
||||||
|
let object = self.maybe(object, depth + 1)?;
|
||||||
|
out.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
out.insert(name, object);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object(&mut self, object: Object<'_>, depth: usize) -> Result<Obj, Error> {
|
||||||
|
self.value()?;
|
||||||
|
if depth > MAX_DEPTH {
|
||||||
|
return Err(limit("64 nested objects"));
|
||||||
|
}
|
||||||
|
Ok(match object {
|
||||||
|
Object::Null(_) => Obj::Null,
|
||||||
|
Object::Boolean(value) => Obj::Bool(value),
|
||||||
|
Object::Number(value) => Obj::Num(value.as_f64()),
|
||||||
|
Object::String(value) => Obj::Str(self.bytes(value.as_bytes())?),
|
||||||
|
Object::Name(value) => Obj::Name(self.name(value.as_ref())?),
|
||||||
|
Object::Array(array) => {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for value in array.raw_iter() {
|
||||||
|
let value = self.maybe(value, depth + 1)?;
|
||||||
|
out.try_reserve(1).map_err(|_| allocation())?;
|
||||||
|
out.push(value);
|
||||||
|
}
|
||||||
|
Obj::Array(out)
|
||||||
|
}
|
||||||
|
Object::Dict(dict) => Obj::Dict(self.dictionary(&dict, depth)?),
|
||||||
|
Object::Stream(stream) => {
|
||||||
|
let dict = self.dictionary(stream.dict(), depth)?;
|
||||||
|
Obj::Stream(dict, self.bytes(&stream.raw_data())?)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod limit_tests {
|
||||||
|
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||||
|
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||||
|
fn shared_reader_preserves_the_target_stream_budget() {
|
||||||
|
let options = super::open_options();
|
||||||
|
let expected = if cfg!(target_pointer_width = "32") { 64 << 20 } else { 256 << 20 };
|
||||||
|
assert_eq!(options.decode_limits.max_decoded_stream(), expected);
|
||||||
|
assert_eq!(options.max_input_bytes, 512 << 20);
|
||||||
|
let bytes = b"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF";
|
||||||
|
let pdf = craft_pdf::open_slice_with_options(bytes, None, &options).unwrap();
|
||||||
|
assert_eq!(pdf.decode_limits().max_decoded_stream(), expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-5
@@ -48,7 +48,10 @@ fn pdf_paths_clip_gradient_and_layers() {
|
|||||||
assert_eq!(page_count(&bytes), 1);
|
assert_eq!(page_count(&bytes), 1);
|
||||||
let doc = parse(&bytes).unwrap();
|
let doc = parse(&bytes).unwrap();
|
||||||
assert_eq!((doc.width, doc.height), (200.0, 100.0));
|
assert_eq!((doc.width, doc.height), (200.0, 100.0));
|
||||||
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}");
|
assert_eq!(layer_names(&doc), vec!["Background", "Art"], "object stream {objstm}, skipped: {:?}", doc.skipped);
|
||||||
|
if objstm {
|
||||||
|
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object")), "the malformed xref repair is reported");
|
||||||
|
}
|
||||||
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
|
assert!(doc.skipped.contains(&"text (no font)".to_string()), "{:?}", doc.skipped);
|
||||||
let (w, h) = doc.pixel_size();
|
let (w, h) = doc.pixel_size();
|
||||||
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
let img = effectcraft_svg::rasterize(&doc, w, h, 1.0);
|
||||||
@@ -196,7 +199,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
|
|||||||
const MAX: usize = 1 << 20;
|
const MAX: usize = 1 << 20;
|
||||||
let flate = object::Obj::Name("FlateDecode".into());
|
let flate = object::Obj::Name("FlateDecode".into());
|
||||||
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
|
let chain: object::Dict = [("Filter".to_string(), object::Obj::Array(vec![flate.clone(), flate]))].into_iter().collect();
|
||||||
let file = object::File::parse(b"");
|
let file = object_file(vec![]);
|
||||||
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
|
let mut nested = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 16 << 20], 6);
|
||||||
nested.resize(MAX - 1, 0);
|
nested.resize(MAX - 1, 0);
|
||||||
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
|
let raw = miniz_oxide::deflate::compress_to_vec_zlib(&nested, 6);
|
||||||
@@ -206,7 +209,7 @@ fn nested_bomb_in_the_second_flate_filter_is_refused() {
|
|||||||
|
|
||||||
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
|
fn bounded_filter(dict: &str, raw: &[u8], max: usize) -> Option<Vec<u8>> {
|
||||||
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
|
let object::Obj::Dict(d) = object::Lexer::new(dict.as_bytes(), 0).next().unwrap() else { panic!("not a dictionary") };
|
||||||
object::decode_stream_within(&object::File::parse(b""), &d, raw, max)
|
object::decode_stream_within(&object_file(vec![]), &d, raw, max)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -660,9 +663,31 @@ fn tiling_patterns_coloured_and_uncoloured() {
|
|||||||
assert_eq!(at(115, 15)[3], 0.0);
|
assert_eq!(at(115, 15)[3], 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build isolated interpreter objects without presenting a function or colour space as a
|
||||||
|
/// document catalog. Complete-document parsing is covered by the page and reader fixtures.
|
||||||
|
fn object_file(objs: Objs) -> object::File {
|
||||||
|
let objects = objs
|
||||||
|
.into_iter()
|
||||||
|
.map(|(number, body, stream)| {
|
||||||
|
let mut lexer = object::Lexer::new(body.as_bytes(), 0);
|
||||||
|
let value = lexer.next().unwrap();
|
||||||
|
let value = lexer.maybe_ref(value);
|
||||||
|
let value = match stream {
|
||||||
|
Some(bytes) => {
|
||||||
|
let object::Obj::Dict(dict) = value else { panic!("stream fixture must have a dictionary") };
|
||||||
|
object::Obj::Stream(dict, bytes)
|
||||||
|
}
|
||||||
|
None => value,
|
||||||
|
};
|
||||||
|
((number, 0), value)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
object::File { objects, trailers: vec![], repairs: vec![] }
|
||||||
|
}
|
||||||
|
|
||||||
/// Object 1 of a file holding just `objs`, read as a function.
|
/// Object 1 of a file holding just `objs`, read as a function.
|
||||||
fn read_function(objs: Objs) -> color::Func {
|
fn read_function(objs: Objs) -> color::Func {
|
||||||
color::Func::read(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0))
|
color::Func::read(&object_file(objs), &object::Obj::Ref(1, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sampled (type 0) function, read from a file holding just its stream.
|
/// A sampled (type 0) function, read from a file holding just its stream.
|
||||||
@@ -672,7 +697,7 @@ fn read_sampled_function(dict: &str, data: Vec<u8>) -> color::Func {
|
|||||||
|
|
||||||
/// Object 1 of a file holding just `objs`, read as a colour space.
|
/// Object 1 of a file holding just `objs`, read as a colour space.
|
||||||
fn read_color_space(objs: Objs) -> color::Cs {
|
fn read_color_space(objs: Objs) -> color::Cs {
|
||||||
color::color_space(&object::File::parse(&pdf(&objs, 1)), &object::Obj::Ref(1, 0), None)
|
color::color_space(&object_file(objs), &object::Obj::Ref(1, 0), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -376,3 +376,9 @@ fn postscript_radix_numbers_outside_2_to_36_are_not_numbers() {
|
|||||||
let _ = parse(src.as_bytes());
|
let _ = parse(src.as_bytes());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extreme_page_indices_have_a_printable_error() {
|
||||||
|
let text = Error::PageOutOfRange(usize::MAX).to_string();
|
||||||
|
assert!(text.starts_with("the PDF has no page "));
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! The shared object adapter preserves scalar references and bounds both tree and graph input.
|
||||||
|
|
||||||
|
use crate::{Error, parse};
|
||||||
|
|
||||||
|
fn document(extra: &str, tail: &[(u32, u16, String)]) -> Vec<u8> {
|
||||||
|
let content = "/GS gs 1 0 0 rg 0 0 20 10 re f";
|
||||||
|
let mut objects = vec![
|
||||||
|
(1, 0, format!("<< /Type /Catalog /Pages 2 0 R {extra} >>")),
|
||||||
|
(2, 0, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into()),
|
||||||
|
(3, 0, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 20 10] /Resources << /ExtGState << /GS << /ca 5 7 R >> >> >> /Contents 4 0 R >>".into()),
|
||||||
|
(4, 0, format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len())),
|
||||||
|
(5, 7, "0.25".into()),
|
||||||
|
];
|
||||||
|
objects.extend_from_slice(tail);
|
||||||
|
let mut data = b"%PDF-1.7\n".to_vec();
|
||||||
|
let mut offsets = std::collections::BTreeMap::new();
|
||||||
|
for (number, generation, value) in objects {
|
||||||
|
offsets.insert(number, (data.len(), generation));
|
||||||
|
data.extend_from_slice(format!("{number} {generation} obj\n{value}\nendobj\n").as_bytes());
|
||||||
|
}
|
||||||
|
let xref = data.len();
|
||||||
|
let count = offsets.keys().last().copied().unwrap() + 1;
|
||||||
|
data.extend_from_slice(format!("xref\n0 {count}\n0000000000 65535 f \n").as_bytes());
|
||||||
|
for number in 1..count {
|
||||||
|
let entry = offsets.get(&number).map_or_else(|| "0000000000 65535 f \n".into(), |(offset, generation)| format!("{offset:010} {generation:05} n \n"));
|
||||||
|
data.extend_from_slice(entry.as_bytes());
|
||||||
|
}
|
||||||
|
data.extend_from_slice(format!("trailer\n<< /Size {count} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n").as_bytes());
|
||||||
|
data
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nonzero_generation_scalar_keeps_the_drawing_opacity() {
|
||||||
|
let doc = parse(&document("", &[])).unwrap();
|
||||||
|
assert!(doc.skipped.is_empty(), "{:?}", doc.skipped);
|
||||||
|
let pixel = effectcraft_svg::rasterize(&doc, 20, 10, 1.0).get(10, 5);
|
||||||
|
assert!((pixel[0] - 0.25).abs() < 0.001 && (pixel[3] - 0.25).abs() < 0.001, "{pixel:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_long_cyclic_reference_graph_does_not_expand_recursively() {
|
||||||
|
let objects: Vec<_> = (10..1010).map(|id| (id, 0, format!("<< /Next {} 0 R >>", if id == 1009 { 10 } else { id + 1 }))).collect();
|
||||||
|
let plain = parse(&document("", &[])).unwrap();
|
||||||
|
let cyclic = parse(&document("/Extra 10 0 R", &objects)).unwrap();
|
||||||
|
assert_eq!(plain, cyclic);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excessive_inline_nesting_returns_an_error_instead_of_partial_art() {
|
||||||
|
let extra = format!("/Extra {}0{}", "[".repeat(80), "]".repeat(80));
|
||||||
|
assert!(matches!(parse(&document(&extra, &[])), Err(Error::Read(message)) if message.contains("64 nested objects")));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn object_stream(entries: &[(u32, &str)]) -> String {
|
||||||
|
let (mut header, mut body) = (String::new(), String::new());
|
||||||
|
for (number, value) in entries {
|
||||||
|
header.push_str(&format!("{number} {} ", body.len()));
|
||||||
|
body.push_str(value);
|
||||||
|
body.push(' ');
|
||||||
|
}
|
||||||
|
let first = header.len();
|
||||||
|
let content = header + &body;
|
||||||
|
format!("<< /Type /ObjStm /N {} /First {first} /Length {} >>\nstream\n{content}\nendstream", entries.len(), content.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn duplicate_unreferenced_compressed_objects_do_not_block_repair() {
|
||||||
|
let objects = vec![(20, 0, object_stream(&[(10, "<< /Value 1 >>"), (30, "null")])), (21, 0, object_stream(&[(30, "42")]))];
|
||||||
|
let doc = parse(&document("/Missing 10 0 R", &objects)).unwrap();
|
||||||
|
assert!(doc.skipped.iter().any(|warning| warning.contains("recovered PDF object from an object stream: 10 0")));
|
||||||
|
assert!(matches!(parse(&document("/Missing 30 0 R", &objects)), Err(Error::Read(message)) if message.contains("ambiguous recovered object 30")));
|
||||||
|
}
|
||||||
@@ -721,3 +721,5 @@ JSON parsing is now the bulk of open), and rendering 5,000-layer comps faster.
|
|||||||
| Date | Change | Summary |
|
| Date | Change | Summary |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected |
|
| 2026-10-10 | minor | Status line added; crate table checked against `crates/` (every crate listed); effect count and VP9 encoder description corrected |
|
||||||
|
|
||||||
|
PDF document structure uses the `craft-pdf` read feature through an app-owned bounded object adapter. EffectCraft retains its drawing tree, fonts, content/image filters and EPS interpreter; ordinary object/xref limits remain 256 MiB on 64-bit and 64 MiB on 32-bit/WASM, including repair.
|
||||||
+6
-3
@@ -1,4 +1,4 @@
|
|||||||
//! Workspace automation: `cargo xtask <layers|assets|wasm|web|ico|ci>`.
|
//! Workspace automation: `cargo xtask <layers|assets|pdf-deps|wasm|web|ico|ci>`.
|
||||||
//!
|
//!
|
||||||
//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges,
|
//! - `layers`: enforces the dependency layering of `docs/architecture.md` §1 (downward-only edges,
|
||||||
//! listed same-layer edges, no UI/OS crates below L5).
|
//! listed same-layer edges, no UI/OS crates below L5).
|
||||||
@@ -9,12 +9,13 @@
|
|||||||
//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete
|
//! - `assets`: every asset file (image, icon, font, LUT, audio, video…) has a complete
|
||||||
//! `<file>.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1).
|
//! `<file>.attribution` sidecar and an entry in `ATTRIBUTION.md` (AGENTS.md §1).
|
||||||
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
|
//! - `ico <out.ico> <in.png>…`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`).
|
||||||
//! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm.
|
//! - `ci`: fmt check, clippy -D warnings, tests, PDF dependency policy, layers, assets, wasm.
|
||||||
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
|
//! - `bench [--bin CLI] [--quick] [--compare OLD.jsonl] …`: render benchmark of generated scenarios
|
||||||
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
|
//! (memory, CPU, wall time) into `target/bench/results.jsonl` (`bench.rs`, docs/testing.md).
|
||||||
|
|
||||||
mod bench;
|
mod bench;
|
||||||
mod ico;
|
mod ico;
|
||||||
|
mod pdf_deps;
|
||||||
mod version;
|
mod version;
|
||||||
|
|
||||||
use std::process::{Command, ExitCode};
|
use std::process::{Command, ExitCode};
|
||||||
@@ -410,6 +411,7 @@ fn ci() -> Result<(), String> {
|
|||||||
run(Command::new(cargo).args(["fmt", "--check"]))?;
|
run(Command::new(cargo).args(["fmt", "--check"]))?;
|
||||||
run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?;
|
run(Command::new(cargo).args(["clippy", "--workspace", "--all-targets", "--release", "--", "-D", "warnings"]))?;
|
||||||
run(Command::new(cargo).args(["test", "--workspace", "--release"]))?;
|
run(Command::new(cargo).args(["test", "--workspace", "--release"]))?;
|
||||||
|
pdf_deps::run(&root())?;
|
||||||
layers()?;
|
layers()?;
|
||||||
assets()?;
|
assets()?;
|
||||||
wasm()
|
wasm()
|
||||||
@@ -421,6 +423,7 @@ fn main() -> ExitCode {
|
|||||||
"layers" => layers(),
|
"layers" => layers(),
|
||||||
"wasm" => wasm(),
|
"wasm" => wasm(),
|
||||||
"assets" => assets(),
|
"assets" => assets(),
|
||||||
|
"pdf-deps" => pdf_deps::run(&root()),
|
||||||
"ci" => ci(),
|
"ci" => ci(),
|
||||||
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
|
"web" => web(&std::env::args().skip(2).collect::<Vec<_>>()),
|
||||||
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
|
"bench" => bench::run(&root(), &std::env::args().skip(2).collect::<Vec<_>>()),
|
||||||
@@ -432,7 +435,7 @@ fn main() -> ExitCode {
|
|||||||
let rest: Vec<String> = std::env::args().skip(2).collect();
|
let rest: Vec<String> = std::env::args().skip(2).collect();
|
||||||
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
|
version::run(&root(), &rest.iter().map(String::as_str).collect::<Vec<_>>())
|
||||||
}
|
}
|
||||||
_ => Err("usage: cargo xtask <layers|assets|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
|
_ => Err("usage: cargo xtask <layers|assets|pdf-deps|wasm|web [--dev] [--serve PORT]|ico OUT.ico IN.png…|version [set X.Y.Z]|ci|bench [--bin CLI] [--quick] [--project F --comp C] [--compare OLD.jsonl] (--help)>".into()),
|
||||||
};
|
};
|
||||||
match r {
|
match r {
|
||||||
Ok(()) => ExitCode::SUCCESS,
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
//! Pin the shared parser and reject interpreter features that would embed backend assets.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
const REV: &str = "89dcbb3060377a26c99a6d80caa16084366704c4";
|
||||||
|
const PACKAGES: &[(&str, &str)] = &[("craft-pdf", "0.1.0"), ("hayro-syntax", "0.7.2")];
|
||||||
|
|
||||||
|
pub fn run(root: &Path) -> Result<(), String> {
|
||||||
|
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||||
|
.current_dir(root)
|
||||||
|
.args(["metadata", "--locked", "--format-version", "1"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
|
||||||
|
}
|
||||||
|
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||||
|
validate(&metadata)?;
|
||||||
|
println!("PDF backend: pinned parser only; no Hayro interpreter, rasterizer or bundled CMaps");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(metadata: &Value) -> Result<(), String> {
|
||||||
|
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
|
||||||
|
let nodes = metadata.pointer("/resolve/nodes").and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
|
||||||
|
let source = format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}");
|
||||||
|
for (name, version) in PACKAGES {
|
||||||
|
let matches: Vec<_> = packages.iter().filter(|p| p["name"] == *name).collect();
|
||||||
|
if matches.len() != 1 || matches[0]["version"] != *version || matches[0]["source"] != source {
|
||||||
|
return Err(format!("{name}: expected exactly {version} from {source}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, allowed) in [("craft-pdf", "read"), ("hayro-syntax", "std")] {
|
||||||
|
let package = packages.iter().find(|p| p["name"] == name).ok_or_else(|| format!("missing {name}"))?;
|
||||||
|
let node = nodes.iter().find(|n| n["id"] == package["id"]).ok_or_else(|| format!("{name}: missing resolved node"))?;
|
||||||
|
let features = node["features"].as_array().ok_or_else(|| format!("{name}: missing resolved features"))?;
|
||||||
|
if features.len() != 1 || features.first().and_then(Value::as_str) != Some(allowed) {
|
||||||
|
return Err(format!("{name}: expected only the {allowed} feature"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for package in packages {
|
||||||
|
let name = package["name"].as_str().unwrap_or("");
|
||||||
|
let active = nodes.iter().any(|node| node["id"] == package["id"]);
|
||||||
|
if active && matches!(name, "hayro" | "hayro-interpret" | "hayro-cmap" | "krilla") {
|
||||||
|
return Err(format!("{name}: EffectCraft must use craft-pdf with only the read feature"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn metadata() -> Value {
|
||||||
|
let packages: Vec<_> = PACKAGES.iter().map(|(name, version)| {
|
||||||
|
json!({"id": name, "name": name, "version": version, "source": format!("git+https://github.com/storytold/craft-libs?rev={REV}#{REV}")})
|
||||||
|
}).collect();
|
||||||
|
json!({"packages": packages, "resolve": {"nodes": [
|
||||||
|
{"id": "hayro-syntax", "features": ["std"]}, {"id": "craft-pdf", "features": ["read"]}
|
||||||
|
]}})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_the_pinned_parser_only_graph() {
|
||||||
|
assert!(validate(&metadata()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_unpinned_duplicate_and_missing_parsers() {
|
||||||
|
let mut m = metadata();
|
||||||
|
m["packages"][0]["source"] = json!("registry");
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
let mut m = metadata();
|
||||||
|
let duplicate = m["packages"][0].clone();
|
||||||
|
m["packages"].as_array_mut().unwrap().push(duplicate);
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_shared_reader_source_drift() {
|
||||||
|
let mut m = metadata();
|
||||||
|
m["packages"][0]["source"] = Value::Null;
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_extra_features_and_missing_active_nodes() {
|
||||||
|
for (index, feature) in [(0, "images"), (1, "write"), (1, "raster")] {
|
||||||
|
let mut m = metadata();
|
||||||
|
m["resolve"]["nodes"][index]["features"].as_array_mut().unwrap().push(json!(feature));
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
let mut m = metadata();
|
||||||
|
m["resolve"]["nodes"].as_array_mut().unwrap().pop();
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_backend_asset_features_and_missing_resolution() {
|
||||||
|
for name in ["hayro", "hayro-interpret", "hayro-cmap", "krilla"] {
|
||||||
|
let mut m = metadata();
|
||||||
|
m["packages"].as_array_mut().unwrap().push(json!({"id": name, "name": name}));
|
||||||
|
m["resolve"]["nodes"].as_array_mut().unwrap().push(json!({"id": name}));
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
let mut m = metadata();
|
||||||
|
m["resolve"] = Value::Null;
|
||||||
|
assert!(validate(&m).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user