From ca92d936c00d4724916f4e57ae8937f78aa691d5 Mon Sep 17 00:00:00 2001 From: Triwarr <170976175+Triwarr@users.noreply.github.com> Date: Sat, 7 Nov 2026 19:41:22 +0100 Subject: [PATCH] PDF: import raster pages through craft-pdf instead of PDFium - craft-pdf (the shared Rust/Hayro reader in craft-libs, rev c5a1cbf) renders each page; no shared library is loaded or shipped, and every desktop platform works. - Raster pages only: editable text/vector import and the pdf_text/pdf_vectors options are removed (they can return later through craft-pdf's interpret device). Page geometry, artboards, page range, budgets, cancellation and warnings are kept; the effective DPI now respects craft-pdf's 8192 px side and 64M px page limits. - Drops pdfium-render, the PDFium download in CI, the build-script copy and the packaging/licence staging; records the Hayro/fonts/CMaps/ICC assets in ATTRIBUTION.md and NOTICE. - \.ai\ files starting with %PDF- now take the PDF importer, matching the comment. - Attribution, architecture and file-format-parity docs updated. --- .github/workflows/ci.yml | 18 - ATTRIBUTION.md | 13 + Cargo.lock | 385 +++++++++---- Cargo.toml | 6 + NOTICE | 11 + apps/photocraft/build.rs | 13 - crates/io/Cargo.toml | 7 +- crates/io/src/lib.rs | 52 +- crates/io/src/pdf_import.rs | 764 ++++++-------------------- crates/io/tests/it/pdf_import.rs | 393 ++++--------- docs/architecture.md | 2 +- docs/file-format-parity.md | 3 +- packaging/windows/install-policy.json | 3 +- packaging/windows/package.ps1 | 75 --- packaging/windows/photocraft.wxs | 7 - 15 files changed, 640 insertions(+), 1112 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4422f41a22e..2c3597f5eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -251,24 +251,6 @@ jobs: with: tool: cargo-nextest@0.9.150 - # The PDF import integration test loads PDFium at run time. Provision the pinned build for - # the integration leg and make an unavailable library fail there; local runs still skip. - - name: PDFium for the PDF import test - if: matrix.part == 'integration' - shell: pwsh - run: | - $dir = Join-Path $env:RUNNER_TEMP 'pdfium' - New-Item -ItemType Directory -Force $dir | Out-Null - $tgz = Join-Path $env:RUNNER_TEMP 'pdfium-win-x64.tgz' - Invoke-WebRequest -Uri 'https://github.com/bblanchon/pdfium-binaries/releases/download/chromium/8138/pdfium-win-x64.tgz' -OutFile $tgz - $hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $tgz).Hash.ToLowerInvariant() - if ($hash -ne '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5') { - throw "PDFium archive checksum $hash does not match the pinned chromium/8138 build" - } - tar -xzf $tgz -C $dir - "PDFIUM_DIR=$dir\bin" >> $env:GITHUB_ENV - "PHOTOCRAFT_REQUIRE_PDFIUM=1" >> $env:GITHUB_ENV - # The unit leg runs `--lib --bins` and the integration leg `--test '*'` (every # integration-test target); the feature selection stays the same, so both legs share the # dependency cache. diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index f11c7f30e0f..bac96333203 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -166,3 +166,16 @@ are adapted from Skyfuller1303’s MIT OR Apache-2.0 implementation in `897f20072762fdf0eb688adcdb8d8cda7b38ce66`. Don Omondi’s embedded AI/PDF/EPS extension and current-main integration build on that source. Clipboard and unrelated Shape Tools changes from that branch are not included. + +## Assets bundled by the PDF reader dependency (craft-pdf) + +PhotoCraft imports PDF pages through `craft-pdf`, pinned to the craft-libs revision +`c5a1cbfb5f9049f9270955caffb5bee5997aebf7`. The renderer vendors Hayro and the assets below; +they ship inside the dependency rather than being copied into this repository. PDFium is no +longer used or shipped. + +| Crate / file | Author | Source | Licence | Use | +| --- | --- | --- | --- | --- | +| `hayro-interpret 0.7.0 / assets/Foxit*.pfb` (13 standard-14 font files) | Foxit Software Inc. / PDFium Authors | | BSD-3-Clause | Renders non-embedded standard-14 PDF fonts | +| `hayro-interpret 0.7.0 / assets/CGATS001Compat-v2-micro.icc` | Clinton Ingram (saucecontrol/Compact-ICC-Profiles) | | CC0-1.0 | Default CMYK colour conversion | +| `hayro-cmap 0.1.0 / assets/cmaps.brotli` | Adobe | | BSD-3-Clause | Character-code mapping for CJK PDFs | diff --git a/Cargo.lock b/Cargo.lock index 049da1acef2..5e2a1dfca1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,7 +22,7 @@ dependencies = [ "accesskit", "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", "zvariant", ] @@ -826,6 +826,17 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + [[package]] name = "brotli-decompressor" version = "5.0.3" @@ -874,12 +885,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -1284,26 +1289,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - -[[package]] -name = "console_log" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86919cef3e37b9356ccf54d4421208c17ecfda01beae61393e7ffd72916c0ef1" -dependencies = [ - "log", - "web-sys", -] - [[package]] name = "const-oid" version = "0.9.6" @@ -1469,6 +1454,18 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-pdf" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro", + "hayro-interpret", + "hayro-syntax", + "thiserror 2.0.21", +] + [[package]] name = "craft-print" version = "0.1.0" @@ -1498,7 +1495,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", - "skrifa", + "skrifa 0.44.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -2133,7 +2130,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.5", "harfrust", "log", "nohash-hasher", @@ -2141,13 +2138,13 @@ dependencies = [ "profiling", "self_cell", "serde", - "skrifa", + "skrifa 0.44.0", "smallvec", "unicode-bidi", "unicode-general-category", "unicode-properties", "unicode-segmentation", - "vello_cpu", + "vello_cpu 0.2.0", ] [[package]] @@ -2403,6 +2400,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.12.5" @@ -2449,7 +2455,7 @@ dependencies = [ "objc2-core-text", "objc2-foundation 0.3.2", "parlance", - "read-fonts", + "read-fonts 0.41.0", "roxmltree 0.21.1", "smallvec", "windows", @@ -2743,6 +2749,23 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "skrifa 0.42.1", + "smallvec", + "vello_common 0.0.9", +] + [[package]] name = "glifo" version = "0.3.0" @@ -2754,9 +2777,9 @@ dependencies = [ "hashbrown 0.17.1", "log", "peniko", - "skrifa", + "skrifa 0.44.0", "smallvec", - "vello_common", + "vello_common 0.2.0", ] [[package]] @@ -2905,7 +2928,7 @@ checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ "bitflags 2.13.2", "bytemuck", - "read-fonts", + "read-fonts 0.41.0", "smallvec", ] @@ -2938,6 +2961,94 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hayro" +version = "0.7.1" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bytemuck", + "hayro-interpret", + "image", + "kurbo 0.13.1", + "log", + "pic-scale", + "vello_cpu 0.0.8", +] + +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-cmap" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14afde3344be69225fec4f6f44f6a219a3a492f9d8301eb25ef608fe4b7014c" +dependencies = [ + "brotli", + "hayro-postscript", +] + +[[package]] +name = "hayro-interpret" +version = "0.7.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "bitflags 2.13.2", + "hayro-cmap", + "hayro-syntax", + "kurbo 0.13.1", + "moxcms", + "phf 0.13.1", + "rustc-hash 2.1.3", + "siphasher", + "skrifa 0.47.0", + "smallvec", + "yoke", +] + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "fearless_simd", + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" +dependencies = [ + "fearless_simd", +] + +[[package]] +name = "hayro-postscript" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc2d80da85f31c3fdc0366431f44cfd645a9da1e242ed1f4a61f213d9765660f" + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" +dependencies = [ + "flate2", + "hayro-ccitt", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "stringprep", + "zune-jpeg 0.5.15", +] + [[package]] name = "heck" version = "0.5.0" @@ -5027,7 +5138,7 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5057,32 +5168,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" -[[package]] -name = "pdfium-render" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "296b1146652b486ea6eea0d316e85ab6837ff69e8d2b1568e6fa7bc38bd4c00e" -dependencies = [ - "bitflags 2.13.2", - "bytemuck", - "bytes", - "chrono", - "console_error_panic_hook", - "console_log", - "image", - "itertools 0.15.0", - "js-sys", - "libloading", - "log", - "maybe-owned", - "once_cell", - "utf16string", - "vecmath", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "pem" version = "3.0.6" @@ -5132,17 +5217,37 @@ dependencies = [ "indexmap", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", +] + [[package]] name = "phf" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_generator" version = "0.14.0" @@ -5150,7 +5255,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5159,13 +5277,22 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.14.0" @@ -5486,12 +5613,12 @@ version = "0.20.1" dependencies = [ "blake3", "craft-docpath", + "craft-pdf", "crc32fast", "flate2", "imagesize", "lopdf", "miniz_oxide 0.9.1", - "pdfium-render", "photocraft-affinity", "photocraft-algo", "photocraft-cms", @@ -5638,7 +5765,7 @@ dependencies = [ "photocraft-psd", "photocraft-raster", "rayon", - "skrifa", + "skrifa 0.44.0", ] [[package]] @@ -5734,6 +5861,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "pic-scale" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb0ff645d35accaec252de081d55984a0d6c952cb1e5dc2b9afa7c78f07288" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -5802,12 +5939,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "piston-float" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" - [[package]] name = "pkg-config" version = "0.3.34" @@ -6365,6 +6496,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types 0.11.3", +] + [[package]] name = "read-fonts" version = "0.41.0" @@ -6372,7 +6513,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.5", + "once_cell", +] + +[[package]] +name = "read-fonts" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" +dependencies = [ + "bytemuck", + "font-types 0.12.5", "once_cell", ] @@ -7319,6 +7471,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts 0.39.2", +] + [[package]] name = "skrifa" version = "0.44.0" @@ -7326,7 +7488,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" dependencies = [ "bytemuck", - "read-fonts", + "read-fonts 0.41.0", +] + +[[package]] +name = "skrifa" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "526e654b303ff4ad73a5a40b25de2b3ce3d039c5e3b6da5f89ae6e57004cb40f" +dependencies = [ + "bytemuck", + "read-fonts 0.44.0", ] [[package]] @@ -8290,15 +8462,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" -[[package]] -name = "utf16string" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" -dependencies = [ - "byteorder", -] - [[package]] name = "utf8-zero" version = "0.8.1" @@ -8341,12 +8504,37 @@ dependencies = [ ] [[package]] -name = "vecmath" -version = "1.0.0" +name = "vello_common" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +checksum = "3361bff7f7d82c0c496b92048db83846691f0e844cc28dee92b1c824291b55ee" dependencies = [ - "piston-float", + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", +] + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "png 0.18.1", + "smallvec", + "thiserror 2.0.21", ] [[package]] @@ -8364,6 +8552,19 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "vello_cpu" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8ded630e8316bb94a55881256506d1f3b9947b5f66db8a7d32ca7ba02decd0" +dependencies = [ + "bytemuck", + "glifo 0.1.1", + "hashbrown 0.17.1", + "png 0.18.1", + "vello_common 0.0.8", +] + [[package]] name = "vello_cpu" version = "0.2.0" @@ -8371,9 +8572,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", - "glifo", + "glifo 0.3.0", "hashbrown 0.17.1", - "vello_common", + "vello_common 0.2.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e01bcf175ab..f5fafa868d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ photocraft-ipp = { path = "crates/ipp" } # external craft-print = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["raster"] } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-jpeg = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "b8f7d93b00dfb8f172938f3d2d796ae62dcfe123" } @@ -203,3 +204,8 @@ eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc79968157 egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } +# craft-pdf's vendored Hayro trees: consumers patch the same craft-libs revision (crates/pdf README). +hayro = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } +hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" } diff --git a/NOTICE b/NOTICE index 9ab6b0a1cf3..826dea74901 100644 --- a/NOTICE +++ b/NOTICE @@ -70,3 +70,14 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Optional PDF reader (craft-pdf) + +PhotoCraft imports PDF pages through craft-pdf (https://github.com/storytold/craft-libs, +MIT OR Apache-2.0), pinned to revision c5a1cbfb5f9049f9270955caffb5bee5997aebf7. It vendors +Hayro 0.7.1, hayro-interpret 0.7.0, hayro-syntax 0.7.2, hayro-jbig2 0.3.0 and +hayro-cmap 0.1.0 (https://github.com/LaurenzV/hayro, MIT OR Apache-2.0). The reader bundles +the standard-14 Foxit fonts (BSD-3-Clause), Adobe CMaps (BSD-3-Clause) and a Compact ICC +profile by Clinton Ingram (CC0-1.0); per-file sources and hashes are in craft-libs +ATTRIBUTION.md. No PDF assets are copied into this repository, and PDFium is no longer used or +shipped. diff --git a/apps/photocraft/build.rs b/apps/photocraft/build.rs index e750904018a..48abc9f1a51 100644 --- a/apps/photocraft/build.rs +++ b/apps/photocraft/build.rs @@ -11,19 +11,6 @@ fn main() { if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { return; } - // PDF import loads PDFium at runtime. When the build provides the shared library - // (`PDFIUM_DLL`, set by the release packaging step), copy it beside the executable so the - // installer's pdfium.dll and the running app agree. - println!("cargo:rerun-if-env-changed=PDFIUM_DLL"); - if let (Some(dll), Ok(out)) = (std::env::var_os("PDFIUM_DLL"), std::env::var("OUT_DIR")) { - let dll = std::path::PathBuf::from(dll); - if let Some(profile_dir) = std::path::Path::new(&out).ancestors().nth(3) { - let dest = profile_dir.join(dll.file_name().unwrap_or_default()); - if let Err(e) = std::fs::copy(&dll, &dest) { - println!("cargo:warning=could not copy PDFium to {}: {e}", dest.display()); - } - } - } // Printing to PDF (winprint's `PdfCreateRenderer`) imports Windows.Data.Pdf.dll, which // stripped Windows images (NTLite, some N/LTSC builds) lack. A load-time import stops the exe // from starting at all ("windows.data.pdf.dll was not found", #5884), so load it on first use; diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index dd5a232f32e..36975e57eb2 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -51,11 +51,8 @@ imagesize = "0.13" resvg = { version = "0.45", default-features = false, features = ["text", "system-fonts", "raster-images"] } # The SVG fallback font search keeps each font file's character map (already in the tree through usvg). ttf-parser = { version = "0.25", default-features = false } -# PDF import (pdf_import.rs): PDFium renders the pages; the direct surfaces build the document. -# Pinned exactly, like the WinTab wrappers in photocraft-tablet: PDFium is FFI-loaded at run time -# and the DLL release is pinned separately in packaging/windows/package.ps1, so a lockfile refresh -# must not move the wrapper under it. Bump deliberately and re-check both. -pdfium-render = "=0.9.5" +# PDF import (pdf_import.rs): craft-pdf rasterizes pages in pure Rust; no shared library ships. +craft-pdf = { workspace = true } [target.'cfg(target_arch = "wasm32")'.dependencies] resvg = { version = "0.45", default-features = false, features = ["text", "raster-images"] } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 54a4d1577e3..7519449224f 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -556,9 +556,9 @@ pub fn import_preflight(name: &str, bytes: &[u8]) -> ImportPreflight { if is_psd_input(name, bytes) { return ImportPreflight::Psd; } - // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized - // raster signature wins over a misleading `.pdf` suffix. - if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { return ImportPreflight::Unbounded; } if photocraft_codecs::detect(bytes) == Some(photocraft_codecs::Format::Tiff) { ImportPreflight::Tiff } else { ImportPreflight::Flat } @@ -612,10 +612,6 @@ pub struct ImportOptions { pub pdf_dpi: f32, /// The 1-based, inclusive page range to import; `None` imports every page (up to the cap). pub pdf_pages: Option<(u32, u32)>, - /// Import PDF text objects as editable text layers (default on). - pub pdf_text: bool, - /// Import PDF vector paths as editable shape layers (default on). - pub pdf_vectors: bool, /// Explicit one-shot authorization for imports that exceed the soft budget but fit within the hard budget. pub psd_high_memory_approved: bool, /// Optional estimate limit for high-memory approval. When set, approval only applies if @@ -632,8 +628,6 @@ impl Default for ImportOptions { flat_max_pixel_bytes: 0, pdf_dpi: 300.0, pdf_pages: None, - pdf_text: true, - pdf_vectors: true, psd_high_memory_approved: false, psd_approved_estimate: None, } @@ -999,9 +993,9 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, o if is_svg_input(name, bytes) { return svg::import_svg_with_group_depth(name, bytes, opts.max_group_depth); } - // PDF (PDFium, native only). `.ai` files are PDFs but keep their existing path; a recognized - // raster signature wins over a misleading `.pdf` suffix. - if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && !has_extension(name, "ai") && photocraft_codecs::detect(bytes).is_none()) { + // PDF (craft-pdf, pure Rust). `.ai` files that start with `%PDF-` are PDFs and take this + // importer too; a recognized raster signature wins over a misleading `.pdf` suffix. + if pdf_import::is_pdf(bytes) || (has_extension(name, "pdf") && photocraft_codecs::detect(bytes).is_none()) { return pdf_import::import(name, bytes, opts, ctl); } match route(name, bytes) { @@ -1628,4 +1622,38 @@ mod tests { assert!(is_svg_input("drawing.svg", b"")); assert_eq!(crate::route("drawing.svg", pdf), crate::Route::Flat); } + + /// `.ai` files that start with `%PDF-` are PDFs: both preflight and dispatch send them to the + /// PDF importer rather than the flat decoder. + #[test] + fn an_ai_with_pdf_content_takes_the_pdf_importer() { + use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content = b"0 0 1 rg 10 10 20 20 re f"; + let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(100.0), Object::Real(100.0)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the .ai fixture"); + assert!(pdf_import::is_pdf(&bytes)); + assert_eq!(import_preflight("illustration.ai", &bytes), ImportPreflight::Unbounded); + let result = import("illustration.ai", &bytes).expect("an .ai starting with %PDF- takes the PDF importer"); + assert_eq!(result.document.artboards().len(), 1); + } } diff --git a/crates/io/src/pdf_import.rs b/crates/io/src/pdf_import.rs index 7cb1da50c2f..0fcc540749f 100644 --- a/crates/io/src/pdf_import.rs +++ b/crates/io/src/pdf_import.rs @@ -1,33 +1,28 @@ -//! PDF import: PDFium renders the selected pages and each page becomes an artboard. +//! PDF import: craft-pdf (a Rust PDF reader and renderer built on Hayro) rasterizes the +//! selected pages, and each page becomes one artboard. //! -//! The document resolution is chosen so the page's physical size is preserved even when the -//! raster is capped: pixels ÷ resolution × 72 pt is the PDF page's size. Pages are laid out side -//! by side; page count, per-page pixels, union pixels and the aggregate pixel budget are bounded. +//! Raster pages only: editable text and vector import would need craft-pdf's `interpret` +//! device and is not part of this importer. The renderer's hard output bounds (8192 px per +//! side and 64M px per page) and a document-wide strip budget are respected by lowering the +//! effective DPI, so each page keeps its physical size: pixels / resolution * 72 pt. //! -//! Text and vector page objects can be imported as editable layers (`ImportOptions::pdf_text` / -//! `pdf_vectors`); their page-space points are mapped to document pixels through the effective DPI. -//! -//! # Known limits -//! -//! - pdfium-render 0.9.5 exposes a path's fill/stroke *presence* but not its paint colour, stroke -//! width or dash pattern, so imported paths are painted black (1 pt centre stroke when stroked). -//! - A text object exposes no fill colour: the colour is sampled from the rendered page's glyph -//! ink, and the font is matched by family/name rather than embedded or loaded from the PDF. -//! - PDFium is provisioned by `packaging/windows/package.ps1` (pinned release, verified DLL); the -//! shared craft-common-actions release workflow does not stage it. +//! The renderer is pure Rust and runs on every desktop platform: no shared library is loaded +//! or shipped, and a malformed file is reported as an error rather than taking the process down. use crate::{ImportOptions, ImportResult, IoError}; +use craft_pdf::interpret::InterpreterSettings; +use craft_pdf::raster::{self, RenderCache}; use photocraft_color::{ColorMode, PixelFormat, SampleType}; -use photocraft_doc::{Artboard, Color, Document, Fill, Group, Knot, Layer, LayerContent, Path, PathOp, ShapeLayer, ShapeStroke, Subpath, TextLayer}; -use photocraft_geom::{Affine, Point, Rect, Size}; +use photocraft_doc::{Artboard, Document, Group, Layer, LayerContent}; +use photocraft_geom::{Rect, Size}; use photocraft_raster::{Interrupt, Surface}; -/// `%PDF-` magic. `.ai` files are PDFs too, but the dispatch keeps them out of this importer. +/// `%PDF-` magic. `.ai` files that start with it are PDFs and render through this importer. pub fn is_pdf(bytes: &[u8]) -> bool { bytes.starts_with(b"%PDF-") } -/// Native PDF import is not available in the web build. +/// PDF import is not available in the web build. #[cfg(target_arch = "wasm32")] pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrupt) -> Result { Err(IoError::Unsupported("PDF import needs the desktop build".into())) @@ -36,23 +31,23 @@ pub fn import(_name: &str, _bytes: &[u8], _opts: &ImportOptions, _ctl: &Interrup /// Renders the selected PDF pages and opens each as one artboard. #[cfg(not(target_arch = "wasm32"))] pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) -> Result { - use pdfium_render::prelude::*; - /// The most pages one import renders. const MAX_PAGES: usize = 16; /// Gap between the artboards, in document pixels. const GAP: u32 = 80; - /// Per-page and per-artboard-strip pixel budgets; a poster at 300 dpi is far below these. - const MAX_PAGE_PIXELS: f64 = 100_000_000.0; + /// The document-wide raster budget across every imported page, in pixels. const MAX_UNION_PIXELS: u64 = 200_000_000; /// The default pixel-memory budget when the caller sets none: a zero /// [`ImportOptions::flat_max_pixel_bytes`] still bounds the import. const DEFAULT_MAX_PIXEL_BYTES: u64 = 2 << 30; + ctl.check().map_err(|_| IoError::Cancelled)?; let requested = if opts.pdf_dpi.is_finite() && opts.pdf_dpi > 0.0 { f64::from(opts.pdf_dpi) } else { 300.0 }; - let pdfium = bind()?; - let document = pdfium.load_pdf_from_byte_slice(bytes, None).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let total = document.pages().len().max(0) as usize; + let pdf = craft_pdf::open_slice_with_options(bytes, None, &craft_pdf::OpenOptions::default()).map_err(open_error)?; + let total = pdf.pages().len(); + if total == 0 { + return Err(IoError::Unsupported("PDF has no pages".into())); + } let (first, last) = match opts.pdf_pages { Some((a, b)) => { let a = a.max(1); @@ -64,46 +59,45 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - } None => (1, total), }; - if total == 0 || first > total { - return Err(IoError::Unsupported("PDF has no pages in the requested range".into())); - } let page_count = last - first + 1; let count = page_count.min(MAX_PAGES); - // The page geometry first: the effective DPI must fit the budgets, capping the raster must - // not change the page's physical size, and editable objects are mapped through the same page - // box and rotation the raster uses. - let mut geoms = Vec::with_capacity(count); + + // Page sizes in points first: the effective DPI must fit the renderer's bounds without + // changing any page's physical size. + let mut sizes = Vec::with_capacity(count); for index in 0..count { + let page = &pdf.pages()[first - 1 + index]; + let (width, height) = page.render_dimensions(); + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err(IoError::Unsupported(format!("PDF page {} has no area", first + index))); + } + sizes.push((f64::from(width), f64::from(height))); ctl.check().map_err(|_| IoError::Cancelled)?; - let page_index = first - 1 + index; - let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - geoms.push(page_geometry(&page, page_index + 1)?); } - let sizes: Vec<(f64, f64)> = geoms.iter().map(|g| (g.width_pt, g.height_pt)).collect(); - let dpi = effective_dpi(requested, &sizes, GAP, MAX_PAGE_PIXELS, MAX_UNION_PIXELS); - let doc_width = sizes.iter().map(|(w, _)| (w * dpi / 72.0).round() as u64).sum::() + u64::from(GAP) * (count as u64 - 1); - let doc_height = sizes.iter().map(|(_, h)| (h * dpi / 72.0).round() as u64).max().unwrap_or(1); + let dpi = effective_dpi(requested, &sizes, u64::from(raster::MAX_SIDE), raster::MAX_PIXELS, MAX_UNION_PIXELS); + + let mut doc_width: u64 = 0; + let mut doc_height: u64 = 0; + let mut retained: u64 = 0; + for (index, &(width_pt, height_pt)) in sizes.iter().enumerate() { + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + doc_width = doc_width.saturating_add(u64::from(width)).saturating_add(if index + 1 < count { u64::from(GAP) } else { 0 }); + doc_height = doc_height.max(u64::from(height)); + let tiles_x = (u64::from(width)).div_ceil(256); + let tiles_y = (u64::from(height)).div_ceil(256); + retained = retained.saturating_add(tiles_x.saturating_mul(tiles_y).saturating_mul(256 * 256 * 4)); + } if doc_width == 0 || doc_height == 0 { return Err(IoError::Unsupported("PDF pages have no area".into())); } - // The aggregate budget: the document keeps every page's raster tiles, while one page at a - // time is converted through the PDFium bitmap, its RGBA copy and the f32 buffer. - let page_px = |w: f64, h: f64| (w * dpi / 72.0).round().max(1.0) * (h * dpi / 72.0).round().max(1.0); - let max_page_pixels = sizes.iter().map(|&(w, h)| page_px(w, h)).fold(0.0_f64, f64::max); - // Retained backing: a page occupies whole 256 px tiles, including the ones its document X - // offset steps into. A 1 x 10,000 px page is 40 tiles (10 MiB), not 10,000 pixels. - const TILE: u64 = 256; - let mut retained: u64 = 0; - let mut page_x: u64 = 0; - for &(w, h) in &sizes { - let px = (w * dpi / 72.0).round().max(1.0) as u64; - let py = (h * dpi / 72.0).round().max(1.0) as u64; - let tiles_x = (page_x + px).div_ceil(TILE) - page_x / TILE; - retained = retained.saturating_add(tiles_x.saturating_mul(py.div_ceil(TILE)).saturating_mul(TILE * TILE * 4)); - page_x += px + u64::from(GAP); + if doc_width > u64::from(u32::MAX) || doc_height > u64::from(u32::MAX) { + return Err(IoError::Unsupported("PDF import is too large at this DPI; lower the import DPI".into())); } - // Plus 24 bytes/px (bitmap, RGBA copy, f32 conversion buffer) for the page being converted. - let estimate = retained.saturating_add((max_page_pixels * 24.0) as u64); + // Retained tiles plus the converting page's peak: craft-pdf's RGBA8 raster (4 B/px) and the + // f32 document buffer (16 B/px). + let max_page = sizes.iter().map(|&(w, h)| u64::from(page_pixels(w, dpi)) * u64::from(page_pixels(h, dpi))).max().unwrap_or(0); + let estimate = retained.saturating_add(max_page.saturating_mul(20)); let budget = if opts.flat_max_pixel_bytes > 0 { opts.flat_max_pixel_bytes } else { DEFAULT_MAX_PIXEL_BYTES }; if estimate > budget { return Err(IoError::Unsupported(format!( @@ -112,576 +106,134 @@ pub fn import(name: &str, bytes: &[u8], opts: &ImportOptions, ctl: &Interrupt) - budget >> 20 ))); } - let mut object_budget = ObjectBudget::default(); - let mut object_warnings: Vec = Vec::new(); - let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); - doc.resolution_dpi = dpi as f32; - let mut x = 0u32; - for (index, geometry) in geoms.iter().enumerate() { - let (w_pt, h_pt) = (geometry.width_pt, geometry.height_pt); - ctl.check().map_err(|_| IoError::Cancelled)?; - let page_index = first - 1 + index; - let target_width = (w_pt * dpi / 72.0).round().max(1.0) as i32; - let target_height = (h_pt * dpi / 72.0).round().max(1.0) as i32; - // A one-pixel-wide page would otherwise produce an arbitrarily tall bitmap before the - // union check runs; reject the final dimensions up front. - if f64::from(target_width) * f64::from(target_height) > MAX_PAGE_PIXELS { - return Err(IoError::Unsupported(format!( - "PDF page {} is too large at {dpi} dpi ({} x {} px); lower the import DPI", - page_index + 1, - target_width, - target_height - ))); - } - let page = document.pages().get(page_index as i32).map_err(|e| IoError::Unsupported(format!("PDF: {e}")))?; - let bitmap = - page.render_with_config(&PdfRenderConfig::new().set_target_width(target_width)).map_err(|e| IoError::Unsupported(format!("PDF render: {e}")))?; - let (width, height) = (bitmap.width().max(0) as u32, bitmap.height().max(0) as u32); - // The rounded target width can render a taller bitmap than the pre-check predicted. - if f64::from(width) * f64::from(height) > MAX_PAGE_PIXELS { - return Err(IoError::Unsupported(format!( - "PDF page {} rendered larger than the {MAX_PAGE_PIXELS} px budget ({} x {}); lower the import DPI", - page_index + 1, - width, - height - ))); - } - // The page's pixels go straight into the document-wide layer surface: no full-strip - // canvas and no PNG round-trip, so the peak is the page bitmap plus its tiles. - let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); - let rgba = bitmap.as_rgba_bytes(); - let mut data = Vec::new(); - if data.try_reserve_exact(rgba.len()).is_err() { - return Err(IoError::Unsupported(format!("PDF page {} needs more memory than is available", page_index + 1))); - } - for chunk in rgba.as_chunks::<4>().0 { - for &v in &chunk[..3] { - data.push(f32::from(v) / 255.0); - } - data.push(f32::from(chunk[3]) / 255.0); - } - surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); - let layer = Layer::new(format!("Page {}", page_index + 1), LayerContent::Raster(surface)); - let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); - let mut children = vec![layer]; - if opts.pdf_text || opts.pdf_vectors { - let frame = PageFrame { geometry: *geometry, dpi, x, rgba: &rgba, width, height }; - let (layers, truncated) = object_layers(&page, opts, &frame, ctl, &mut object_budget)?; - children.extend(layers); - if truncated { - object_warnings.push(format!("PDF page {}: editable text/vector layers omitted at the import limit (the raster page is kept)", page_index + 1)); - } - } - let group = Group { children, expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; - doc.layers.push(Layer::new(format!("Page {}", page_index + 1), LayerContent::Group(group))); - x += width + GAP; - ctl.progress((index + 1) as f32 / count as f32); - } - let mut warnings = object_warnings; + + let mut warnings = Vec::new(); if first > 1 || last < total || count < page_count { let shown_last = first + count - 1; let reason = if count < page_count { " (the page limit is 16)" } else { "" }; warnings.push(format!("PDF has {total} pages; imported pages {first}-{shown_last}{reason}")); } + if dpi + 0.5 < requested { + warnings.push(format!("PDF was rendered at {:.0} dpi to fit the import limits", dpi.max(1.0))); + } + + let mut doc = Document::new(name, Size::new(doc_width as u32, doc_height as u32), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = dpi as f32; + let mut x: u32 = 0; + for index in 0..count { + ctl.check().map_err(|_| IoError::Cancelled)?; + let page_number = first + index; + let page = &pdf.pages()[page_number - 1]; + let (width_pt, height_pt) = sizes[index]; + let width = page_pixels(width_pt, dpi); + let height = page_pixels(height_pt, dpi); + // The DPI solve keeps these inside the renderer's bounds; re-check rather than panic if + // rounding on an edge moves one pixel past them. + if width > raster::MAX_SIDE || height > raster::MAX_SIDE || u64::from(width) * u64::from(height) > raster::MAX_PIXELS { + return Err(IoError::Unsupported(format!("PDF page {page_number} is too large at {dpi:.0} dpi; lower the import DPI"))); + } + let cache = RenderCache::new(); + let settings = InterpreterSettings::default(); + let options = raster::Options { + scale: [(dpi / 72.0) as f32; 2], + size: Some([width, height]), + offset: [0.0, 0.0], + // PDF pages composite onto white, as every PDF viewer and the previous importer did. + background: [255, 255, 255, 255], + color_replacement: None, + }; + let rendered = raster::render(page, &cache, &settings, &options).map_err(|error| render_error(page_number, dpi, error))?; + if rendered.diagnostics.image_decode_failed { + warnings.push(format!("PDF page {page_number}: an image could not be decoded and is missing")); + } + if rendered.diagnostics.content_truncated { + warnings.push(format!("PDF page {page_number}: some content was omitted (a decode or memory limit)")); + } + ctl.check().map_err(|_| IoError::Cancelled)?; + + let mut surface = Surface::new(PixelFormat::new(ColorMode::Rgb, SampleType::U8, true)); + let bytes = rendered.bytes(); + let mut data = Vec::new(); + if data.try_reserve_exact(bytes.len()).is_err() { + return Err(IoError::Unsupported(format!("PDF page {page_number} needs more memory than is available"))); + } + for chunk in bytes.as_chunks::<4>().0 { + for &value in &chunk[..3] { + data.push(f32::from(value) / 255.0); + } + data.push(f32::from(chunk[3]) / 255.0); + } + surface.write_region(Rect::from_xywh(x as i32, 0, width, height), &data); + let rect = Rect::new(x as i32, 0, (x + width) as i32, height as i32); + let layer = Layer::new(format!("Page {page_number}"), LayerContent::Raster(surface)); + let group = Group { children: vec![layer], expanded: true, artboard: Some(Artboard::new(rect)), frame_stroke: None }; + doc.layers.push(Layer::new(format!("Page {page_number}"), LayerContent::Group(group))); + x = x.saturating_add(width).saturating_add(GAP); + ctl.progress((index + 1) as f32 / count as f32); + } + Ok(ImportResult { warnings, document: doc, source_read_only: false, preview_only: false }) } -/// Document-wide bound on editable objects. Text layers and path knots are allocated outside the -/// pixel-memory estimate, so a pathological PDF could otherwise exhaust memory. -const MAX_OBJECT_LAYERS: usize = 20_000; -const MAX_PATH_KNOTS: usize = 1_000_000; - -/// Tracks the editable objects created so far, document-wide. -#[derive(Default)] -struct ObjectBudget { - layers: usize, - knots: usize, -} - -impl ObjectBudget { - /// Claims one editable layer; false once the document-wide limit is reached. - fn claim_layer(&mut self) -> bool { - if self.layers >= MAX_OBJECT_LAYERS { - false - } else { - self.layers += 1; - true - } - } - - /// Claims `count` path knots; false if that would pass the document-wide limit. - fn claim_knots(&mut self, count: usize) -> bool { - if self.knots.saturating_add(count) > MAX_PATH_KNOTS { - false - } else { - self.knots += count; - true - } - } -} -/// A page's visible area and rotation, in PDF points. -#[derive(Clone, Copy)] -struct PageGeometry { - /// Displayed size (rotation applied): the size the raster is rendered into. - width_pt: f64, - height_pt: f64, - /// The crop box origin in unrotated PDF user space. - origin_x: f64, - origin_y: f64, - /// The crop box size in unrotated PDF user space. - crop_w: f64, - crop_h: f64, - /// Clockwise rotation the renderer applies: 0, 90, 180 or 270. - rotation: u16, -} - -/// The page's visible box and rotation. PDF page objects stay in unrotated user space, while the -/// raster is rendered through the crop box and the page's `/Rotate`, so editable layers must use -/// the same transform. +/// One page's raster width or height in pixels at `dpi`, at least one. #[cfg(not(target_arch = "wasm32"))] -fn page_geometry(page: &pdfium_render::prelude::PdfPage, page_number: usize) -> Result { - let width_pt = f64::from(page.width().value); - let height_pt = f64::from(page.height().value); - if width_pt <= 0.0 || height_pt <= 0.0 { - return Err(IoError::Unsupported(format!("PDF page {page_number} has no area"))); - } - let rotation = page.rotation().map(|r| r.as_degrees() as u16).unwrap_or(0); - // The page's effective box, as PDFium renders it: the bounding box (media box clipped to the - // crop box), not the raw crop box, which may extend past the media box. - let (origin_x, origin_y, crop_w, crop_h) = match page.boundaries().bounding().or_else(|_| page.boundaries().crop()).or_else(|_| page.boundaries().media()) { - Ok(boundary) => { - let r = boundary.bounds; - (f64::from(r.left().value), f64::from(r.bottom().value), f64::from(r.right().value - r.left().value), f64::from(r.top().value - r.bottom().value)) - } - Err(_) => (0.0, 0.0, width_pt, height_pt), - }; - // A malformed box falls back to the displayed size; 90 and 270 swap the axes. - let (crop_w, crop_h) = if crop_w > 0.0 && crop_h > 0.0 { - (crop_w, crop_h) - } else if rotation == 90 || rotation == 270 { - (height_pt, width_pt) - } else { - (width_pt, height_pt) - }; - Ok(PageGeometry { width_pt, height_pt, origin_x, origin_y, crop_w, crop_h, rotation }) +fn page_pixels(points: f64, dpi: f64) -> u32 { + (points * dpi / 72.0).round().max(1.0) as u32 } -/// One rendered page's frame: its geometry, DPI, document x, and the RGBA pixels. -struct PageFrame<'a> { - geometry: PageGeometry, - dpi: f64, - x: u32, - rgba: &'a [u8], - width: u32, - height: u32, -} - -impl PageFrame<'_> { - /// Maps a point in PDF page space to the page's own pixels: crop box, rotation and scale. - fn to_page_px(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { - let g = &self.geometry; - let u = f64::from(x_pt) - g.origin_x; - let v = f64::from(y_pt) - g.origin_y; - let (dx, dy) = match g.rotation { - 90 => (v, u), - 180 => (g.crop_w - u, v), - 270 => (g.crop_h - v, g.crop_w - u), - _ => (u, g.crop_h - v), - }; - let scale = self.dpi / 72.0; - (dx * scale, dy * scale) - } - - /// Maps a point in PDF page space to document pixels: the page's own pixels plus its X offset. - fn to_doc(&self, x_pt: f32, y_pt: f32) -> (f64, f64) { - let (px, py) = self.to_page_px(x_pt, y_pt); - ((px + f64::from(self.x)).round(), py.round()) - } - - /// The text layer's transform at document pixel (`x`, `y`): the page rotation turns the text - /// layout too, so rotated pages get rotated editable text rather than upright text at a - /// rotated position. - fn text_transform(&self, x: f64, y: f64) -> Affine { - let (a, b, c, d) = match self.geometry.rotation { - 90 => (0.0, 1.0, -1.0, 0.0), - 180 => (-1.0, 0.0, 0.0, -1.0), - 270 => (0.0, -1.0, 1.0, 0.0), - _ => (1.0, 0.0, 0.0, 1.0), - }; - Affine { m: [a, b, c, d, x, y] } - } - - /// The rendered page's colour at local pixel (`x`, `y`), or black outside it. - fn color_at(&self, x: u32, y: u32) -> Color { - let i = (y as usize * self.width as usize + x as usize) * 4; - if i + 3 < self.rgba.len() && self.rgba[i + 3] > 0 { - Color::rgb(f32::from(self.rgba[i]) / 255.0, f32::from(self.rgba[i + 1]) / 255.0, f32::from(self.rgba[i + 2]) / 255.0) - } else { - Color::BLACK - } - } - - /// The ink colour of the text in `bounds` (page points). - /// - /// The box holds both glyphs and the page behind them, so sampling one corner usually finds - /// background or an antialiased edge. Take the pixel farthest from the box's most common - /// colour instead: for dark text on a light page that is a glyph core, and for light text on a - /// dark page it is the glyph rather than the background. - fn text_color(&self, left: f32, top: f32, right: f32, bottom: f32) -> Color { - // The box's four corners through the same rotation-aware mapping as the glyphs. - let (mut x0, mut y0, mut x1, mut y1) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for (x, y) in [(left, bottom), (left, top), (right, top), (right, bottom)] { - let (px, py) = self.to_page_px(x, y); - x0 = x0.min(px); - x1 = x1.max(px); - y0 = y0.min(py); - y1 = y1.max(py); - } - let x0 = x0.floor().max(0.0) as u32; - let x1 = x1.ceil().clamp(0.0, f64::from(self.width)) as u32; - let y0 = y0.floor().max(0.0) as u32; - let y1 = y1.ceil().clamp(0.0, f64::from(self.height)) as u32; - if x1 <= x0 || y1 <= y0 { - return Color::BLACK; - } - // At most 4096 samples, so a page-sized text box stays cheap. - let step = ((f64::from(x1 - x0) * f64::from(y1 - y0) / 4096.0).sqrt().ceil() as u32).max(1); - let mut samples: Vec<[f32; 3]> = Vec::new(); - let mut buckets: std::collections::HashMap = std::collections::HashMap::new(); - let mut y = y0; - 'rows: while y < y1 { - let mut x = x0; - while x < x1 { - if samples.len() >= 4096 { - break 'rows; - } - let c = self.color_at(x, y); - let rgb = [c.c[0], c.c[1], c.c[2]]; - let key = (rgb[0] * 31.0) as u32 * 1024 + (rgb[1] * 31.0) as u32 * 32 + (rgb[2] * 31.0) as u32; - let entry = buckets.entry(key).or_insert((0, [0.0; 3])); - entry.0 += 1; - for (sum, v) in entry.1.iter_mut().zip(rgb) { - *sum += v; - } - samples.push(rgb); - x += step; - } - y += step; - } - let Some((_, (count, sum))) = buckets.iter().max_by_key(|(_, (count, _))| *count) else { return Color::BLACK }; - let mode = [sum[0] / *count as f32, sum[1] / *count as f32, sum[2] / *count as f32]; - let mut best = mode; - let mut best_distance = -1.0f32; - for rgb in samples { - let distance = (rgb[0] - mode[0]).powi(2) + (rgb[1] - mode[1]).powi(2) + (rgb[2] - mode[2]).powi(2); - if distance > best_distance { - best_distance = distance; - best = rgb; - } - } - Color::rgb(best[0], best[1], best[2]) - } -} - -/// Editable text and shape layers for one rendered page, in document pixels. +/// The highest DPI not above `requested` that keeps every page inside the renderer's side and +/// pixel bounds and the whole strip inside `max_union_pixels`. Rendering at that DPI keeps each +/// page's physical size unchanged; unreachable bounds report 1 DPI and the render call then fails +/// with a clear message. #[cfg(not(target_arch = "wasm32"))] -fn object_layers( - page: &pdfium_render::prelude::PdfPage, - opts: &ImportOptions, - frame: &PageFrame, - ctl: &Interrupt, - budget: &mut ObjectBudget, -) -> Result<(Vec, bool), IoError> { - use pdfium_render::prelude::*; - - let objects = page.objects(); - let mut layers = Vec::new(); - let mut truncated = false; - 'objects: for object in objects.iter() { - if budget.layers >= MAX_OBJECT_LAYERS { - truncated = true; - break; - } - if ctl.check().is_err() { - return Err(IoError::Cancelled); - } - match object { - PdfPageObject::Text(text) if opts.pdf_text => { - let content = text.text(); - if content.trim().is_empty() { - continue; - } - let Ok(bounds) = text.bounds() else { continue }; - let (x_px, y_px) = frame.to_doc(bounds.left().value, bounds.top().value); - // The text layer's size is in points; the transform is in document pixels. - // The text object does not expose its fill colour; sample the glyph ink from the - // page. The font is matched by family/name, not embedded: a document font that is - // not installed renders with the closest family the text engine has. - let color = frame.text_color(bounds.left().value, bounds.top().value, bounds.right().value, bounds.bottom().value); - let size_pt = f64::from(text.scaled_font_size().value).max(1.0); - let font = text.font(); - let family = if font.family().is_empty() { font.name() } else { font.family() }; - let mut layer = Layer::new( - "Text", - LayerContent::Text(TextLayer { - text: content, - font_family: family, - size_pt: size_pt as f32, - color, - transform: frame.text_transform(x_px, y_px), - ..Default::default() - }), - ); - if !text.is_visible() { - layer.visible = false; - } - if !budget.claim_layer() { - truncated = true; - break 'objects; - } - layers.push(layer); +fn effective_dpi(requested: f64, sizes: &[(f64, f64)], max_side: u64, max_page_pixels: u64, max_union_pixels: u64) -> f64 { + let mut dpi = requested.clamp(1.0, 2400.0); + loop { + let mut union = 0u64; + let mut fits = true; + for &(width_pt, height_pt) in sizes { + let width = u64::from(page_pixels(width_pt, dpi)); + let height = u64::from(page_pixels(height_pt, dpi)); + if width > max_side || height > max_side || width.saturating_mul(height) > max_page_pixels { + fits = false; + break; } - PdfPageObject::Path(path) if opts.pdf_vectors => { - // PDFium reports the segments in object space; the object's matrix places them on - // the page. Without it, scaled or rotated paths land in the wrong place. - let matrix = path.matrix().unwrap_or(PdfMatrix::IDENTITY); - // The fill and stroke modes are exposed; the colours, stroke width and dash - // pattern are not, so both paints are black and a stroke is 1 pt wide. - let filled = !matches!(path.fill_mode(), Ok(PdfPathFillMode::None)); - let stroked = path.is_stroked().unwrap_or(false); - if !filled && !stroked { - continue; - } - let mut subpaths: Vec = Vec::new(); - let mut knots: Vec = Vec::new(); - let mut closed = false; - let mut bezier: Vec<(f64, f64)> = Vec::new(); - for segment in path.segments().transform(matrix).iter() { - let point = || frame.to_doc(segment.x().value, segment.y().value); - match segment.segment_type() { - PdfPathSegmentType::MoveTo => { - if !knots.is_empty() { - subpaths.push(Subpath { closed, knots: std::mem::take(&mut knots), op: PathOp::Combine }); - closed = false; - } - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - let (px, py) = point(); - knots.push(Knot::corner(px, py)); - } - PdfPathSegmentType::LineTo => { - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - let (px, py) = point(); - knots.push(Knot::corner(px, py)); - } - PdfPathSegmentType::BezierTo => { - // PDF draws a cubic Bézier as three consecutive BezierTo points: the - // two control points, then the end point. - bezier.push(point()); - if bezier.len() == 3 { - let ((c1x, c1y), (c2x, c2y), (ex, ey)) = (bezier[0], bezier[1], bezier[2]); - if let Some(prev) = knots.last_mut() { - prev.out_ctrl = Point::new(c1x, c1y); - } - if !budget.claim_knots(1) { - truncated = true; - break 'objects; - } - knots.push(Knot::smooth(Point::new(ex, ey), Point::new(c2x, c2y), Point::new(ex, ey))); - bezier.clear(); - } - } - PdfPathSegmentType::Unknown => {} - } - // A closing segment can also be the endpoint that closes a Bézier; process it - // above before marking the subpath closed so its control points are consumed. - if segment.is_close() { - closed = true; - } - } - if !knots.is_empty() { - subpaths.push(Subpath { closed, knots, op: PathOp::Combine }); - } - if subpaths.is_empty() { - continue; - } - let stroke = stroked.then(|| ShapeStroke { paint: Fill::Solid(Color::BLACK), width: 1.0, ..Default::default() }); - if !budget.claim_layer() { - truncated = true; - break 'objects; - } - layers.push(Layer::new( - "Path", - LayerContent::Shape(ShapeLayer { - path: Path::new(subpaths), - fill: filled.then_some(Fill::Solid(Color::BLACK)), - stroke, - ..Default::default() - }), - )); - } - _ => {} + union = union.saturating_add(width.saturating_mul(height)); } + if fits && union <= max_union_pixels { + return dpi; + } + if dpi <= 1.0 { + return 1.0; + } + // The strip's gaps contribute a constant width, so a geometric step converges without + // needing a closed form per page. + dpi = (dpi * 0.9).max(1.0); } - Ok((layers, truncated)) } -/// The highest DPI not above `requested` that keeps every page and the artboard strip within -/// their pixel budgets. Rendering at that DPI keeps each page's physical size unchanged. +/// Maps a craft-pdf open failure to the importer's error. #[cfg(not(target_arch = "wasm32"))] -fn effective_dpi(requested: f64, sizes: &[(f64, f64)], gap: u32, max_page: f64, max_union: u64) -> f64 { - let mut dpi = requested; - for (w, h) in sizes { - dpi = dpi.min(72.0 * (max_page / (w * h)).sqrt()); +fn open_error(error: craft_pdf::OpenError) -> IoError { + use craft_pdf::OpenError; + match error { + OpenError::InputLimit { limit } => IoError::Unsupported(format!("PDF is larger than the {limit}-byte input limit")), + OpenError::Allocation => IoError::Unsupported("PDF needs more memory than is available".into()), + OpenError::BackendPanic => IoError::Unsupported("PDF reader failed on this file; it may be damaged".into()), + OpenError::Load(error) => IoError::Unsupported(format!("PDF: {error:?}")), } - let n = sizes.len() as f64; - let sum_w: f64 = sizes.iter().map(|(w, _)| w).sum(); - let max_h = sizes.iter().map(|(_, h)| h).fold(0.0_f64, |a, &h| a.max(h)); - // (sum_w * k + gaps) * (max_h * k) <= max_union, where k = dpi / 72: solve a k² + b k - c = 0. - let gaps = f64::from(gap) * (n - 1.0); - let a = sum_w * max_h; - let b = gaps * max_h; - let k = if a > 0.0 { (-b + (b * b + 4.0 * a * max_union as f64).sqrt()) / (2.0 * a) } else { f64::INFINITY }; - dpi.min(72.0 * k).max(1.0) } -/// Loads the PDFium shared library from the executable's folder (`PDFIUM_DIR` overrides it). -/// -/// pdfium-render keeps the bindings in a process-wide static: the first import initializes them -/// and later `bind_to_library` calls fail with `PdfiumLibraryBindingsAlreadyInitialized`. Remember -/// the outcome and reuse the initialized bindings, so opening a second PDF in one session works. +/// Maps a craft-pdf render failure to the importer's error. #[cfg(not(target_arch = "wasm32"))] -fn bind() -> Result { - use pdfium_render::prelude::Pdfium; - - static INIT: std::sync::OnceLock> = std::sync::OnceLock::new(); - let init = INIT.get_or_init(|| { - let dir = std::env::var_os("PDFIUM_DIR") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::current_exe().ok().and_then(|exe| exe.parent().map(std::path::Path::to_path_buf)).unwrap_or_default()); - let library = Pdfium::pdfium_platform_library_name_at_path(&dir); - let platform = Pdfium::pdfium_platform_library_name().to_string_lossy().into_owned(); - match Pdfium::bind_to_library(&library) { - Ok(bindings) => { - // Initializes the library inside pdfium-render's process-wide bindings. - drop(Pdfium::new(bindings)); - Ok(()) - } - Err(e) => Err(format!( - "PDF import: cannot load PDFium from {} ({e}); put {platform} next to the executable or set PDFIUM_DIR, then restart the application", - library.display() - )), - } - }); - match init { - // The bindings are already initialized; `default()` re-uses them. - Ok(()) => Ok(Pdfium::default()), - Err(message) => Err(IoError::Unsupported(message.clone())), - } -} - -#[cfg(test)] -mod tests { - use super::{MAX_OBJECT_LAYERS, MAX_PATH_KNOTS, ObjectBudget, PageFrame, PageGeometry}; - - /// A 595 x 842 pt page with no offset, rotated by `rotation` degrees. - fn geometry(rotation: u16) -> PageGeometry { - PageGeometry { width_pt: 595.0, height_pt: 842.0, origin_x: 0.0, origin_y: 0.0, crop_w: 595.0, crop_h: 842.0, rotation } - } - - /// A full-page frame at `dpi`, starting at document x `x`. - fn frame(dpi: f64, x: u32) -> PageFrame<'static> { - PageFrame { geometry: geometry(0), dpi, x, rgba: &[], width: 595, height: 842 } - } - - #[test] - /// Page points map through the DPI scale, the Y flip and the page's document X offset. - fn page_points_map_to_document_pixels() { - assert_eq!(frame(72.0, 0).to_doc(60.0, 420.0), (60.0, 422.0)); - // The same point at four times the resolution. - assert_eq!(frame(300.0, 0).to_doc(60.0, 420.0), (250.0, 1758.0)); - // Page 2 starts after page 1's width: the document X offset is part of the mapping. - assert_eq!(frame(300.0, 2480).to_doc(60.0, 420.0), (2730.0, 1758.0)); - } - - #[test] - /// The document-wide budget refuses layers and knots once its limits are reached. - fn object_budget_stops_at_its_limits() { - let mut budget = ObjectBudget::default(); - for _ in 0..MAX_OBJECT_LAYERS { - assert!(budget.claim_layer()); - } - assert!(!budget.claim_layer()); - let mut knots = ObjectBudget::default(); - assert!(knots.claim_knots(MAX_PATH_KNOTS)); - assert!(!knots.claim_knots(1)); - assert!(!knots.claim_knots(usize::MAX)); - } - #[test] - /// Crop-box offsets and 90/180/270 rotations land where the raster renders them. - fn crop_box_offsets_and_rotations_map_like_the_raster() { - // A 200 x 100 pt crop box at (10, 20), displayed rotated 90 degrees clockwise: 100 x 200. - let rotated = |rotation| PageFrame { - geometry: PageGeometry { width_pt: 100.0, height_pt: 200.0, origin_x: 10.0, origin_y: 20.0, crop_w: 200.0, crop_h: 100.0, rotation }, - dpi: 72.0, - x: 0, - rgba: &[], - width: 100, - height: 200, - }; - // At 72 dpi the rectangle (20, 30)..(60, 70) lands at (10, 10)..(50, 50). - assert_eq!(rotated(90).to_doc(20.0, 30.0), (10.0, 10.0)); - assert_eq!(rotated(90).to_doc(60.0, 70.0), (50.0, 50.0)); - // 180 mirrors X; 270 swaps the axes back. - assert_eq!(rotated(180).to_doc(20.0, 30.0), (190.0, 10.0)); - assert_eq!(rotated(270).to_doc(20.0, 30.0), (90.0, 190.0)); - // The page's document X offset is added after the page-space mapping. - let mut offset = rotated(90); - offset.x = 500; - assert_eq!(offset.to_doc(20.0, 30.0), (510.0, 10.0)); - } - - #[test] - /// The page sampler reads the requested pixel and reports black outside the page. - fn samples_the_rendered_page() { - // Two pixels: white then black. - let rgba = [255u8, 255, 255, 255, 0, 0, 0, 255]; - let page = PageFrame { - geometry: PageGeometry { width_pt: 2.0, height_pt: 1.0, origin_x: 0.0, origin_y: 0.0, crop_w: 2.0, crop_h: 1.0, rotation: 0 }, - dpi: 72.0, - x: 0, - rgba: &rgba, - width: 2, - height: 1, - }; - assert_eq!(page.color_at(0, 0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); - assert_eq!(page.color_at(1, 0), photocraft_doc::Color::BLACK); - assert_eq!(page.color_at(2, 0), photocraft_doc::Color::BLACK); - } - - #[test] - /// Glyph ink wins over the background inside a text box, while an empty box keeps background. - fn text_colour_samples_the_glyph_ink() { - // An 8 x 4 white page with one black glyph pixel at (2, 1). - let mut rgba = vec![255u8; 8 * 4 * 4]; - let glyph = (8 + 2) * 4; - rgba[glyph..glyph + 3].fill(0); - let page = PageFrame { - geometry: PageGeometry { width_pt: 8.0, height_pt: 4.0, origin_x: 0.0, origin_y: 0.0, crop_w: 8.0, crop_h: 4.0, rotation: 0 }, - dpi: 72.0, - x: 0, - rgba: &rgba, - width: 8, - height: 4, - }; - // The whole page box contains the glyph: the ink wins over the white background. - assert_eq!(page.text_color(0.0, 4.0, 8.0, 0.0), photocraft_doc::Color::BLACK); - // A box that contains only background keeps the background colour. - assert_eq!(page.text_color(4.0, 4.0, 8.0, 2.0), photocraft_doc::Color::rgb(1.0, 1.0, 1.0)); +fn render_error(page: usize, dpi: f64, error: raster::Error) -> IoError { + use raster::Error; + match error { + Error::Cancelled => IoError::Cancelled, + Error::OutputLimit => IoError::Unsupported(format!("PDF page {page} is too large at {dpi:.0} dpi; lower the import DPI")), + Error::InvalidPage => IoError::Unsupported(format!("PDF page {page} has an empty or invalid page box")), + Error::Allocation => IoError::Unsupported(format!("PDF page {page} needs more memory than is available")), + Error::BackendPanic => IoError::Unsupported(format!("PDF renderer failed on page {page}; it may be damaged")), + Error::InvalidOptions | Error::BufferLength => IoError::Unsupported(format!("PDF page {page} could not be rendered")), } } diff --git a/crates/io/tests/it/pdf_import.rs b/crates/io/tests/it/pdf_import.rs index ef02111309a..f63ff194820 100644 --- a/crates/io/tests/it/pdf_import.rs +++ b/crates/io/tests/it/pdf_import.rs @@ -1,239 +1,26 @@ -//! PDF import end-to-end through PDFium. +//! PDF import end-to-end through craft-pdf (pure Rust, no shared library). //! -//! A synthetic two-page PDF with non-origin red text, a transformed blue rectangle and green text -//! is imported at 72 and 300 DPI, and the resulting editable layers are checked against the -//! fixture's geometry and pixels. PDFium is loaded at run time: when it is unavailable (no -//! `PDFIUM_DIR`, no DLL beside the test binary) the test prints an explicit SKIPPED line and -//! returns instead of pretending the end-to-end wiring passed. +//! A six-page synthetic PDF (plain, CropBox offset, rotations 90/180/270 and an oversized +//! CropBox) imports at 72 and 300 DPI as one artboard per page, with the page's print size kept, +//! a blue rectangle sampled from the raster, and the renderer's side/pixel limits respected by +//! lowering the effective DPI on a huge page. use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use lopdf::{Document as PdfDocument, Object, Stream, dictionary}; -use pdfium_render::prelude::{PdfRenderConfig, Pdfium}; -use photocraft_doc::{Color, Document, Layer, LayerContent, ShapeLayer, TextLayer}; +use photocraft_doc::LayerContent; use photocraft_geom::Rect; -use photocraft_io::{ImportOptions, ImportResult, IoError, import_with_options_and_interrupt}; +use photocraft_io::{ImportOptions, IoError, import_with_options_and_interrupt}; use photocraft_raster::Interrupt; const PAGE_W: f64 = 200.0; const PAGE_H: f64 = 100.0; -const GAP: f64 = 80.0; -/// Imports the four-page fixture at 72 and 300 DPI and checks every layer it produces. -#[test] -fn imports_text_and_a_transformed_path_on_two_pages() { - let pdf = three_page_pdf(); - for dpi in [72.0f32, 300.0] { - let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; - let result = match import_with_options_and_interrupt("fixture.pdf", &pdf, &options, &photocraft_raster::Interrupt::NONE) { - Ok(result) => result, - Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { - eprintln!("SKIPPED the PDF import end-to-end test: PDFium is unavailable ({message})"); - // The Windows integration leg provisions PDFium and sets this: there a missing - // library must fail rather than let the runner report an unreached pass. - assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for this test but could not load: {message}"); - return; - } - Err(error) => panic!("PDF import failed at {dpi} dpi: {error}"), - }; - check(&result.document, dpi); - eprintln!("PDF import executed at {dpi} dpi: {} page groups, layer checks passed", result.document.layers.len()); - } -} - -/// Page 1: red 48 pt text at (20, 50). Page 2: a 20 x 10 pt rectangle scaled 2x and moved by -/// (10, 5) - corners (30, 25)..(70, 45) - plus green 48 pt text at (20, 20). Page 3: a crop box -/// offset to (10, 20, 210, 120) and rotated 90 degrees clockwise, with a 40 x 40 pt rectangle at -/// (20, 30)..(60, 70) that renders at (10, 10)..(50, 50) on a 100 x 200 page. -fn three_page_pdf() -> Vec { - let mut pdf = PdfDocument::with_version("1.5"); - let pages_id = pdf.new_object_id(); - let font_id = pdf.add_object(dictionary! { - "Type" => "Font", - "Subtype" => "Type1", - "BaseFont" => "Helvetica", - }); - let resources = pdf.add_object(dictionary! { - "Font" => dictionary! { "F1" => font_id }, - }); - let pages = [ - ("1 0 0 rg BT /F1 48 Tf 20 50 Td (Red) Tj ET", [0.0f32, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), - ("0 0 1 rg q 2 0 0 2 10 5 cm 10 10 20 10 re f Q 0 1 0 rg BT /F1 48 Tf 20 20 Td (Green) Tj ET", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, None), - ("0 0 1 rg 20 30 40 40 re f 0 0.5 0 rg BT /F1 24 Tf 40 60 Td (Rot) Tj ET", [10.0, 20.0, 210.0, 120.0], Some(90i64), None), - // A CropBox larger than the MediaBox: the rendered page and the mapping use the - // intersection, so the rectangle must not be offset by the crop box's negative origin. - ("0 0.5 0 rg 20 30 40 40 re f", [0.0, 0.0, PAGE_W as f32, PAGE_H as f32], None, Some([-50.0f32, -50.0, 250.0, 150.0])), - ]; - let mut kids = Vec::new(); - for (content, [left, bottom, right, top], rotate, crop) in pages { - let content_id = pdf.add_object(Stream::new(dictionary! {}, content.as_bytes().to_vec())); - let mut page = dictionary! { - "Type" => "Page", - "Parent" => pages_id, - "MediaBox" => Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)]), - "Contents" => content_id, - "Resources" => resources, - }; - if let Some(rotate) = rotate { - page.set("Rotate", rotate); - } - if let Some([left, bottom, right, top]) = crop { - page.set("CropBox", Object::Array(vec![Object::Real(left), Object::Real(bottom), Object::Real(right), Object::Real(top)])); - } - let page_id = pdf.add_object(page); - kids.push(Object::Reference(page_id)); - } - let count = kids.len() as i64; - pdf.objects.insert( - pages_id, - Object::Dictionary(dictionary! { - "Type" => "Pages", - "Kids" => Object::Array(kids), - "Count" => count, - }), - ); - let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); - pdf.trailer.set("Root", catalog); - let mut bytes = Vec::new(); - pdf.save_to(&mut bytes).expect("writing the PDF fixture"); - bytes -} - -/// Asserts the imported text and path layers at one DPI against the fixture geometry. -fn check(document: &Document, dpi: f32) { - let scale = f64::from(dpi) / 72.0; - let page_two_x = (PAGE_W * scale).round() + GAP; - - // Page 1: the editable text keeps its position and takes the red ink from the page. - let text = text_layer(page_children(document, 1)); - assert_red(&text.color, "page 1 text"); - let x = text.transform.m[4]; - let y = text.transform.m[5]; - assert!((x - 20.0 * scale).abs() < 8.0 * scale, "page 1 text x is {x} at {dpi} dpi"); - // 48 pt Helvetica's bounds top is about 35 pt above the baseline at y = 50. - assert!((y - 15.0 * scale).abs() < 20.0 * scale, "page 1 text y is {y} at {dpi} dpi"); - - // Page 2: the transformed rectangle lands at (30, 25)..(70, 45) plus the page's document X. - let shape = shape_layer(page_children(document, 2)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - assert!((min_x - (page_two_x + 30.0 * scale)).abs() < 4.0, "path min x is {min_x}, page 2 starts at {page_two_x} ({dpi} dpi)"); - assert!((max_x - (page_two_x + 70.0 * scale)).abs() < 4.0, "path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - (PAGE_H - 45.0) * scale).abs() < 4.0, "path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - (PAGE_H - 25.0) * scale).abs() < 4.0, "path max y is {max_y} ({dpi} dpi)"); - - let text = text_layer(page_children(document, 2)); - assert_green(&text.color, "page 2 text"); - assert!(text.transform.m[4] > page_two_x + 10.0 * scale, "page 2 text x misses the page offset: {} <= {page_two_x}", text.transform.m[4]); - - // Page 3: the crop box (10, 20, 210, 120) and /Rotate 90 put the rectangle at (10..50, 10..50). - let shape = shape_layer(page_children(document, 3)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - let page_three_x = 2.0 * (PAGE_W * scale).round() + 2.0 * GAP; - assert!((min_x - (page_three_x + 10.0 * scale)).abs() < 3.0, "rotated path min x is {min_x}, page 3 starts at {page_three_x} ({dpi} dpi)"); - assert!((max_x - (page_three_x + 50.0 * scale)).abs() < 3.0, "rotated path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - 10.0 * scale).abs() < 3.0, "rotated path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - 50.0 * scale).abs() < 3.0, "rotated path max y is {max_y} ({dpi} dpi)"); - - // Rotated text: the layer's transform carries the 90 degree turn, not just its position. - let text = text_layer(page_children(document, 3)); - let m = text.transform.m; - assert!(m[0].abs() < 0.01 && (m[1] - 1.0).abs() < 0.01 && (m[2] + 1.0).abs() < 0.01 && m[3].abs() < 0.01, "page 3 text rotation is {m:?}"); - assert!(m[4] > page_three_x, "page 3 text x misses the page offset: {} <= {page_three_x}", m[4]); - assert!(text.color.c[1] > 0.3 && text.color.c[0] < 0.2 && text.color.c[2] < 0.2, "page 3 text should be green, got {:?}", text.color.c); - - // Page 4: a CropBox (-50, -50, 250, 150) around a 200 x 100 MediaBox must not offset the rect. - let shape = shape_layer(page_children(document, 4)); - let (mut min_x, mut min_y, mut max_x, mut max_y) = (f64::MAX, f64::MAX, f64::MIN, f64::MIN); - for knot in shape.path.subpaths.iter().flat_map(|subpath| &subpath.knots) { - min_x = min_x.min(knot.anchor.x); - max_x = max_x.max(knot.anchor.x); - min_y = min_y.min(knot.anchor.y); - max_y = max_y.max(knot.anchor.y); - } - // Page 3 is rotated 90 degrees, so it is 100 pt wide on the strip, not 200. - let page_four_x = 2.0 * (PAGE_W * scale).round() + (100.0 * scale).round() + 3.0 * GAP; - assert!((min_x - (page_four_x + 20.0 * scale)).abs() < 3.0, "oversized-crop path min x is {min_x}, page 4 starts at {page_four_x} ({dpi} dpi)"); - assert!((max_x - (page_four_x + 60.0 * scale)).abs() < 3.0, "oversized-crop path max x is {max_x} ({dpi} dpi)"); - assert!((min_y - 30.0 * scale).abs() < 3.0, "oversized-crop path min y is {min_y} ({dpi} dpi)"); - assert!((max_y - 70.0 * scale).abs() < 3.0, "oversized-crop path max y is {max_y} ({dpi} dpi)"); -} - -/// The layer children of the imported `Page N` group. -fn page_children(document: &Document, page: usize) -> &[Layer] { - let name = format!("Page {page}"); - let layer = document.layers.iter().find(|layer| layer.name == name).unwrap_or_else(|| panic!("no {name} layer")); - match &layer.content { - LayerContent::Group(group) => &group.children, - _ => panic!("{name} is not a group"), - } -} - -/// The first text layer among `children`. -fn text_layer(children: &[Layer]) -> &TextLayer { - children - .iter() - .find_map(|layer| match &layer.content { - LayerContent::Text(text) => Some(text), - _ => None, - }) - .expect("a text layer") -} - -/// The first shape layer among `children`. -fn shape_layer(children: &[Layer]) -> &ShapeLayer { - children - .iter() - .find_map(|layer| match &layer.content { - LayerContent::Shape(shape) => Some(shape), - _ => None, - }) - .expect("a shape layer") -} - -/// Asserts `color` is the fixture's red ink. -fn assert_red(color: &Color, what: &str) { - assert!(color.c[0] > 0.8 && color.c[1] < 0.2 && color.c[2] < 0.2, "{what} should be red, got {:?}", color.c); -} - -/// Asserts `color` is the fixture's green ink. -fn assert_green(color: &Color, what: &str) { - assert!(color.c[1] > 0.8 && color.c[0] < 0.2 && color.c[2] < 0.2, "{what} should be green, got {:?}", color.c); -} - -/// Imports `bytes`, or prints SKIPPED and returns `None` when PDFium cannot load. The Windows -/// integration leg sets `PHOTOCRAFT_REQUIRE_PDFIUM`, where a missing library must fail. -fn imported_or_skipped(bytes: &[u8], options: &ImportOptions, ctl: &Interrupt<'_>, what: &str) -> Option { - match import_with_options_and_interrupt("probe.pdf", bytes, options, ctl) { - Ok(result) => Some(result), - Err(IoError::Unsupported(message)) if message.contains("cannot load PDFium") => { - eprintln!("SKIPPED {what}: PDFium is unavailable ({message})"); - assert!(std::env::var_os("PHOTOCRAFT_REQUIRE_PDFIUM").is_none(), "PDFium is required for {what} but could not load: {message}"); - None - } - Err(error) => panic!("{what} failed: {error}"), - } -} - -/// Six one-page variants for the cancellation and fidelity probes: plain, a CropBox offset to -/// (10, 20, 210, 120), rotations of 90, 180 and 270 degrees, and an oversized CropBox -/// (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page draws a 40 x 40 blue rectangle -/// and a 10 x 10 red one at (20, 30). +/// Six one-page variants: plain, CropBox offset to (10, 20, 210, 120), rotations of 90/180/270 +/// degrees and an oversized CropBox (-50, -50, 250, 150) around the 200 x 100 MediaBox. Each page +/// draws a 40 x 40 blue rectangle at (20, 30) in PDF space. fn variant_pdf() -> Vec { - let mut pdf = PdfDocument::with_version("1.5"); - let pages_id = pdf.new_object_id(); let pages: [(Option, Option<[f32; 4]>); 6] = [ (None, None), (None, Some([10.0, 20.0, 210.0, 120.0])), @@ -242,9 +29,11 @@ fn variant_pdf() -> Vec { (Some(270), None), (None, Some([-50.0, -50.0, 250.0, 150.0])), ]; + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); let mut kids = Vec::new(); for (rotate, crop) in pages { - let content = b"0 0 1 rg 20 30 40 40 re f 1 0 0 rg 20 30 10 10 re f"; + let content = b"0 0 1 rg 20 30 40 40 re f"; let content_id = pdf.add_object(Stream::new(dictionary! {}, content.to_vec())); let mut page = dictionary! { "Type" => "Page", @@ -277,8 +66,102 @@ fn variant_pdf() -> Vec { bytes } -/// A cancel that lands on the last object's check, or on the public boundary after -/// `import_stages`, must be reported as `Cancelled` rather than returning a document. +/// One page of `width` x `height` points with the same blue rectangle. +fn one_page_pdf(width: f32, height: f32) -> Vec { + let mut pdf = PdfDocument::with_version("1.5"); + let pages_id = pdf.new_object_id(); + let content_id = pdf.add_object(Stream::new(dictionary! {}, b"0 0 1 rg 20 30 40 40 re f".to_vec())); + let page_id = pdf.add_object(dictionary! { + "Type" => "Page", + "Parent" => pages_id, + "MediaBox" => Object::Array(vec![Object::Real(0.0), Object::Real(0.0), Object::Real(width), Object::Real(height)]), + "Contents" => content_id, + "Resources" => dictionary! {}, + }); + pdf.objects.insert( + pages_id, + Object::Dictionary(dictionary! { + "Type" => "Pages", + "Kids" => Object::Array(vec![Object::Reference(page_id)]), + "Count" => 1, + }), + ); + let catalog = pdf.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id }); + pdf.trailer.set("Root", catalog); + let mut bytes = Vec::new(); + pdf.save_to(&mut bytes).expect("writing the page PDF"); + bytes +} + +/// The first artboard rectangle of `document`. +fn first_artboard(document: &photocraft_doc::Document) -> Rect { + let layer = document.layers.first().expect("an imported page group"); + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a group", layer.name), + }; + group.artboard.as_ref().expect("page artboard").rect +} + +#[test] +fn imports_every_page_as_an_artboard_with_its_print_size() { + let pdf = variant_pdf(); + for dpi in [72.0f32, 300.0] { + let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert_eq!(result.document.layers.len(), 6, "one artboard per page at {dpi} dpi"); + let mut top_left = 0i32; + for (index, layer) in result.document.layers.iter().enumerate() { + let group = match &layer.content { + LayerContent::Group(group) => group, + _ => panic!("{} is not a page group", layer.name), + }; + let artboard = group.artboard.as_ref().expect("page artboard").rect; + let expected = match index { + // CropBox [10, 20, 210, 120] intersected with the MediaBox is 190 x 80 points. + 1 => (190.0, 80.0), + // Rotations 90 and 270 swap the axes. + 2 | 4 => (100.0, 200.0), + _ => (200.0, 100.0), + }; + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(dpi); + let print_height = f64::from(artboard.height()) * 72.0 / f64::from(dpi); + assert!((print_width - expected.0).abs() < 1.0, "page {} width is {print_width:.1} pt, expected {} at {dpi} dpi", index + 1, expected.0); + assert!((print_height - expected.1).abs() < 1.0, "page {} height is {print_height:.1} pt, expected {} at {dpi} dpi", index + 1, expected.1); + assert_eq!(artboard.x0, top_left, "pages lay out side by side from x=0"); + top_left = artboard.x1 + 80; + let surface = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); + if index == 0 { + // The rectangle's centre in PDF space is (40, 50); at the document's top-left + // origin that is y = PAGE_H - 50. + let scale = f64::from(dpi) / 72.0; + let x = artboard.x0 + (40.0 * scale).round() as i32; + let y = ((PAGE_H - 50.0) * scale).round() as i32; + let mut pixel = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(x, y, 1, 1), &mut pixel); + assert!(pixel[0][2] > 120 && pixel[0][0] < 120, "page 1 should be blue inside the rectangle, got {:?}", pixel[0]); + let mut background = [[0u8; 4]; 1]; + surface.read_rgba8_into(Rect::from_xywh(artboard.x0 + 2, 2, 1, 1), &mut background); + assert!(background[0][0] > 240 && background[0][1] > 240 && background[0][2] > 240, "page background should be white, got {:?}", background[0]); + } + } + } +} + +#[test] +fn huge_pages_lower_the_effective_dpi_within_the_renderer_bounds() { + // 3000 pt at 300 dpi would be 12500 px wide, over craft-pdf's 8192 px side limit. + let pdf = one_page_pdf(3000.0, 100.0); + let options = ImportOptions { pdf_dpi: 300.0, ..Default::default() }; + let result = import_with_options_and_interrupt("wide.pdf", &pdf, &options, &Interrupt::NONE).expect("PDF import"); + assert!(result.warnings.iter().any(|warning| warning.contains("rendered at")), "warnings: {:?}", result.warnings); + let artboard = first_artboard(&result.document); + assert!(artboard.width() <= 8192, "width {} is over the renderer's side limit", artboard.width()); + assert!(result.document.resolution_dpi < 300.0, "the effective DPI should be lowered"); + let print_width = f64::from(artboard.width()) * 72.0 / f64::from(result.document.resolution_dpi); + assert!((print_width - 3000.0).abs() < 1.0, "print width {print_width:.1} pt should stay 3000 pt"); +} + #[test] fn cancelling_on_the_last_checks_is_reported() { let pdf = variant_pdf(); @@ -289,17 +172,16 @@ fn cancelling_on_the_last_checks_is_reported() { false }; let ctl = Interrupt::cancel_only(&cancel); - if imported_or_skipped(&pdf, &options, &ctl, "the last-check cancellation test").is_none() { - return; - } + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); + assert!(result.is_ok(), "uncancelled import failed: {:?}", result.err()); let total = count.load(Ordering::SeqCst); assert!(total >= 3, "a complete import should check cancellation at least three times, got {total}"); - for (label, threshold) in [("the last per-object check", total - 1), ("the public boundary check", total)] { + for (label, threshold) in [("the last page check", total - 1), ("the public boundary check", total)] { let seen = AtomicUsize::new(0); let cancel = || seen.fetch_add(1, Ordering::SeqCst) + 1 >= threshold; let ctl = Interrupt::cancel_only(&cancel); let started = Instant::now(); - let result = import_with_options_and_interrupt("probe.pdf", &pdf, &options, &ctl); + let result = import_with_options_and_interrupt("variants.pdf", &pdf, &options, &ctl); let elapsed = started.elapsed(); assert_eq!(seen.load(Ordering::SeqCst), threshold, "cancelling on {label} should stop at check {threshold} of {total}"); match result { @@ -310,52 +192,3 @@ fn cancelling_on_the_last_checks_is_reported() { assert!(elapsed < Duration::from_secs(10), "cancelling on {label} took {elapsed:?}"); } } - -/// Every imported page's stored raster must equal a direct PDFium render of the same page at the -/// same target width: crop intersection, rotation and the strip placement may not move or resample -/// a pixel. At 72, 150 and 300 DPI over the six variants that is 18 page renders. -#[test] -fn raster_matches_a_direct_pdfium_render() { - let pdf = variant_pdf(); - let mut compared = 0; - for dpi in [72.0f32, 150.0, 300.0] { - let options = ImportOptions { pdf_dpi: dpi, ..Default::default() }; - let Some(result) = imported_or_skipped(&pdf, &options, &Interrupt::NONE, "the raster fidelity matrix") else { - return; - }; - let pdfium = Pdfium::default(); - let reference = pdfium.load_pdf_from_byte_slice(&pdf, None).expect("reference PDF"); - assert_eq!(reference.pages().len() as usize, result.document.layers.len(), "page count at {dpi} dpi"); - for (index, layer) in result.document.layers.iter().enumerate() { - let group = match &layer.content { - LayerContent::Group(group) => group, - _ => panic!("page layer {} is not a group", layer.name), - }; - let artboard = group.artboard.as_ref().expect("page artboard").rect; - let raster = group.children.first().and_then(|layer| layer.surface()).expect("page raster layer"); - let (width, height) = (artboard.width(), artboard.height()); - let mut got = vec![[0u8; 4]; (width as usize) * (height as usize)]; - raster.read_rgba8_into(Rect::from_xywh(artboard.x0, artboard.y0, width, height), &mut got); - - let page = reference.pages().get(index as i32).expect("reference page"); - let bitmap = page.render_with_config(&PdfRenderConfig::new().set_target_width(width as i32)).expect("reference render"); - assert_eq!((bitmap.width() as u32, bitmap.height() as u32), (width, height), "page {} size at {dpi} dpi", index + 1); - let want = bitmap.as_rgba_bytes(); - - let mut max_diff = 0u32; - let mut sum = 0u64; - for (pixel, reference) in got.iter().zip(want.as_chunks::<4>().0.iter()) { - for channel in 0..4 { - let diff = (pixel[channel] as i32 - reference[channel] as i32).unsigned_abs(); - sum += u64::from(diff); - max_diff = max_diff.max(diff); - } - } - let channels = u64::from(width) * u64::from(height) * 4; - let mean = sum as f64 / channels as f64; - assert!(max_diff <= 1, "page {} at {dpi} dpi differs from the direct render: max channel diff {max_diff}, mean {mean:.4}", index + 1); - compared += 1; - } - } - assert_eq!(compared, 18, "six pages at three DPIs should all be compared"); -} diff --git a/docs/architecture.md b/docs/architecture.md index 0125f266939..ee9650cb102 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,7 +183,7 @@ photocraft/ 3. **`photocraft-psd` depends on no workspace crate.** The doc ⇄ PSD mapping lives in `io`. This keeps the PSD crate publishable and reusable by other projects. The same holds for every standalone crate, with one documented exception: `codecs` → `heif` (both standalone and publishable; `STANDALONE_EXCEPTIONS` in `xtask/src/layers.rs`). 4. **wasm gate:** every crate in L0–L5 (except feature-gated native backends) must build for `wasm32-unknown-unknown`. CI runs `cargo build -p photocraft-engine --target wasm32-unknown-unknown --no-default-features --features web`. 5. `gpu` is optional for `engine`. Engine features are `gpu` (default on) and `cpu-only`, and `cpu-only` builds are what the headless CLI and CI tests use. -6. **C dependencies** (optional LibRaw, pdfium) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. +6. **C dependencies** (optional LibRaw) only behind features, only in `codecs`, `raw` or `io`, and never on by default for the web target. --- diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 468365c6f6d..02a2da7f79c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -40,7 +40,7 @@ XL are missing (gap G10). Remaining: 300–500 h including PSD fidelity. | Portable Bit Map (PBM/PGM/PPM/PNM/PFM/PAM) | R/W | yes | yes | 8/16/32f | | FITS | R/W (FITS Liberator plug-in) | yes | yes | 8/16/32f gray or RGB, header cards kept; no fpack, no debayer | | Targa | R/W | yes | yes | 8-bit | -| Photoshop PDF | R/W | no (#2541) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | No vector/text PDF, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10); read still missing (#2541) | +| Photoshop PDF | R/W | yes, raster pages (craft-pdf; up to 16 pages, 8192 px per side, 64M px per page) | yes, one flattened page (Save As / Save a Copy / CLI, #3401; also print and artboards-to-PDF) | Read renders each page as a raster artboard: no editable text/vector layers yet (craft-pdf `interpret` is the follow-up), no PDF forms or annotations; write is still one flattened page, no multi-page save, no PDF presentation, layers are not preserved (FILE-215-10) | | Photoshop EPS, DCS 1.0/2.0, EPS TIFF/PICT preview | R/W | no | no | | | Generic EPS / AI | R (rasterize) | no | no | | | SVG | R (rasterize) | yes, as editable shape layers or a vector smart object | no | Better than Photoshop for editing; gradients approximated (FILE-215-9) | @@ -145,6 +145,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. Embedded SVG and PDF-compatible AI/PDF/EPS payloads can use an explicit VectorCraft editor choice, including nested PSB contents. AI/PDF/EPS working copies retain their format on save-back; a separate source-bound SVG display preview does not replace the native payload. Placement, shared instances, masks and Undo/Redo use existing smart-object transactions, with native bytes and previews persisted through .pcraft and PSD/PSB saves. Unsupported editor features may still change; standalone EPS/DCS import and lossless Adobe round-trip compatibility remain unverified. See [workflow, safety and validation limits](embedded-vector-editing.md). No corpus floor or parity percentage is changed. ## Revision history +| 2026-11-07 | minor | Photoshop PDF read: pages import as raster artboards through the shared craft-pdf (Hayro) renderer, up to 16 pages, 8192 px per side and 64M px per page; editable text/vectors and PDFium are gone. Write is unchanged. | The original reference row retains its source date and historical totals; current totals are in the status and Actions row above. The original reference entry retains its source date and historical totals; current totals are in the status above. diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json index 8d01fab4ac6..fcba1850ae2 100644 --- a/packaging/windows/install-policy.json +++ b/packaging/windows/install-policy.json @@ -15,8 +15,7 @@ ], "payloads": [ "photocraft-cli.exe", - "photocraft.exe", - "pdfium.dll" + "photocraft.exe" ], "registry": [ [ diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 8b4e8d0fea9..d984e3930a6 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -55,77 +55,6 @@ New-Item -ItemType Directory -Force -Path $Dist | Out-Null if (-not $env:PHOTOCRAFT_BUILD_SHA) { $env:PHOTOCRAFT_BUILD_SHA = (git -C $Root rev-parse HEAD 2>$null) } if (-not $env:PHOTOCRAFT_BUILD_DATE) { $env:PHOTOCRAFT_BUILD_DATE = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') } -# PDF import loads PDFium at runtime. Stage the Windows build's DLL before cargo, so the app's -# build script copies it beside the exe and both the MSI and the portable ZIP ship it. -# -# Pinned to a reviewed pdfium-binaries release: the archive is verified before extraction and a -# cached DLL is verified before reuse, so a moved or compromised "latest" cannot be packaged. -# Bump the release and both hashes together after re-checking the published build. -$PdfiumRelease = 'chromium/8138' -$PdfiumArchiveSha256 = @{ - x64 = '8d48973e3eb472dda0f5f2435e595ac043f9f2219dd71883392f549445afa8a5' - x86 = '5c32d44711a73ed8ae86b85322078da8444fc696b9326527eed2d9fcd620fc84' - arm64 = '5e04abdb36ba9b4b5c8630123eaf042036f8a62abda6e5529708fee3d35fbf4f' -} -$PdfiumDllSha256 = @{ - x64 = '71a3e63e6ab8f5746a0fc9de9bc3d0c0dc07eb60990dfd37d9db7fc891821943' - x86 = 'ee592bd01eaef454aada95ed483ceca1c4f6ce0f0b9568608f40c3861fbda035' - arm64 = '0b1b9a52b3297448cbe461ffbca1455603d85161d5e96090e1a89bd7c4a62a33' -} -$PdfiumDir = Join-Path $Root "target\pdfium\$Arch" -$PdfiumDll = Join-Path $PdfiumDir 'pdfium.dll' -$PdfiumLicense = Join-Path $PdfiumDir 'LICENSE' -$PdfiumLicenses = Join-Path $PdfiumDir 'licenses' -$PdfiumTag = Join-Path $PdfiumDir 'release.txt' - -function Get-Sha256([string] $Path) { (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() } - -# A cache from another pinned release (or missing its licence set) is discarded whole. -$cachedRelease = if (Test-Path -LiteralPath $PdfiumTag) { (Get-Content -Raw -LiteralPath $PdfiumTag).Trim() } else { '' } -if ($cachedRelease -ne $PdfiumRelease) { - Remove-Item -LiteralPath $PdfiumDir -Recurse -Force -ErrorAction SilentlyContinue -} -if (Test-Path -LiteralPath $PdfiumDll) { - $actualDll = Get-Sha256 $PdfiumDll - if ($actualDll -ne $PdfiumDllSha256[$Arch]) { - Write-Warning "cached PDFium DLL is not the pinned $PdfiumRelease build ($actualDll); re-downloading" - Remove-Item -LiteralPath $PdfiumDll -Force - } -} -if (-not ((Test-Path -LiteralPath $PdfiumDll) -and (Test-Path -LiteralPath $PdfiumLicense) -and (Test-Path -LiteralPath $PdfiumLicenses))) { - $expectedArchive = $PdfiumArchiveSha256[$Arch] - if (-not $expectedArchive) { throw "no pinned PDFium checksum for $Arch" } - $tgz = Join-Path $env:TEMP "pdfium-win-$Arch.tgz" - Invoke-Native "download PDFium ($Arch, $PdfiumRelease)" { - Remove-Item -LiteralPath $tgz -Force -ErrorAction SilentlyContinue - Invoke-WebRequest -Uri "https://github.com/bblanchon/pdfium-binaries/releases/download/$PdfiumRelease/pdfium-win-$Arch.tgz" -OutFile $tgz - $actualArchive = Get-Sha256 $tgz - if ($actualArchive -ne $expectedArchive) { throw "PDFium $Arch archive checksum $actualArchive does not match the pinned $expectedArchive" } - New-Item -ItemType Directory -Force -Path $PdfiumDir | Out-Null - # A DLL left in bin\ by an earlier run must not be mistaken for this archive's extraction. - $extracted = Join-Path $PdfiumDir 'bin\pdfium.dll' - Remove-Item -LiteralPath $extracted -Force -ErrorAction SilentlyContinue - tar -xzf $tgz -C $PdfiumDir - if ($LASTEXITCODE -ne 0) { throw "PDFium $Arch archive extraction failed with exit code $LASTEXITCODE" } - if (-not (Test-Path -LiteralPath $extracted)) { throw 'PDFium archive had no bin/pdfium.dll' } - Move-Item -Force $extracted $PdfiumDll - Set-Content -LiteralPath $PdfiumTag -Value $PdfiumRelease -NoNewline - if ((Get-Sha256 $PdfiumDll) -ne $PdfiumDllSha256[$Arch]) { throw "extracted PDFium $Arch DLL checksum does not match the pinned build" } - Remove-Item -LiteralPath $tgz -Force - } -} -$env:PDFIUM_DLL = $PdfiumDll - -# PDFium's BSD licence and the licences of the libraries bundled into its DLL ship beside it. They -# are staged flat with a `pdfium-` prefix, so both the MSI's INSTALLFOLDER and the portable root -# carry them whether the DLL came from this download or the cache. -function Stage-PdfiumLicences([string] $Dest) { - Copy-Item -LiteralPath $PdfiumLicense -Destination (Join-Path $Dest 'pdfium-LICENSE.txt') -Force - Get-ChildItem -LiteralPath $PdfiumLicenses -File | ForEach-Object { - Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $Dest ("pdfium-license-" + $_.Name)) -Force - } -} - Write-Output "PhotoCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { @@ -160,8 +89,6 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'photocraft.exe'), (Join-Path $Bin 'photocraft-cli.exe') $Stage -Copy-Item -LiteralPath $PdfiumDll -Destination $Stage -Stage-PdfiumLicences $Stage & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'photocraft.exe') (Join-Path $Stage 'photocraft-cli.exe') @@ -244,8 +171,6 @@ $Portable = Join-Path $TargetDir "windows-package\photocraft-$Version-windows-$A Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable -Copy-Item -LiteralPath $PdfiumDll -Destination $Portable -Stage-PdfiumLicences $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } diff --git a/packaging/windows/photocraft.wxs b/packaging/windows/photocraft.wxs index f727880a5a6..6ff961fd994 100644 --- a/packaging/windows/photocraft.wxs +++ b/packaging/windows/photocraft.wxs @@ -33,7 +33,6 @@ - - - - - -