mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
M6.4: PDF export (krilla) + file.exportPdf
New crate designcraft-pdf (L3): pages or spreads with MediaBox/TrimBox/BleedBox,
parent items with per-page folios, fills (solid + gradient swatches), aligned/dashed
strokes, corner options, opacity/blend, simple drop shadow, clipped images (JPEG
passthrough), and real text: composed glyph runs with embedded font subsets and
Unicode mapping from the story. CMYK as DeviceCMYK, RGB as DeviceRGB, spot swatches
as Separation, [Registration] as /All. Crop/bleed marks and page info. PDF/A-2b via
krilla; PDF/X-4 writes PDF 1.6 (output intent TODO).
Engine command file.exportPdf {path?, pages?, spreads?, bleed?, marks?, standard?,
compressImages?, title?, author?} (base64 when no path), CLI --export out.pdf with
--pdf-options, File > Export PDF... in the UI.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
91ae2b3cc9
commit
cc4a8610c2
15 files changed
+1984
-9
No files matched your search
Generated
+411
-4
@@ -134,6 +134,21 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloc-no-stdlib"
|
||||
version = "2.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3"
|
||||
|
||||
[[package]]
|
||||
name = "alloc-stdlib"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "allocator-api2"
|
||||
version = "0.2.21"
|
||||
@@ -194,6 +209,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -395,6 +416,12 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -468,6 +495,27 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[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"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -684,6 +732,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_maths"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
|
||||
dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -807,8 +864,13 @@ dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"designcraft-pdf",
|
||||
"designcraft-render",
|
||||
"designcraft-tools",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -836,6 +898,25 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
"designcraft-fonts",
|
||||
"designcraft-geom",
|
||||
"hayro-cmap",
|
||||
"hayro-interpret",
|
||||
"hayro-syntax",
|
||||
"image",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.1.0"
|
||||
@@ -1102,7 +1183,7 @@ dependencies = [
|
||||
"ecolor",
|
||||
"emath",
|
||||
"epaint_default_fonts",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"harfrust 0.12.0",
|
||||
"log",
|
||||
"nohash-hasher",
|
||||
@@ -1217,6 +1298,15 @@ dependencies = [
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -1229,6 +1319,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"
|
||||
@@ -1565,6 +1664,82 @@ dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[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.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
"hayro-syntax",
|
||||
"kurbo",
|
||||
"moxcms",
|
||||
"phf",
|
||||
"rustc-hash 2.1.3",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"yoke",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
|
||||
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.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -1606,6 +1781,12 @@ dependencies = [
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "imagesize"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -1727,6 +1908,34 @@ version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
"pdf-writer",
|
||||
"png",
|
||||
"rustc-hash 2.1.3",
|
||||
"rustybuzz",
|
||||
"siphasher",
|
||||
"skrifa 0.42.1",
|
||||
"smallvec",
|
||||
"subsetter",
|
||||
"tiny-skia-path",
|
||||
"xmp-writer",
|
||||
"yoke",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kurbo"
|
||||
version = "0.13.1"
|
||||
@@ -2357,6 +2566,18 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdf-writer"
|
||||
version = "0.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"itoa",
|
||||
"memchr",
|
||||
"ryu",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "peniko"
|
||||
version = "0.6.1"
|
||||
@@ -2719,6 +2940,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"
|
||||
@@ -2726,7 +2957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -2737,7 +2968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -2748,7 +2979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"font-types",
|
||||
"font-types 0.12.5",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
@@ -2883,6 +3114,30 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"core_maths",
|
||||
"log",
|
||||
"smallvec",
|
||||
"ttf-parser",
|
||||
"unicode-bidi-mirroring",
|
||||
"unicode-ccc",
|
||||
"unicode-properties",
|
||||
"unicode-script",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -3014,6 +3269,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"
|
||||
@@ -3139,12 +3404,36 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"rustc-hash 2.1.3",
|
||||
"skrifa 0.42.1",
|
||||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
@@ -3167,6 +3456,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3252,6 +3552,17 @@ dependencies = [
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -3323,6 +3634,15 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
|
||||
dependencies = [
|
||||
"core_maths",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "type-map"
|
||||
version = "0.5.1"
|
||||
@@ -3349,6 +3669,18 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi-mirroring"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ccc"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
@@ -3361,6 +3693,18 @@ version = "1.0.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-properties"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-script"
|
||||
version = "0.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
@@ -4228,6 +4572,19 @@ version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "write-fonts"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
|
||||
dependencies = [
|
||||
"font-types 0.11.3",
|
||||
"indexmap",
|
||||
"kurbo",
|
||||
"log",
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
version = "2.21.0"
|
||||
@@ -4291,6 +4648,12 @@ version = "0.8.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xmp-writer"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
@@ -4298,6 +4661,29 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
"zerofrom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke-derive"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
@@ -4424,6 +4810,27 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
|
||||
dependencies = [
|
||||
"zerofrom-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerofrom-derive"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zlib-rs"
|
||||
version = "0.6.8"
|
||||
|
||||
+3
-2
@@ -10,9 +10,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
- Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text.
|
||||
- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes.
|
||||
- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection (basic), Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom.
|
||||
- PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`.
|
||||
- ~100 commands, all reachable through the JSON control channel; headless CLI rendering to PNG.
|
||||
|
||||
**Next (in order):** MCP server · PDF export (krilla, PDF/X-4) · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
|
||||
**Next (in order):** MCP server · PDF/X-4 output intent, bookmarks, tagged PDF · IDML import/export · native format as zip · web build · Pen tool & Direct Selection anchors · Story Editor · Find/Change (+GREP) · tables · Liang hyphenation · smart guides · Links panel + relink · Preflight · TOC/index/footnotes · EPUB.
|
||||
|
||||
## Milestones
|
||||
|
||||
@@ -24,7 +25,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
| M3 | Styles (nested/GREP, bullets, keeps, span columns) | started |
|
||||
| M4 | Color & effects | started |
|
||||
| M5 | Graphics & links | started |
|
||||
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | next |
|
||||
| M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (PDF export) |
|
||||
| M7 | Long documents (sections, TOC, index, footnotes, books) | |
|
||||
| M8 | Tables | |
|
||||
| M9 | Performance (MT composition, tiles) | |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Headless DesignCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.designcraft] [--all-pages DIR]
|
||||
//! designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT.png|.jpg|.pdf|.designcraft] [--pdf-options JSON] [--all-pages DIR]
|
||||
//! designcraft-cli commands # list every command (JSON)
|
||||
//! designcraft-cli mcp [--connect PORT] # MCP server over stdio
|
||||
//! ```
|
||||
@@ -22,7 +22,7 @@ fn main() -> ExitCode {
|
||||
Some("mcp") => report(mcp(&args[1..])),
|
||||
_ => {
|
||||
eprintln!(
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT]"
|
||||
"usage: designcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--page N] [--scale S] [--pdf-options JSON] [--export OUT] [--all-pages DIR]\n designcraft-cli commands\n designcraft-cli mcp [--connect PORT]"
|
||||
);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
@@ -49,6 +49,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut scale = 1.0f64;
|
||||
let mut it = args.iter();
|
||||
let mut opened = false;
|
||||
let mut pdf_opts = json!({});
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = || it.next().cloned().ok_or_else(|| format!("{a} needs a value"));
|
||||
match a.as_str() {
|
||||
@@ -76,9 +77,22 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
"--page" => page = val()?.parse().map_err(|_| "bad --page")?,
|
||||
"--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
|
||||
"--pdf-options" => {
|
||||
pdf_opts = serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))?;
|
||||
}
|
||||
"--export" => {
|
||||
let out = val()?;
|
||||
export(&mut s, &out, page, scale)?;
|
||||
if out.ends_with(".pdf") {
|
||||
let mut p = pdf_opts.clone();
|
||||
p["path"] = json!(out);
|
||||
let r = s.execute("file.exportPdf", &p).map_err(|e| e.to_string())?;
|
||||
eprintln!("wrote {out} ({} pages, {} bytes)", r["pages"], r["bytes"]);
|
||||
for w in r["warnings"].as_array().into_iter().flatten() {
|
||||
eprintln!("warning: {}", w.as_str().unwrap_or_default());
|
||||
}
|
||||
} else {
|
||||
export(&mut s, &out, page, scale)?;
|
||||
}
|
||||
}
|
||||
"--all-pages" => {
|
||||
let dir = val()?;
|
||||
|
||||
@@ -15,8 +15,15 @@ designcraft-doc = { workspace = true }
|
||||
designcraft-fonts = { workspace = true }
|
||||
designcraft-compose = { workspace = true }
|
||||
designcraft-render = { workspace = true }
|
||||
designcraft-pdf = { workspace = true }
|
||||
designcraft-tools = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
hayro-cmap = "0.1"
|
||||
kurbo = { workspace = true }
|
||||
@@ -0,0 +1,88 @@
|
||||
//! File › Export: Adobe PDF (Print).
|
||||
|
||||
use designcraft_pdf::{Marks, PdfOptions, Standard};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_doc, str_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(noundo "file.exportPdf", "Export PDF…", ["File"], None,
|
||||
"{path?, pages?: \"1-3,5\" | [1,3] (1-based positions; default all), spreads?: bool, bleed?: bool (document bleed), marks?: bool | {crop?, bleed?, pageInfo?, weight?, offset?}, standard?: \"none\"|\"x4\"|\"a2b\", compressImages?: bool, title?, author?} → {path, bytes, pages, warnings} (no path: {base64, …})",
|
||||
has_doc, export_pdf)]
|
||||
}
|
||||
|
||||
pub(crate) fn options(p: &Value, page_count: usize) -> Result<PdfOptions> {
|
||||
const ID: &str = "file.exportPdf";
|
||||
|
||||
let pages = match p.get("pages") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(s)) => Some(designcraft_pdf::parse_page_range(s, page_count).map_err(|e| bad(ID, e.to_string()))?),
|
||||
Some(Value::Array(a)) => {
|
||||
let mut v = Vec::new();
|
||||
for x in a {
|
||||
let n = x.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {x}")))?;
|
||||
v.push(n as usize - 1);
|
||||
}
|
||||
Some(v)
|
||||
}
|
||||
Some(Value::Number(n)) => {
|
||||
let n = n.as_u64().filter(|n| *n >= 1 && (*n as usize) <= page_count).ok_or_else(|| bad(ID, format!("bad page {n}")))?;
|
||||
Some(vec![n as usize - 1])
|
||||
}
|
||||
Some(v) => return Err(bad(ID, format!("bad `pages`: {v}"))),
|
||||
};
|
||||
let spreads = p.get("spreads").and_then(Value::as_bool).unwrap_or(false);
|
||||
let bleed = p.get("bleed").and_then(Value::as_bool).unwrap_or(false);
|
||||
let marks = match p.get("marks") {
|
||||
Some(Value::Bool(true)) => Marks::ALL,
|
||||
Some(Value::Object(m)) => {
|
||||
let b = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
Marks {
|
||||
crop: b("crop"),
|
||||
bleed: b("bleed"),
|
||||
page_info: b("pageInfo"),
|
||||
weight: m.get("weight").and_then(Value::as_f64).unwrap_or(0.25),
|
||||
offset: m.get("offset").and_then(Value::as_f64).unwrap_or(6.0),
|
||||
}
|
||||
}
|
||||
_ => Marks::NONE,
|
||||
};
|
||||
let standard = match str_param(p, "standard") {
|
||||
Some(s) => Standard::parse(s).ok_or_else(|| bad(ID, format!("unknown standard `{s}` (none, x4, a2b)")))?,
|
||||
None => Standard::None,
|
||||
};
|
||||
Ok(PdfOptions {
|
||||
pages,
|
||||
spreads,
|
||||
bleed,
|
||||
marks,
|
||||
standard,
|
||||
compress_images: p.get("compressImages").and_then(Value::as_bool).unwrap_or(false),
|
||||
title: str_param(p, "title").map(str::to_string),
|
||||
author: str_param(p, "author").map(str::to_string),
|
||||
..PdfOptions::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = options(p, st.doc.page_count())?;
|
||||
let r = designcraft_pdf::export_pdf_with_report(&st.doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
write_file(path, &r.bytes)?;
|
||||
Ok(json!({"path": path, "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings}))
|
||||
}
|
||||
None => Ok(json!({"base64": super::base64_encode(&r.bytes), "bytes": r.bytes.len(), "pages": r.pages, "warnings": r.warnings})),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other(format!("{path}: no file system on the web; omit `path` to get the bytes")))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The command registry. Ids follow InDesign's menu structure.
|
||||
|
||||
mod edit;
|
||||
mod export;
|
||||
mod file;
|
||||
mod inspect;
|
||||
mod layout;
|
||||
@@ -100,6 +101,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
SPECS.get_or_init(|| {
|
||||
let mut v = Vec::new();
|
||||
v.extend(file::specs());
|
||||
v.extend(export::specs());
|
||||
v.extend(edit::specs());
|
||||
v.extend(object::specs());
|
||||
v.extend(text::specs());
|
||||
|
||||
@@ -104,3 +104,69 @@ fn every_command_has_metadata() {
|
||||
}
|
||||
assert!(command_specs().len() > 60);
|
||||
}
|
||||
|
||||
/// Unicode text per page, through hayro's interpreter (ToUnicode / ActualText).
|
||||
fn pdf_text(bytes: &[u8]) -> Vec<String> {
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
interpret_page,
|
||||
};
|
||||
struct Ex(String);
|
||||
impl Device<'_> for Ex {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
|
||||
match g.as_unicode() {
|
||||
Some(hayro_cmap::BfString::Char(c)) => self.0.push(c),
|
||||
Some(hayro_cmap::BfString::String(s)) => self.0.push_str(&s),
|
||||
None => self.0.push('\u{FFFD}'),
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).expect("valid PDF");
|
||||
let cache = InterpreterCache::new();
|
||||
pdf.pages()
|
||||
.iter()
|
||||
.map(|page| {
|
||||
let mut ctx =
|
||||
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
let mut ex = Ex(String::new());
|
||||
interpret_page(page, &mut ctx, &mut ex);
|
||||
ex.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_pdf_of_the_sample() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.newSample", &json!({})).unwrap();
|
||||
let r = s.execute("file.exportPdf", &json!({"bleed": true, "marks": true})).unwrap();
|
||||
assert_eq!(r["pages"], 4);
|
||||
let bytes = cmd::base64_decode(r["base64"].as_str().unwrap());
|
||||
assert!(bytes.starts_with(b"%PDF-"));
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
|
||||
assert_eq!(pdf.pages().len(), 4);
|
||||
let raw = String::from_utf8_lossy(&bytes);
|
||||
assert!(raw.contains("/FontFile2") || raw.contains("/FontFile3"), "fonts embedded");
|
||||
let text = pdf_text(&bytes);
|
||||
let all: String = text.join("\n");
|
||||
assert!(all.contains("Notes on the Grid"), "{all}");
|
||||
// Page range + spreads + file output.
|
||||
let dir = std::env::temp_dir().join(format!("dc-pdf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("out.pdf");
|
||||
let r = s.execute("file.exportPdf", &json!({"path": path.to_string_lossy(), "pages": "2-3", "spreads": true})).unwrap();
|
||||
assert_eq!(r["pages"], 1, "pages 2–3 are one spread");
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(s.execute("file.exportPdf", &json!({"pages": "9"})).is_err());
|
||||
assert!(s.execute("file.exportPdf", &json!({"standard": "bogus"})).is_err());
|
||||
}
|
||||
@@ -78,6 +78,10 @@ impl FontFace {
|
||||
pub fn hb(&self) -> Option<harfrust::FontRef<'_>> {
|
||||
harfrust::FontRef::from_index(self.data(), self.index).ok()
|
||||
}
|
||||
/// Face index within the font file (collections); 0 for plain fonts.
|
||||
pub fn index(&self) -> u32 {
|
||||
self.index
|
||||
}
|
||||
/// Unique id of this face within the process.
|
||||
pub fn id(&self) -> u32 {
|
||||
self.id
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "designcraft-pdf"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DesignCraft PDF export (krilla): real text with embedded font subsets, CMYK/spot colour, bleed and printer's marks"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
designcraft-fonts = { workspace = true }
|
||||
designcraft-compose = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
image = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = "0.7.2"
|
||||
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
|
||||
hayro-cmap = "0.1"
|
||||
kurbo = { workspace = true }
|
||||
@@ -0,0 +1,659 @@
|
||||
//! Document → PDF. The item walk mirrors `designcraft-render`'s `draw_spread` / `draw_item`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_color::swatch::{ColorType, SwatchValue};
|
||||
use designcraft_color::{BlendMode as DcBlend, Color, GradientKind};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::{AssetId, Content, Document, Item, PageSide, Spread, StrokeAlign, StrokeType};
|
||||
use designcraft_geom::kurbo::{PathEl, Shape as _};
|
||||
use designcraft_geom::{Affine, BezPath, Rect, Vec2, corners};
|
||||
use krilla::color::{cmyk, luma, rgb};
|
||||
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
|
||||
use krilla::geom::{Path, PathBuilder, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::page::PageSettings;
|
||||
use krilla::paint::{Fill, FillRule, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
|
||||
use krilla::surface::Surface;
|
||||
|
||||
use crate::{ExportReport, PdfError, PdfOptions, Result, Standard};
|
||||
|
||||
/// Export `doc` as PDF bytes.
|
||||
pub fn export_pdf(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
export_pdf_with_report(doc, cache, opts).map(|r| r.bytes)
|
||||
}
|
||||
|
||||
/// One output page: a document page or a spread, in spread coordinates.
|
||||
pub(crate) struct Sheet {
|
||||
pub spread: usize,
|
||||
pub trim: Rect,
|
||||
pub bleed: Rect,
|
||||
pub media: Rect,
|
||||
/// Page name(s) for the page-information mark.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
pub(crate) const MARK_LEN: f64 = 18.0;
|
||||
|
||||
fn bleed_lr(side: PageSide, b: [f64; 4]) -> (f64, f64) {
|
||||
// Bleed is top, bottom, inside, outside; single-sided pages read inside as left.
|
||||
if side == PageSide::Left { (b[3], b[2]) } else { (b[2], b[3]) }
|
||||
}
|
||||
|
||||
fn sheets(doc: &Document, opts: &PdfOptions) -> Result<Vec<Sheet>> {
|
||||
let count = doc.page_count();
|
||||
let pages: Vec<usize> = match &opts.pages {
|
||||
Some(v) => v.clone(),
|
||||
None => (0..count).collect(),
|
||||
};
|
||||
if pages.is_empty() {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
if let Some(bad) = pages.iter().find(|p| **p >= count) {
|
||||
return Err(PdfError::BadPage(bad + 1));
|
||||
}
|
||||
let b = doc.settings.bleed.map(|v| if opts.bleed { v.max(0.0) } else { 0.0 });
|
||||
let mut out: Vec<Sheet> = Vec::new();
|
||||
let mut seen_spreads: Vec<usize> = Vec::new();
|
||||
for abs in pages {
|
||||
let (si, pi) = doc.page_loc(abs).ok_or(PdfError::BadPage(abs + 1))?;
|
||||
let sp: &Spread = &doc.spreads[si];
|
||||
let (trim, l, r, label) = if opts.spreads {
|
||||
if seen_spreads.contains(&si) {
|
||||
continue;
|
||||
}
|
||||
seen_spreads.push(si);
|
||||
let first = doc.first_page_of_spread(si);
|
||||
let (l, _) = sp.pages.first().map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
|
||||
let (_, r) = sp.pages.last().map(|p| bleed_lr(p.side, b)).unwrap_or((b[2], b[3]));
|
||||
let names: Vec<String> = (0..sp.pages.len()).map(|i| doc.page_name(first + i)).collect();
|
||||
(sp.bounds(), l, r, names.join("–"))
|
||||
} else {
|
||||
let p = &sp.pages[pi];
|
||||
let (l, r) = bleed_lr(p.side, b);
|
||||
(p.bounds(), l, r, doc.page_name(abs))
|
||||
};
|
||||
let bleed = Rect::new(trim.x0 - l, trim.y0 - b[0], trim.x1 + r, trim.y1 + b[1]);
|
||||
let media = if opts.marks.any() {
|
||||
let m = mark_start(opts, b) + MARK_LEN + 6.0;
|
||||
trim.inflate(m, m)
|
||||
} else {
|
||||
bleed
|
||||
};
|
||||
out.push(Sheet { spread: si, trim, bleed, media, label });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Distance from the trim edge where marks start: the offset, but never inside the bleed.
|
||||
pub(crate) fn mark_start(opts: &PdfOptions, bleed: [f64; 4]) -> f64 {
|
||||
bleed.iter().copied().fold(opts.marks.offset.max(0.0), f64::max)
|
||||
}
|
||||
|
||||
/// Like [`export_pdf`], also returning warnings about approximated or dropped features.
|
||||
pub fn export_pdf_with_report(doc: &Document, cache: &Cache, opts: &PdfOptions) -> Result<ExportReport> {
|
||||
let sheets = sheets(doc, opts)?;
|
||||
let mut warnings = Vec::new();
|
||||
let (version, archival) = match opts.standard {
|
||||
Standard::None => (PdfVersion::Pdf17, None),
|
||||
Standard::PdfX4 => {
|
||||
warnings.push(
|
||||
"PDF/X-4: the output intent and PDF/X identification are not written yet; the file is PDF 1.6 with trim and bleed boxes".to_string(),
|
||||
);
|
||||
(PdfVersion::Pdf16, None)
|
||||
}
|
||||
Standard::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
|
||||
};
|
||||
let mut cb = ConfigurationBuilder::new().with_version(version);
|
||||
if let Some(a) = archival {
|
||||
cb = cb.with_archival_validator(a);
|
||||
}
|
||||
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
|
||||
let mut pdf = krilla::Document::new_with(settings);
|
||||
|
||||
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
|
||||
let mut meta = Metadata::new().creator("DesignCraft".into()).producer("DesignCraft".into());
|
||||
if !title.is_empty() {
|
||||
meta = meta.title(title.clone());
|
||||
}
|
||||
if let Some(a) = &opts.author {
|
||||
meta = meta.authors(vec![a.clone()]);
|
||||
}
|
||||
let created = opts.created.or_else(now_unix);
|
||||
if let Some(t) = created {
|
||||
let c = civil(t);
|
||||
meta = meta.creation_date(
|
||||
krilla::metadata::DateTime::new(c.0.clamp(0, 9999) as u16)
|
||||
.month(c.1)
|
||||
.day(c.2)
|
||||
.hour(c.3)
|
||||
.minute(c.4)
|
||||
.second(c.5)
|
||||
.utc_offset_hour(0)
|
||||
.utc_offset_minute(0),
|
||||
);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
|
||||
let mut ex = Exporter {
|
||||
doc,
|
||||
cache,
|
||||
opts,
|
||||
clip: Rect::ZERO,
|
||||
warnings,
|
||||
images: HashMap::new(),
|
||||
fonts: HashMap::new(),
|
||||
reverse_cmaps: HashMap::new(),
|
||||
rgb_only: archival.is_some(),
|
||||
};
|
||||
if ex.rgb_only {
|
||||
ex.warn("PDF/A: CMYK colours were converted to RGB (no CMYK output intent profile is available yet)");
|
||||
}
|
||||
for sh in &sheets {
|
||||
let size = Size::from_wh(sh.media.width().max(1.0) as f32, sh.media.height().max(1.0) as f32).ok_or(PdfError::NoPages)?;
|
||||
let local = |r: Rect| {
|
||||
krilla::geom::Rect::from_ltrb(
|
||||
(r.x0 - sh.media.x0) as f32,
|
||||
(r.y0 - sh.media.y0) as f32,
|
||||
(r.x1 - sh.media.x0) as f32,
|
||||
(r.y1 - sh.media.y0) as f32,
|
||||
)
|
||||
};
|
||||
let settings = PageSettings::new(size).with_trim_box(local(sh.trim)).with_bleed_box(local(sh.bleed));
|
||||
let mut page = pdf.start_page_with(settings);
|
||||
let mut s = page.surface();
|
||||
s.push_transform(&tf(Affine::translate(-sh.media.origin().to_vec2())));
|
||||
ex.clip = sh.bleed;
|
||||
if let Some(clip) = to_path(&sh.bleed.to_path(0.1)) {
|
||||
s.push_clip_path(&clip, &FillRule::NonZero);
|
||||
ex.spread(&mut s, sh.spread);
|
||||
s.pop();
|
||||
}
|
||||
ex.marks(&mut s, sh, &title, created);
|
||||
s.pop();
|
||||
s.finish();
|
||||
page.finish();
|
||||
}
|
||||
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
|
||||
let mut warnings = ex.warnings;
|
||||
warnings.dedup();
|
||||
Ok(ExportReport { bytes, pages: sheets.len(), warnings })
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn now_unix() -> Option<i64> {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn now_unix() -> Option<i64> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Unix seconds (UTC) → (year, month, day, hour, minute, second), proleptic Gregorian.
|
||||
pub(crate) fn civil(t: i64) -> (i64, u8, u8, u8, u8, u8) {
|
||||
let days = t.div_euclid(86_400);
|
||||
let secs = t.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let day = doy - (153 * mp + 2) / 5 + 1;
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = yoe + era * 400 + i64::from(month <= 2);
|
||||
(year, month as u8, day as u8, (secs / 3600) as u8, (secs / 60 % 60) as u8, (secs % 60) as u8)
|
||||
}
|
||||
|
||||
pub(crate) struct Exporter<'a> {
|
||||
pub doc: &'a Document,
|
||||
pub cache: &'a Cache,
|
||||
pub opts: &'a PdfOptions,
|
||||
/// Current sheet's visible area (spread space) for culling.
|
||||
pub clip: Rect,
|
||||
pub warnings: Vec<String>,
|
||||
images: HashMap<AssetId, Option<Image>>,
|
||||
pub fonts: HashMap<u32, Option<krilla::text::Font>>,
|
||||
pub reverse_cmaps: HashMap<u32, Arc<HashMap<u32, char>>>,
|
||||
/// Convert CMYK to RGB (PDF/A: krilla needs a CMYK output profile we don't ship yet).
|
||||
pub rgb_only: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn tf(a: Affine) -> Transform {
|
||||
let c = a.as_coeffs();
|
||||
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
|
||||
}
|
||||
|
||||
pub(crate) fn to_path(bp: &BezPath) -> Option<Path> {
|
||||
let mut pb = PathBuilder::new();
|
||||
for el in bp.elements() {
|
||||
match *el {
|
||||
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
|
||||
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
|
||||
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
|
||||
PathEl::ClosePath => pb.close(),
|
||||
}
|
||||
}
|
||||
pb.finish()
|
||||
}
|
||||
|
||||
pub(crate) fn norm(v: f32) -> NormalizedF32 {
|
||||
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
|
||||
}
|
||||
|
||||
fn q(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
|
||||
fn regular(c: &Color, rgb_only: bool) -> krilla::color::RegularColor {
|
||||
use krilla::color::RegularColor as R;
|
||||
if rgb_only && matches!(c, Color::Cmyk { .. }) {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
return R::Rgb(rgb::Color::new(q(r), q(g), q(b)));
|
||||
}
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => R::Rgb(rgb::Color::new(q(r), q(g), q(b))),
|
||||
Color::Cmyk { c, m, y, k } => R::Cmyk(cmyk::Color::new(q(c), q(m), q(y), q(k))),
|
||||
// DesignCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness.
|
||||
Color::Gray { k } => R::Luma(luma::Color::new(q(1.0 - k))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A process colour in its own device space (CMYK → DeviceCMYK, RGB → DeviceRGB, Gray → DeviceGray).
|
||||
/// `rgb_only` converts CMYK to RGB (PDF/A without a CMYK output profile).
|
||||
pub(crate) fn device(c: &Color, rgb_only: bool) -> krilla::color::Color {
|
||||
match regular(c, rgb_only) {
|
||||
krilla::color::RegularColor::Rgb(v) => v.into(),
|
||||
krilla::color::RegularColor::Cmyk(v) => v.into(),
|
||||
krilla::color::RegularColor::Luma(v) => v.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `[Registration]` prints on every plate: `/Separation /All`.
|
||||
pub(crate) fn registration(tint: f32, rgb_only: bool) -> krilla::color::Color {
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
let alt = regular(&Color::cmyk(1.0, 1.0, 1.0, 1.0), rgb_only);
|
||||
SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::AllColorants, alt)).into()
|
||||
}
|
||||
|
||||
fn blend(b: DcBlend) -> krilla::blend::BlendMode {
|
||||
use krilla::blend::BlendMode as K;
|
||||
match b {
|
||||
DcBlend::Normal => K::Normal,
|
||||
DcBlend::Darken => K::Darken,
|
||||
DcBlend::Multiply => K::Multiply,
|
||||
DcBlend::ColorBurn => K::ColorBurn,
|
||||
DcBlend::Lighten => K::Lighten,
|
||||
DcBlend::Screen => K::Screen,
|
||||
DcBlend::ColorDodge => K::ColorDodge,
|
||||
DcBlend::Overlay => K::Overlay,
|
||||
DcBlend::SoftLight => K::SoftLight,
|
||||
DcBlend::HardLight => K::HardLight,
|
||||
DcBlend::Difference => K::Difference,
|
||||
DcBlend::Exclusion => K::Exclusion,
|
||||
DcBlend::Hue => K::Hue,
|
||||
DcBlend::Saturation => K::Saturation,
|
||||
DcBlend::Color => K::Color,
|
||||
DcBlend::Luminosity => K::Luminosity,
|
||||
}
|
||||
}
|
||||
|
||||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
pub(crate) fn solid_fill(c: krilla::color::Color, opacity: f32) -> Fill {
|
||||
Fill { paint: c.into(), opacity: norm(opacity), rule: FillRule::NonZero }
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
pub(crate) fn warn(&mut self, w: impl Into<String>) {
|
||||
let w = w.into();
|
||||
if !self.warnings.contains(&w) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// A swatch + tint as a PDF colour: spot swatches (and tints of them) become `/Separation`
|
||||
/// with the swatch's values as the alternate space; process colours stay in their device space.
|
||||
pub(crate) fn swatch_color(&self, name: &str, tint: f32) -> Option<krilla::color::Color> {
|
||||
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
|
||||
let mut n = name;
|
||||
let mut t = tint;
|
||||
for _ in 0..8 {
|
||||
let sw = self.doc.swatch(n)?;
|
||||
match &sw.value {
|
||||
SwatchValue::None => return None,
|
||||
SwatchValue::Tint { base, tint: bt } => {
|
||||
t *= bt;
|
||||
n = base;
|
||||
}
|
||||
SwatchValue::Registration => return Some(registration(t, self.rgb_only)),
|
||||
SwatchValue::Color { color, color_type: ColorType::Spot } => {
|
||||
let space = SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), regular(color, self.rgb_only));
|
||||
return Some(SepColor::new(q(t), space).into());
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
self.doc.resolve_color(name, tint).map(|c| device(&c, self.rgb_only))
|
||||
}
|
||||
|
||||
fn spread(&mut self, s: &mut Surface, si: usize) {
|
||||
let doc = self.doc;
|
||||
let Some(sp) = doc.spreads.get(si) else { return };
|
||||
let first = doc.first_page_of_spread(si);
|
||||
for layer in doc.layers.iter().rev() {
|
||||
if !layer.visible || !layer.printable {
|
||||
continue;
|
||||
}
|
||||
for (pi, page) in sp.pages.iter().enumerate() {
|
||||
if !page.show_parent_items {
|
||||
continue;
|
||||
}
|
||||
let Some((ppi, ppage)) = doc.parent_page_for(first + pi) else { continue };
|
||||
let parent = &doc.parents[ppi];
|
||||
let dx = page.x - parent.pages[ppage].x;
|
||||
let page_name = doc.page_name(first + pi);
|
||||
for it in &parent.items {
|
||||
if it.layer != layer.id || page.overridden.contains(&it.id) {
|
||||
continue;
|
||||
}
|
||||
// Parent items belong to the parent page they sit on.
|
||||
if parent.page_at_x(it.bounds().center().x) != Some(ppage) && parent.pages.len() > 1 {
|
||||
continue;
|
||||
}
|
||||
self.item(s, it, Affine::translate((dx, 0.0)), Some(&page_name));
|
||||
}
|
||||
}
|
||||
for it in &sp.items {
|
||||
if it.layer == layer.id {
|
||||
self.item(s, it, Affine::IDENTITY, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Push blend/opacity for an item drawn as a group. Returns the number of pushes.
|
||||
fn push_group(s: &mut Surface, opacity: f32, mode: DcBlend) -> usize {
|
||||
let mut n = 0;
|
||||
if mode != DcBlend::Normal {
|
||||
s.push_blend_mode(blend(mode));
|
||||
n += 1;
|
||||
}
|
||||
if opacity < 0.999 {
|
||||
s.push_opacity(norm(opacity));
|
||||
n += 1;
|
||||
} else if mode != DcBlend::Normal {
|
||||
s.push_isolated();
|
||||
n += 1;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn item(&mut self, s: &mut Surface, it: &Item, parent: Affine, page_name: Option<&str>) {
|
||||
if it.hidden || it.nonprinting {
|
||||
return;
|
||||
}
|
||||
let ds = &it.effects.drop_shadow;
|
||||
let grow = it.stroke.weight + 2.0 + if ds.on { ds.distance.abs() + ds.size.abs() } else { 0.0 };
|
||||
let vb = parent.transform_rect_bbox(it.bounds()).inflate(grow, grow);
|
||||
if !rects_overlap(vb, self.clip) {
|
||||
return;
|
||||
}
|
||||
let xf = parent * it.xf;
|
||||
if !it.children().is_empty() {
|
||||
let pushes = Self::push_group(s, it.opacity, it.blend);
|
||||
for c in it.children() {
|
||||
self.item(s, c, xf, page_name);
|
||||
}
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let doc = self.doc;
|
||||
let bp = if it.corners.is_none() { it.path.to_bezpath() } else { corners::apply(&it.path, &it.corners) };
|
||||
let path = to_path(&bp);
|
||||
let pushes = Self::push_group(s, it.opacity, it.blend);
|
||||
// Drop shadow (simple offset silhouette, like the renderer).
|
||||
if ds.on
|
||||
&& let Some(p) = &path
|
||||
&& let Some(c) = self.swatch_color(&ds.color, 1.0)
|
||||
{
|
||||
let a = ds.angle.to_radians();
|
||||
let off = Vec2::new(-a.cos() * ds.distance, a.sin() * ds.distance);
|
||||
s.push_transform(&tf(Affine::translate(off) * xf));
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(solid_fill(c, ds.opacity)));
|
||||
s.draw_path(p);
|
||||
s.set_fill(None);
|
||||
s.pop();
|
||||
}
|
||||
s.push_transform(&tf(xf));
|
||||
// Fill.
|
||||
if !it.fill.is_none()
|
||||
&& it.path.is_closed()
|
||||
&& let Some(p) = &path
|
||||
&& let Some(paint) = self.fill_paint(&it.fill.swatch, it.fill.tint, bp.bounding_box(), it.fill.gradient_angle)
|
||||
{
|
||||
if it.fill.overprint {
|
||||
self.warn("overprint is not exported yet");
|
||||
}
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: FillRule::NonZero }));
|
||||
s.draw_path(p);
|
||||
s.set_fill(None);
|
||||
}
|
||||
// Content.
|
||||
match &it.content {
|
||||
Content::Graphic(g) => {
|
||||
if let Some(p) = &path {
|
||||
s.push_clip_path(p, &FillRule::NonZero);
|
||||
self.graphic(s, g);
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
Content::Text(tfr) => {
|
||||
let cs = self.cache.get(doc, tfr.story, page_name);
|
||||
if let Some(ft) = cs.frame(it.id) {
|
||||
let text = doc.story(tfr.story).map(|st| st.text.as_str()).unwrap_or("");
|
||||
self.frame_text(s, &cs, ft, text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Stroke.
|
||||
if !it.stroke.is_none()
|
||||
&& let Some(p) = &path
|
||||
{
|
||||
self.stroke(s, it, &bp, p);
|
||||
}
|
||||
s.pop();
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// A swatch fill (solid or gradient) in the item's inner space. `None` for [None]/unknown.
|
||||
fn fill_paint(&self, swatch: &str, tint: f32, bounds: Rect, angle: Option<f64>) -> Option<krilla::paint::Paint> {
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
|
||||
let mut stops: Vec<Stop> = Vec::new();
|
||||
let mut last = 0.0f32;
|
||||
for (o, c, a) in g.expanded_stops() {
|
||||
let o = o.clamp(last, 1.0);
|
||||
last = o;
|
||||
stops.push(Stop { offset: norm(o), color: device(&c, self.rgb_only), opacity: norm(a) });
|
||||
}
|
||||
if stops.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let c = bounds.center();
|
||||
return Some(match g.kind {
|
||||
GradientKind::Radial => {
|
||||
let r = (bounds.width().max(bounds.height()) / 2.0).max(1e-3) as f32;
|
||||
let (cx, cy) = (c.x as f32, c.y as f32);
|
||||
RadialGradient {
|
||||
fx: cx,
|
||||
fy: cy,
|
||||
fr: 0.0,
|
||||
cx,
|
||||
cy,
|
||||
cr: r,
|
||||
transform: Transform::identity(),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
let a = angle.unwrap_or(0.0).to_radians();
|
||||
let half = ((bounds.width() * a.cos().abs() + bounds.height() * a.sin().abs()) / 2.0).max(1e-3);
|
||||
let d = Vec2::new(a.cos(), -a.sin()) * half;
|
||||
let (p0, p1) = (c - d, c + d);
|
||||
LinearGradient {
|
||||
x1: p0.x as f32,
|
||||
y1: p0.y as f32,
|
||||
x2: p1.x as f32,
|
||||
y2: p1.y as f32,
|
||||
transform: Transform::identity(),
|
||||
spread_method: SpreadMethod::Pad,
|
||||
stops,
|
||||
anti_alias: false,
|
||||
}
|
||||
.into()
|
||||
}
|
||||
});
|
||||
}
|
||||
self.swatch_color(swatch, tint).map(Into::into)
|
||||
}
|
||||
|
||||
fn stroke(&mut self, s: &mut Surface, it: &Item, bp: &BezPath, path: &Path) {
|
||||
let st = &it.stroke;
|
||||
let Some(c) = self.swatch_color(&st.swatch, st.tint) else { return };
|
||||
let closed = it.path.is_closed();
|
||||
let mut cap = match st.cap {
|
||||
designcraft_doc::Cap::Butt => krilla::paint::LineCap::Butt,
|
||||
designcraft_doc::Cap::Round => krilla::paint::LineCap::Round,
|
||||
designcraft_doc::Cap::Projecting => krilla::paint::LineCap::Square,
|
||||
};
|
||||
let dash = match &st.kind {
|
||||
StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => {
|
||||
let mut pat: Vec<f32> = pattern.iter().map(|v| v.max(0.0) as f32).collect();
|
||||
if pat.len() % 2 == 1 {
|
||||
pat.extend(pat.clone());
|
||||
}
|
||||
Some(StrokeDash { array: pat, offset: 0.0 })
|
||||
}
|
||||
StrokeType::Dotted => {
|
||||
cap = krilla::paint::LineCap::Round;
|
||||
Some(StrokeDash { array: vec![0.0, (st.weight * 2.0) as f32], offset: 0.0 })
|
||||
}
|
||||
StrokeType::Solid | StrokeType::Dashed { .. } => None,
|
||||
_ => {
|
||||
self.warn("striped stroke types are exported as solid strokes");
|
||||
None
|
||||
}
|
||||
};
|
||||
let aligned = closed && st.align != StrokeAlign::Center;
|
||||
let width = if aligned { st.weight * 2.0 } else { st.weight };
|
||||
let mut pushes = 0;
|
||||
match st.align {
|
||||
StrokeAlign::Inside if closed => {
|
||||
s.push_clip_path(path, &FillRule::NonZero);
|
||||
pushes += 1;
|
||||
}
|
||||
StrokeAlign::Outside if closed => {
|
||||
// Clip to everything outside the path: a big frame plus the path, even-odd.
|
||||
let b = bp.bounding_box().inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
|
||||
let mut outside = b.to_path(0.1);
|
||||
outside.extend(bp.iter());
|
||||
if let Some(p) = to_path(&outside) {
|
||||
s.push_clip_path(&p, &FillRule::EvenOdd);
|
||||
pushes += 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: c.into(),
|
||||
width: width as f32,
|
||||
miter_limit: st.miter_limit.max(1.0) as f32,
|
||||
line_cap: cap,
|
||||
line_join: match st.join {
|
||||
designcraft_doc::Join::Miter => krilla::paint::LineJoin::Miter,
|
||||
designcraft_doc::Join::Round => krilla::paint::LineJoin::Round,
|
||||
designcraft_doc::Join::Bevel => krilla::paint::LineJoin::Bevel,
|
||||
},
|
||||
opacity: NormalizedF32::ONE,
|
||||
dash,
|
||||
}));
|
||||
s.draw_path(path);
|
||||
s.set_stroke(None);
|
||||
for _ in 0..pushes {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_image(&mut self, id: AssetId) -> Option<Image> {
|
||||
if let Some(i) = self.images.get(&id) {
|
||||
return i.clone();
|
||||
}
|
||||
let asset = self.doc.assets.get(&id)?.clone();
|
||||
let data = asset.data.clone();
|
||||
let fmt = image::guess_format(&data).ok();
|
||||
let img = match fmt {
|
||||
Some(image::ImageFormat::Jpeg) => Image::from_jpeg(data.clone().into(), true).ok(),
|
||||
_ if self.opts.compress_images => recompress(&data).or_else(|| lossless(&data, fmt)),
|
||||
_ => lossless(&data, fmt),
|
||||
};
|
||||
if img.is_none() {
|
||||
self.warn(format!("image `{}` could not be decoded and was skipped", asset.name));
|
||||
}
|
||||
self.images.insert(id, img.clone());
|
||||
img
|
||||
}
|
||||
|
||||
fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) {
|
||||
let Some(img) = self.load_image(g.asset) else { return };
|
||||
let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return };
|
||||
s.push_transform(&tf(g.xf));
|
||||
s.draw_image(img, size);
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
fn lossless(data: &Arc<Vec<u8>>, fmt: Option<image::ImageFormat>) -> Option<Image> {
|
||||
let direct = match fmt {
|
||||
Some(image::ImageFormat::Png) => Image::from_png(data.clone().into(), true).ok(),
|
||||
Some(image::ImageFormat::Gif) => Image::from_gif(data.clone().into(), true).ok(),
|
||||
Some(image::ImageFormat::WebP) => Image::from_webp(data.clone().into(), true).ok(),
|
||||
_ => None,
|
||||
};
|
||||
direct.or_else(|| {
|
||||
let rgba = image::load_from_memory(data).ok()?.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
Some(Image::from_rgba8(rgba.into_raw(), w, h))
|
||||
})
|
||||
}
|
||||
|
||||
/// Opaque raster → JPEG (quality 90). `None` when the image has transparency or can't be decoded.
|
||||
fn recompress(data: &[u8]) -> Option<Image> {
|
||||
let img = image::load_from_memory(data).ok()?;
|
||||
if img.color().has_alpha() && img.to_rgba8().pixels().any(|p| p[3] < 255) {
|
||||
return None;
|
||||
}
|
||||
let rgb = img.to_rgb8();
|
||||
let mut buf = Vec::new();
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 90);
|
||||
image::ImageEncoder::write_image(enc, rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8).ok()?;
|
||||
Image::from_jpeg(buf.into(), true).ok()
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! DesignCraft PDF export (print), written with `krilla`.
|
||||
//!
|
||||
//! [`export_pdf`] writes one PDF page per document page (or per spread) and mirrors the renderer's
|
||||
//! walk (`designcraft-render`): layers back to front, parent items first (with page-number markers
|
||||
//! resolved for each page), then the spread's own items. What goes into the file:
|
||||
//!
|
||||
//! - **Boxes:** MediaBox = trim + bleed (+ room for printer's marks), TrimBox = the page, BleedBox =
|
||||
//! page + document bleed. Content is clipped to the bleed box.
|
||||
//! - **Vectors:** fills (solid and gradient swatches), strokes with alignment, dashes, caps, joins
|
||||
//! and miter limits, corner options, opacity and blend modes (transparency groups), the simple
|
||||
//! drop shadow.
|
||||
//! - **Colour:** CMYK as DeviceCMYK, RGB as DeviceRGB, Gray as DeviceGray, spot swatches (and their
|
||||
//! tints) as `/Separation` with the swatch's own values as the alternate space, `[Registration]`
|
||||
//! as `/Separation /All`.
|
||||
//! - **Images:** clipped to their frame; JPEG data is passed through, PNG/GIF/WebP are embedded
|
||||
//! losslessly (or re-encoded as JPEG with [`PdfOptions::compress_images`]).
|
||||
//! - **Text as real text:** the composed glyph runs are emitted with embedded, subsetted fonts and a
|
||||
//! Unicode mapping taken from the story text (ligatures, page numbers and inserted hyphens get the
|
||||
//! right characters), so text is selectable and searchable.
|
||||
//! - **Marks:** crop marks, bleed marks and a page-information line, drawn in `[Registration]`.
|
||||
//! - **Standards:** PDF/A-2b through krilla's validator. PDF/X-4 writes PDF 1.6 with trim/bleed
|
||||
//! boxes, but the PDF/X output intent and identification are not written yet (krilla 0.8 has no
|
||||
//! PDF/X support) — the export report says so.
|
||||
//!
|
||||
//! Not yet: tagged PDF, bookmarks, hyperlinks, overprint, layers as optional content.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod export;
|
||||
mod marks;
|
||||
mod text;
|
||||
|
||||
pub use export::{export_pdf, export_pdf_with_report};
|
||||
|
||||
/// PDF standard to target.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Standard {
|
||||
/// Plain PDF 1.7.
|
||||
#[default]
|
||||
None,
|
||||
/// PDF/X-4 (PDF 1.6, trim/bleed boxes). TODO: OutputIntent + GTS_PDFXVersion once krilla
|
||||
/// supports PDF/X.
|
||||
PdfX4,
|
||||
/// PDF/A-2b (archival; validated by krilla).
|
||||
PdfA2b,
|
||||
}
|
||||
|
||||
impl Standard {
|
||||
/// `none`, `x4` / `pdfx4` / `PDF/X-4`, `a2b` / `pdfa2b` / `PDF/A-2b`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
"" | "none" => Some(Self::None),
|
||||
"x4" | "pdfx4" | "pdfx42008" | "pdfx42010" => Some(Self::PdfX4),
|
||||
"a2b" | "pdfa2b" => Some(Self::PdfA2b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Printer's marks (Export PDF › Marks and Bleeds).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Marks {
|
||||
pub crop: bool,
|
||||
pub bleed: bool,
|
||||
/// Document title, page name, date — in the slug area under the page.
|
||||
pub page_info: bool,
|
||||
/// Line weight in points (0.25 default).
|
||||
pub weight: f64,
|
||||
/// Distance of the marks from the trim edge in points (6 default). Marks never start inside
|
||||
/// the bleed.
|
||||
pub offset: f64,
|
||||
}
|
||||
|
||||
impl Marks {
|
||||
pub const NONE: Marks = Marks { crop: false, bleed: false, page_info: false, weight: 0.25, offset: 6.0 };
|
||||
pub const ALL: Marks = Marks { crop: true, bleed: true, page_info: true, weight: 0.25, offset: 6.0 };
|
||||
pub fn any(&self) -> bool {
|
||||
self.crop || self.bleed || self.page_info
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Marks {
|
||||
fn default() -> Self {
|
||||
Self::NONE
|
||||
}
|
||||
}
|
||||
|
||||
/// Export options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PdfOptions {
|
||||
/// 0-based absolute page indices to export, in order; `None` = all pages.
|
||||
pub pages: Option<Vec<usize>>,
|
||||
/// One PDF page per spread (spreads containing any chosen page) instead of per page.
|
||||
pub spreads: bool,
|
||||
/// Include the document bleed (`DocSettings::bleed`) around each page.
|
||||
pub bleed: bool,
|
||||
pub marks: Marks,
|
||||
pub standard: Standard,
|
||||
/// Re-encode non-JPEG raster images as JPEG (quality 90) when they have no transparency.
|
||||
/// Off = lossless (JPEG data is always passed through unchanged).
|
||||
pub compress_images: bool,
|
||||
/// Flate-compress content streams.
|
||||
pub compress: bool,
|
||||
/// Title for the metadata; `None` = the document title.
|
||||
pub title: Option<String>,
|
||||
pub author: Option<String>,
|
||||
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm).
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
impl Default for PdfOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pages: None,
|
||||
spreads: false,
|
||||
bleed: false,
|
||||
marks: Marks::NONE,
|
||||
standard: Standard::None,
|
||||
compress_images: false,
|
||||
compress: true,
|
||||
title: None,
|
||||
author: None,
|
||||
created: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes plus non-fatal warnings (features approximated or dropped).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportReport {
|
||||
pub bytes: Vec<u8>,
|
||||
pub pages: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq)]
|
||||
pub enum PdfError {
|
||||
#[error("nothing to export (no pages selected)")]
|
||||
NoPages,
|
||||
#[error("page {0} does not exist")]
|
||||
BadPage(usize),
|
||||
#[error("bad page range `{0}`")]
|
||||
BadRange(String),
|
||||
#[error("PDF writer error: {0}")]
|
||||
Write(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PdfError>;
|
||||
|
||||
/// Parse a 1-based page range like `"1-3, 5, 8-"` into 0-based indices (`count` pages in the
|
||||
/// document). `""` / `"all"` = every page.
|
||||
pub fn parse_page_range(s: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let t = s.trim();
|
||||
if t.is_empty() || t.eq_ignore_ascii_case("all") {
|
||||
return Ok((0..count).collect());
|
||||
}
|
||||
let bad = || PdfError::BadRange(s.to_string());
|
||||
let num = |v: &str| v.trim().parse::<usize>().map_err(|_| bad());
|
||||
let mut out = Vec::new();
|
||||
for part in t.split(',').map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => {
|
||||
let a = if a.trim().is_empty() { 1 } else { num(a)? };
|
||||
let b = if b.trim().is_empty() { count } else { num(b)? };
|
||||
(a, b)
|
||||
}
|
||||
None => {
|
||||
let v = num(part)?;
|
||||
(v, v)
|
||||
}
|
||||
};
|
||||
if a == 0 || b == 0 || a > b {
|
||||
return Err(bad());
|
||||
}
|
||||
for p in a..=b {
|
||||
if p > count {
|
||||
return Err(PdfError::BadPage(p));
|
||||
}
|
||||
if !out.contains(&(p - 1)) {
|
||||
out.push(p - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Printer's marks drawn in the slug area: crop marks at the trim corners, bleed marks at the bleed
|
||||
//! corners, and a page-information line. All marks use `[Registration]` (every plate).
|
||||
|
||||
use designcraft_geom::{BezPath, Point};
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::paint::{LineCap, LineJoin, Stroke};
|
||||
use krilla::surface::Surface;
|
||||
|
||||
use crate::export::{Exporter, MARK_LEN, Sheet, civil, mark_start, registration, to_path};
|
||||
|
||||
impl Exporter<'_> {
|
||||
pub(crate) fn marks(&mut self, s: &mut Surface, sh: &Sheet, title: &str, created: Option<i64>) {
|
||||
let m = self.opts.marks;
|
||||
if !m.any() {
|
||||
return;
|
||||
}
|
||||
let bleed = if self.opts.bleed { self.doc.settings.bleed.map(|v| v.max(0.0)) } else { [0.0; 4] };
|
||||
let start = mark_start(self.opts, bleed);
|
||||
let t = sh.trim;
|
||||
let mut bp = BezPath::new();
|
||||
let mut seg = |a: (f64, f64), b: (f64, f64)| {
|
||||
bp.move_to(Point::new(a.0, a.1));
|
||||
bp.line_to(Point::new(b.0, b.1));
|
||||
};
|
||||
if m.crop {
|
||||
// Each trim corner: a horizontal mark beside it and a vertical one above/below it.
|
||||
for (x, sx) in [(t.x0, -1.0), (t.x1, 1.0)] {
|
||||
for (y, sy) in [(t.y0, -1.0), (t.y1, 1.0)] {
|
||||
seg((x + sx * start, y), (x + sx * (start + MARK_LEN), y));
|
||||
seg((x, y + sy * start), (x, y + sy * (start + MARK_LEN)));
|
||||
}
|
||||
}
|
||||
}
|
||||
let b = sh.bleed;
|
||||
if m.bleed && (b.x0 < t.x0 || b.y0 < t.y0 || b.x1 > t.x1 || b.y1 > t.y1) {
|
||||
// Shorter marks on the bleed lines, outside the crop-mark start.
|
||||
let len = MARK_LEN / 2.0;
|
||||
for (x, bx, sx) in [(t.x0, b.x0, -1.0), (t.x1, b.x1, 1.0)] {
|
||||
for (y, by, sy) in [(t.y0, b.y0, -1.0), (t.y1, b.y1, 1.0)] {
|
||||
seg((x + sx * start, by), (x + sx * (start + len), by));
|
||||
seg((bx, y + sy * start), (bx, y + sy * (start + len)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(p) = to_path(&bp) {
|
||||
s.set_fill(None);
|
||||
s.set_stroke(Some(Stroke {
|
||||
paint: registration(1.0, self.rgb_only).into(),
|
||||
width: m.weight.max(0.05) as f32,
|
||||
miter_limit: 4.0,
|
||||
line_cap: LineCap::Butt,
|
||||
line_join: LineJoin::Miter,
|
||||
opacity: NormalizedF32::ONE,
|
||||
dash: None,
|
||||
}));
|
||||
s.draw_path(&p);
|
||||
s.set_stroke(None);
|
||||
}
|
||||
if m.page_info {
|
||||
let mut info = format!("{title} Page {}", sh.label);
|
||||
if let Some(c) = created.map(civil) {
|
||||
info.push_str(&format!(" {:04}-{:02}-{:02} {:02}:{:02} UTC", c.0, c.1, c.2, c.3, c.4));
|
||||
}
|
||||
let at = (t.x0 + start + 6.0, t.y1 + start + 10.0);
|
||||
self.plain_text(s, &info, at, 6.0, registration(1.0, self.rgb_only));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use designcraft_color::swatch::{ColorType, SwatchValue};
|
||||
use designcraft_color::{Color, Swatch};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, ParaFormat, SpreadRef};
|
||||
use designcraft_geom::Rect;
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn doc_with_text(text: &str) -> Document {
|
||||
let mut d = Document::new(&NewDocument { pages: 2, ..NewDocument::default() });
|
||||
let lid = d.default_layer();
|
||||
d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 576.0, 300.0), lid, text, ParaFormat::default()).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn add_box(d: &mut Document, r: Rect, swatch: &str) {
|
||||
let lid = d.default_layer();
|
||||
let id = ItemId(d.alloc());
|
||||
let mut b = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||||
b.fill = Fill::swatch(swatch);
|
||||
d.insert_item(SpreadRef::Doc(0), b, None).unwrap();
|
||||
}
|
||||
|
||||
fn uncompressed(d: &Document, opts: PdfOptions) -> String {
|
||||
let bytes = export_pdf(d, &Cache::new(), &PdfOptions { compress: false, ..opts }).expect("export");
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
/// Text of every page through hayro's interpreter (glyph → Unicode via ToUnicode / ActualText).
|
||||
pub(crate) fn extract_text(bytes: &[u8]) -> Vec<String> {
|
||||
use hayro_interpret::font::Glyph;
|
||||
use hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
|
||||
interpret_page,
|
||||
};
|
||||
use hayro_syntax::Pdf;
|
||||
struct Ex(String);
|
||||
impl Device<'_> for Ex {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &kurbo::BezPath, _: kurbo::Affine, _: &Paint<'_>, _: &PathDrawMode) {}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, g: &Glyph<'_>, _: kurbo::Affine, _: kurbo::Affine, _: &Paint<'_>, _: &GlyphDrawMode) {
|
||||
match g.as_unicode() {
|
||||
Some(hayro_cmap::BfString::Char(c)) => self.0.push(c),
|
||||
Some(hayro_cmap::BfString::String(s)) => self.0.push_str(&s),
|
||||
None => self.0.push('\u{FFFD}'),
|
||||
}
|
||||
}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: kurbo::Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
let pdf = Pdf::new(bytes.to_vec()).expect("parse");
|
||||
let cache = InterpreterCache::new();
|
||||
pdf.pages()
|
||||
.iter()
|
||||
.map(|page| {
|
||||
let mut ctx =
|
||||
Context::new(kurbo::Affine::IDENTITY, kurbo::Rect::new(0.0, 0.0, 1.0, 1.0), &cache, pdf.xref(), InterpreterSettings::default());
|
||||
let mut ex = Ex(String::new());
|
||||
interpret_page(page, &mut ctx, &mut ex);
|
||||
ex.0
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_valid_pdf_with_one_page_per_page() {
|
||||
let d = doc_with_text("Hello DesignCraft");
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF-1.7"));
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).expect("parse");
|
||||
assert_eq!(pdf.pages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_is_real_text() {
|
||||
let d = doc_with_text("Hello DesignCraft — efficient affine");
|
||||
let s = uncompressed(&d, PdfOptions::default());
|
||||
assert!(s.contains("/FontFile2") || s.contains("/FontFile3"), "font embedded");
|
||||
assert!(s.contains("/ToUnicode"));
|
||||
assert!(s.contains(")Tj") || s.contains("]TJ"), "text operators");
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions::default()).unwrap();
|
||||
let text = extract_text(&bytes);
|
||||
let flat: String = text[0].split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
assert!(flat.contains("Hello"), "{flat}");
|
||||
assert!(text[0].contains("DesignCraft"), "{text:?}");
|
||||
assert!(text[0].contains("efficient") && text[0].contains("affine"), "ligatures map back: {text:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colours_keep_their_space() {
|
||||
let mut d = doc_with_text("x");
|
||||
add_box(&mut d, Rect::new(36.0, 400.0, 136.0, 500.0), "C=100 M=0 Y=0 K=0");
|
||||
d.swatches.push(Swatch::color("Brand Red", Color::rgb(0.9, 0.1, 0.1)));
|
||||
add_box(&mut d, Rect::new(200.0, 400.0, 300.0, 500.0), "Brand Red");
|
||||
d.swatches.push(Swatch {
|
||||
name: "PANTONE Test".into(),
|
||||
value: SwatchValue::Color { color: Color::cmyk(0.0, 0.5, 1.0, 0.0), color_type: ColorType::Spot },
|
||||
locked: false,
|
||||
named: true,
|
||||
});
|
||||
add_box(&mut d, Rect::new(320.0, 400.0, 420.0, 500.0), "PANTONE Test");
|
||||
let s = uncompressed(&d, PdfOptions::default());
|
||||
assert!(s.contains("1 0 0 0 k"), "DeviceCMYK fill");
|
||||
assert!(s.contains(" rg"), "DeviceRGB fill");
|
||||
assert!(s.contains("/Separation") && s.contains("/PANTONE#20Test"), "spot as Separation");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_bleed_and_marks() {
|
||||
let mut d = doc_with_text("x");
|
||||
d.settings.bleed = [9.0; 4];
|
||||
let s = uncompressed(&d, PdfOptions { bleed: true, ..Default::default() });
|
||||
assert!(s.contains("/TrimBox[9 9 621 801]"), "{}", &s[..s.len().min(4000)]);
|
||||
assert!(s.contains("/MediaBox[0 0 630 810]"));
|
||||
assert!(s.contains("/BleedBox[0 0 630 810]"));
|
||||
let s = uncompressed(&d, PdfOptions { bleed: true, marks: Marks::ALL, pages: Some(vec![1]), ..Default::default() });
|
||||
// margin = max(offset 6, bleed 9) + 18 + 6 = 33
|
||||
assert!(s.contains("/MediaBox[0 0 678 858]"), "marks area");
|
||||
assert!(s.contains("/All"), "registration colour");
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { marks: Marks::ALL, pages: Some(vec![1]), ..Default::default() }).unwrap();
|
||||
let t = extract_text(&bytes);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert!(t[0].contains("Page 2"), "{t:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spreads_and_ranges() {
|
||||
let mut d = Document::new(&NewDocument { pages: 4, facing_pages: true, ..NewDocument::default() });
|
||||
let _ = &mut d;
|
||||
let n = d.page_count();
|
||||
assert_eq!(n, 4);
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { spreads: true, ..Default::default() }).unwrap();
|
||||
let pages = hayro_syntax::Pdf::new(bytes).unwrap().pages().len();
|
||||
assert_eq!(pages, d.spreads.len());
|
||||
assert_eq!(parse_page_range("1-2, 4", 4).unwrap(), vec![0, 1, 3]);
|
||||
assert_eq!(parse_page_range("3-", 4).unwrap(), vec![2, 3]);
|
||||
assert_eq!(parse_page_range("", 2).unwrap(), vec![0, 1]);
|
||||
assert!(parse_page_range("5", 4).is_err());
|
||||
assert!(parse_page_range("x", 4).is_err());
|
||||
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![1, 2]), ..Default::default() }).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(bytes).unwrap().pages().len(), 2);
|
||||
assert_eq!(export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![9]), ..Default::default() }), Err(PdfError::BadPage(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standards() {
|
||||
let d = doc_with_text("Archive me");
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfA2b, created: Some(1_700_000_000), ..Default::default() });
|
||||
let r = r.expect("PDF/A-2b export");
|
||||
assert!(String::from_utf8_lossy(&r.bytes).contains("pdfaid"));
|
||||
let r = export_pdf_with_report(&d, &Cache::new(), &PdfOptions { standard: Standard::PdfX4, ..Default::default() }).unwrap();
|
||||
assert!(r.bytes.starts_with(b"%PDF-1.6"));
|
||||
assert!(!r.warnings.is_empty());
|
||||
assert_eq!(Standard::parse("PDF/X-4"), Some(Standard::PdfX4));
|
||||
assert_eq!(Standard::parse("pdfa-2b"), Some(Standard::PdfA2b));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Composed text → PDF text objects with embedded (subsetted) fonts.
|
||||
//!
|
||||
//! Each line is split into runs of glyphs sharing face, style, scale and baseline offset. A run is
|
||||
//! drawn with one `draw_glyphs` call: the run origin, horizontal scale and skew go into the
|
||||
//! transform, glyph advances are taken from the composed x positions (so justification, tracking
|
||||
//! and kerning are exact), and every glyph carries the story text it came from so viewers can
|
||||
//! select, search and copy it.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_compose::{ComposedStory, FrameText, PlacedGlyph};
|
||||
use designcraft_fonts::{FontDb, FontFace};
|
||||
use designcraft_geom::kurbo::Shape as _;
|
||||
use designcraft_geom::{Affine, BezPath, Rect};
|
||||
use krilla::geom::Point;
|
||||
use krilla::paint::Stroke;
|
||||
use krilla::surface::Surface;
|
||||
use krilla::text::{Font, GlyphId, KrillaGlyph};
|
||||
|
||||
use crate::export::{Exporter, solid_fill, tf, to_path};
|
||||
|
||||
/// Private-use characters DesignCraft stores for markers (page numbers, section markers, breaks).
|
||||
fn is_marker(c: char) -> bool {
|
||||
('\u{E000}'..='\u{E0FF}').contains(&c)
|
||||
}
|
||||
|
||||
fn same_run(a: &PlacedGlyph, b: &PlacedGlyph) -> bool {
|
||||
b.visible
|
||||
&& a.face.id() == b.face.id()
|
||||
&& a.style == b.style
|
||||
&& (a.sx - b.sx).abs() < 1e-9
|
||||
&& (a.sy - b.sy).abs() < 1e-9
|
||||
&& (a.y - b.y).abs() < 1e-6
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
pub(crate) fn font(&mut self, face: &FontFace) -> Option<Font> {
|
||||
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
|
||||
}
|
||||
|
||||
fn reverse_cmap(&mut self, face: &FontFace) -> Arc<HashMap<u32, char>> {
|
||||
self.reverse_cmaps
|
||||
.entry(face.id())
|
||||
.or_insert_with(|| {
|
||||
let mut m = HashMap::new();
|
||||
// Sorted by code point: the lowest code point wins (`-` before U+2010).
|
||||
for (c, g) in face.chars() {
|
||||
m.entry(g).or_insert(c);
|
||||
}
|
||||
Arc::new(m)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn frame_text(&mut self, s: &mut Surface, cs: &ComposedStory, ft: &FrameText, story: &str) {
|
||||
// Paragraph shading and rules under the text.
|
||||
for d in &ft.decos {
|
||||
if let Some(c) = self.swatch_color(&d.color, d.tint) {
|
||||
self.fill_rect(s, d.rect, c);
|
||||
}
|
||||
}
|
||||
let mut deco: Vec<(u32, Rect)> = Vec::new();
|
||||
for l in &ft.lines {
|
||||
let gs = &l.glyphs;
|
||||
let mut i = 0;
|
||||
while i < gs.len() {
|
||||
let g = &gs[i];
|
||||
if !g.visible || g.sx <= 0.0 || g.sy <= 0.0 {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < gs.len() && same_run(g, &gs[j]) {
|
||||
j += 1;
|
||||
}
|
||||
self.run(s, cs, &gs[i..j], l.baseline, story);
|
||||
i = j;
|
||||
}
|
||||
// Underline / strikethrough, merged per style along the line.
|
||||
for g in gs.iter().filter(|g| g.visible) {
|
||||
let st = &cs.styles[g.style as usize];
|
||||
let w = st.size / 14.0;
|
||||
let mut add = |y: f64| {
|
||||
let r = Rect::new(g.x, y, g.x + g.adv, y + w);
|
||||
match deco.last_mut() {
|
||||
Some((si, last)) if *si == g.style && (last.y0 - r.y0).abs() < 1e-6 && (r.x0 - last.x1).abs() < 0.5 => last.x1 = r.x1,
|
||||
_ => deco.push((g.style, r)),
|
||||
}
|
||||
};
|
||||
if st.underline {
|
||||
add(l.baseline + st.size * 0.12);
|
||||
}
|
||||
if st.strikethrough {
|
||||
add(l.baseline - st.size * 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (si, r) in deco {
|
||||
let st = &cs.styles[si as usize];
|
||||
if let Some(c) = self.swatch_color(&st.fill, st.fill_tint) {
|
||||
self.fill_rect(s, r, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill_rect(&self, s: &mut Surface, r: Rect, c: krilla::color::Color) {
|
||||
if let Some(p) = to_path(&r.to_path(0.1)) {
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(solid_fill(c, 1.0)));
|
||||
s.draw_path(&p);
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unicode text for a run's glyphs: (text, per-glyph byte range into it).
|
||||
fn run_text(&mut self, glyphs: &[PlacedGlyph], story: &str) -> (String, Vec<Range<usize>>) {
|
||||
let cmap = self.reverse_cmap(&glyphs[0].face);
|
||||
let mut text = String::new();
|
||||
let mut ranges: Vec<Range<usize>> = Vec::with_capacity(glyphs.len());
|
||||
// Byte of the previous glyph when it came straight from the story (cluster continuation).
|
||||
let mut prev_src: Option<usize> = None;
|
||||
for g in glyphs {
|
||||
let src = story.get(g.byte..g.byte + g.len).filter(|t| !t.is_empty() && !t.chars().any(is_marker));
|
||||
let range = if let Some(t) = src {
|
||||
prev_src = Some(g.byte);
|
||||
let a = text.len();
|
||||
text.push_str(t);
|
||||
a..text.len()
|
||||
} else if g.len == 0 && prev_src == Some(g.byte) && !ranges.is_empty() {
|
||||
// Second glyph of a multi-glyph cluster: share the cluster's text.
|
||||
ranges[ranges.len() - 1].clone()
|
||||
} else {
|
||||
// Inserted glyphs (page numbers, list labels, hyphens): map back through the font.
|
||||
prev_src = None;
|
||||
match cmap.get(&g.gid) {
|
||||
Some(c) => {
|
||||
let a = text.len();
|
||||
text.push(*c);
|
||||
a..text.len()
|
||||
}
|
||||
None => match ranges.last() {
|
||||
Some(r) => r.clone(),
|
||||
None => {
|
||||
text.push('\u{FFFD}');
|
||||
0..text.len()
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
ranges.push(range);
|
||||
}
|
||||
(text, ranges)
|
||||
}
|
||||
|
||||
fn run(&mut self, s: &mut Surface, cs: &ComposedStory, glyphs: &[PlacedGlyph], baseline: f64, story: &str) {
|
||||
let g0 = &glyphs[0];
|
||||
let st = &cs.styles[g0.style as usize];
|
||||
let fill = self.swatch_color(&st.fill, st.fill_tint);
|
||||
let stroke =
|
||||
if st.stroke != designcraft_color::swatch::NONE && st.stroke_weight > 0.0 { self.swatch_color(&st.stroke, st.stroke_tint) } else { None };
|
||||
if fill.is_none() && stroke.is_none() {
|
||||
return;
|
||||
}
|
||||
let face = g0.face.clone();
|
||||
let size = g0.sy * face.upem;
|
||||
let hs = g0.sx / g0.sy;
|
||||
let skew = if st.skew != 0.0 { Affine::new([1.0, 0.0, -st.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
let origin = Affine::translate((g0.x, baseline + g0.y)) * skew * Affine::scale_non_uniform(hs, 1.0);
|
||||
let Some(font) = self.font(&face) else {
|
||||
self.warn(format!("font `{} {}` could not be embedded; its text was drawn as outlines", face.family, face.style));
|
||||
self.outline_run(s, glyphs, origin, size, fill, stroke, st.stroke_weight);
|
||||
return;
|
||||
};
|
||||
let (text, ranges) = self.run_text(glyphs, story);
|
||||
let k = 1.0 / (hs * size);
|
||||
let kg: Vec<KrillaGlyph> = glyphs
|
||||
.iter()
|
||||
.zip(ranges)
|
||||
.enumerate()
|
||||
.map(|(i, (g, r))| {
|
||||
let next = glyphs.get(i + 1).map_or(g.x + g.adv, |n| n.x);
|
||||
KrillaGlyph::new(GlyphId::new(g.gid), ((next - g.x) * k) as f32, 0.0, 0.0, 0.0, r, None)
|
||||
})
|
||||
.collect();
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: st.stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, size as f32, false);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// Fallback for faces krilla can't embed: glyph outlines as paths.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn outline_run(
|
||||
&mut self,
|
||||
s: &mut Surface,
|
||||
glyphs: &[PlacedGlyph],
|
||||
origin: Affine,
|
||||
size: f64,
|
||||
fill: Option<krilla::color::Color>,
|
||||
stroke: Option<krilla::color::Color>,
|
||||
stroke_weight: f64,
|
||||
) {
|
||||
let db = FontDb::global();
|
||||
let g0 = &glyphs[0];
|
||||
let k = size / g0.face.upem;
|
||||
let mut bp = BezPath::new();
|
||||
for g in glyphs {
|
||||
let a = Affine::translate(((g.x - g0.x) / (g0.sx / g0.sy), 0.0)) * Affine::scale(k);
|
||||
let mut o = (*db.outline(&g.face, g.gid)).clone();
|
||||
o.apply_affine(a);
|
||||
bp.extend(o.iter());
|
||||
}
|
||||
let Some(p) = to_path(&bp) else { return };
|
||||
s.push_transform(&tf(origin));
|
||||
s.set_fill(fill.map(|c| solid_fill(c, 1.0)));
|
||||
s.set_stroke(stroke.map(|c| Stroke { paint: c.into(), width: stroke_weight as f32, ..Default::default() }));
|
||||
s.draw_path(&p);
|
||||
s.set_fill(None);
|
||||
s.set_stroke(None);
|
||||
s.pop();
|
||||
}
|
||||
|
||||
/// One line of plain text in the bundled UI face (page information mark).
|
||||
pub(crate) fn plain_text(&mut self, s: &mut Surface, text: &str, at: (f64, f64), size: f64, color: krilla::color::Color) {
|
||||
let face = FontDb::global().face(designcraft_fonts::FALLBACK_FAMILY, "Regular");
|
||||
let Some(font) = self.font(&face) else { return };
|
||||
let shaped = designcraft_fonts::shape(&face, text, &[], |c| c);
|
||||
let upem = face.upem as f32;
|
||||
let glyphs: Vec<KrillaGlyph> = shaped
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, g)| {
|
||||
let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|c| *c != g.cluster).unwrap_or(text.len());
|
||||
let end = if end > g.cluster { end } else { text.len() };
|
||||
KrillaGlyph::new(
|
||||
GlyphId::new(g.gid),
|
||||
g.x_advance as f32 / upem,
|
||||
g.x_offset as f32 / upem,
|
||||
g.y_offset as f32 / upem,
|
||||
0.0,
|
||||
g.cluster..end,
|
||||
None,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
s.set_stroke(None);
|
||||
s.set_fill(Some(solid_fill(color, 1.0)));
|
||||
s.draw_glyphs(Point::from_xy(at.0 as f32, at.1 as f32), &glyphs, font, text, size as f32, false);
|
||||
s.set_fill(None);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.saveDialog", "Save As…", Some("Cmd+Shift+S"), "{}"),
|
||||
("app.save", "Save", Some("Cmd+S"), "{}"),
|
||||
("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"),
|
||||
("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"),
|
||||
("app.palette", "Command Palette…", Some("Cmd+K"), "{}"),
|
||||
("view.zoomIn", "Zoom In", Some("Cmd+="), "{}"),
|
||||
("view.zoomOut", "Zoom Out", Some("Cmd+-"), "{}"),
|
||||
@@ -54,6 +55,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"-",
|
||||
"ui:app.placeDialog",
|
||||
"-",
|
||||
"ui:app.exportPdf",
|
||||
"ui:app.exportPng",
|
||||
"-",
|
||||
"cmd:layout.documentSetup",
|
||||
@@ -197,6 +199,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"app.exportPng" => export_png(app, p),
|
||||
"app.exportPdf" => export_pdf(app, p),
|
||||
"app.palette" => {
|
||||
app.ui.palette = Some(String::new());
|
||||
Ok(Value::Null)
|
||||
@@ -311,6 +314,31 @@ fn export_png(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// File › Export PDF…: ask for a path, export through `file.exportPdf` and write the bytes with the
|
||||
/// platform writer (a download on the web).
|
||||
fn export_pdf(app: &mut DesignApp, p: &Value) -> Result<Value, String> {
|
||||
let st = app.session.active().ok_or("no document")?;
|
||||
let path = match p.get("path").and_then(Value::as_str) {
|
||||
Some(s) => Some(s.to_string()),
|
||||
None => {
|
||||
let name = format!("{}.pdf", st.doc.title);
|
||||
app.services.pick_save.as_mut().and_then(|f| f(&name))
|
||||
}
|
||||
};
|
||||
let Some(path) = path else { return Ok(Value::Null) };
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("path");
|
||||
o.entry("bleed").or_insert(json!(true));
|
||||
}
|
||||
let r = app.run("file.exportPdf", params)?;
|
||||
let bytes = designcraft_engine::cmd::base64_decode(r["base64"].as_str().unwrap_or_default());
|
||||
match app.services.write.as_mut() {
|
||||
Some(w) => w(&path, &bytes).map(|_| json!({"path": path, "bytes": bytes.len(), "pages": r["pages"], "warnings": r["warnings"]})),
|
||||
None => Err("no writer".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a command enabled (for menu greying)?
|
||||
pub fn enabled(app: &DesignApp, id: &str) -> bool {
|
||||
match designcraft_engine::find_command(id) {
|
||||
|
||||
Reference in new issue
Block a user