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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 11:20:59 -04:00
1 parent 91ae2b3cc9
commit cc4a8610c2
15 files changed
+1984 -9

No files matched your search

Generated
+411 -4
View File
@@ -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
View File
@@ -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) | |
+17 -3
View File
@@ -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()?;
+7
View File
@@ -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 }
+88
View File
@@ -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")))
}
+2
View File
@@ -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());
+66
View File
@@ -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());
}
+4
View File
@@ -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
+26
View File
@@ -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 }
+659
View File
@@ -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()
}
+188
View File
@@ -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;
+68
View File
@@ -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));
}
}
}
+161
View File
@@ -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));
}
+256
View File
@@ -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);
}
}
+28
View File
@@ -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) {