From d257bf503db55a7cd1db9f6f848b54b5ece07e14 Mon Sep 17 00:00:00 2001 From: Alan Szmyt Date: Sun, 27 Sep 2026 21:38:05 -0400 Subject: [PATCH] Implement bounded print-interior PDF route for ordered pages (#416) --- Cargo.lock | 381 ++++++- ROADMAP.md | 21 +- crates/renderflow-core/Cargo.toml | 4 + .../renderflow-core/data/adapter-packs.yaml | 28 +- .../renderflow-core/data/tool-registry.yaml | 3 +- .../renderflow-core/src/graph/dag_executor.rs | 53 +- crates/renderflow-core/src/lib.rs | 3 + crates/renderflow-core/src/planning.rs | 323 +++++- crates/renderflow-core/src/print_pdf.rs | 255 +++++ crates/renderflow-core/src/print_pdf_image.rs | 846 ++++++++++++++++ .../renderflow-core/src/print_pdf_inspect.rs | 940 ++++++++++++++++++ crates/renderflow-core/src/process.rs | 65 ++ crates/renderflow-core/src/spec.rs | 71 ++ crates/renderflow-core/src/toolchain.rs | 67 +- .../tests/fixtures/print-pdf/README.md | 13 + .../tests/fixtures/print-pdf/page-001.jpg | Bin 0 -> 826 bytes .../tests/fixtures/print-pdf/page-001.png | Bin 0 -> 293 bytes .../tests/fixtures/print-pdf/page-002.jpg | Bin 0 -> 826 bytes .../tests/fixtures/print-pdf/page-002.png | Bin 0 -> 292 bytes .../tests/print_pdf_interior.rs | 420 ++++++++ docs/user-guide/adapter-ecosystem.md | 3 +- docs/user-guide/ordered-collections.md | 8 +- docs/user-guide/print-interior-pdf.md | 90 ++ docs/user-guide/spec-v2-reference.md | 16 + docs/user-guide/tool-registry.md | 1 + mkdocs.yml | 1 + schemas/renderflow-v2.schema.json | 67 ++ scripts/generate_spec_v2_reference.py | 1 + 28 files changed, 3619 insertions(+), 61 deletions(-) create mode 100644 crates/renderflow-core/src/print_pdf.rs create mode 100644 crates/renderflow-core/src/print_pdf_image.rs create mode 100644 crates/renderflow-core/src/print_pdf_inspect.rs create mode 100644 crates/renderflow-core/tests/fixtures/print-pdf/README.md create mode 100644 crates/renderflow-core/tests/fixtures/print-pdf/page-001.jpg create mode 100644 crates/renderflow-core/tests/fixtures/print-pdf/page-001.png create mode 100644 crates/renderflow-core/tests/fixtures/print-pdf/page-002.jpg create mode 100644 crates/renderflow-core/tests/fixtures/print-pdf/page-002.png create mode 100644 crates/renderflow-core/tests/print_pdf_interior.rs create mode 100644 docs/user-guide/print-interior-pdf.md diff --git a/Cargo.lock b/Cargo.lock index 9b9b7e3..161dc5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -17,6 +28,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "alloca" version = "0.4.0" @@ -126,9 +152,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "block-buffer" @@ -139,6 +165,24 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -148,6 +192,16 @@ dependencies = [ "objc2", ] +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bstr" version = "1.12.1" @@ -170,6 +224,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.57" @@ -192,6 +255,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -252,6 +326,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -311,12 +395,30 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -326,6 +428,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -420,6 +531,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctrlc" version = "3.5.2" @@ -454,8 +574,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] @@ -464,7 +595,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "block2", "libc", "objc2", @@ -481,6 +612,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher", +] + [[package]] name = "either" version = "1.15.0" @@ -493,6 +633,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -602,6 +756,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -625,7 +780,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "ignore", "walkdir", ] @@ -652,9 +807,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -671,6 +826,15 @@ dependencies = [ "libm", ] +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -822,12 +986,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -865,6 +1029,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -895,6 +1069,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -955,7 +1138,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "libc", "plain", "redox_syscall", @@ -979,6 +1162,43 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lopdf" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfffda0fe1ab0157e1a13c14bebd3f28671f2fccb7922f0722ec53926e6922d3" +dependencies = [ + "aes", + "bitflags 2.13.2", + "brotli-decompressor", + "cbc", + "ecb", + "encoding_rs", + "flate2", + "getrandom 0.4.2", + "indexmap", + "itoa", + "log", + "md-5", + "nom", + "rand 0.10.3", + "rangemap", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.20", + "weezl", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1007,25 +1227,40 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nix" version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "notify" version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "crossbeam-channel", "filetime", "fsevent-sys", @@ -1171,7 +1406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1212,7 +1447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1330,7 +1565,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -1340,7 +1586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1352,6 +1598,18 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "rayon" version = "1.11.0" @@ -1378,7 +1636,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", ] [[package]] @@ -1416,10 +1674,14 @@ version = "0.2.1" dependencies = [ "anyhow", "clap", + "crc32fast", "criterion", "ctrlc", + "flate2", "indicatif", "itertools 0.14.0", + "jpeg-decoder", + "lopdf", "notify", "notify-debouncer-mini", "petgraph", @@ -1427,7 +1689,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.10.9", "tempfile", "tera", "thiserror 1.0.69", @@ -1476,7 +1738,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -1539,6 +1801,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -1608,8 +1876,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1633,6 +1912,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.2" @@ -1661,6 +1946,17 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[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]] name = "strsim" version = "0.11.1" @@ -1733,7 +2029,7 @@ dependencies = [ "percent-encoding", "pest", "pest_derive", - "rand", + "rand 0.8.5", "regex", "serde", "serde_json", @@ -1810,6 +2106,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + [[package]] name = "tracing" version = "0.1.44" @@ -1879,12 +2181,33 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[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]] name = "unicode-segmentation" version = "1.12.0" @@ -2076,7 +2399,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.2", "hashbrown 0.15.5", "indexmap", "semver", @@ -2120,6 +2443,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "winapi" version = "0.3.9" @@ -2425,7 +2754,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.2", "indexmap", "log", "serde", diff --git a/ROADMAP.md b/ROADMAP.md index 63fa6e0..d754121 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,12 +3,12 @@ schema: aether.architecture-document/v1 id: renderflow-roadmap title: Renderflow Roadmap kind: architecture-document -version: 0.1.3 +version: 0.1.4 status: draft owners: - egohygiene created: 2026-08-19 -updated: 2026-09-27 +updated: 2026-09-28 governed_by: - architecture-roadmap depends_on: @@ -26,6 +26,23 @@ supersedes: [] # Renderflow Roadmap +## 2026-09-28 print-interior PDF review handoff + +Checkpoint #416 adds the exact `publication.generate.pdf.interior` capability +through canonical ordered-collection planning and execution. The initial route +uses homogeneous local PNG or JPEG pages, explicit uniform trim geometry and +bleed, the observed img2pdf 0.6.3 provider, bounded direct argv, and independent +PDF page/box/image-stream inspection before materialization. Synthetic tests +cover the two-page route, a generated 44-page recipe, refusal, interruption, +immutable source bytes, and reproducible clean builds. The tool and adapter +catalogs advertise only this bounded route; generic image aggregation remains +experimental. No real publication pages, printer, retailer, or upload were used. + +After review and merge, #417 may proceed independently for fixed-layout EPUB. +#418 independently validates that EPUB capability; #419 releases the exact +integration candidate consumed by Flow #52. PDF physical proof and retailer +acceptance remain outside #416. + ## 2026-09-27 ordered collection review handoff Checkpoint #415 adds canonical planning and execution for one explicitly diff --git a/crates/renderflow-core/Cargo.toml b/crates/renderflow-core/Cargo.toml index 33bb67f..b21daa2 100644 --- a/crates/renderflow-core/Cargo.toml +++ b/crates/renderflow-core/Cargo.toml @@ -34,6 +34,10 @@ serde_json = "1" serde_yaml_ng = "0.9" sha2 = "0.10" +lopdf = { version = "=0.45.0", default-features = false } +flate2 = "1" +crc32fast = "1" +jpeg-decoder = "0.3" anyhow = "1" thiserror = "1" diff --git a/crates/renderflow-core/data/adapter-packs.yaml b/crates/renderflow-core/data/adapter-packs.yaml index 2a19cd2..7d3cb40 100644 --- a/crates/renderflow-core/data/adapter-packs.yaml +++ b/crates/renderflow-core/data/adapter-packs.yaml @@ -194,7 +194,33 @@ providers: validation: [validator.core.pdf] provenance: [provider_id, provider_version, argv_digest, ordered_input_digests, output_digest] upstream: https://gitlab.mister-muffin.de/josch/img2pdf - rationale: Strong lossless image-to-PDF candidate; collection execution remains experimental until Transform v2 aggregation coverage lands. + rationale: Generic image aggregation remains experimental; the separately registered print-interior route has narrower inputs, a pinned provider, and independent validation. + + - id: adapter.publication.print-interior + runtime_tool: tool.img2pdf + name: Ordered print-interior PDF adapter + families: [publication, images, pdf] + maturity: integrated + selection_priority: 10 + capabilities: [publication.generate.pdf.interior] + input_media_types: [image/png, image/jpeg] + output_media_types: [application/pdf] + determinism: deterministic + locality: local + fidelity: lossless + execution: *bounded_local + configuration_schema: + source: homogeneous_ordered_immutable_collection + geometry: uniform_explicit_trim_mm_and_bleed_mm + boxes: media_bleed_trim_inset + rotation: none + scaling: fit_with_exact_aspect + color: preserve_rgb_gray + provider: exact_observed_img2pdf_version + validation: [validator.print_pdf.interior] + provenance: [provider_id, provider_version, configuration_digest, ordered_input_digests, inspected_page_stream_digests, output_digest] + upstream: https://gitlab.mister-muffin.de/josch/img2pdf + rationale: Canonical v2 print-interior path verifies every ordered page stream, geometry, boxes, and source lineage before materialization. - id: adapter.pdf.ghostscript runtime_tool: tool.ghostscript diff --git a/crates/renderflow-core/data/tool-registry.yaml b/crates/renderflow-core/data/tool-registry.yaml index bb3c086..766e232 100644 --- a/crates/renderflow-core/data/tool-registry.yaml +++ b/crates/renderflow-core/data/tool-registry.yaml @@ -176,6 +176,7 @@ tools: operating_systems: [linux, macos, windows] capabilities: - image.aggregate.pdf + - publication.generate.pdf.interior input_media_types: [image/*] output_media_types: [application/pdf] determinism: deterministic @@ -183,7 +184,7 @@ tools: fidelity: lossless support_tier: experimental license_notes: "Consult the img2pdf upstream license for redistribution terms." - distribution_notes: "Used by lossless image-to-PDF aggregation when configured." + distribution_notes: "Generic aggregation is experimental; the exact print-interior route requires a pinned compatible version and independent page inspection." - id: tool.ghostscript name: Ghostscript diff --git a/crates/renderflow-core/src/graph/dag_executor.rs b/crates/renderflow-core/src/graph/dag_executor.rs index 118afd0..6a76bb8 100644 --- a/crates/renderflow-core/src/graph/dag_executor.rs +++ b/crates/renderflow-core/src/graph/dag_executor.rs @@ -156,6 +156,13 @@ fn failed_step( ) -> StepEvidence { let message = redact_sensitive_text(&format!("{error:#}")); let step_id = format!("step:{}-to-{}", edge.from, edge.to); + let provider_failure = error + .chain() + .find_map(|cause| cause.downcast_ref::()); + let inspection_failure = error.chain().find_map(|cause| { + cause.downcast_ref::() + }); + let cancelled = provider_failure.is_some_and(|failure| failure.cancelled); StepEvidence { step_id: step_id.clone(), transform: edge_identity(edge), @@ -175,9 +182,15 @@ fn failed_step( started_at_unix_ms, completed_at_unix_ms: unix_time_ms(), duration_ms, - state: StepState::Failed, + state: if cancelled { + StepState::Cancelled + } else { + StepState::Failed + }, cache: CacheDisposition::Miss, - validation: if message.contains("returned artifact format") { + validation: if cancelled { + ValidationState::Skipped + } else if message.contains("returned artifact format") { ValidationState::Invalid } else { ValidationState::Unavailable @@ -185,8 +198,16 @@ fn failed_step( fidelity: edge_fidelity(edge), skip_reason: None, diagnostics: vec![ExecutionDiagnostic { - severity: DiagnosticSeverity::FatalFailure, - code: "execution.transform_failed".to_string(), + severity: if cancelled { + DiagnosticSeverity::Cancelled + } else { + DiagnosticSeverity::FatalFailure + }, + code: provider_failure + .map(|failure| failure.code) + .or_else(|| inspection_failure.map(|failure| failure.code)) + .unwrap_or("execution.transform_failed") + .to_string(), message, step_id: Some(step_id), }], @@ -400,6 +421,17 @@ impl DagExecutor { Ok(self) } + /// Register a collection-input transform for the `from → to` edge. + pub fn register_collection_artifact( + &mut self, + from: Format, + to: Format, + transform: Arc, + ) -> &mut Self { + self.collection_transforms.insert((from, to), transform); + self + } + /// Register a collection-input transform for the `from → to` edge. pub fn register_aggregation( &mut self, @@ -672,16 +704,9 @@ impl DagExecutor { started_at_unix_ms, duration_ms, ); - diagnostics.push(ExecutionDiagnostic { - severity: DiagnosticSeverity::FatalFailure, - code: "execution.transform_failed".to_string(), - message: step - .diagnostics - .first() - .map(|diagnostic| diagnostic.message.clone()) - .unwrap_or_else(|| "Transform failed".to_string()), - step_id: Some(step.step_id.clone()), - }); + if let Some(diagnostic) = step.diagnostics.first() { + diagnostics.push(diagnostic.clone()); + } steps.push(step); } } diff --git a/crates/renderflow-core/src/lib.rs b/crates/renderflow-core/src/lib.rs index 9ee2eb4..0edb912 100644 --- a/crates/renderflow-core/src/lib.rs +++ b/crates/renderflow-core/src/lib.rs @@ -31,6 +31,9 @@ pub mod intake; pub mod optimization; mod pipeline; pub mod planning; +pub mod print_pdf; +pub mod print_pdf_image; +pub mod print_pdf_inspect; pub mod process; pub mod publication; mod sdk; diff --git a/crates/renderflow-core/src/planning.rs b/crates/renderflow-core/src/planning.rs index d60f37e..a366920 100644 --- a/crates/renderflow-core/src/planning.rs +++ b/crates/renderflow-core/src/planning.rs @@ -22,28 +22,32 @@ use crate::checkpoint::CheckpointContext; use crate::evidence::{ redact_sensitive_text, run_id, sha256_serialized, unix_time_ms, ArtifactEvidence, ArtifactManifest, ArtifactRole, DiagnosticSeverity, ExecutionDiagnostic, FidelityDeclaration, - ProducerEvidence, RunManifest, RunState, StepEvidence, StepState, ValidationState, - ARTIFACT_MANIFEST_SCHEMA_V1, RUN_MANIFEST_SCHEMA_V1, + ProducerEvidence, RunManifest, RunState, StepEvidence, StepState, ValidationDiagnostic, + ValidationState, ValidatorEvidence, ARTIFACT_MANIFEST_SCHEMA_V1, RUN_MANIFEST_SCHEMA_V1, }; use crate::graph::capability::{FormatCapabilityRegistry, FormatFamily}; use crate::graph::{ ArtifactForest, DagExecutionReport, DagExecutor, DiagnosticLevel, ExecutionPlan, ForestBranch, - ForestBranchState, Format, MultiTargetDag, PlanCollectionMember, PlanSourceArtifact, + ForestBranchState, Format, InputKind, MultiTargetDag, PlanCollectionMember, PlanSourceArtifact, PlanSourceCollection, TransformEdge, TransformGraph, }; use crate::hygiene::{HygieneEngine, HygieneEvidence}; use crate::intake::{IntakeEngine, IntakeReport, IntakeRequest, ResolvedArtifactProfile}; use crate::optimization::OptimizationMode; +use crate::print_pdf::{PrintInteriorPdfTransform, PRINT_PDF_CAPABILITY, PRINT_PDF_PROVIDER}; +use crate::print_pdf_image::{inspect_print_image, PrintImageColorSpace}; +use crate::print_pdf_inspect::ExpectedPrintPage; use crate::publication::write_release_metadata; use crate::spec::{ load_spec, AiPolicy, CollisionPolicy, DerivativeProfile, HygienePolicy, IntermediatePolicy, - RejectedLossClass, SelectorSet, SourceKind, SourceSpec, SourceSpecVersion, SpecV2, - TargetRequirement, TargetSelection, TargetSpec, ValidationFailureMode, + PrintPdfInteriorPolicy, RejectedLossClass, SelectorSet, SourceKind, SourceSpec, + SourceSpecVersion, SpecV2, TargetRequirement, TargetSelection, TargetSpec, + ValidationFailureMode, }; use crate::super_resolution::{select_upscayl_variants, UpscaylModelCatalog}; use crate::toolchain::{ - transform_capability_id, CapabilityId, ToolDeterminism, ToolId, ToolLocality, ToolRegistry, - ToolRuntimeContext, ToolchainSnapshot, + transform_capability_id, CapabilityId, ToolDeterminism, ToolDiscovery, ToolFidelity, ToolId, + ToolLocality, ToolRegistry, ToolRuntimeContext, ToolVersionRequirement, ToolchainSnapshot, }; use crate::transforms::yaml_loader::build_graph_executor_and_tools_from_yaml; use crate::validation::{ArtifactValidationOutcome, ValidationRegistry}; @@ -166,6 +170,7 @@ pub struct ResolvedExecution { source_path: PathBuf, source_format: Format, source_members: Vec, + print_pages: Option>, targets: Vec, dag: MultiTargetDag, executor: DagExecutor, @@ -223,7 +228,9 @@ impl ResolvedExecution { self } - pub(crate) fn with_cancellation_flag(mut self, cancellation: Arc) -> Self { + /// Attach a cooperative cancellation flag to this resolved execution. + /// Providers that support cancellation receive the same shared flag. + pub fn with_cancellation_flag(mut self, cancellation: Arc) -> Self { self.cancellation = Some(cancellation); self } @@ -408,6 +415,7 @@ pub fn resolve(request: PlanningRequest) -> Result { let source_format = source_format.context("source collection has no members")?; let source_path = source_members[0].path.clone(); let source_intake = &source_members[0].intake; + let print_pages = resolve_print_pages(&spec, collection, source_format, &source_members)?; let (mut graph, mut executor, mut tool_registry) = if let Some(transforms_path) = &spec.transforms { @@ -431,6 +439,9 @@ pub fn resolve(request: PlanningRequest) -> Result { }; register_builtin_strategy_edges(&mut graph, &mut tool_registry)?; + if let Some(policy) = &spec.execution.print_pdf_interior { + register_print_pdf_edge(&mut graph, &mut tool_registry, source_format, policy)?; + } let policy_graph = apply_execution_policy(&graph, &tool_registry, &spec); let policy_graph = if collection { policy_graph @@ -443,6 +454,14 @@ pub fn resolve(request: PlanningRequest) -> Result { anyhow::bail!("target selection resolved to no executable artifact formats"); } validate_publication_target_roles(&spec, &requested_targets)?; + if print_pages.is_some() + && (requested_targets.len() != 1 + || requested_targets[0].format != Format::Pdf + || requested_targets[0].role.as_deref() != Some("interior") + || requested_targets[0].requirement != TargetRequirement::Required) + { + anyhow::bail!("print_pdf.target: exactly one required PDF target with role 'interior' must be selected"); + } let provider_inventory = tool_registry.assess_ids_current(policy_graph.provider_ids()); let available_graph = @@ -496,6 +515,14 @@ pub fn resolve(request: PlanningRequest) -> Result { { anyhow::bail!("collection.transform.unsupported: a collection requires a registered collection-input transform and a distinct output format"); } + if print_pages.is_some() + && (dag.all_edges().len() != 1 + || dag.all_edges()[0].capability_id.as_deref() != Some(PRINT_PDF_CAPABILITY)) + { + anyhow::bail!( + "print_pdf.route: selected plan must contain only the exact print-interior capability" + ); + } register_builtin_strategy_executors( &mut executor, @@ -559,6 +586,9 @@ pub fn resolve(request: PlanningRequest) -> Result { let selected_ids = selected_provider_ids(&dag); let selected_inventory = tool_registry.assess_ids_current(selected_ids.iter()); + if let Some(policy) = &spec.execution.print_pdf_interior { + validate_print_provider_version(&selected_inventory, policy)?; + } for blocked in selected_inventory .tools .iter() @@ -605,6 +635,7 @@ pub fn resolve(request: PlanningRequest) -> Result { source_path, source_format, source_members, + print_pages, targets, dag, executor, @@ -802,7 +833,29 @@ pub fn execute(mut resolved: ResolvedExecution, dry_run: bool) -> Result Result Result>(); + let valid = artifact + .metadata() + .contains_key("renderflow.print_pdf.inspection") + && artifact.sources().iter().collect::>() == expected_sources; + let state = if valid { + ValidationState::Valid + } else { + ValidationState::Invalid + }; + outcome.validators.push(ValidatorEvidence { + validator_id: "validator.print_pdf.interior".to_string(), + validator_version: env!("CARGO_PKG_VERSION").to_string(), + provider: "renderflow.core".to_string(), + state, + diagnostics: if valid { + Vec::new() + } else { + vec![ValidationDiagnostic { + code: "print_pdf.validation.evidence".to_string(), + message: "PDF inspection or ordered source lineage is missing".to_string(), + }] + }, + }); + if !valid { + outcome.state = ValidationState::Invalid; + } + } let step_id = producing_step_id(artifact, &report.steps); if let Some(step) = report.steps.iter_mut().find(|step| { step.output_artifacts @@ -1909,6 +2008,189 @@ fn intake_source(source: &SourceSpec, path: &Path, store: &ArtifactStore) -> Res .with_context(|| format!("source.intake.failed: '{}'", source.id)) } +fn resolve_print_pages( + spec: &SpecV2, + collection: bool, + source_format: Format, + members: &[ResolvedSourceMember], +) -> Result>> { + let Some(policy) = &spec.execution.print_pdf_interior else { + return Ok(None); + }; + policy.validate()?; + if !collection || !matches!(source_format, Format::Png | Format::Jpeg) { + anyhow::bail!("print_pdf.input.format: print interior requires a homogeneous ordered PNG or JPEG collection"); + } + if spec.transforms.is_some() || spec.execution.hygiene_policy.is_some() { + anyhow::bail!("print_pdf.policy: custom transforms and post-render hygiene are unsupported for this exact route"); + } + if members.len() > policy.max_pages { + anyhow::bail!("print_pdf.bounds: collection exceeds max_pages"); + } + let input_bytes = members.iter().try_fold(0_u64, |sum, member| { + sum.checked_add(member.intake.source.size_bytes()) + .context("print_pdf.bounds: input byte count overflow") + })?; + if input_bytes > policy.max_input_bytes { + anyhow::bail!("print_pdf.bounds: collection exceeds max_input_bytes"); + } + let mut pages = Vec::with_capacity(members.len()); + let mut common_geometry = None; + for member in members { + let geometry = member.spec.geometry.as_ref().with_context(|| { + format!( + "print_pdf.geometry.missing: '{}' requires explicit trim size and bleed", + member.spec.id + ) + })?; + let bleed = geometry.bleed.with_context(|| { + format!( + "print_pdf.geometry.bleed: '{}' requires explicit bleed, including zero", + member.spec.id + ) + })?; + if geometry.unit != "mm" + || !geometry.width.is_finite() + || !geometry.height.is_finite() + || !bleed.is_finite() + || geometry.width <= 0.0 + || geometry.height <= 0.0 + || bleed < 0.0 + || geometry.margin.is_some() + || geometry.safe_area.is_some() + { + anyhow::bail!("print_pdf.geometry.unsupported: '{}' requires positive trim width/height in mm, nonnegative bleed, and no undeclared margin/safe-area policy", member.spec.id); + } + if let Some(common) = &common_geometry { + if common != geometry { + anyhow::bail!("print_pdf.geometry.mixed: current img2pdf route requires uniform page geometry"); + } + } else { + common_geometry = Some(geometry.clone()); + } + let image = inspect_print_image(&member.path, source_format).with_context(|| { + format!( + "print_pdf.image.unreadable: '{}' did not pass image preflight", + member.spec.id + ) + })?; + let media_width_pt = (geometry.width + 2.0 * bleed) * 72.0 / 25.4; + let media_height_pt = (geometry.height + 2.0 * bleed) * 72.0 / 25.4; + if !media_width_pt.is_finite() + || !media_height_pt.is_finite() + || media_width_pt > 14_400.0 + || media_height_pt > 14_400.0 + { + anyhow::bail!("print_pdf.geometry.bounds: page exceeds PDF media bounds"); + } + let scaled_width_pt = + media_height_pt * f64::from(image.width_px) / f64::from(image.height_px); + if (media_width_pt - scaled_width_pt).abs() > 0.02 { + anyhow::bail!("print_pdf.scaling.aspect: '{}' aspect ratio would leave a border or require crop/stretch", member.spec.id); + } + pages.push(ExpectedPrintPage { + media_width_pt, + media_height_pt, + trim_inset_pt: bleed * 72.0 / 25.4, + pixel_width: image.width_px, + pixel_height: image.height_px, + rotation: 0, + color_space: match image.color_space { + PrintImageColorSpace::Rgb => "DeviceRGB".to_string(), + PrintImageColorSpace::Gray => "DeviceGray".to_string(), + }, + image_filter: match source_format { + Format::Png => "FlateDecode".to_string(), + Format::Jpeg => "DCTDecode".to_string(), + _ => unreachable!("source format checked above"), + }, + image_stream_sha256: image.image_stream_sha256, + }); + } + if let Some(publication) = &spec.publication { + let geometry = common_geometry + .as_ref() + .context("print_pdf.geometry.missing")?; + if &publication.geometry != geometry + || publication + .color_policy + .as_deref() + .is_some_and(|color| color != policy.color_policy) + { + anyhow::bail!("print_pdf.publication.constraints: publication geometry or color policy differs from the selected interior"); + } + if let Some(role) = publication.output_roles.get("interior") { + if role.format != "pdf" + || role + .geometry + .as_ref() + .is_some_and(|declared| declared != geometry) + || role + .color_policy + .as_deref() + .is_some_and(|color| color != policy.color_policy) + || role.require_embedded_fonts + || !role.validators.is_empty() + { + anyhow::bail!("print_pdf.publication.constraints: unsupported or inconsistent interior role constraint"); + } + if let Some(dpi) = role.minimum_image_dpi { + let media_width_in = pages[0].media_width_pt / 72.0; + let media_height_in = pages[0].media_height_pt / 72.0; + if pages.iter().any(|page| { + f64::from(page.pixel_width) / media_width_in < f64::from(dpi) + || f64::from(page.pixel_height) / media_height_in < f64::from(dpi) + }) { + anyhow::bail!("print_pdf.publication.dpi: page is below minimum_image_dpi"); + } + } + } + } + Ok(Some(pages)) +} + +fn register_print_pdf_edge( + graph: &mut TransformGraph, + tools: &mut ToolRegistry, + source_format: Format, + policy: &PrintPdfInteriorPolicy, +) -> Result<()> { + let mut descriptor = tools + .get(PRINT_PDF_PROVIDER) + .context("print_pdf.provider: img2pdf tool descriptor missing")? + .clone(); + let patch = policy + .provider_version + .rsplit('.') + .next() + .context("print_pdf.provider: invalid version")? + .parse::()? + .checked_add(1) + .context("print_pdf.provider: patch version overflow")?; + let prefix = policy.provider_version.rsplit_once('.').unwrap().0; + descriptor.discovery = ToolDiscovery::Executable { + candidates: vec![policy.executable.clone()], + version_args: vec!["--version".to_string()], + }; + descriptor.version = ToolVersionRequirement { + min_inclusive: Some(policy.provider_version.clone()), + max_exclusive: Some(format!("{prefix}.{patch}")), + }; + descriptor.determinism = ToolDeterminism::Deterministic; + descriptor.locality = ToolLocality::Local; + descriptor.fidelity = ToolFidelity::Lossless; + tools.register(descriptor)?; + let capability = CapabilityId::new(PRINT_PDF_CAPABILITY)?; + tools.add_capability(&ToolId::new(PRINT_PDF_PROVIDER)?, capability)?; + graph.add_transform( + TransformEdge::with_input_kind(source_format, Format::Pdf, 0.1, 1.0, InputKind::Collection) + .with_provider(PRINT_PDF_PROVIDER, PRINT_PDF_CAPABILITY) + .with_evidence("transform_id", PRINT_PDF_CAPABILITY) + .with_evidence("print_policy_sha256", sha256_serialized(policy)?.value), + ); + Ok(()) +} + fn resolve_path_relative_to_config(config_path: &Path, value: &str) -> PathBuf { let path = PathBuf::from(value); if path.is_absolute() { @@ -2623,8 +2905,14 @@ fn selected_provider_ids(dag: &MultiTargetDag) -> BTreeSet { } fn preflight_selected_providers(resolved: &ResolvedExecution) -> Result<()> { + if resolved.print_pages.is_some() && resolved.plan.toolchain.is_none() { + anyhow::bail!("print_pdf.provider.unavailable: print interior was planned without an observed compatible img2pdf toolchain; re-plan when it is installed"); + } let ids = selected_provider_ids(&resolved.dag); let inventory = resolved.tool_registry.assess_ids_current(ids.iter()); + if let Some(policy) = &resolved.spec.execution.print_pdf_interior { + validate_print_provider_version(&inventory, policy)?; + } let blocked: Vec = inventory .tools .iter() @@ -2640,6 +2928,21 @@ fn preflight_selected_providers(resolved: &ResolvedExecution) -> Result<()> { Ok(()) } +fn validate_print_provider_version( + inventory: &crate::toolchain::ToolInventory, + policy: &PrintPdfInteriorPolicy, +) -> Result<()> { + if let Some(provider) = inventory.get(PRINT_PDF_PROVIDER) { + let expected_line = format!("img2pdf {}", policy.provider_version); + if provider.is_available() + && provider.version_line.as_deref() != Some(expected_line.as_str()) + { + anyhow::bail!("print_pdf.provider.version: observed img2pdf version line does not exactly match the proven 0.6.3 release"); + } + } + Ok(()) +} + fn validate_pre_execution_budgets(resolved: &ResolvedExecution) -> Result<()> { let budgets = &resolved.spec.execution.budgets; if let Some(max_depth) = budgets.max_depth { diff --git a/crates/renderflow-core/src/print_pdf.rs b/crates/renderflow-core/src/print_pdf.rs new file mode 100644 index 0000000..a95840e --- /dev/null +++ b/crates/renderflow-core/src/print_pdf.rs @@ -0,0 +1,255 @@ +//! Exact, local print-interior PDF transform. The provider's output is not +//! admitted to the artifact store until independent page inspection succeeds. + +use std::path::Path; +use std::sync::{atomic::AtomicBool, Arc}; +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::artifact::{ + Artifact, ArtifactCollection, ArtifactCollectionTransform, ArtifactDescriptor, + ArtifactStorageClass, ArtifactStore, +}; +use crate::evidence::FidelityDeclaration; +use crate::graph::Format; +use crate::print_pdf_inspect::{inspect_print_pdf, ExpectedPrintPage}; +use crate::process::{ + ProcessCancellationToken, ProcessExecutor, ProcessExpectedOutput, ProcessInput, + ProcessNetworkPolicy, ProcessOutputMode, ProcessRequest, ProcessTermination, + DEFAULT_CAPTURE_LIMIT_BYTES, +}; +use crate::spec::PrintPdfInteriorPolicy; + +pub const PRINT_PDF_CAPABILITY: &str = "publication.generate.pdf.interior"; +pub const PRINT_PDF_PROVIDER: &str = "tool.img2pdf"; + +/// A provider failure that retains its machine-readable reason through the DAG. +#[derive(Debug, thiserror::Error)] +#[error("{code}: {message}")] +pub struct PrintPdfProviderError { + pub code: &'static str, + pub message: String, + pub cancelled: bool, +} + +fn provider_error(code: &'static str, message: impl Into) -> anyhow::Error { + PrintPdfProviderError { + code, + message: message.into(), + cancelled: code == "print_pdf.provider.cancelled", + } + .into() +} + +pub struct PrintInteriorPdfTransform { + policy: PrintPdfInteriorPolicy, + pages: Vec, + input_digests: Vec, + cache_identity: String, + cancellation: Option>, +} + +impl PrintInteriorPdfTransform { + pub fn new( + policy: PrintPdfInteriorPolicy, + pages: Vec, + input_digests: Vec, + cancellation: Option>, + ) -> Result { + policy.validate()?; + if pages.is_empty() || pages.len() != input_digests.len() || pages.len() > policy.max_pages + { + anyhow::bail!("print_pdf.bounds: expected page and digest lists must be nonempty and within max_pages"); + } + let cache_identity = serde_json::to_string(&(&policy, &pages, &input_digests))?; + Ok(Self { + policy, + pages, + input_digests, + cache_identity, + cancellation, + }) + } +} + +impl ArtifactCollectionTransform for PrintInteriorPdfTransform { + fn name(&self) -> &str { + PRINT_PDF_CAPABILITY + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn cache_identity(&self) -> String { + self.cache_identity.clone() + } + + fn fidelity(&self) -> Option { + Some(FidelityDeclaration::Lossless) + } + + fn apply( + &self, + inputs: &ArtifactCollection, + output_format: Format, + store: &ArtifactStore, + ) -> Result { + if output_format != Format::Pdf || inputs.len() != self.pages.len() { + anyhow::bail!( + "print_pdf.input.count: expected exactly one PDF output and {} ordered pages", + self.pages.len() + ); + } + let total = inputs.iter().try_fold(0_u64, |sum, artifact| { + sum.checked_add(artifact.size_bytes()) + .context("print_pdf.bounds: input byte count overflow") + })?; + if total > self.policy.max_input_bytes { + anyhow::bail!("print_pdf.bounds: source bytes exceed max_input_bytes"); + } + let paths = inputs + .iter() + .zip(&self.input_digests) + .map(|(artifact, digest)| { + if artifact.digest().to_string() != *digest { + anyhow::bail!( + "print_pdf.input.changed: artifact differs from frozen source identity" + ); + } + store.payload_path(artifact) + }) + .collect::>>()?; + let first = &self.pages[0]; + let mm_per_point = 25.4 / 72.0; + let media_width_mm = first.media_width_pt * mm_per_point; + let media_height_mm = first.media_height_pt * mm_per_point; + let trim_inset_mm = first.trim_inset_pt * mm_per_point; + let mut args = vec![ + "--nodate".to_string(), + "--engine".to_string(), + "internal".to_string(), + "--rotation".to_string(), + "none".to_string(), + "--pagesize".to_string(), + format!("{media_width_mm:.6}mmx{media_height_mm:.6}mm"), + "--imgsize".to_string(), + format!("{media_width_mm:.6}mmx{media_height_mm:.6}mm"), + "--fit".to_string(), + "into".to_string(), + "--bleed-border".to_string(), + "0mm".to_string(), + "--trim-border".to_string(), + format!("{trim_inset_mm:.6}mm"), + ]; + let temporary = tempfile::Builder::new() + .prefix("print-interior-") + .suffix(".pdf") + .tempfile_in(store.temporary_directory()) + .context("print_pdf.output.temporary: cannot create temporary PDF")? + .into_temp_path(); + let output = temporary + .to_str() + .context("print_pdf.output.path: temporary PDF path is not UTF-8")?; + args.extend(["--output".to_string(), output.to_string()]); + for path in &paths { + args.push( + path.to_str() + .context("print_pdf.input.path: artifact-store path is not UTF-8")? + .to_string(), + ); + } + let mut request = ProcessRequest::direct(&self.policy.executable) + .args(args) + .stdin(ProcessInput::Null) + .stdout(ProcessOutputMode::capture(DEFAULT_CAPTURE_LIMIT_BYTES)) + .stderr(ProcessOutputMode::capture(DEFAULT_CAPTURE_LIMIT_BYTES)) + .timeout(Duration::from_secs(self.policy.timeout_seconds)) + .network_policy(ProcessNetworkPolicy::Deny) + .expect_output( + ProcessExpectedOutput::file(Path::new(output)) + .require_non_empty() + .require_change() + .max_bytes(self.policy.max_output_bytes), + ); + if let Some(cancellation) = &self.cancellation { + request = + request.cancellation(ProcessCancellationToken::from_shared(cancellation.clone())); + } + let result = ProcessExecutor::new().execute(request).map_err(|error| { + let code = if matches!( + error, + crate::process::ProcessError::MissingExecutable { .. } + ) { + "print_pdf.provider.unavailable" + } else { + "print_pdf.provider.launch" + }; + provider_error(code, error.to_string()) + })?; + let code = match result.termination() { + ProcessTermination::Exited { code: 0 } if result.is_success() => None, + ProcessTermination::Exited { code: 0 } => Some("print_pdf.provider.output"), + ProcessTermination::Exited { .. } => Some("print_pdf.provider.nonzero"), + ProcessTermination::Signaled => Some("print_pdf.provider.signaled"), + ProcessTermination::TimedOut => Some("print_pdf.provider.timeout"), + ProcessTermination::Cancelled => Some("print_pdf.provider.cancelled"), + ProcessTermination::OutputLimitExceeded => Some("print_pdf.provider.output_limit"), + }; + if let Some(code) = code { + return Err(provider_error(code, result.failure_message())); + } + if self + .cancellation + .as_ref() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)) + { + return Err(provider_error( + "print_pdf.provider.cancelled", + "execution was cancelled after provider completion", + )); + } + let metadata = std::fs::symlink_metadata(&temporary) + .context("print_pdf.output.missing: provider output vanished")?; + if !metadata.file_type().is_file() { + anyhow::bail!("print_pdf.output.unsafe: provider output is not a regular file"); + } + if metadata.len() > self.policy.max_output_bytes { + anyhow::bail!("print_pdf.bounds: output exceeds max_output_bytes"); + } + let inspection = inspect_print_pdf(&temporary, &self.pages) + .context("print_pdf.output.invalid: independent PDF inspection failed")?; + if self + .cancellation + .as_ref() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::SeqCst)) + { + return Err(provider_error( + "print_pdf.provider.cancelled", + "execution was cancelled before artifact import", + )); + } + store + .import_path( + &temporary, + ArtifactDescriptor::for_format(Format::Pdf, ArtifactStorageClass::Intermediate) + .with_sources(inputs.iter().map(|artifact| artifact.id().clone())) + .with_metadata("renderflow.transform", PRINT_PDF_CAPABILITY) + .with_metadata( + "renderflow.print_pdf.policy", + serde_json::to_value(&self.policy)?, + ) + .with_metadata( + "renderflow.print_pdf.inspection", + serde_json::to_value(&inspection)?, + ) + .with_metadata("renderflow.print_pdf.provider", PRINT_PDF_PROVIDER) + .with_metadata( + "renderflow.print_pdf.provider_version", + self.policy.provider_version.clone(), + ), + ) + .context("print_pdf.output.import: failed to persist validated PDF") + } +} diff --git a/crates/renderflow-core/src/print_pdf_image.rs b/crates/renderflow-core/src/print_pdf_image.rs new file mode 100644 index 0000000..8868217 --- /dev/null +++ b/crates/renderflow-core/src/print_pdf_image.rs @@ -0,0 +1,846 @@ +//! Bounded, read-only preflight for images embedded without raster conversion. +//! +//! This module deliberately accepts a narrow subset. A caller must still bind the +//! inspected bytes to the planned source digest before writing a publication. + +use std::fs::File; +use std::io::{Read, Take}; +use std::path::Path; + +use anyhow::{bail, Context, Result}; +use flate2::read::ZlibDecoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::graph::Format; + +const MAX_IMAGE_BYTES: u64 = 128 * 1024 * 1024; +const MAX_DECODED_BYTES: u64 = 512 * 1024 * 1024; +const MAX_DIMENSION: u32 = 100_000; +const MAX_PNG_CHUNKS: usize = 65_536; + +/// Pixel space used by the PDF image XObject. JPEG's YCbCr is decoded to RGB. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrintImageColorSpace { + #[serde(rename = "RGB")] + Rgb, + #[serde(rename = "Gray")] + Gray, +} + +/// The immutable image facts frozen into an image-only print PDF plan. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PrintImageInfo { + pub width_px: u32, + pub height_px: u32, + pub color_space: PrintImageColorSpace, + /// SHA-256 over the bytes passed to the PDF image stream: complete JPEG or + /// concatenated PNG IDAT payloads (including the zlib wrapper). + pub image_stream_sha256: String, +} + +/// Inspect one local PNG or JPEG without modifying or transcoding it. +/// +/// The file read is bounded even if another process grows it during inspection. +/// Input ownership, root confinement, and planned-source digest checks belong to +/// the caller. Structural rejection uses stable `print_pdf.image.*` prefixes. +pub fn inspect_print_image(path: &Path, format: Format) -> Result { + if !matches!(format, Format::Png | Format::Jpeg) { + bail!("print_pdf.image.unsupported_format: expected PNG or JPEG, got {format}"); + } + let metadata = path.symlink_metadata().with_context(|| { + format!( + "print_pdf.image.read_failed: cannot stat {}", + path.display() + ) + })?; + if !metadata.file_type().is_file() { + bail!("print_pdf.image.invalid_source: expected a regular image file"); + } + if metadata.len() > MAX_IMAGE_BYTES { + bail!("print_pdf.image.size_limit: image exceeds 128 MiB"); + } + let file = File::open(path).with_context(|| { + format!( + "print_pdf.image.read_failed: cannot open {}", + path.display() + ) + })?; + let mut bytes = Vec::new(); + let mut bounded: Take = file.take(MAX_IMAGE_BYTES + 1); + bounded.read_to_end(&mut bytes).with_context(|| { + format!( + "print_pdf.image.read_failed: cannot read {}", + path.display() + ) + })?; + if bytes.len() as u64 > MAX_IMAGE_BYTES { + bail!("print_pdf.image.size_limit: image exceeds 128 MiB"); + } + match format { + Format::Png => inspect_png(&bytes), + Format::Jpeg => inspect_jpeg(&bytes), + _ => unreachable!(), + } +} + +fn checked_dimensions(width: u32, height: u32) -> Result<()> { + if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION { + bail!("print_pdf.image.dimensions: zero or out-of-range pixel dimensions"); + } + Ok(()) +} + +fn inspect_png(bytes: &[u8]) -> Result { + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + bail!("print_pdf.image.png_header: invalid PNG signature"); + } + let mut pos = 8usize; + let mut dimensions = None; + let mut idat = Vec::new(); + let mut saw_idat = false; + let mut after_idat = false; + let mut saw_end = false; + let mut chunk_count = 0usize; + while pos < bytes.len() { + chunk_count += 1; + if chunk_count > MAX_PNG_CHUNKS { + bail!("print_pdf.image.png_chunks: too many PNG chunks"); + } + let header = bytes + .get(pos..pos.saturating_add(8)) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.png_chunks: truncated chunk header"))?; + let len = u32::from_be_bytes(header[..4].try_into().unwrap()) as usize; + let kind = &header[4..8]; + if !kind.iter().all(u8::is_ascii_alphabetic) || !kind[2].is_ascii_uppercase() { + bail!("print_pdf.image.png_chunks: invalid or reserved chunk type"); + } + let data_start = pos + 8; + let data_end = data_start + .checked_add(len) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.png_chunks: chunk length overflow"))?; + let end = data_end + .checked_add(4) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.png_chunks: chunk CRC overflow"))?; + let data = bytes.get(data_start..data_end).ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.png_chunks: truncated chunk payload") + })?; + let expected_crc = bytes + .get(data_end..end) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.png_chunks: truncated chunk CRC"))?; + let mut crc = crc32fast::Hasher::new(); + crc.update(kind); + crc.update(data); + if crc.finalize() != u32::from_be_bytes(expected_crc.try_into().unwrap()) { + bail!("print_pdf.image.png_crc: PNG chunk CRC mismatch"); + } + if dimensions.is_none() && kind != b"IHDR" { + bail!("print_pdf.image.png_chunks: IHDR must be first"); + } + if kind != b"IDAT" && saw_idat { + after_idat = true; + } + match kind { + b"IHDR" => { + if dimensions.is_some() || pos != 8 || data.len() != 13 { + bail!("print_pdf.image.png_header: duplicate or malformed IHDR"); + } + let width = u32::from_be_bytes(data[..4].try_into().unwrap()); + let height = u32::from_be_bytes(data[4..8].try_into().unwrap()); + checked_dimensions(width, height)?; + let color_space = match (data[8], data[9]) { + (8, 0) => PrintImageColorSpace::Gray, + (8, 2) => PrintImageColorSpace::Rgb, + _ => bail!("print_pdf.image.png_color: only 8-bit grayscale or RGB without alpha/palette is supported"), + }; + if data[10..13] != [0, 0, 0] { + bail!( + "print_pdf.image.png_layout: unsupported compression, filter, or interlace" + ); + } + dimensions = Some((width, height, color_space)); + } + b"IDAT" => { + if after_idat { + bail!("print_pdf.image.png_chunks: IDAT chunks must be contiguous"); + } + saw_idat = true; + idat.extend_from_slice(data); + } + b"IEND" => { + if !data.is_empty() || idat.is_empty() || end != bytes.len() { + bail!("print_pdf.image.png_chunks: invalid IEND or trailing bytes"); + } + saw_end = true; + } + b"PLTE" | b"tRNS" | b"iCCP" | b"eXIf" | b"acTL" | b"fcTL" | b"fdAT" => { + bail!("print_pdf.image.png_metadata: palette, transparency, profile, EXIF, and animation are unsupported"); + } + b"sRGB" | b"gAMA" | b"cHRM" | b"cICP" | b"sBIT" => { + bail!("print_pdf.image.png_color: embedded color intent or significant-bit metadata cannot be preserved by the device-color PDF route"); + } + b"pHYs" => { + if data.len() != 9 + || u32::from_be_bytes(data[..4].try_into().unwrap()) + != u32::from_be_bytes(data[4..8].try_into().unwrap()) + || data[8] > 1 + { + bail!("print_pdf.image.png_aspect: non-square or malformed pixel aspect metadata is unsupported"); + } + } + _ if kind[0].is_ascii_uppercase() => { + bail!("print_pdf.image.png_chunks: unknown critical PNG chunk"); + } + _ => {} + } + pos = end; + if saw_end { + break; + } + } + if !saw_end { + bail!("print_pdf.image.png_chunks: missing IEND"); + } + let (width_px, height_px, color_space) = dimensions.expect("IHDR required above"); + check_png_pixels(&idat, width_px, height_px, color_space)?; + Ok(PrintImageInfo { + width_px, + height_px, + color_space, + image_stream_sha256: format!("{:x}", Sha256::digest(&idat)), + }) +} + +fn check_png_pixels( + idat: &[u8], + width: u32, + height: u32, + color_space: PrintImageColorSpace, +) -> Result<()> { + let channels = match color_space { + PrintImageColorSpace::Gray => 1u64, + PrintImageColorSpace::Rgb => 3, + }; + let row_size = u64::from(width) * channels + 1; + let expected = row_size * u64::from(height); + if expected > MAX_DECODED_BYTES { + bail!("print_pdf.image.decoded_size_limit: decoded PNG exceeds 512 MiB"); + } + let mut row = vec![0u8; row_size as usize]; + let mut decoder = ZlibDecoder::new(idat); + for _ in 0..height { + decoder.read_exact(&mut row).map_err(|err| { + anyhow::anyhow!("print_pdf.image.png_zlib: corrupt or short image data: {err}") + })?; + if row[0] > 4 { + bail!("print_pdf.image.png_filter: invalid PNG scanline filter"); + } + } + let mut extra = [0u8; 1]; + if decoder.read(&mut extra).map_err(|err| { + anyhow::anyhow!("print_pdf.image.png_zlib: corrupt compressed image data: {err}") + })? != 0 + || decoder.total_in() != idat.len() as u64 + { + bail!("print_pdf.image.png_zlib: unexpected extra compressed or decoded bytes"); + } + Ok(()) +} + +fn inspect_jpeg(bytes: &[u8]) -> Result { + if !bytes.starts_with(&[0xff, 0xd8]) { + bail!("print_pdf.image.jpeg_header: missing JPEG SOI"); + } + let mut pos = 2usize; + let mut frame: Option<(u32, u32, PrintImageColorSpace, Vec, bool)> = None; + let mut saw_scan = false; + let mut saw_exif = false; + let mut saw_jfif = false; + loop { + let marker = read_jpeg_marker(bytes, &mut pos)?; + if marker == 0xd9 { + if !saw_scan || pos != bytes.len() { + bail!("print_pdf.image.jpeg_structure: missing scan or trailing bytes after EOI"); + } + break; + } + if marker == 0xd8 || marker == 0x01 || (0xd0..=0xd7).contains(&marker) { + bail!("print_pdf.image.jpeg_structure: unexpected standalone marker"); + } + let len_bytes = bytes.get(pos..pos.saturating_add(2)).ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_structure: truncated segment length") + })?; + let len = u16::from_be_bytes(len_bytes.try_into().unwrap()) as usize; + if len < 2 { + bail!("print_pdf.image.jpeg_structure: invalid segment length"); + } + let data_start = pos + 2; + let data_end = pos.checked_add(len).ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_structure: segment length overflow") + })?; + let data = bytes + .get(data_start..data_end) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_structure: truncated segment"))?; + pos = data_end; + match marker { + 0xe0 if data.starts_with(b"JFIF\0") => { + if saw_jfif || data.len() < 14 { + bail!("print_pdf.image.jpeg_jfif: repeated or malformed JFIF header"); + } + saw_jfif = true; + let x_density = u16::from_be_bytes([data[8], data[9]]); + let y_density = u16::from_be_bytes([data[10], data[11]]); + let thumbnail_bytes = usize::from(data[12]) * usize::from(data[13]) * 3; + if data[7] > 2 + || x_density == 0 + || x_density != y_density + || data.len() != 14 + thumbnail_bytes + { + bail!("print_pdf.image.jpeg_aspect: non-square or malformed JFIF pixel aspect is unsupported"); + } + } + 0xc0 | 0xc2 => { + if frame.is_some() || saw_scan || data.len() < 6 || data[0] != 8 { + bail!("print_pdf.image.jpeg_frame: duplicate or unsupported frame"); + } + let height = u16::from_be_bytes([data[1], data[2]]) as u32; + let width = u16::from_be_bytes([data[3], data[4]]) as u32; + checked_dimensions(width, height)?; + let count = data[5] as usize; + let color_space = match count { + 1 => PrintImageColorSpace::Gray, + 3 => PrintImageColorSpace::Rgb, + _ => bail!("print_pdf.image.jpeg_color: only grayscale or three-component JPEG is supported"), + }; + if data.len() != 6 + 3 * count { + bail!("print_pdf.image.jpeg_frame: malformed frame components"); + } + let components: Vec = data[6..] + .as_chunks::<3>() + .0 + .iter() + .map(|part| part[0]) + .collect(); + if components + .iter() + .enumerate() + .any(|(i, id)| components[..i].contains(id)) + { + bail!("print_pdf.image.jpeg_frame: duplicate frame component IDs"); + } + frame = Some((width, height, color_space, components, marker == 0xc2)); + } + 0xc1 | 0xc3 | 0xc5..=0xc7 | 0xc9..=0xcb | 0xcd..=0xcf => { + bail!("print_pdf.image.jpeg_frame: unsupported JPEG coding mode"); + } + 0xda => { + let (_, _, _, components, progressive) = frame.as_ref().ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_structure: SOS precedes SOF") + })?; + validate_scan_header(data, components, *progressive)?; + saw_scan = true; + // Entropy-coded bytes may contain stuffed FF 00 and restart + // markers. The next non-restart marker starts a new segment. + let entropy_start = pos; + loop { + let value = *bytes.get(pos).ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_structure: truncated scan") + })?; + if value != 0xff { + pos += 1; + continue; + } + let marker_pos = pos; + while bytes.get(pos) == Some(&0xff) { + pos += 1; + } + let next = *bytes.get(pos).ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_structure: truncated scan marker") + })?; + if next == 0x00 || (0xd0..=0xd7).contains(&next) { + pos += 1; + continue; + } + if marker_pos == entropy_start { + bail!("print_pdf.image.jpeg_scan: empty entropy-coded scan"); + } + pos = marker_pos; + break; + } + } + 0xe1 if data.starts_with(b"Exif\0\0") => { + if saw_exif { + bail!("print_pdf.image.jpeg_exif: multiple EXIF segments"); + } + saw_exif = true; + check_exif_orientation(&data[6..])?; + } + 0xe2 if data.starts_with(b"ICC_PROFILE\0") => { + bail!("print_pdf.image.jpeg_icc: embedded ICC profile is unsupported"); + } + 0xee if data.starts_with(b"Adobe") => { + // Adobe APP14 can change the interpretation of a three-component + // DCT stream. The narrow DeviceRGB/Gray PDF contract excludes it. + bail!("print_pdf.image.jpeg_color: Adobe APP14 color transforms are unsupported"); + } + 0xdc => bail!("print_pdf.image.jpeg_frame: DNL dimensions are unsupported"), + _ => {} + } + } + let (width_px, height_px, color_space, _, _) = frame.ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_frame: missing baseline or progressive SOF") + })?; + check_jpeg_pixels(bytes, width_px, height_px, color_space)?; + Ok(PrintImageInfo { + width_px, + height_px, + color_space, + image_stream_sha256: format!("{:x}", Sha256::digest(bytes)), + }) +} + +fn check_jpeg_pixels( + bytes: &[u8], + width: u32, + height: u32, + color_space: PrintImageColorSpace, +) -> Result<()> { + let (channels, expected_pixel_format) = match color_space { + PrintImageColorSpace::Gray => (1u64, jpeg_decoder::PixelFormat::L8), + PrintImageColorSpace::Rgb => (3u64, jpeg_decoder::PixelFormat::RGB24), + }; + let expected = u64::from(width) * u64::from(height) * channels; + if expected > MAX_DECODED_BYTES { + bail!("print_pdf.image.decoded_size_limit: decoded JPEG exceeds 512 MiB"); + } + let mut decoder = jpeg_decoder::Decoder::new(bytes); + decoder.set_max_decoding_buffer_size(MAX_DECODED_BYTES as usize); + let decoded = decoder.decode().map_err(|err| { + anyhow::anyhow!("print_pdf.image.jpeg_decode: unreadable JPEG entropy: {err}") + })?; + let info = decoder.info().ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_decode: missing decoded image properties") + })?; + if u32::from(info.width) != width + || u32::from(info.height) != height + || info.pixel_format != expected_pixel_format + || decoded.len() as u64 != expected + { + bail!("print_pdf.image.jpeg_decode: decoded image differs from inspected frame"); + } + Ok(()) +} + +fn read_jpeg_marker(bytes: &[u8], pos: &mut usize) -> Result { + if bytes.get(*pos) != Some(&0xff) { + bail!("print_pdf.image.jpeg_structure: expected marker"); + } + while bytes.get(*pos) == Some(&0xff) { + *pos += 1; + } + let marker = *bytes + .get(*pos) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_structure: truncated marker"))?; + if marker == 0x00 { + bail!("print_pdf.image.jpeg_structure: stuffed byte outside scan"); + } + *pos += 1; + Ok(marker) +} + +fn validate_scan_header(data: &[u8], components: &[u8], progressive: bool) -> Result<()> { + let count = + usize::from(*data.first().ok_or_else(|| { + anyhow::anyhow!("print_pdf.image.jpeg_scan: missing scan components") + })?); + if count == 0 || count > components.len() || data.len() != 4 + 2 * count { + bail!("print_pdf.image.jpeg_scan: malformed scan components"); + } + let mut seen = Vec::new(); + for chunk in data[1..1 + count * 2].as_chunks::<2>().0 { + if !components.contains(&chunk[0]) || seen.contains(&chunk[0]) { + bail!("print_pdf.image.jpeg_scan: unknown or repeated scan component"); + } + seen.push(chunk[0]); + } + let ss = data[1 + count * 2]; + let se = data[2 + count * 2]; + let approximation = data[3 + count * 2]; + if progressive { + if ss > se + || se > 63 + || (ss == 0 && se != 0) + || (ss != 0 && count != 1) + || approximation >> 4 > 13 + || approximation & 0x0f > 13 + { + bail!("print_pdf.image.jpeg_scan: invalid progressive scan parameters"); + } + } else if (ss, se, approximation) != (0, 63, 0) { + bail!("print_pdf.image.jpeg_scan: invalid baseline scan parameters"); + } + Ok(()) +} + +fn check_exif_orientation(tiff: &[u8]) -> Result<()> { + let endian = match tiff.get(..2) { + Some(b"II") => true, + Some(b"MM") => false, + _ => bail!("print_pdf.image.jpeg_exif: invalid TIFF byte order"), + }; + let read_u16 = |bytes: &[u8]| -> u16 { + if endian { + u16::from_le_bytes([bytes[0], bytes[1]]) + } else { + u16::from_be_bytes([bytes[0], bytes[1]]) + } + }; + let read_u32 = |bytes: &[u8]| -> u32 { + if endian { + u32::from_le_bytes(bytes.try_into().unwrap()) + } else { + u32::from_be_bytes(bytes.try_into().unwrap()) + } + }; + let header = tiff + .get(..8) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_exif: short TIFF header"))?; + if read_u16(&header[2..4]) != 42 { + bail!("print_pdf.image.jpeg_exif: invalid TIFF magic"); + } + let offset = read_u32(&header[4..8]) as usize; + let count_bytes = tiff + .get(offset..offset.saturating_add(2)) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_exif: missing IFD0"))?; + let count = read_u16(count_bytes) as usize; + let entries_end = offset + .checked_add(2) + .and_then(|value| value.checked_add(count * 12)) + .and_then(|value| value.checked_add(4)) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_exif: IFD0 offset overflow"))?; + if tiff.get(..entries_end).is_none() { + bail!("print_pdf.image.jpeg_exif: truncated IFD0"); + } + let mut orientation = None; + for i in 0..count { + let start = offset + .checked_add(2) + .and_then(|n| n.checked_add(i * 12)) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_exif: IFD0 offset overflow"))?; + let field = tiff + .get(start..start.saturating_add(12)) + .ok_or_else(|| anyhow::anyhow!("print_pdf.image.jpeg_exif: truncated IFD0"))?; + if read_u16(&field[..2]) == 0x0112 { + if orientation.is_some() || read_u16(&field[2..4]) != 3 || read_u32(&field[4..8]) != 1 { + bail!("print_pdf.image.jpeg_exif: malformed or repeated orientation"); + } + orientation = Some(read_u16(&field[8..10])); + } else { + // EXIF may carry a profile, pixel-aspect, or color interpretation + // in other IFD entries. Keep this route limited to an explicit + // orientation declaration, which does not change image color. + bail!( + "print_pdf.image.jpeg_exif: only an identity-orientation EXIF entry is supported" + ); + } + } + if read_u32(&tiff[entries_end - 4..entries_end]) != 0 { + bail!("print_pdf.image.jpeg_exif: linked EXIF directories are unsupported"); + } + if orientation.is_some_and(|value| value != 1) { + bail!("print_pdf.image.jpeg_orientation: nonidentity EXIF orientation is unsupported"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::ZlibEncoder; + use flate2::Compression; + use std::io::Write; + + fn png_chunk(kind: &[u8; 4], data: &[u8]) -> Vec { + let mut chunk = Vec::new(); + chunk.extend_from_slice(&(data.len() as u32).to_be_bytes()); + chunk.extend_from_slice(kind); + chunk.extend_from_slice(data); + let mut crc = crc32fast::Hasher::new(); + crc.update(kind); + crc.update(data); + chunk.extend_from_slice(&crc.finalize().to_be_bytes()); + chunk + } + + fn png(width: u32, height: u32, color_type: u8) -> Vec { + let channels = if color_type == 0 { 1 } else { 3 }; + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut ihdr = Vec::new(); + ihdr.extend_from_slice(&width.to_be_bytes()); + ihdr.extend_from_slice(&height.to_be_bytes()); + ihdr.extend_from_slice(&[8, color_type, 0, 0, 0]); + bytes.extend(png_chunk(b"IHDR", &ihdr)); + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + for _ in 0..height { + encoder + .write_all(&vec![0; 1 + width as usize * channels]) + .unwrap(); + } + bytes.extend(png_chunk(b"IDAT", &encoder.finish().unwrap())); + bytes.extend(png_chunk(b"IEND", &[])); + bytes + } + + fn jpeg_marker_structure(components: u8) -> Vec { + // This fixture exercises unsupported channels before entropy decoding. + let mut bytes = vec![0xff, 0xd8]; + let mut sof = vec![8, 0, 1, 0, 1, components]; + for component in 1..=components { + sof.extend([component, 0x11, 0]); + } + bytes.extend([0xff, 0xc0]); + bytes.extend(((sof.len() + 2) as u16).to_be_bytes()); + bytes.extend(sof); + let mut sos = vec![components]; + for component in 1..=components { + sos.extend([component, 0]); + } + sos.extend([0, 63, 0]); + bytes.extend([0xff, 0xda]); + bytes.extend(((sos.len() + 2) as u16).to_be_bytes()); + bytes.extend(sos); + bytes.extend([0x42, 0xff, 0x00, 0x17, 0xff, 0xd9]); + bytes + } + + fn valid_jpeg_rgb() -> Vec { + // Optimized 1x1 JPEG made with Pillow; checked-in bytes keep the test + // deterministic without a runtime image encoder. + decode_hex(concat!( + "ffd8ffe000104a46494600010100000100010000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432", + "ffdb0043010909090c0b0c180d0d1832211c213232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232ffc00011080001000103012200021101031101", + "ffc4001500010100000000000000000000000000000007ffc40014100100000000000000000000000000000000ffc40014010100000000000000000000000000000004ffc40014110100000000000000000000000000000000ffda000c03010002110311003f009680608fffd9" + )) + } + + fn valid_jpeg_gray() -> Vec { + decode_hex(concat!( + "ffd8ffe000104a46494600010100000100010000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432", + "ffc0000b080001000101011100ffc40014000100000000000000000000000000000000ffc40014100100000000000000000000000000000000ffda0008010100003f003fffd9" + )) + } + + fn valid_jpeg_progressive_gray() -> Vec { + decode_hex(concat!( + "ffd8ffe000104a46494600010100000100010000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432", + "ffc2000b080001000101011100ffc40014000100000000000000000000000000000000ffda00080101000000017fffc40014100100000000000000000000000000000000ffda00080101000105027fff", + "c40014100100000000000000000000000000000000ffda0008010100063f027fffc40014100100000000000000000000000000000000ffda0008010100013f217fffda00080101000000107fffc40014100100000000000000000000000000000000ffda0008010100013f107fffd9" + )) + } + + fn decode_hex(hex: &str) -> Vec { + hex.as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) + .collect() + } + + fn inspect_fixture(bytes: &[u8], format: Format) -> Result { + let path = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(path.path(), bytes).unwrap(); + inspect_print_image(path.path(), format) + } + + #[test] + fn png_rgb_gray_crc_and_stream_hash() { + for (kind, expected_color) in [ + (2, PrintImageColorSpace::Rgb), + (0, PrintImageColorSpace::Gray), + ] { + let bytes = png(2, 3, kind); + let image = inspect_fixture(&bytes, Format::Png).unwrap(); + assert_eq!( + (image.width_px, image.height_px, image.color_space), + (2, 3, expected_color) + ); + assert_eq!(image.image_stream_sha256.len(), 64); + assert!(serde_json::to_string(&image) + .unwrap() + .contains(if kind == 2 { "RGB" } else { "Gray" })); + } + } + + #[test] + fn png_refuses_bad_crc_zlib_color_or_metadata() { + let mut bad_crc = png(1, 1, 2); + bad_crc[29] ^= 1; + assert!(inspect_fixture(&bad_crc, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_crc")); + let mut bad_data = png(1, 1, 2); + let idat_pos = 8 + 25 + 8; + bad_data[idat_pos] ^= 1; + let idat_len = u32::from_be_bytes(bad_data[33..37].try_into().unwrap()) as usize; + let mut crc = crc32fast::Hasher::new(); + crc.update(b"IDAT"); + crc.update(&bad_data[idat_pos..idat_pos + idat_len]); + bad_data[idat_pos + idat_len..idat_pos + idat_len + 4] + .copy_from_slice(&crc.finalize().to_be_bytes()); + assert!(inspect_fixture(&bad_data, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_zlib")); + let alpha = png(1, 1, 6); + assert!(inspect_fixture(&alpha, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_color")); + let mut icc = png(1, 1, 2); + let first_idat = 8 + 25; + icc.splice(first_idat..first_idat, png_chunk(b"iCCP", b"profile\0")); + assert!(inspect_fixture(&icc, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_metadata")); + let mut srgb = png(1, 1, 2); + srgb.splice(first_idat..first_idat, png_chunk(b"sRGB", &[0])); + assert!(inspect_fixture(&srgb, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_color")); + let mut nonsquare = png(1, 1, 2); + nonsquare.splice( + first_idat..first_idat, + png_chunk(b"pHYs", &[0, 0, 0, 1, 0, 0, 0, 2, 1]), + ); + assert!(inspect_fixture(&nonsquare, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_aspect")); + } + + #[test] + fn jpeg_refuses_cmyk_icc_and_nonidentity_orientation() { + let rgb = valid_jpeg_rgb(); + let info = inspect_fixture(&rgb, Format::Jpeg).unwrap(); + assert_eq!( + (info.width_px, info.height_px, info.color_space), + (1, 1, PrintImageColorSpace::Rgb) + ); + assert_eq!( + info.image_stream_sha256, + format!("{:x}", Sha256::digest(&rgb)) + ); + let gray = inspect_fixture(&valid_jpeg_gray(), Format::Jpeg).unwrap(); + assert_eq!(gray.color_space, PrintImageColorSpace::Gray); + let progressive = inspect_fixture(&valid_jpeg_progressive_gray(), Format::Jpeg).unwrap(); + assert_eq!(progressive.color_space, PrintImageColorSpace::Gray); + assert!(inspect_fixture(&jpeg_marker_structure(4), Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_color")); + let mut icc = rgb.clone(); + let app = b"ICC_PROFILE\0\x01\x01"; + icc.splice( + 2..2, + [ + vec![0xff, 0xe2], + ((app.len() + 2) as u16).to_be_bytes().to_vec(), + app.to_vec(), + ] + .concat(), + ); + assert!(inspect_fixture(&icc, Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_icc")); + let mut rotated = rgb; + let tiff = b"Exif\0\0II\x2a\0\x08\0\0\0\x01\0\x12\x01\x03\0\x01\0\0\0\x06\0\0\0\0\0\0\0"; + rotated.splice( + 2..2, + [ + vec![0xff, 0xe1], + ((tiff.len() + 2) as u16).to_be_bytes().to_vec(), + tiff.to_vec(), + ] + .concat(), + ); + assert!(inspect_fixture(&rotated, Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_orientation")); + rotated[30] = 1; + assert_eq!( + inspect_fixture(&rotated, Format::Jpeg).unwrap().color_space, + PrintImageColorSpace::Rgb + ); + let mut nonsquare = valid_jpeg_rgb(); + nonsquare[17] = 2; + assert!(inspect_fixture(&nonsquare, Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_aspect")); + } + + #[test] + fn jpeg_refuses_unreadable_tables_before_embedding() { + let mut jpeg = valid_jpeg_rgb(); + // The DQT table header follows the JFIF APP0 marker. + jpeg[24] = 0xff; + assert!(inspect_fixture(&jpeg, Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_decode")); + } + + #[test] + fn file_bounds_and_wrong_format_fail_without_mutation() { + let path = tempfile::NamedTempFile::new().unwrap(); + let bytes = png(1, 1, 2); + std::fs::write(path.path(), &bytes).unwrap(); + assert!(inspect_print_image(path.path(), Format::Tiff) + .unwrap_err() + .to_string() + .contains("print_pdf.image.unsupported_format")); + assert!(inspect_print_image(path.path(), Format::Jpeg) + .unwrap_err() + .to_string() + .contains("print_pdf.image.jpeg_header")); + assert_eq!(std::fs::read(path.path()).unwrap(), bytes); + + let too_wide = png(MAX_DIMENSION + 1, 0, 2); + assert!(inspect_fixture(&too_wide, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.dimensions")); + let truncated = &bytes[..bytes.len() - 1]; + assert!(inspect_fixture(truncated, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.png_chunks")); + + let oversized = tempfile::NamedTempFile::new().unwrap(); + oversized.as_file().set_len(MAX_IMAGE_BYTES + 1).unwrap(); + assert!(inspect_print_image(oversized.path(), Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.size_limit")); + } + + #[cfg(unix)] + #[test] + fn symlink_source_is_refused() { + let directory = tempfile::tempdir().unwrap(); + let source = directory.path().join("image.png"); + std::fs::write(&source, png(1, 1, 2)).unwrap(); + let link = directory.path().join("link.png"); + std::os::unix::fs::symlink(&source, &link).unwrap(); + assert!(inspect_print_image(&link, Format::Png) + .unwrap_err() + .to_string() + .contains("print_pdf.image.invalid_source")); + } +} diff --git a/crates/renderflow-core/src/print_pdf_inspect.rs b/crates/renderflow-core/src/print_pdf_inspect.rs new file mode 100644 index 0000000..a8394c8 --- /dev/null +++ b/crates/renderflow-core/src/print_pdf_inspect.rs @@ -0,0 +1,940 @@ +//! Independent structural inspection of a deliberately narrow print-interior PDF. +//! +//! This validates the PDF object tree and the single, full-bleed image drawn on +//! each page. The expected image-stream hash is computed from the immutable +//! source by the caller, independently of the PDF. It proves output ordering +//! for the supported passthrough JPEG and PNG IDAT routes, even when every page +//! has the same geometry. It is not a general PDF conformance or rasterization +//! engine: image codec validity and visual color fidelity require separate +//! evidence. + +use std::collections::HashSet; +use std::fs::File; +use std::io::Read; +use std::path::Path; + +use anyhow::Result; +use lopdf::{content::Content, Dictionary, Document, LoadOptions, Object, ObjectId}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const PRINT_PDF_INSPECTION_SCHEMA_V1: &str = "renderflow.print_pdf_inspection/v1"; +pub const MAX_PRINT_PDF_BYTES: u64 = 512 * 1024 * 1024; +pub const MAX_PRINT_PDF_PAGES: usize = 1_000; +const MAX_DECOMPRESSED_STREAM_BYTES: usize = 16 * 1024 * 1024; +const MAX_PAGE_CONTENT_BYTES: usize = 1024 * 1024; +const BOX_TOLERANCE_PT: f64 = 0.02; + +/// Exact, independently obtained source expectations for one ordered page. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectedPrintPage { + pub media_width_pt: f64, + pub media_height_pt: f64, + pub trim_inset_pt: f64, + pub pixel_width: u32, + pub pixel_height: u32, + /// Rotation in clockwise degrees; the initial print route supports zero. + pub rotation: u16, + /// Exactly `DeviceRGB` or `DeviceGray` for this route. + pub color_space: String, + /// `DCTDecode` for JPEG or `FlateDecode` for direct PNG IDAT embedding. + pub image_filter: String, + /// SHA-256 of the bytes the provider must embed as its image stream. + pub image_stream_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrintPdfPageEvidence { + pub page_number: u32, + pub media_box_pt: [f64; 4], + pub bleed_box_pt: [f64; 4], + pub trim_box_pt: [f64; 4], + pub crop_box_pt: Option<[f64; 4]>, + pub rotation: u16, + pub pixel_width: u32, + pub pixel_height: u32, + pub color_space: String, + pub image_filter: String, + pub image_stream_sha256: String, + /// PDF current-transformation matrix used to draw the sole page image. + pub image_draw_matrix: [f64; 6], +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrintPdfInspection { + pub schema: String, + pub inspector: String, + pub byte_length: u64, + pub sha256: String, + pub page_count: usize, + pub pages: Vec, +} + +/// Downcastable, stable diagnostic code for a failed PDF inspection. +#[derive(Debug, Error)] +#[error("{code}: {message}")] +pub struct PrintPdfInspectionError { + pub code: &'static str, + pub message: String, +} + +fn diagnostic(code: &'static str, message: impl Into) -> anyhow::Error { + PrintPdfInspectionError { + code, + message: message.into(), + } + .into() +} + +fn require(condition: bool, code: &'static str, message: impl Into) -> Result<()> { + if condition { + Ok(()) + } else { + Err(diagnostic(code, message)) + } +} + +fn parse_number(object: &Object) -> Result { + let value = match object { + Object::Integer(value) => *value as f64, + Object::Real(value) => f64::from(*value), + _ => { + return Err(diagnostic( + "print_pdf.invalid_number", + "PDF box or matrix has a nonnumeric coordinate", + )) + } + }; + require( + value.is_finite(), + "print_pdf.invalid_number", + "PDF coordinate is not finite", + )?; + Ok(value) +} + +fn rectangle(doc: &Document, page: &Dictionary, key: &[u8], page_number: u32) -> Result<[f64; 4]> { + let value = page.get_deref(key, doc).map_err(|_| { + diagnostic( + "print_pdf.missing_box", + format!( + "page {page_number} is missing /{}", + String::from_utf8_lossy(key) + ), + ) + })?; + rectangle_value(value, page_number) +} + +fn rectangle_value(value: &Object, page_number: u32) -> Result<[f64; 4]> { + let coordinates = value.as_array().map_err(|_| { + diagnostic( + "print_pdf.invalid_box", + format!("page {page_number} has an invalid box array"), + ) + })?; + require( + coordinates.len() == 4, + "print_pdf.invalid_box", + format!("page {page_number} box needs four coordinates"), + )?; + let box_coordinates = [ + parse_number(&coordinates[0])?, + parse_number(&coordinates[1])?, + parse_number(&coordinates[2])?, + parse_number(&coordinates[3])?, + ]; + require( + box_coordinates[0] < box_coordinates[2] && box_coordinates[1] < box_coordinates[3], + "print_pdf.invalid_box", + format!("page {page_number} has an empty or reversed box"), + )?; + Ok(box_coordinates) +} + +// /Rotate and /CropBox may be inherited from a Pages ancestor. Refusing an +// unnoticed inherited value is necessary to verify effective page geometry. +fn inherited_page_entry<'a>( + doc: &'a Document, + page_id: ObjectId, + key: &[u8], +) -> Result> { + let mut current = page_id; + let mut visited = HashSet::new(); + for _ in 0..32 { + require( + visited.insert(current), + "print_pdf.invalid_structure", + "PDF page tree contains a parent cycle", + )?; + let node = doc + .get_dictionary(current) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + if let Ok(value) = node.get(key) { + return doc + .dereference(value) + .map(|(_, value)| Some(value)) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string())); + } + match node.get(b"Parent") { + Ok(parent) => { + current = parent.as_reference().map_err(|_| { + diagnostic( + "print_pdf.invalid_structure", + "PDF page Parent is not indirect", + ) + })?; + } + Err(_) => return Ok(None), + } + } + Err(diagnostic( + "print_pdf.invalid_structure", + "PDF page tree exceeds the inspector depth limit", + )) +} + +fn same_box(actual: [f64; 4], expected: [f64; 4]) -> bool { + actual + .iter() + .zip(expected.iter()) + .all(|(actual, expected)| (actual - expected).abs() <= BOX_TOLERANCE_PT) +} + +fn checked_expected(expected: &[ExpectedPrintPage]) -> Result<()> { + require( + !expected.is_empty(), + "print_pdf.invalid_expectation", + "at least one page is required", + )?; + require( + expected.len() <= MAX_PRINT_PDF_PAGES, + "print_pdf.page_limit", + "expected page count exceeds the inspector limit", + )?; + for (index, page) in expected.iter().enumerate() { + require( + page.media_width_pt.is_finite() + && page.media_height_pt.is_finite() + && page.trim_inset_pt.is_finite() + && page.media_width_pt > 0.0 + && page.media_height_pt > 0.0 + && page.trim_inset_pt >= 0.0 + && page.trim_inset_pt * 2.0 < page.media_width_pt + && page.trim_inset_pt * 2.0 < page.media_height_pt + && page.pixel_width > 0 + && page.pixel_height > 0, + "print_pdf.invalid_expectation", + format!( + "expected page {} has invalid dimensions or trim inset", + index + 1 + ), + )?; + require( + page.rotation == 0, + "print_pdf.unsupported_rotation", + "the proven print route supports only unrotated pages", + )?; + require( + matches!(page.color_space.as_str(), "DeviceRGB" | "DeviceGray") + && matches!(page.image_filter.as_str(), "DCTDecode" | "FlateDecode"), + "print_pdf.invalid_expectation", + format!( + "expected page {} has an unsupported image color space or filter", + index + 1 + ), + )?; + require( + page.image_stream_sha256.len() == 64 + && page + .image_stream_sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "print_pdf.invalid_expectation", + format!( + "expected page {} has an invalid image-stream SHA-256", + index + 1 + ), + )?; + } + Ok(()) +} + +/// Inspect a bounded PDF against independently measured, ordered page inputs. +/// +/// The PDF's image stream must equal each expected source-derived stream hash +/// in sequence. The method checks parsed PDF structure, explicit print boxes, +/// page image resources, pixels, color/filter, and the sole drawing operation. +/// It intentionally refuses page artwork overlays, annotations, nonzero +/// rotation, and non-full-bleed placement because they are not proven here. +pub fn inspect_print_pdf( + path: &Path, + expected: &[ExpectedPrintPage], +) -> Result { + checked_expected(expected)?; + let file = + File::open(path).map_err(|error| diagnostic("print_pdf.unreadable", error.to_string()))?; + let metadata = file + .metadata() + .map_err(|error| diagnostic("print_pdf.unreadable", error.to_string()))?; + require( + metadata.len() <= MAX_PRINT_PDF_BYTES, + "print_pdf.byte_limit", + "PDF exceeds the inspector byte limit", + )?; + let mut pdf_bytes = Vec::new(); + file.take(MAX_PRINT_PDF_BYTES + 1) + .read_to_end(&mut pdf_bytes) + .map_err(|error| diagnostic("print_pdf.unreadable", error.to_string()))?; + require( + pdf_bytes.len() as u64 <= MAX_PRINT_PDF_BYTES, + "print_pdf.byte_limit", + "PDF grew beyond the inspector byte limit", + )?; + require( + pdf_bytes.starts_with(b"%PDF-"), + "print_pdf.invalid_structure", + "PDF header is missing", + )?; + let output_digest = format!("{:x}", Sha256::digest(&pdf_bytes)); + let doc = Document::load_mem_with_options( + &pdf_bytes, + LoadOptions { + strict: true, + max_decompressed_size: Some(MAX_DECOMPRESSED_STREAM_BYTES), + ..LoadOptions::default() + }, + ) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + require( + !doc.is_encrypted(), + "print_pdf.encrypted", + "encrypted PDFs cannot be independently inspected", + )?; + + let catalog = doc + .catalog() + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + require( + ![b"OpenAction".as_slice(), b"AA", b"AcroForm", b"Names"] + .iter() + .any(|key| catalog.has(key)), + "print_pdf.unexpected_content", + "PDF catalog declares active actions, forms, or name trees outside the print route", + )?; + let pages_root = catalog + .get(b"Pages") + .and_then(Object::as_reference) + .and_then(|id| doc.get_dictionary(id)) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + let declared_count = pages_root + .get(b"Count") + .and_then(Object::as_i64) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + let pages = doc.get_pages(); + require( + declared_count >= 0 + && declared_count as usize == pages.len() + && pages.len() == expected.len(), + "print_pdf.page_count_mismatch", + format!( + "PDF declares {declared_count} pages, parser reached {}, expected {}", + pages.len(), + expected.len() + ), + )?; + let unique_pages: HashSet = pages.values().copied().collect(); + require( + unique_pages.len() == pages.len(), + "print_pdf.invalid_structure", + "PDF page tree repeats a page object", + )?; + + let mut inspected = Vec::with_capacity(pages.len()); + for (number, id) in pages { + let source = &expected[(number - 1) as usize]; + inspected.push(inspect_page(&doc, number, id, source)?); + } + Ok(PrintPdfInspection { + schema: PRINT_PDF_INSPECTION_SCHEMA_V1.to_string(), + inspector: "renderflow.print_pdf_inspector/v1+lopdf-0.45.0".to_string(), + byte_length: pdf_bytes.len() as u64, + sha256: output_digest, + page_count: inspected.len(), + pages: inspected, + }) +} + +fn inspect_page( + doc: &Document, + number: u32, + id: ObjectId, + expected: &ExpectedPrintPage, +) -> Result { + let page = doc + .get_dictionary(id) + .map_err(|error| diagnostic("print_pdf.invalid_structure", error.to_string()))?; + require( + !page.has(b"Annots") && !page.has(b"AA"), + "print_pdf.unexpected_content", + format!("page {number} has annotations or additional actions"), + )?; + if let Ok(unit) = page.get(b"UserUnit") { + require( + (parse_number(unit)? - 1.0).abs() <= f64::EPSILON, + "print_pdf.invalid_geometry", + format!("page {number} uses nonstandard UserUnit"), + )?; + } + let media = rectangle(doc, page, b"MediaBox", number)?; + let bleed = rectangle(doc, page, b"BleedBox", number)?; + let trim = rectangle(doc, page, b"TrimBox", number)?; + let crop = inherited_page_entry(doc, id, b"CropBox")? + .map(|value| rectangle_value(value, number)) + .transpose()?; + let expected_media = [0.0, 0.0, expected.media_width_pt, expected.media_height_pt]; + let inset = expected.trim_inset_pt; + let expected_trim = [ + inset, + inset, + expected.media_width_pt - inset, + expected.media_height_pt - inset, + ]; + require( + same_box(media, expected_media), + "print_pdf.media_box_mismatch", + format!("page {number} has unexpected MediaBox {media:?}"), + )?; + require( + same_box(bleed, expected_media), + "print_pdf.bleed_box_mismatch", + format!("page {number} has unexpected BleedBox {bleed:?}"), + )?; + require( + same_box(trim, expected_trim), + "print_pdf.trim_box_mismatch", + format!("page {number} has unexpected TrimBox {trim:?}"), + )?; + if let Some(box_coordinates) = crop { + require( + same_box(box_coordinates, expected_media), + "print_pdf.crop_box_mismatch", + format!("page {number} has unexpected CropBox {box_coordinates:?}"), + )?; + } + let rotation = inherited_page_entry(doc, id, b"Rotate")? + .map_or(Ok(0_i64), Object::as_i64) + .map_err(|error| diagnostic("print_pdf.invalid_rotation", error.to_string()))?; + require( + rotation == i64::from(expected.rotation), + "print_pdf.rotation_mismatch", + format!( + "page {number} rotation {rotation} differs from expected {}", + expected.rotation + ), + )?; + + let resources = page + .get_deref(b"Resources", doc) + .and_then(Object::as_dict) + .map_err(|error| { + diagnostic( + "print_pdf.missing_image", + format!("page {number} has no resources: {error}"), + ) + })?; + require( + !resources.has(b"Font"), + "print_pdf.unexpected_content", + format!("page {number} has font resources"), + )?; + let xobjects = resources + .get_deref(b"XObject", doc) + .and_then(Object::as_dict) + .map_err(|error| { + diagnostic( + "print_pdf.missing_image", + format!("page {number} has no image XObject: {error}"), + ) + })?; + require( + xobjects.len() == 1, + "print_pdf.image_count_mismatch", + format!( + "page {number} has {} XObjects, expected one", + xobjects.len() + ), + )?; + let (image_name, image_ref) = xobjects.iter().next().expect("checked one XObject"); + let image_id = image_ref.as_reference().map_err(|_| { + diagnostic( + "print_pdf.invalid_image", + format!("page {number} image XObject is not indirect"), + ) + })?; + let image = doc + .get_object(image_id) + .and_then(Object::as_stream) + .map_err(|error| { + diagnostic( + "print_pdf.invalid_image", + format!("page {number} image stream is invalid: {error}"), + ) + })?; + require( + image.dict.get(b"Subtype").and_then(Object::as_name).ok() == Some(b"Image"), + "print_pdf.invalid_image", + format!("page {number} XObject is not an image"), + )?; + require( + !image.dict.has(b"SMask") && !image.dict.has(b"Mask"), + "print_pdf.unsupported_image", + format!("page {number} has image transparency or a mask"), + )?; + require( + ![ + b"Decode".as_slice(), + b"Interpolate", + b"Alternates", + b"ImageMask", + ] + .iter() + .any(|key| image.dict.has(key)), + "print_pdf.unsupported_image", + format!("page {number} changes the source image interpretation"), + )?; + let width = image + .dict + .get(b"Width") + .and_then(Object::as_i64) + .map_err(|error| diagnostic("print_pdf.invalid_image", error.to_string()))?; + let height = image + .dict + .get(b"Height") + .and_then(Object::as_i64) + .map_err(|error| diagnostic("print_pdf.invalid_image", error.to_string()))?; + require( + width == i64::from(expected.pixel_width) && height == i64::from(expected.pixel_height), + "print_pdf.image_dimensions_mismatch", + format!( + "page {number} image is {width}x{height}, expected {}x{}", + expected.pixel_width, expected.pixel_height + ), + )?; + require( + image + .dict + .get(b"BitsPerComponent") + .and_then(Object::as_i64) + .ok() + == Some(8), + "print_pdf.unsupported_image", + format!("page {number} image is not 8-bit"), + )?; + let color = image + .dict + .get(b"ColorSpace") + .and_then(Object::as_name) + .map_err(|_| { + diagnostic( + "print_pdf.unsupported_image", + format!("page {number} image has a non-device color space"), + ) + })?; + require( + color == expected.color_space.as_bytes(), + "print_pdf.color_space_mismatch", + format!( + "page {number} image color space differs from {}", + expected.color_space + ), + )?; + let filter = image + .dict + .get(b"Filter") + .and_then(Object::as_name) + .map_err(|_| { + diagnostic( + "print_pdf.unsupported_image", + format!("page {number} image has no single declared filter"), + ) + })?; + require( + filter == expected.image_filter.as_bytes(), + "print_pdf.image_filter_mismatch", + format!( + "page {number} image filter differs from {}", + expected.image_filter + ), + )?; + let stream_digest = format!("{:x}", Sha256::digest(&image.content)); + require( + stream_digest == expected.image_stream_sha256, + "print_pdf.page_order_mismatch", + format!("page {number} image stream differs from the ordered source"), + )?; + + let content_ids = doc.get_page_contents(id); + require( + content_ids.len() == 1, + "print_pdf.unexpected_content", + format!("page {number} needs exactly one content stream"), + )?; + let content_bytes = doc + .get_page_content_with_limit(id, MAX_PAGE_CONTENT_BYTES) + .map_err(|error| diagnostic("print_pdf.invalid_content", error.to_string()))?; + let content = Content::decode_strict(&content_bytes) + .map_err(|error| diagnostic("print_pdf.invalid_content", error.to_string()))?; + let mut depth = 0_i32; + let mut matrix = None; + let mut draw_count = 0; + for operation in content.operations { + match operation.operator.as_str() { + "q" if operation.operands.is_empty() => depth += 1, + "Q" if operation.operands.is_empty() && depth > 0 => depth -= 1, + "cm" if operation.operands.len() == 6 && depth == 1 && matrix.is_none() => { + matrix = Some([ + parse_number(&operation.operands[0])?, + parse_number(&operation.operands[1])?, + parse_number(&operation.operands[2])?, + parse_number(&operation.operands[3])?, + parse_number(&operation.operands[4])?, + parse_number(&operation.operands[5])?, + ]); + } + "Do" if operation.operands.len() == 1 && depth == 1 && matrix.is_some() => { + let name = operation.operands[0].as_name().map_err(|_| { + diagnostic( + "print_pdf.unexpected_content", + format!("page {number} has an invalid image draw"), + ) + })?; + require( + name == image_name, + "print_pdf.unexpected_content", + format!("page {number} draws an unrecognized XObject"), + )?; + draw_count += 1; + } + _ => { + return Err(diagnostic( + "print_pdf.unexpected_content", + format!( + "page {number} uses unsupported drawing operation {}", + operation.operator + ), + )) + } + } + } + require( + depth == 0 && draw_count == 1, + "print_pdf.unexpected_content", + format!("page {number} does not draw exactly one balanced image"), + )?; + let matrix = matrix.ok_or_else(|| { + diagnostic( + "print_pdf.unexpected_content", + format!("page {number} has no image draw matrix"), + ) + })?; + let expected_matrix = [ + expected.media_width_pt, + 0.0, + 0.0, + expected.media_height_pt, + 0.0, + 0.0, + ]; + require( + matrix + .iter() + .zip(expected_matrix) + .all(|(actual, expected)| (actual - expected).abs() <= BOX_TOLERANCE_PT), + "print_pdf.image_placement_mismatch", + format!("page {number} image is not drawn full bleed: {matrix:?}"), + )?; + Ok(PrintPdfPageEvidence { + page_number: number, + media_box_pt: media, + bleed_box_pt: bleed, + trim_box_pt: trim, + crop_box_pt: crop, + rotation: rotation as u16, + pixel_width: width as u32, + pixel_height: height as u32, + color_space: String::from_utf8_lossy(color).into_owned(), + image_filter: String::from_utf8_lossy(filter).into_owned(), + image_stream_sha256: stream_digest, + image_draw_matrix: matrix, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lopdf::{dictionary, Stream}; + + fn expectation(image_data: &[u8]) -> ExpectedPrintPage { + ExpectedPrintPage { + media_width_pt: 100.0, + media_height_pt: 100.0, + trim_inset_pt: 5.0, + pixel_width: 2, + pixel_height: 2, + rotation: 0, + color_space: "DeviceRGB".to_string(), + image_filter: "FlateDecode".to_string(), + image_stream_sha256: format!("{:x}", Sha256::digest(image_data)), + } + } + + // Structurally representative synthetic PDFs; deliberately fake image + // compressed bytes are sufficient for this independent object inspector. + // End-to-end provider tests must inspect actual PNG/JPEG-derived streams. + fn fixture( + image_data: &[&[u8]], + mutate: impl FnOnce(&mut Document, &[ObjectId], &[ObjectId]), + ) -> tempfile::NamedTempFile { + let mut document = Document::with_version("1.4"); + let pages_id = document.new_object_id(); + let mut page_ids = Vec::new(); + let mut image_ids = Vec::new(); + for data in image_data { + let image_id = document.add_object(Stream::new( + dictionary! { + "Type" => "XObject", + "Subtype" => "Image", + "Width" => 2, + "Height" => 2, + "ColorSpace" => "DeviceRGB", + "BitsPerComponent" => 8, + "Filter" => "FlateDecode", + }, + data.to_vec(), + )); + image_ids.push(image_id); + let content_id = document.add_object(Stream::new( + dictionary! {}, + b"q 100 0 0 100 0 0 cm /Im0 Do Q".to_vec(), + )); + let page_id = document.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "Contents" => content_id, + "Resources" => dictionary! { + "XObject" => dictionary! { "Im0" => image_id }, + }, + "MediaBox" => vec![0.into(), 0.into(), 100.into(), 100.into()], + "BleedBox" => vec![0.into(), 0.into(), 100.into(), 100.into()], + "TrimBox" => vec![5.into(), 5.into(), 95.into(), 95.into()], + }); + page_ids.push(page_id); + } + document.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => page_ids.iter().copied().map(Object::from).collect::>(), + "Count" => page_ids.len() as i64, + }), + ); + let catalog_id = document.add_object(dictionary! { + "Type" => "Catalog", + "Pages" => pages_id, + }); + document.trailer.set("Root", catalog_id); + mutate(&mut document, &page_ids, &image_ids); + let file = tempfile::NamedTempFile::new().unwrap(); + document.save(file.path()).unwrap(); + file + } + + fn code(error: &anyhow::Error) -> &str { + error + .downcast_ref::() + .expect("typed inspector diagnostic") + .code + } + + #[test] + fn ordered_images_and_print_boxes_are_inspected() { + let first = b"synthetic image one"; + let second = b"synthetic image two"; + let pdf = fixture(&[first, second], |_, _, _| {}); + let report = + inspect_print_pdf(pdf.path(), &[expectation(first), expectation(second)]).unwrap(); + assert_eq!(report.page_count, 2); + assert_eq!(report.pages[0].trim_box_pt, [5.0, 5.0, 95.0, 95.0]); + assert_eq!( + report.pages[1].image_stream_sha256, + expectation(second).image_stream_sha256 + ); + assert_eq!( + report.pages[0].image_draw_matrix, + [100.0, 0.0, 0.0, 100.0, 0.0, 0.0] + ); + assert_eq!(report.sha256.len(), 64); + assert_eq!(report.schema, PRINT_PDF_INSPECTION_SCHEMA_V1); + } + + #[test] + fn same_size_pages_in_wrong_order_are_refused() { + let first = b"synthetic image one"; + let second = b"synthetic image two"; + let pdf = fixture(&[first, second], |_, _, _| {}); + let error = + inspect_print_pdf(pdf.path(), &[expectation(second), expectation(first)]).unwrap_err(); + assert_eq!(code(&error), "print_pdf.page_order_mismatch"); + } + + #[test] + fn page_count_and_geometry_mismatches_are_refused() { + let pdf = fixture(&[b"page"], |_, _, _| {}); + let input = expectation(b"page"); + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[input.clone(), input.clone()]).unwrap_err()), + "print_pdf.page_count_mismatch" + ); + + let mut wrong_trim = input.clone(); + wrong_trim.trim_inset_pt = 6.0; + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[wrong_trim]).unwrap_err()), + "print_pdf.trim_box_mismatch" + ); + + let mut wrong_pixels = input; + wrong_pixels.pixel_width = 3; + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[wrong_pixels]).unwrap_err()), + "print_pdf.image_dimensions_mismatch" + ); + } + + #[test] + fn inherited_rotation_and_crop_cannot_hide_on_page_tree() { + let rotated = fixture(&[b"page"], |document, page_ids, _| { + let parent = document + .get_dictionary(page_ids[0]) + .unwrap() + .get(b"Parent") + .unwrap() + .as_reference() + .unwrap(); + document + .get_dictionary_mut(parent) + .unwrap() + .set("Rotate", 90); + }); + assert_eq!( + code(&inspect_print_pdf(rotated.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.rotation_mismatch" + ); + + let cropped = fixture(&[b"page"], |document, page_ids, _| { + let parent = document + .get_dictionary(page_ids[0]) + .unwrap() + .get(b"Parent") + .unwrap() + .as_reference() + .unwrap(); + document + .get_dictionary_mut(parent) + .unwrap() + .set("CropBox", vec![1.into(), 1.into(), 99.into(), 99.into()]); + }); + assert_eq!( + code(&inspect_print_pdf(cropped.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.crop_box_mismatch" + ); + } + + #[test] + fn unsupported_images_and_overlay_drawing_are_refused() { + let pdf = fixture(&[b"page"], |document, _, image_ids| { + document + .get_object_mut(image_ids[0]) + .unwrap() + .as_stream_mut() + .unwrap() + .dict + .set("ColorSpace", "DeviceCMYK"); + }); + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.color_space_mismatch" + ); + + let pdf = fixture(&[b"page"], |document, _, image_ids| { + document + .get_object_mut(image_ids[0]) + .unwrap() + .as_stream_mut() + .unwrap() + .dict + .set("Decode", vec![1.into(), 0.into()]); + }); + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.unsupported_image" + ); + + let pdf = fixture(&[b"page"], |document, page_ids, _| { + let page = document.get_dictionary(page_ids[0]).unwrap(); + let content_id = page.get(b"Contents").unwrap().as_reference().unwrap(); + document + .get_object_mut(content_id) + .unwrap() + .as_stream_mut() + .unwrap() + .set_content(b"q 100 0 0 100 0 0 cm /Im0 Do 0 0 m 1 1 l S Q".to_vec()); + }); + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.unexpected_content" + ); + } + + #[test] + fn active_pdf_catalog_content_is_refused() { + let pdf = fixture(&[b"page"], |document, _, _| { + document.catalog_mut().unwrap().set( + "OpenAction", + dictionary! { + "S" => "URI", + "URI" => Object::string_literal("https://example.invalid"), + }, + ); + }); + assert_eq!( + code(&inspect_print_pdf(pdf.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.unexpected_content" + ); + } + + #[test] + fn malformed_and_oversized_inputs_are_refused_before_acceptance() { + let invalid = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(invalid.path(), b"%PDF-not-a-real-document").unwrap(); + assert_eq!( + code(&inspect_print_pdf(invalid.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.invalid_structure" + ); + + let oversized = tempfile::NamedTempFile::new().unwrap(); + oversized + .as_file() + .set_len(MAX_PRINT_PDF_BYTES + 1) + .unwrap(); + assert_eq!( + code(&inspect_print_pdf(oversized.path(), &[expectation(b"page")]).unwrap_err()), + "print_pdf.byte_limit" + ); + } +} diff --git a/crates/renderflow-core/src/process.rs b/crates/renderflow-core/src/process.rs index 1f3b098..68254b1 100644 --- a/crates/renderflow-core/src/process.rs +++ b/crates/renderflow-core/src/process.rs @@ -312,6 +312,7 @@ pub struct ProcessExpectedOutput { kind: ExpectedOutputKind, require_non_empty: bool, require_change: bool, + max_bytes: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -328,6 +329,7 @@ impl ProcessExpectedOutput { kind: ExpectedOutputKind::Any, require_non_empty: false, require_change: false, + max_bytes: None, } } @@ -337,6 +339,7 @@ impl ProcessExpectedOutput { kind: ExpectedOutputKind::File, require_non_empty: false, require_change: false, + max_bytes: None, } } @@ -346,6 +349,7 @@ impl ProcessExpectedOutput { kind: ExpectedOutputKind::Directory, require_non_empty: false, require_change: false, + max_bytes: None, } } @@ -359,6 +363,13 @@ impl ProcessExpectedOutput { self } + /// Stop a command whose declared output grows beyond this file-size bound. + /// Polled while the process is running, then checked once more after exit. + pub fn max_bytes(mut self, limit: u64) -> Self { + self.max_bytes = Some(limit); + self + } + pub fn path(&self) -> &Path { &self.path } @@ -680,6 +691,18 @@ impl ProcessExecutor { }; let termination = loop { + if request.expected_outputs.iter().any(|expected| { + expected.max_bytes.is_some_and(|limit| { + fs::metadata(&expected.path).is_ok_and(|metadata| metadata.len() > limit) + }) + }) { + terminate_process_tree(&mut child, request.tree_mode).map_err(|error| { + ProcessError::Io(redactor.redact(&format!( + "failed to terminate process after output limit: {error}" + ))) + })?; + break ProcessTermination::OutputLimitExceeded; + } if request .cancellation .as_ref() @@ -848,6 +871,7 @@ pub enum ProcessTermination { Signaled, TimedOut, Cancelled, + OutputLimitExceeded, } impl ProcessTermination { @@ -988,6 +1012,12 @@ impl ProcessResult { ProcessTermination::Cancelled => { format!("Command `{}` was cancelled", self.command_display) } + ProcessTermination::OutputLimitExceeded => { + format!( + "Command `{}` exceeded its declared output byte limit", + self.command_display + ) + } }; let stderr = self.stderr.diagnostic_text(); @@ -1140,6 +1170,15 @@ impl ProcessExpectedOutput { self.path.display() )); } + if self + .max_bytes + .is_some_and(|limit| after.len.is_some_and(|len| len > limit)) + { + return Some(format!( + "expected output '{}' exceeded its declared byte limit", + self.path.display() + )); + } if self.require_change && &after == before { return Some(format!( "expected output '{}' was not changed by the process", @@ -1504,6 +1543,32 @@ mod tests { assert!(started.elapsed() < Duration::from_secs(2)); } + #[cfg(unix)] + #[test] + fn declared_output_limit_terminates_process_before_timeout() { + let directory = tempfile::tempdir().unwrap(); + let output = directory.path().join("oversized.bin"); + let started = Instant::now(); + let result = ProcessExecutor::new() + .execute( + ProcessRequest::shell("sh") + .args([ + "-c".to_string(), + "printf '%0500d' 1 > \"$1\"; sleep 5".to_string(), + "sh".to_string(), + output.display().to_string(), + ]) + .expect_output(ProcessExpectedOutput::file(&output).max_bytes(128)) + .timeout(Duration::from_secs(3)), + ) + .unwrap(); + assert_eq!( + result.termination(), + ProcessTermination::OutputLimitExceeded + ); + assert!(started.elapsed() < Duration::from_secs(2)); + } + #[cfg(unix)] #[test] fn cancellation_terminates_process_tree() { diff --git a/crates/renderflow-core/src/spec.rs b/crates/renderflow-core/src/spec.rs index 7a4be59..f26d98a 100644 --- a/crates/renderflow-core/src/spec.rs +++ b/crates/renderflow-core/src/spec.rs @@ -505,6 +505,50 @@ pub struct ExecutionPolicy { /// Named hygiene policy applied to the complete selected publication bundle. #[serde(default)] pub hygiene_policy: Option, + /// Exact, bounded print-interior route for an ordered PNG/JPEG collection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub print_pdf_interior: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrintPdfInteriorPolicy { + pub executable: String, + pub provider_version: String, + pub box_policy: String, + pub rotation: String, + pub scaling: String, + pub color_policy: String, + pub max_pages: usize, + pub max_input_bytes: u64, + pub max_output_bytes: u64, + pub timeout_seconds: u64, +} + +impl PrintPdfInteriorPolicy { + pub fn validate(&self) -> Result<()> { + if self.executable.trim().is_empty() || self.executable.contains('\0') { + anyhow::bail!("print_pdf.provider: executable must name a local img2pdf command"); + } + if self.provider_version != "0.6.3" { + anyhow::bail!("print_pdf.provider: the proven route requires img2pdf version 0.6.3"); + } + if self.box_policy != "media_bleed_trim_inset" + || self.rotation != "none" + || self.scaling != "fit" + || self.color_policy != "preserve_rgb_gray" + { + anyhow::bail!("print_pdf.policy: supported route requires box_policy=media_bleed_trim_inset, rotation=none, scaling=fit, color_policy=preserve_rgb_gray"); + } + if !(1..=1000).contains(&self.max_pages) + || !(1..=536_870_912).contains(&self.max_input_bytes) + || !(1..=536_870_912).contains(&self.max_output_bytes) + || !(1..=3600).contains(&self.timeout_seconds) + { + anyhow::bail!("print_pdf.bounds: max_pages (1..1000), input/output bytes (1..512 MiB), and timeout_seconds (1..3600) are required"); + } + Ok(()) + } } impl Default for ExecutionPolicy { @@ -526,6 +570,7 @@ impl Default for ExecutionPolicy { publication_policy: None, redaction_policy: None, hygiene_policy: None, + print_pdf_interior: None, } } } @@ -648,6 +693,15 @@ impl SpecV2 { format!("expected schema '{SPEC_V2_ID}', got '{}'", self.schema), )); } + if let Some(print) = &self.execution.print_pdf_interior { + if let Err(error) = print.validate() { + diagnostics.push(SpecDiagnostic::new( + "$.execution.print_pdf_interior", + "print_pdf.policy.invalid", + error.to_string(), + )); + } + } if self.sources.is_empty() { diagnostics.push(SpecDiagnostic::new( @@ -1726,6 +1780,22 @@ pub fn json_schema() -> Value { "allow_unavailable": {"type": "boolean", "default": false} } }, + "printPdfInterior": { + "type": "object", "additionalProperties": false, + "required": ["executable", "provider_version", "box_policy", "rotation", "scaling", "color_policy", "max_pages", "max_input_bytes", "max_output_bytes", "timeout_seconds"], + "properties": { + "executable": {"type": "string", "minLength": 1}, + "provider_version": {"const": "0.6.3"}, + "box_policy": {"const": "media_bleed_trim_inset"}, + "rotation": {"const": "none"}, + "scaling": {"const": "fit"}, + "color_policy": {"const": "preserve_rgb_gray"}, + "max_pages": {"type": "integer", "minimum": 1, "maximum": 1000}, + "max_input_bytes": {"type": "integer", "minimum": 1, "maximum": 536870912}, + "max_output_bytes": {"type": "integer", "minimum": 1, "maximum": 536870912}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 3600} + } + }, "executionPolicy": { "type": "object", "additionalProperties": false, @@ -1751,6 +1821,7 @@ pub fn json_schema() -> Value { "publication_policy": {"type": ["string", "null"]}, "redaction_policy": {"type": ["string", "null"]}, "hygiene_policy": {"anyOf": [{"$ref": "#/$defs/stableId"}, {"type": "null"}]} + ,"print_pdf_interior": {"anyOf": [{"$ref": "#/$defs/printPdfInterior"}, {"type": "null"}]} } }, "outputLayout": { diff --git a/crates/renderflow-core/src/toolchain.rs b/crates/renderflow-core/src/toolchain.rs index 4601f70..caf5171 100644 --- a/crates/renderflow-core/src/toolchain.rs +++ b/crates/renderflow-core/src/toolchain.rs @@ -223,9 +223,32 @@ impl ToolVersionRequirement { struct NumericVersion([u64; 3]); fn parse_numeric_version(text: &str) -> Option { - for token in text.split(|character: char| !(character.is_ascii_digit() || character == '.')) { + let bytes = text.as_bytes(); + for (start, byte) in bytes.iter().enumerate() { + if !byte.is_ascii_digit() { + continue; + } + // A number embedded in a product name is not its version: the `2` in + // `img2pdf 0.6.3` used to masquerade as version 2.0.0. Accept a `v` + // prefix only when that prefix begins a separate token. + if start > 0 { + let previous = bytes[start - 1]; + let prefixed = matches!(previous, b'v' | b'V') + && (start == 1 + || !(bytes[start - 2].is_ascii_alphanumeric() || bytes[start - 2] == b'_')); + if !prefixed + && (previous.is_ascii_alphanumeric() || previous == b'_' || previous == b'.') + { + continue; + } + } + let end = bytes[start..] + .iter() + .position(|byte| !(byte.is_ascii_digit() || *byte == b'.')) + .map_or(bytes.len(), |length| start + length); + let token = &text[start..end]; let token = token.trim_matches('.'); - if token.is_empty() || !token.chars().next().is_some_and(|c| c.is_ascii_digit()) { + if token.is_empty() { continue; } let mut parts = [0_u64; 3]; @@ -1285,6 +1308,46 @@ mod tests { ); } + #[test] + fn version_parser_skips_digits_embedded_in_executable_names() { + assert_eq!( + parse_numeric_version("img2pdf 0.6.3"), + Some(NumericVersion([0, 6, 3])) + ); + assert_eq!(parse_numeric_version("img2pdf"), None); + assert_eq!( + parse_numeric_version("ffmpeg version 7.1.1 Copyright"), + Some(NumericVersion([7, 1, 1])) + ); + assert_eq!( + parse_numeric_version("tool v1.94.0"), + Some(NumericVersion([1, 94, 0])) + ); + assert_eq!( + parse_numeric_version("tool-v1.2.3"), + Some(NumericVersion([1, 2, 3])) + ); + } + + #[test] + fn img2pdf_observed_version_satisfies_exact_patch_requirement() { + let mut descriptor = test_descriptor("tool.img2pdf", "img2pdf"); + descriptor.version.min_inclusive = Some("0.6.3".to_string()); + descriptor.version.max_exclusive = Some("0.6.4".to_string()); + let mut registry = ToolRegistry::new(); + registry.register(descriptor).unwrap(); + let probe = FakeProbe::default().with( + "img2pdf", + ProcessProbeStatus::Available, + Some("img2pdf 0.6.3"), + ); + let inventory = + registry.assess_all_with(&probe, &ToolRuntimeContext::for_platform("linux", "x86_64")); + let tool = inventory.get("tool.img2pdf").unwrap(); + assert_eq!(tool.status, ToolAvailabilityStatus::Available); + assert_eq!(tool.normalized_version.as_deref(), Some("0.6.3")); + } + #[test] fn doctor_states_distinguish_service_credential_config_and_platform() { let mut registry = ToolRegistry::new(); diff --git a/crates/renderflow-core/tests/fixtures/print-pdf/README.md b/crates/renderflow-core/tests/fixtures/print-pdf/README.md new file mode 100644 index 0000000..7c50bb8 --- /dev/null +++ b/crates/renderflow-core/tests/fixtures/print-pdf/README.md @@ -0,0 +1,13 @@ +# Synthetic print pages + +Four 100 × 100 pixel RGB images generated for tests with Pillow 12.3.0. The +red and blue pages are authored test data, with no photographic or publication +assets. PNG files are unprofiled, noninterlaced RGB; JPEG files are baseline +JFIF RGB without EXIF. The print fixture declares a 90 mm square trim with +5 mm bleed, yielding a 100 mm square media/image area. The ordered collection +tests also create a 44-page recipe by copying these two immutable byte patterns +into distinct root-relative source paths. + +The optional real-provider test requires `RENDERFLOW_TEST_IMG2PDF` to name an +installed `img2pdf` executable. The default suite exercises preflight and +failure cases with synthetic shims and does not require that external tool. diff --git a/crates/renderflow-core/tests/fixtures/print-pdf/page-001.jpg b/crates/renderflow-core/tests/fixtures/print-pdf/page-001.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f23bad8e1c16417cff5106c24c6583179a309641 GIT binary patch literal 826 zcmex=iF;N$`UAd82aiwDF383NJD#LCRf%Eivc4pu@E@&5pWAO}MVLkcsa5(ASU zBeNjm|04|YKzFi&odL?6mQqXwbzED#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2 zZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx#W#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8 z+42=DS8dw7W$U)>J9h3mboj{8W5-XNJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2 z`tAFVpT9u3cne5k^_L*fUreAlU=!0ld(M2vX6_bamA3wQP+^h|8D{S$XWt6 literal 0 HcmV?d00001 diff --git a/crates/renderflow-core/tests/fixtures/print-pdf/page-001.png b/crates/renderflow-core/tests/fixtures/print-pdf/page-001.png new file mode 100644 index 0000000000000000000000000000000000000000..9247b5ad4abbcadeeb2bdf101dd5fda2c121c92f GIT binary patch literal 293 zcmeAS@N?(olHy`uVBq!ia0vp^DImiF;N$`UAd82aiwDF383NJD#LCRf%Eivc4pu@E@&5pWAO}MVLkcsa5(ASU zBeNjm|04|YKzFi&odL?6mQqXwbzED#l4gO`Kd};u4Zls%q*Qnp!5NX66=_R?aT2 zZtfnQUcn)uVc`*xQOPN(Y3Ui6S;Zx#W#tu>Rn0A}ZS5VMU6UqHnL2IyjG40*Enc#8 z+42=DS8dw7W$U)>J9h3mboj{8W5-XNJay^vm8;jT-?(|};iJb-o<4j2;^nK4pFV&2 z`tAFVpT9u3cne5k^_L*fUreAlU=!0ld(M2vX6_bamA3 T8tcCZ{Nd49)WtBA@&B6uR|)~6 literal 0 HcmV?d00001 diff --git a/crates/renderflow-core/tests/fixtures/print-pdf/page-002.png b/crates/renderflow-core/tests/fixtures/print-pdf/page-002.png new file mode 100644 index 0000000000000000000000000000000000000000..64e598eb837a7094c5bdd3c9106c2fa88233d959 GIT binary patch literal 292 zcmeAS@N?(olHy`uVBq!ia0vp^DIm>, +} + +impl Fixture { + fn new(format: &str, count: usize, executable: &Path, version: &str) -> Self { + let dir = tempfile::tempdir().unwrap(); + let (extension, media_type, pages) = match format { + "png" => ("png", "image/png", [PNG_FIRST, PNG_SECOND]), + "jpeg" => ("jpg", "image/jpeg", [JPEG_FIRST, JPEG_SECOND]), + other => panic!("unsupported test format: {other}"), + }; + let mut sources = String::new(); + let mut member_ids = Vec::new(); + let mut originals = Vec::new(); + for index in 0..count { + let id = format!("source.page{index:03}"); + let locator = format!("page-{index:03}.{extension}"); + let bytes = pages[index % pages.len()]; + fs::write(dir.path().join(&locator), bytes).unwrap(); + let digest = format!("{:x}", Sha256::digest(bytes)); + sources.push_str(&format!( + " - id: {id}\n path: {locator}\n format: {format}\n media_type: {media_type}\n sha256: \"{digest}\"\n geometry: {{ width: 90, height: 90, unit: mm, bleed: 5 }}\n" + )); + member_ids.push(id); + originals.push(bytes.to_vec()); + } + sources.push_str(&format!( + " - id: source.interior\n kind: collection\n members: [{}]\n", + member_ids.join(", ") + )); + let config = dir.path().join("renderflow.yaml"); + fs::write( + &config, + format!( + "schema: renderflow/v2\nsources:\n{sources}targets:\n exact:\n - id: target.interior\n role: interior\n format: pdf\n requirement: required\nexecution:\n print_pdf_interior:\n executable: \"{}\"\n provider_version: \"{version}\"\n box_policy: media_bleed_trim_inset\n rotation: none\n scaling: fit\n color_policy: preserve_rgb_gray\n max_pages: 50\n max_input_bytes: 1000000\n max_output_bytes: 5000000\n timeout_seconds: 2\noutput:\n bundle_root: \"{}\"\n naming_template: \"{{source.id}}/{{target.role}}.{{ext}}\"\n", + executable.display(), + dir.path().join("dist").display() + ), + ) + .unwrap(); + Self { + dir, + config, + originals, + } + } + + fn source(&self, index: usize, extension: &str) -> PathBuf { + self.dir.path().join(format!("page-{index:03}.{extension}")) + } + + fn rewrite(&self, from: &str, to: &str) { + let spec = fs::read_to_string(&self.config).unwrap(); + assert!( + spec.contains(from), + "expected test material was missing: {from}" + ); + fs::write(&self.config, spec.replacen(from, to, 1)).unwrap(); + } +} + +fn error_for(path: &Path) -> String { + format!( + "{:#}", + resolve(PlanningRequest::from_path(path)).err().unwrap() + ) +} + +fn unavailable_provider(dir: &TempDir) -> PathBuf { + dir.path().join("missing-img2pdf-executable") +} + +fn verify_unchanged(fixture: &Fixture, extension: &str) { + for (index, bytes) in fixture.originals.iter().enumerate() { + assert_eq!(fs::read(fixture.source(index, extension)).unwrap(), *bytes); + } +} + +#[test] +fn absent_provider_keeps_plan_inspectable_and_refuses_execution() { + for (format, extension) in [("png", "png"), ("jpeg", "jpg")] { + let temporary = tempfile::tempdir().unwrap(); + let fixture = Fixture::new(format, 2, &unavailable_provider(&temporary), "0.6.3"); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + assert!(resolved.plan().toolchain.is_none()); + assert_eq!( + resolved + .plan() + .source_collection + .as_ref() + .unwrap() + .members + .len(), + 2 + ); + assert!(resolved.plan().edges.iter().any(|edge| { + edge.capability_id.as_deref() == Some("publication.generate.pdf.interior") + })); + let planned = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + true, + ) + .unwrap(); + assert_eq!(planned.run_manifest.state, RunState::Planned); + assert!(planned.manifest_path.is_none()); + assert!(!fixture.dir.path().join("dist").exists()); + let result = execute(resolved, false).unwrap(); + assert_eq!(result.run_manifest.state, RunState::Failed); + assert!(result.outputs.is_empty()); + assert!(result.run_manifest.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("print_pdf.provider.unavailable") + })); + assert!(Path::new(result.manifest_path.as_ref().unwrap()).is_file()); + verify_unchanged(&fixture, extension); + } +} + +#[test] +fn print_policy_rejects_geometry_aspect_bounds_and_stale_declarations() { + let temporary = tempfile::tempdir().unwrap(); + let provider = unavailable_provider(&temporary); + for (before, after, expected) in [ + ("bleed: 5", "bleed: 0", "print_pdf.geometry.mixed"), + ( + "width: 90, height: 90", + "width: 91, height: 91", + "print_pdf.geometry.mixed", + ), + ("max_pages: 50", "max_pages: 1", "print_pdf.bounds"), + ( + "max_input_bytes: 1000000", + "max_input_bytes: 1", + "print_pdf.bounds", + ), + ("role: interior", "role: cover", "print_pdf.target"), + ] { + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + fixture.rewrite(before, after); + let error = error_for(&fixture.config); + assert!(error.contains(expected), "expected {expected}: {error}"); + assert!(!fixture.dir.path().join("dist").exists()); + } + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + fixture.rewrite("width: 90", "width: 100"); + assert!(error_for(&fixture.config).contains("print_pdf.scaling.aspect")); + + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + fixture.rewrite("sha256: \"", "sha256: \"f"); + assert!(error_for(&fixture.config).contains("collection.member.digest")); + + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + fixture.rewrite("page-000.png", "../page-000.png"); + assert!(error_for(&fixture.config).contains("collection.member.locator")); + + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + fixture.rewrite("page-000.png", "missing.png"); + assert!(error_for(&fixture.config).contains("collection.member.missing")); +} + +#[test] +fn cancellation_before_transform_has_no_pdf_and_keeps_source_immutable() { + let temporary = tempfile::tempdir().unwrap(); + let fixture = Fixture::new("png", 2, &unavailable_provider(&temporary), "0.6.3"); + let result = cancelled(resolve(PlanningRequest::from_path(&fixture.config)).unwrap()).unwrap(); + assert_eq!(result.run_manifest.state, RunState::Cancelled); + assert!(result.outputs.is_empty()); + assert!(Path::new(result.manifest_path.as_ref().unwrap()).is_file()); + verify_unchanged(&fixture, "png"); +} + +#[cfg(unix)] +fn shim(root: &Path, action: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = root.join("fake-img2pdf"); + fs::write( + &path, + format!( + "#!/bin/sh\nif [ \"${{1:-}}\" = \"--version\" ]; then\n printf \"img2pdf 0.6.3\\n\"\n exit 0\nfi\n{action}\n" + ), + ) + .unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o755)).unwrap(); + path +} + +#[cfg(unix)] +#[test] +fn stale_source_is_refused_even_with_available_provider() { + let temporary = tempfile::tempdir().unwrap(); + let provider = shim(temporary.path(), "exit 99"); + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + assert!( + resolved.plan().toolchain.is_some(), + "{:?}", + resolved.plan().diagnostics + ); + fs::write(fixture.source(1, "png"), b"changed after planning").unwrap(); + let result = execute(resolved, false).unwrap(); + assert_eq!(result.run_manifest.state, RunState::Failed); + assert!(result.outputs.is_empty()); + assert!(result + .run_manifest + .diagnostics + .iter() + .any(|diagnostic| { diagnostic.code == "execution.source_changed_after_intake" })); +} + +#[cfg(unix)] +#[test] +fn nonzero_timeout_and_invalid_provider_outputs_never_publish_a_pdf() { + for (action, code) in [ + ("exit 23", "print_pdf.provider.nonzero"), + ("sleep 4", "print_pdf.provider.timeout"), + ("exit 0", "print_pdf.provider.output"), + ("while [ \"$#\" -gt 0 ]; do if [ \"$1\" = \"--output\" ]; then shift; printf \"invalid pdf\" > \"$1\"; exit 0; fi; shift; done; exit 24", "print_pdf.invalid_structure"), + ] { + let temporary = tempfile::tempdir().unwrap(); + let provider = shim(temporary.path(), action); + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + let result = execute(resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), false).unwrap(); + assert_ne!(result.run_manifest.state, RunState::Complete); + assert!(result.outputs.is_empty(), "provider action published output: {action}"); + assert!(result.run_manifest.steps.iter().any(|step| { + step.state == StepState::Failed + && step.diagnostics.iter().any(|diagnostic| diagnostic.code == code) + }), "missing typed failure {code} for {action}: {:?}", result.run_manifest.steps); + assert!(Path::new(result.manifest_path.as_ref().unwrap()).is_file()); + verify_unchanged(&fixture, "png"); + } +} + +#[cfg(unix)] +#[test] +fn cancelling_a_running_provider_records_cancelled_step_without_pdf() { + let temporary = tempfile::tempdir().unwrap(); + let marker = temporary.path().join("provider-started"); + let provider = shim( + temporary.path(), + &format!( + "printf \"started\\n\" > \"{}\"\nsleep 4\nexit 0", + marker.display() + ), + ); + let fixture = Fixture::new("png", 2, &provider, "0.6.3"); + let cancellation = Arc::new(AtomicBool::new(false)); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)) + .unwrap() + .with_cancellation_flag(Arc::clone(&cancellation)); + let execution = thread::spawn(move || execute(resolved, false).unwrap()); + let deadline = Instant::now() + Duration::from_secs(3); + while !marker.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(20)); + } + assert!(marker.exists(), "provider did not start before its timeout"); + cancellation.store(true, Ordering::SeqCst); + let result = execution.join().unwrap(); + assert_eq!(result.run_manifest.state, RunState::Cancelled); + assert!(result.outputs.is_empty()); + assert!(result.run_manifest.steps.iter().any(|step| { + step.state == StepState::Cancelled + && step + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "print_pdf.provider.cancelled") + })); + assert!(Path::new(result.manifest_path.as_ref().unwrap()).is_file()); + verify_unchanged(&fixture, "png"); +} + +fn installed_provider() -> Option<(PathBuf, String)> { + let path = std::env::var_os("RENDERFLOW_TEST_IMG2PDF").map(PathBuf::from)?; + let output = Command::new(&path) + .arg("--version") + .output() + .unwrap_or_else(|error| { + panic!( + "RENDERFLOW_TEST_IMG2PDF={} cannot run: {error}", + path.display() + ) + }); + assert!( + output.status.success(), + "configured img2pdf --version failed" + ); + let line = String::from_utf8_lossy(&output.stdout); + let version = line + .split(|ch: char| !(ch.is_ascii_digit() || ch == '.')) + .find(|part| { + part.split('.').count() == 3 + && part.split('.').all(|number| { + !number.is_empty() && number.bytes().all(|byte| byte.is_ascii_digit()) + }) + }) + .unwrap_or_else(|| panic!("img2pdf emitted no numeric version: {line}")); + let numbers = version.split('.').collect::>(); + assert_eq!( + numbers.len(), + 3, + "img2pdf version must have three numbers: {line}" + ); + assert_eq!( + version, "0.6.3", + "the tested print route is pinned to img2pdf 0.6.3" + ); + Some((path, version.to_string())) +} + +#[test] +fn installed_img2pdf_proves_ordered_pdf_and_clean_build_determinism() { + let Some((provider, version)) = installed_provider() else { + eprintln!("set RENDERFLOW_TEST_IMG2PDF to run the real-provider PDF integration fixture"); + return; + }; + for (format, extension) in [("png", "png"), ("jpeg", "jpg")] { + let mut pdfs = Vec::new(); + for _ in 0..2 { + let fixture = Fixture::new(format, 2, &provider, &version); + let resolved = resolve(PlanningRequest::from_path(&fixture.config)).unwrap(); + assert!(resolved.plan().toolchain.is_some()); + let result = execute(resolved, false).unwrap(); + assert_eq!( + result.run_manifest.state, + RunState::Complete, + "{:?}", + result.run_manifest.diagnostics + ); + assert_eq!(result.outputs.len(), 1); + let pdf = fs::read(&result.outputs[0]).unwrap(); + assert!(pdf.starts_with(b"%PDF-")); + let artifacts = &result.run_manifest.artifact_manifest.artifacts; + let sources = artifacts + .iter() + .filter(|item| item.lifecycle == ArtifactRole::Source) + .collect::>(); + let interior = artifacts + .iter() + .find(|item| item.role == "interior") + .unwrap(); + assert_eq!(sources.len(), 2); + assert_eq!(sources[0].metadata["renderflow.collection.index"], 0); + assert_eq!(sources[1].metadata["renderflow.collection.index"], 1); + assert_eq!( + interior.sources, + sources + .iter() + .map(|item| item.artifact_id.clone()) + .collect::>() + ); + assert_eq!(interior.validation, ValidationState::Valid); + let inspection = &interior.metadata["renderflow.print_pdf.inspection"]; + assert_eq!(inspection["page_count"], 2); + assert_eq!(inspection["pages"].as_array().unwrap().len(), 2); + assert_ne!( + inspection["pages"][0]["image_stream_sha256"], + inspection["pages"][1]["image_stream_sha256"] + ); + verify_unchanged(&fixture, extension); + pdfs.push(pdf); + } + assert_eq!(pdfs[0], pdfs[1], "{format} clean builds differ"); + } + + let fixture = Fixture::new("png", 44, &provider, &version); + let result = execute( + resolve(PlanningRequest::from_path(&fixture.config)).unwrap(), + false, + ) + .unwrap(); + assert_eq!( + result.run_manifest.state, + RunState::Complete, + "{:?}", + result.run_manifest.diagnostics + ); + let interior = result + .run_manifest + .artifact_manifest + .artifacts + .iter() + .find(|item| item.role == "interior") + .unwrap(); + assert_eq!( + interior.metadata["renderflow.print_pdf.inspection"]["page_count"], + 44 + ); + assert_eq!(interior.sources.len(), 44); + verify_unchanged(&fixture, "png"); +} diff --git a/docs/user-guide/adapter-ecosystem.md b/docs/user-guide/adapter-ecosystem.md index f474a46..9885e97 100644 --- a/docs/user-guide/adapter-ecosystem.md +++ b/docs/user-guide/adapter-ecosystem.md @@ -48,7 +48,8 @@ registers an edge and executor. | Images/audio | FFmpeg; ImageMagick fallback | Integrated/experimental | Keep choices typed and delegates provenance-visible | | Video/subtitles | FFmpeg | Partial | Register only concrete implemented graph edges | | Archives | ZIP | Experimental | Normalize ordering and timestamps before promotion | -| Image-to-PDF | img2pdf | Experimental | Promote with collection/aggregation fixtures | +| Generic image-to-PDF | img2pdf | Experimental | Does not claim print-interior validation | +| Ordered print-interior PDF | img2pdf 0.6.3 | Integrated exact route | PNG or JPEG collection; inspect ordered streams and page boxes | | PDF processing | Ghostscript | Experimental | Require explicit licensing and fidelity policy | | Local image AI | Upscayl NCNN | Experimental | Require model identity, license evidence, and AI opt-in | | E-books | Calibre evaluation | Deferred to #344 | Integrate as ordinary providers | diff --git a/docs/user-guide/ordered-collections.md b/docs/user-guide/ordered-collections.md index 68ab0cf..9356515 100644 --- a/docs/user-guide/ordered-collections.md +++ b/docs/user-guide/ordered-collections.md @@ -4,8 +4,8 @@ A `renderflow/v2` collection names immutable local artifacts in the exact order collection-input transform receives them. The public `renderflow spec validate`, `renderflow build --dry-run`, `renderflow build`, and Rust `planning::{resolve, execute}` surfaces use the same canonical plan and run -evidence. This is the collection source boundary for later PDF and EPUB -exporters; this checkpoint does not ship those exporters. +evidence. The exact [print-interior PDF](print-interior-pdf.md) route now consumes +homogeneous PNG or JPEG page collections. Fixed-layout EPUB remains separate. ```yaml schema: renderflow/v2 @@ -85,5 +85,5 @@ rules. The currently supported canonical execution path requires all root collection members to share a format and the first edge to consume a collection. Mixed -media, same-format aggregation output, arbitrary root fan-out, PDF/EPUB -generation, and publication approval are outside this checkpoint. +media, same-format aggregation output, arbitrary root fan-out, fixed-layout +EPUB generation, and publication approval remain outside this checkpoint. diff --git a/docs/user-guide/print-interior-pdf.md b/docs/user-guide/print-interior-pdf.md new file mode 100644 index 0000000..5d03124 --- /dev/null +++ b/docs/user-guide/print-interior-pdf.md @@ -0,0 +1,90 @@ +# Print-interior PDF from ordered pages + +`publication.generate.pdf.interior` consumes one explicitly ordered, immutable +`renderflow/v2` collection of local PNG **or** JPEG pages. The canonical planner +and executor use a locally installed `img2pdf` 0.6.3 provider and independently +inspect its output before admitting it to the artifact store. This route creates +an interior candidate; it does not approve a print job or claim retailer +acceptance. + +```yaml +schema: renderflow/v2 +sources: + - id: source.page001 + path: pages/001.png + format: png + media_type: image/png + sha256: "<64 lowercase hex characters for the exact file bytes>" + geometry: { width: 50, height: 50, unit: mm, bleed: 3 } + - id: source.page002 + path: pages/002.png + format: png + media_type: image/png + sha256: "<64 lowercase hex characters for the exact file bytes>" + geometry: { width: 50, height: 50, unit: mm, bleed: 3 } + - id: source.pages + kind: collection + members: [source.page001, source.page002] +targets: + exact: + - id: target.interior + role: interior + format: pdf +execution: + print_pdf_interior: + executable: img2pdf + provider_version: "0.6.3" + box_policy: media_bleed_trim_inset + rotation: none + scaling: fit + color_policy: preserve_rgb_gray + max_pages: 44 + max_input_bytes: 268435456 + max_output_bytes: 268435456 + timeout_seconds: 120 +output: + bundle_root: dist +``` + +The geometry width and height are the **trim** size. `bleed` expands the +MediaBox equally on all sides; BleedBox equals MediaBox, and TrimBox is inset +by the declared bleed. Zero bleed must be stated explicitly. Every page must +have identical geometry and an image aspect ratio matching the full MediaBox +within 0.02 PDF points. The first route uses a single homogeneous PNG or JPEG +collection, no custom transform registry, no rotation, and a full-page fit +without crop or stretch. PNG supports 8-bit RGB or grayscale without alpha, +interlace, ICC, EXIF, or color intent metadata. JPEG supports decoded RGB or +grayscale without ICC, nonidentity EXIF orientation, or nonsquare pixel aspect. +The color policy proves unprofiled RGB or grayscale image bytes embedded in +the corresponding PDF device space; it does not claim calibrated color +reproduction. Unsupported or ambiguous inputs are refused. +Each image is limited to 128 MiB of source bytes and 512 MiB of decoded data, +in addition to the configured collection and PDF limits. + +Use `renderflow spec validate --config renderflow.yaml`, then +`renderflow build --config renderflow.yaml --dry-run`, then +`renderflow build --config renderflow.yaml`. The executable may be an absolute +path when tool installation is isolated; it must be a trusted local +`img2pdf` 0.6.3 binary. Renderflow passes direct argv with `--nodate`, the +internal PDF engine, explicit page/image sizes, box borders, fit and rotation. +The process has a timeout, cancellation, bounded capture, input/page limits, +and a monitored output-byte limit. `network: deny` is recorded as process +intent; it is not an operating-system network sandbox. This adapter makes no +network request itself. + +Planning preflights every source and freezes ordered IDs, locators, source +digests, geometry, decoded image properties, expected embedded image-stream +digests, and the exact provider/version configuration. Execution re-imports +each source and refuses changed bytes before running the provider. The PDF +inspector parses the resulting document and requires exactly one drawn image +per page, exact ordered stream digest, matching pixel dimensions and color +space, full-page placement, explicit page boxes, zero rotation, and exact page +count. It records PDF digest, page details, and ordered source lineage in run, +artifact, validation, provider/toolchain, and checkpoint evidence. Failed or +cancelled runs do not materialize an interior artifact. + +Publication contracts, when provided, must agree on geometry and color; an +`interior` output-role minimum image DPI is enforced. Font embedding and +arbitrary additional output-role validators are unsupported for this +image-only route. A separate print preflight and physical proof remain necessary +before any publishing or retailer submission. diff --git a/docs/user-guide/spec-v2-reference.md b/docs/user-guide/spec-v2-reference.md index 93c51e3..51936f6 100644 --- a/docs/user-guide/spec-v2-reference.md +++ b/docs/user-guide/spec-v2-reference.md @@ -101,6 +101,7 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `minimum_fidelity` | `number` / `null` | no | — | | `network` | `deny` / `allow` | no | `"deny"` | | `optimization` | `speed` / `quality` / `balanced` / `pareto` | no | `"balanced"` | +| `print_pdf_interior` | `object` | no | — | | `publication_policy` | `string` / `null` | no | — | | `redaction_policy` | `string` / `null` | no | — | | `reject_loss_classes` | `array` | no | `[]` | @@ -111,6 +112,21 @@ Spec v2 describes source intent, derivative selection, execution policy, and det | `transforms` | `allowDeny` | no | — | | `validation` | `validation` | no | — | +## Print-interior PDF policy + +| Field | Type | Required | Default | +| --- | --- | --- | --- | +| `box_policy` | `"media_bleed_trim_inset"` | yes | — | +| `color_policy` | `"preserve_rgb_gray"` | yes | — | +| `executable` | `string` | yes | — | +| `max_input_bytes` | `integer` | yes | — | +| `max_output_bytes` | `integer` | yes | — | +| `max_pages` | `integer` | yes | — | +| `provider_version` | `"0.6.3"` | yes | — | +| `rotation` | `"none"` | yes | — | +| `scaling` | `"fit"` | yes | — | +| `timeout_seconds` | `integer` | yes | — | + ## Output layout | Field | Type | Required | Default | diff --git a/docs/user-guide/tool-registry.md b/docs/user-guide/tool-registry.md index 1bbcf45..7ab1844 100644 --- a/docs/user-guide/tool-registry.md +++ b/docs/user-guide/tool-registry.md @@ -47,6 +47,7 @@ without editing this built-in catalog. | `video.transcode.whole_file` | `tool.handbrake` | | `image.convert` | `tool.imagemagick` | | `image.aggregate.pdf` | `tool.img2pdf` | +| `publication.generate.pdf.interior` | `tool.img2pdf` | | `data.json.transform` | `tool.jq` | | `ebook.convert.kepub` | `tool.kepubify` | | `publication.lulu.bookstore.preflight` | `tool.lulu-rules` | diff --git a/mkdocs.yml b/mkdocs.yml index a7c03b4..9abaa9c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Configuration: user-guide/configuration.md - Spec v2 Reference: user-guide/spec-v2-reference.md - Ordered Collections: user-guide/ordered-collections.md + - Print-interior PDF: user-guide/print-interior-pdf.md - Supported Formats: user-guide/supported-formats.md - Tool Registry: user-guide/tool-registry.md - Adapter Ecosystem: user-guide/adapter-ecosystem.md diff --git a/schemas/renderflow-v2.schema.json b/schemas/renderflow-v2.schema.json index fa83591..41ab948 100644 --- a/schemas/renderflow-v2.schema.json +++ b/schemas/renderflow-v2.schema.json @@ -165,6 +165,16 @@ "pareto" ] }, + "print_pdf_interior": { + "anyOf": [ + { + "$ref": "#/$defs/printPdfInterior" + }, + { + "type": "null" + } + ] + }, "publication_policy": { "type": [ "string", @@ -348,6 +358,63 @@ ], "type": "object" }, + "printPdfInterior": { + "additionalProperties": false, + "properties": { + "box_policy": { + "const": "media_bleed_trim_inset" + }, + "color_policy": { + "const": "preserve_rgb_gray" + }, + "executable": { + "minLength": 1, + "type": "string" + }, + "max_input_bytes": { + "maximum": 536870912, + "minimum": 1, + "type": "integer" + }, + "max_output_bytes": { + "maximum": 536870912, + "minimum": 1, + "type": "integer" + }, + "max_pages": { + "maximum": 1000, + "minimum": 1, + "type": "integer" + }, + "provider_version": { + "const": "0.6.3" + }, + "rotation": { + "const": "none" + }, + "scaling": { + "const": "fit" + }, + "timeout_seconds": { + "maximum": 3600, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "executable", + "provider_version", + "box_policy", + "rotation", + "scaling", + "color_policy", + "max_pages", + "max_input_bytes", + "max_output_bytes", + "timeout_seconds" + ], + "type": "object" + }, "profile": { "additionalProperties": false, "properties": { diff --git a/scripts/generate_spec_v2_reference.py b/scripts/generate_spec_v2_reference.py index 97c3d8e..6cc611b 100644 --- a/scripts/generate_spec_v2_reference.py +++ b/scripts/generate_spec_v2_reference.py @@ -82,6 +82,7 @@ def main() -> None: ) lines.extend(render_properties("Target selection", definitions["targetSelection"])) lines.extend(render_properties("Execution policy", definitions["executionPolicy"])) + lines.extend(render_properties("Print-interior PDF policy", definitions["printPdfInterior"])) lines.extend(render_properties("Output layout", definitions["outputLayout"])) lines.extend( [