From 639957c68ab780f35af59fb966adb0c12cd4302f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 00:39:54 -0400 Subject: [PATCH] PDF export: slides, notes pages and handouts with selectable text layer, links and bookmarks; Export dialog PDF options Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 357 +++++++++++++++++++++++++- Cargo.toml | 1 + ROADMAP.md | 7 +- crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/file.rs | 31 ++- crates/pdf/Cargo.toml | 20 ++ crates/pdf/src/lib.rs | 442 +++++++++++++++++++++++++++++++++ crates/pdf/src/tests.rs | 68 +++++ crates/render/src/lib.rs | 2 + crates/render/src/placed.rs | 117 +++++++++ crates/ui-egui/src/dialogs.rs | 72 +++++- crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/ribbon.rs | 3 +- crates/ui-egui/src/show.rs | 7 +- crates/ui-egui/src/sorter.rs | 9 +- crates/ui-egui/src/textures.rs | 2 +- docs/parity.md | 10 +- xtask/src/layers.rs | 7 +- 18 files changed, 1123 insertions(+), 41 deletions(-) create mode 100644 crates/pdf/Cargo.toml create mode 100644 crates/pdf/src/lib.rs create mode 100644 crates/pdf/src/tests.rs create mode 100644 crates/render/src/placed.rs diff --git a/Cargo.lock b/Cargo.lock index 8b80eb3..4417fbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,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" @@ -410,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.10.0" @@ -693,6 +705,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" @@ -1014,7 +1035,7 @@ dependencies = [ "ecolor", "emath", "epaint_default_fonts", - "font-types", + "font-types 0.12.6", "harfrust 0.12.0", "log", "nohash-hasher", @@ -1112,6 +1133,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" +[[package]] +name = "fearless_simd" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -1129,12 +1156,30 @@ 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 = "foldhash" 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.6" @@ -1480,6 +1525,57 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hayro-ccitt" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2866f0d976e76497480b853d5e258dab6e4b89ecfe991124e08f15dfa8fd03ad" + +[[package]] +name = "hayro-jbig2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" +dependencies = [ + "fearless_simd 1.1.0", + "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 0.4.1", +] + +[[package]] +name = "hayro-syntax" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" +dependencies = [ + "flate2", + "hayro-jbig2", + "hayro-jpeg2000", + "memchr", + "rustc-hash 2.1.3", + "smallvec", + "zune-jpeg", +] + +[[package]] +name = "hayro-write" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" +dependencies = [ + "flate2", + "hayro-syntax", + "pdf-writer", +] + [[package]] name = "hermit-abi" version = "0.5.3" @@ -1521,6 +1617,12 @@ dependencies = [ "quick-error", ] +[[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" @@ -1671,6 +1773,35 @@ dependencies = [ "accesskit_consumer 0.35.0", ] +[[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", + "hayro-write", + "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" @@ -2317,6 +2448,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" @@ -2616,6 +2759,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" @@ -2623,7 +2776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.6", "once_cell", ] @@ -2634,7 +2787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.6", "once_cell", ] @@ -2645,7 +2798,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8" dependencies = [ "bytemuck", - "font-types", + "font-types 0.12.6", "once_cell", ] @@ -2762,6 +2915,30 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[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" @@ -2902,6 +3079,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" @@ -2982,6 +3169,7 @@ dependencies = [ "slidecraft-format", "slidecraft-geom", "slidecraft-model", + "slidecraft-pdf", "slidecraft-pptx", "slidecraft-render", "slidecraft-text", @@ -3040,6 +3228,21 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "slidecraft-pdf" +version = "0.1.0" +dependencies = [ + "krilla", + "kurbo", + "log", + "serde", + "slidecraft-fonts", + "slidecraft-model", + "slidecraft-render", + "slidecraft-text", + "thiserror 2.0.21", +] + [[package]] name = "slidecraft-pptx" version = "0.1.0" @@ -3202,12 +3405,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" @@ -3230,6 +3457,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" @@ -3315,6 +3553,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.2" @@ -3399,6 +3648,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" @@ -3425,6 +3683,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[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" @@ -3443,6 +3713,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[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" @@ -3473,7 +3755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", @@ -3488,7 +3770,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", - "fearless_simd", + "fearless_simd 0.4.1", "guillotiere", "log", "peniko", @@ -4301,6 +4583,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" @@ -4364,6 +4659,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" @@ -4371,6 +4672,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" @@ -4497,6 +4821,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 = "zip" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index f2c1730..71400ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ slidecraft-model = { path = "crates/model" } slidecraft-fonts = { path = "crates/fonts" } slidecraft-text = { path = "crates/text" } slidecraft-render = { path = "crates/render" } +slidecraft-pdf = { path = "crates/pdf" } slidecraft-pptx = { path = "crates/pptx" } slidecraft-format = { path = "crates/format" } slidecraft-anim = { path = "crates/anim" } diff --git a/ROADMAP.md b/ROADMAP.md index 112d238..553ac35 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av - Views: Normal (thumbnails with sections, slide, notes), Outline, Slide Sorter, Notes Page, Slide Master; zoom; grayscale. - Review: comments, accessibility checker, spelling. +- PDF export: slides, notes pages and handouts (1–9 per page) with a selectable real-text layer, hyperlinks and slide bookmarks (`file.export {format: "pdf", layout}`, File › Export…). - UI: PowerPoint-style ribbon with contextual tabs, ~240 original icons, status bar, panes, command palette, light/dark. - Automation: ~200 commands, every one reachable from `slidecraft-cli`, the app's JSON control @@ -33,7 +34,7 @@ format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and av **In progress:** PPTX import/export · web (WASM) build · release packaging for all platforms. -**Next (in order):** PDF export · audio/video playback · Format Shape pane depth · connectors with +**Next (in order):** vector PDF artwork · audio/video playback · Format Shape pane depth · connectors with glue, freeform and edit points · merge shapes · Animation Pane polish · native macOS menu bar · print · Notes/Handout masters · equations · SVG pictures. @@ -41,7 +42,7 @@ Milestone details live in `plan/execution-plan.md` (M0–M14, local planning not ## How far from full parity (estimate, 2026-10-07) -**Breadth: ~73% weighted** (P0 core 87%, P1 67%, P2 33%) over the 187 features of the PowerPoint +**Breadth: ~74% weighted** (P0 core 88%, P1 67%, P2 33%) over the 187 features of the PowerPoint catalogue, scored row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still lack some of PowerPoint's options, dialogs or pixel fidelity, so **overall parity including depth is about 55%**. @@ -51,7 +52,7 @@ it). Many features scored done still lack some of PowerPoint's options, dialogs | Work | Estimate | |---|---| -| Open P0 (15 partial, 2 missing: PPTX fidelity, PDF export, media playback, Format Shape pane, presenter view…) | 30 h | +| Open P0 (16 partial, 1 missing: PPTX fidelity, vector PDF, media playback, Format Shape pane, presenter view…) | 30 h | | Open P1 (23 partial, 10 missing: connectors/glue, freeform, merge shapes, print, multi-monitor, SVG…) | 45 h | | Open P2 (11 partial, 28 missing: equations, video export, 3-D, remove background, thesaurus…) | 40 h | | Depth and pixel fidelity of every ribbon group, dialog and pane against PowerPoint | 75 h | diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index cff9086..981acc6 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -15,6 +15,7 @@ slidecraft-model = { workspace = true } slidecraft-fonts = { workspace = true } slidecraft-text = { workspace = true } slidecraft-render = { workspace = true } +slidecraft-pdf = { workspace = true } slidecraft-format = { workspace = true } slidecraft-pptx = { workspace = true } serde = { workspace = true } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index a03a5ab..cc58889 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -15,7 +15,7 @@ pub fn specs() -> Vec { cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as), cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as), cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: slidecraft|pptx} → {data: base64}", has_doc, save_bytes_cmd), - cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|slidecraft|outline|pdf, slide?: index, scale?: px per pt}", has_doc, export), + cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|slidecraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, dpi?, slides?: [index], includeHidden?, textLayer?, frame?}", has_doc, export), cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render), cmd!(noundo "file.close", "Close", ["File"], Some("Cmd+W"), "{}", has_doc, close), cmd!( @@ -105,10 +105,33 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> { match format { "pptx" | "potx" | "ppsx" => slidecraft_pptx::export(doc).map_err(|e| EngineError::Other(e.to_string())), "outline" | "txt" => Ok(slidecraft_format::slides_to_outline(doc).into_bytes()), + "pdf" => slidecraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())), _ => slidecraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())), } } +/// PDF options from command params: `{layout: slides|notes|handouts, perPage, dpi, slides: [i], +/// includeHidden, textLayer, frame}`. +pub fn pdf_options(p: &Value) -> slidecraft_pdf::PdfOptions { + let mut o = slidecraft_pdf::PdfOptions { layout: slidecraft_pdf::PageLayout::Slides, ..Default::default() }; + o.layout = match str_param(p, "layout").unwrap_or("slides") { + "notes" => slidecraft_pdf::PageLayout::Notes, + "handouts" => slidecraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).min(9) as u8 }, + _ => slidecraft_pdf::PageLayout::Slides, + }; + o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0); + o.slides = p.get("slides").and_then(Value::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as usize)).collect()); + o.include_hidden = bool_or(p, "includeHidden", false); + o.text_layer = bool_or(p, "textLayer", true); + o.frame_slides = bool_or(p, "frame", true); + o +} + +/// PDF bytes for `doc` with [`pdf_options`] from `p`. +pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result> { + slidecraft_pdf::export(doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string())) +} + pub fn format_for_path(path: &str) -> &'static str { let l = path.to_ascii_lowercase(); if l.ends_with(".pptx") || l.ends_with(".potx") || l.ends_with(".ppsx") { @@ -228,7 +251,11 @@ fn export(s: &mut Session, p: &Value) -> Result { } Ok(json!({"files": written})) } - "pdf" => Err(EngineError::Other("PDF export is not available yet".into())), + "pdf" => { + let bytes = slidecraft_pdf::export(&st.doc, &pdf_options(p)).map_err(|e| EngineError::Other(e.to_string()))?; + write_file(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len()})) + } other => { let bytes = save_bytes(&st.doc, other)?; write_file(&path, &bytes)?; diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml new file mode 100644 index 0000000..3e4e367 --- /dev/null +++ b/crates/pdf/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "slidecraft-pdf" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "SlideCraft PDF export (krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks" + +[lints] +workspace = true + +[dependencies] +slidecraft-model = { workspace = true } +slidecraft-fonts = { workspace = true } +slidecraft-text = { workspace = true } +slidecraft-render = { workspace = true } +krilla = { workspace = true } +kurbo = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs new file mode 100644 index 0000000..f48670a --- /dev/null +++ b/crates/pdf/src/lib.rs @@ -0,0 +1,442 @@ +//! PDF export. +//! +//! Each slide is drawn by the SlideCraft renderer at print resolution (so the PDF looks exactly like +//! the slide, effects included) and overlaid with an invisible layer of real text from the text +//! layout engine with embedded font subsets, so the text can be searched, selected and copied. +//! Hyperlinks and shape actions become link annotations, slide titles become bookmarks. +//! +//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3, +//! 4, 6 or 9 slides per page). + +use std::collections::HashMap; + +use krilla::action::{Action, LinkAction}; +use krilla::annotation::{Annotation, LinkAnnotation, Target}; +use krilla::destination::XyzDestination; +use krilla::geom::{Point, Size, Transform}; +use krilla::image::Image; +use krilla::metadata::Metadata; +use krilla::num::NormalizedF32; +use krilla::outline::{Outline, OutlineNode}; +use krilla::page::PageSettings; +use krilla::paint::Fill; +use krilla::surface::Surface; +use krilla::text::{Font, GlyphId, KrillaGlyph, TextDirection}; +use kurbo::{Affine, Rect}; +use serde::{Deserialize, Serialize}; +use slidecraft_fonts::FontDb; +use slidecraft_model::Presentation; +use slidecraft_model::text::Action as ClickAction; +use slidecraft_render::{PlacedText, RenderOpts}; + +#[derive(Debug, thiserror::Error)] +pub enum PdfError { + #[error("nothing to export (no slides in range)")] + NoPages, + #[error("PDF writer: {0}")] + Write(String), +} + +pub type Result = std::result::Result; + +/// What each page shows. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PageLayout { + /// One slide per page at the slide size. + #[default] + Slides, + /// Portrait page: the slide above its notes. + Notes, + /// Portrait page with `per_page` slides (1, 2, 3, 4, 6 or 9). + Handouts { per_page: u8 }, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PdfOptions { + pub layout: PageLayout, + /// Raster resolution of slide artwork (dots per inch). + pub dpi: f64, + /// Slide indices to export (0-based); `None` = all. + pub slides: Option>, + pub include_hidden: bool, + /// Invisible real-text layer (search, select, copy). + pub text_layer: bool, + /// Thin frame around each slide (notes/handouts). + pub frame_slides: bool, + /// Document title (defaults to the presentation title). + pub title: Option, + /// Handout/notes page size in points (default US Letter portrait). + pub paper: (f64, f64), +} + +impl Default for PdfOptions { + fn default() -> Self { + PdfOptions { + layout: PageLayout::Slides, + dpi: 200.0, + slides: None, + include_hidden: false, + text_layer: true, + frame_slides: true, + title: None, + paper: (612.0, 792.0), + } + } +} + +/// Export `pres` as PDF bytes. +pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { + let slides: Vec = match &opts.slides { + Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(), + None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(), + }; + if slides.is_empty() { + return Err(PdfError::NoPages); + } + let mut pdf = krilla::Document::new(); + let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone()); + let mut meta = Metadata::new().creator("SlideCraft".into()).producer("SlideCraft".into()); + if !title.trim().is_empty() { + meta = meta.title(title); + } + if !pres.props.author.trim().is_empty() { + meta = meta.authors(vec![pres.props.author.clone()]); + } + pdf.set_metadata(meta); + + let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); + let mut ex = Exporter { pres, opts, fonts: HashMap::new() }; + // Page plan: per page, the slides on it with their boxes (page points). + let pages: Vec> = match opts.layout { + PageLayout::Slides => slides.iter().map(|&i| vec![(i, Rect::new(0.0, 0.0, sw, sh))]).collect(), + PageLayout::Notes => { + let (pw, _) = opts.paper; + let w = pw - 2.0 * 72.0; + let h = w * sh / sw; + slides.iter().map(|&i| vec![(i, Rect::new(72.0, 54.0, 72.0 + w, 54.0 + h))]).collect() + } + PageLayout::Handouts { per_page } => { + let boxes = handout_boxes(per_page, opts.paper, sw / sh); + slides.chunks(boxes.len()).map(|c| c.iter().zip(&boxes).map(|(i, b)| (*i, *b)).collect()).collect() + } + }; + // Bookmarks and slide → page lookup for internal links. + let page_of: HashMap = pages.iter().enumerate().flat_map(|(p, v)| v.iter().map(move |(i, _)| (*i, p))).collect(); + let mut outline = Outline::new(); + for (p, v) in pages.iter().enumerate() { + for (i, _) in v { + let t = pres.slides[*i].title(); + let t = t.lines().next().unwrap_or("").trim().to_string(); + let label = if t.is_empty() { format!("Slide {}", i + 1) } else { format!("{}. {t}", i + 1) }; + outline.push_child(OutlineNode::new(label, XyzDestination::new(p, Point::from_xy(0.0, 0.0)))); + } + } + pdf.set_outline(outline); + + for v in &pages { + let (pw, ph) = match opts.layout { + PageLayout::Slides => (sw, sh), + _ => opts.paper, + }; + let size = Size::from_wh(pw as f32, ph as f32).ok_or(PdfError::NoPages)?; + let mut page = pdf.start_page_with(PageSettings::new(size)); + let mut annots = Vec::new(); + { + let mut s = page.surface(); + for (i, b) in v { + ex.slide(&mut s, *i, *b); + annots.extend(ex.links(*i, *b, &page_of)); + } + match opts.layout { + PageLayout::Notes => { + if let Some((i, b)) = v.first() { + let notes = pres.slides[*i].notes_text(); + let area = Rect::new(72.0, b.y1 + 36.0, pw - 72.0, ph - 54.0); + ex.paragraphs(&mut s, ¬es, area, 12.0); + } + ex.footer(&mut s, pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1)); + } + PageLayout::Handouts { per_page } => { + if per_page == 3 { + for (_, b) in v { + ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1)); + } + } + ex.footer(&mut s, pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1)); + } + PageLayout::Slides => {} + } + s.finish(); + } + for a in annots { + page.add_annotation(a); + } + page.finish(); + } + pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) +} + +/// Slide boxes for a handout page (portrait paper, half-inch-ish margins). +pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec { + let (pw, ph) = paper; + let (mx, top, bottom) = (54.0, 54.0, 54.0); + let (cols, rows, notes) = match per_page { + 0 | 1 => (1, 1, false), + 2 => (1, 2, false), + 3 => (1, 3, true), + 4 => (2, 2, false), + 6 => (2, 3, false), + _ => (3, 3, false), + }; + let gap = 18.0; + let aw = if notes { (pw - 2.0 * mx) * 0.5 } else { pw - 2.0 * mx }; + let ah = ph - top - bottom - 18.0; + let cw = (aw - gap * (cols as f64 - 1.0)) / cols as f64; + let ch = (ah - gap * (rows as f64 - 1.0)) / rows as f64; + let (w, h) = if cw / ch > aspect { (ch * aspect, ch) } else { (cw, cw / aspect) }; + let mut out = Vec::new(); + for r in 0..rows { + for c in 0..cols { + let cx = mx + c as f64 * (cw + gap) + (cw - w) / 2.0; + let cy = top + r as f64 * (ch + gap) + (ch - h) / 2.0; + let cx = if notes { mx } else { cx }; + out.push(Rect::new(cx, cy, cx + w, cy + h)); + } + } + out +} + +struct Exporter<'a> { + pres: &'a Presentation, + opts: &'a PdfOptions, + fonts: HashMap>, +} + +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) +} + +fn grey(v: u8, opacity: f32) -> Fill { + Fill { + paint: krilla::color::rgb::Color::new(v, v, v).into(), + opacity: NormalizedF32::new(opacity.clamp(0.0, 1.0)).unwrap_or(NormalizedF32::ONE), + ..Default::default() + } +} + +impl Exporter<'_> { + fn font(&mut self, face: &slidecraft_fonts::FontFace) -> Option { + self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone() + } + + /// Slide `i` drawn into `b` (page points). + fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) { + let pres = self.pres; + let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0)); + let k = b.width() / sw; + // Raster at the requested dpi relative to the box size on paper. + let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw; + let img = slidecraft_render::render_slide(pres, i, &RenderOpts { scale, threads: 4, ..Default::default() }); + if img.width > 0 && img.height > 0 { + let mut rgba = img.pixels.clone(); + for px in rgba.as_chunks_mut::<4>().0 { + // Composite onto white (slides are normally opaque). + let a = px[3] as u32; + for c in &mut px[..3] { + *c = (*c as u32 + (255 - a)).min(255) as u8; + } + px[3] = 255; + } + let image = Image::from_rgba8(rgba, img.width, img.height); + if let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) { + s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32)); + s.draw_image(image, size); + s.pop(); + } + } + if self.opts.frame_slides && self.opts.layout != PageLayout::Slides { + let p = kurbo::Shape::to_path(&b, 0.1); + if let Some(path) = to_path(&p) { + s.set_fill(None); + s.set_stroke(Some(krilla::paint::Stroke { + paint: krilla::color::rgb::Color::new(0x80, 0x80, 0x80).into(), + width: 0.5, + ..Default::default() + })); + s.draw_path(&path); + s.set_stroke(None); + } + } + if self.opts.text_layer { + let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); + for pt in slidecraft_render::place_slide(pres, i).texts { + self.text_layer(s, &pt, view); + } + let _ = sh; + } + } + + /// Invisible glyphs exactly over the rasterised text. + fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine) { + for run in &pt.layout.runs { + if run.glyphs.is_empty() || run.size <= 0.0 { + continue; + } + let text: String = pt + .body + .paragraphs + .get(run.para) + .map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::()) + .unwrap_or_default() + .chars() + .skip(run.chars.0) + .take(run.chars.1.saturating_sub(run.chars.0)) + .collect(); + if text.trim().is_empty() { + continue; + } + let Some(font) = self.font(&run.face) else { continue }; + let n = run.glyphs.len(); + let char_starts: Vec = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect(); + let one_to_one = char_starts.len() - 1 == n; + let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2); + let kg: Vec = run + .glyphs + .iter() + .enumerate() + .map(|(gi, g)| { + let next = run.glyphs.get(gi + 1).map_or(g.1 + run.face.advance(g.0) * run.size / run.face.upem.max(1.0), |nx| nx.1); + let range = if one_to_one { + char_starts[gi]..char_starts[gi + 1] + } else if gi == 0 { + 0..text.len() + } else { + text.len()..text.len() + }; + KrillaGlyph::new(GlyphId::new(g.0), ((next - g.1) / run.size) as f32, 0.0, ((g.2 - y0) / run.size) as f32, 0.0, range, None) + }) + .collect(); + s.push_transform(&tf(view * pt.transform * Affine::translate((x0, y0)))); + s.set_fill(Some(grey(0, 0.0))); + s.draw_glyphs(Point::from_xy(0.0, 0.0), &kg, font, &text, run.size as f32, false); + s.set_fill(None); + s.pop(); + } + } + + fn links(&self, i: usize, b: Rect, page_of: &HashMap) -> Vec { + let pres = self.pres; + let k = b.width() / pres.slide_size.width.max(1.0); + let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); + let mut out = Vec::new(); + for l in slidecraft_render::place_slide(pres, i).links { + let r = view.transform_rect_bbox(l.rect).intersect(b); + let Some(rect) = krilla::geom::Rect::from_ltrb(r.x0 as f32, r.y0 as f32, r.x1 as f32, r.y1 as f32) else { continue }; + let goto = |slide: Option| { + slide.and_then(|s| page_of.get(&s)).map(|p| Target::Destination(XyzDestination::new(*p, Point::from_xy(0.0, 0.0)).into())) + }; + let target = match &l.link.action { + ClickAction::Url { url } => Some(Target::Action(Action::Link(LinkAction::new(url.clone())))), + ClickAction::Slide { slide } => goto(pres.slides.iter().position(|x| x.id == *slide)), + ClickAction::NextSlide => goto(Some(i + 1)), + ClickAction::PreviousSlide => goto(i.checked_sub(1)), + ClickAction::FirstSlide => goto(Some(0)), + ClickAction::LastSlide => goto(pres.slides.len().checked_sub(1)), + _ => None, + }; + if let Some(t) = target { + let alt = if l.link.tooltip.is_empty() { None } else { Some(l.link.tooltip.clone()) }; + out.push(Annotation::new_link(LinkAnnotation::new(rect, t), alt)); + } + } + out + } + + fn ui_font(&mut self) -> Option<(Font, std::sync::Arc)> { + let face = FontDb::global().face(slidecraft_fonts::DEFAULT_FAMILY, "Regular"); + let f = self.font(&face)?; + Some((f, face)) + } + + /// Wrapped plain text (notes). + fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) { + let Some((font, face)) = self.ui_font() else { return }; + let k = size / face.upem.max(1.0); + let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::(); + let lh = size * 1.3; + let mut y = area.y0 + size; + s.set_fill(Some(grey(0x20, 1.0))); + 'outer: for para in text.split('\n') { + let mut line = String::new(); + for word in para.split(' ') { + let cand = if line.is_empty() { word.to_string() } else { format!("{line} {word}") }; + if width(&cand) > area.width() && !line.is_empty() { + if y > area.y1 { + break 'outer; + } + s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); + y += lh; + line = word.to_string(); + } else { + line = cand; + } + } + if y > area.y1 { + break; + } + if !line.is_empty() { + s.draw_text(Point::from_xy(area.x0 as f32, y as f32), font.clone(), size as f32, &line, false, TextDirection::Auto); + } + y += lh; + } + s.set_fill(None); + } + + /// Ruled lines for handwritten notes (3-per-page handouts). + fn note_lines(&self, s: &mut Surface, r: Rect) { + let mut y = r.y0 + 18.0; + s.set_stroke(Some(krilla::paint::Stroke { + paint: krilla::color::rgb::Color::new(0xB0, 0xB0, 0xB0).into(), + width: 0.5, + ..Default::default() + })); + while y <= r.y1 { + let mut p = kurbo::BezPath::new(); + p.move_to((r.x0, y)); + p.line_to((r.x1, y)); + if let Some(path) = to_path(&p) { + s.draw_path(&path); + } + y += 22.0; + } + s.set_stroke(None); + } + + fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) { + let Some((font, _)) = self.ui_font() else { return }; + s.set_fill(Some(grey(0x60, 1.0))); + s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto); + s.set_fill(None); + } +} + +fn to_path(bp: &kurbo::BezPath) -> Option { + use kurbo::PathEl; + let mut pb = krilla::geom::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() +} + +#[cfg(test)] +mod tests; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs new file mode 100644 index 0000000..c6d8a72 --- /dev/null +++ b/crates/pdf/src/tests.rs @@ -0,0 +1,68 @@ +use std::sync::Arc; + +use super::*; +use slidecraft_model::text::TextBody; + +fn deck(n: usize) -> Presentation { + let mut p = slidecraft_model::defaults::new_presentation(None); + let layout = p.slides[0].layout; + while p.slides.len() < n { + let s = slidecraft_model::defaults::new_slide(&mut p, layout); + p.slides.push(Arc::new(s)); + } + for (i, s) in p.slides.iter_mut().enumerate() { + let s = Arc::make_mut(s); + if let Some(sh) = s.shapes.first_mut() { + sh.text = Some(TextBody::from_text(&format!("Slide title {i}"))); + } + s.notes = TextBody::from_text("Speaker notes that wrap across the page width to test line breaking of long notes."); + } + p +} + +fn opts(layout: PageLayout) -> PdfOptions { + PdfOptions { layout, dpi: 48.0, ..Default::default() } +} + +#[test] +fn exports_slides_notes_and_handouts() { + let p = deck(5); + for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] { + let bytes = export(&p, &opts(layout)).expect("export"); + assert!(bytes.starts_with(b"%PDF-"), "{layout:?}"); + assert!(bytes.len() > 1000); + } +} + +#[test] +fn empty_range_is_an_error() { + let p = deck(1); + let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) }; + assert!(matches!(export(&p, &o), Err(PdfError::NoPages))); +} + +#[test] +fn hidden_slides_skipped_unless_asked() { + let mut p = deck(2); + Arc::make_mut(&mut p.slides[1]).hidden = true; + let a = export(&p, &opts(PageLayout::Slides)).unwrap(); + let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap(); + assert!(b.len() > a.len()); +} + +#[test] +fn handout_boxes_fit_the_paper() { + for n in [1, 2, 3, 4, 6, 9] { + let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0); + assert_eq!(v.len(), n as usize); + for r in &v { + assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= 612.0 && r.y1 <= 792.0, "{n}: {r:?}"); + assert!((r.width() / r.height() - 16.0 / 9.0).abs() < 1e-6); + } + for (i, a) in v.iter().enumerate() { + for b in &v[i + 1..] { + assert!(a.intersect(*b).area() <= 0.0, "{n}: overlap"); + } + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8accca9..cad6aa3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -10,6 +10,7 @@ mod chart; mod images; mod paint; +mod placed; mod table; use std::collections::HashMap; @@ -28,6 +29,7 @@ use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::{RenderContext, Resources, peniko}; pub use images::decode as decode_image; +pub use placed::{Placed, PlacedLink, PlacedText, place_slide}; /// Largest pixmap side we render (vello_cpu uses u16 sizes). pub const MAX_SIDE: u32 = 16_000; diff --git a/crates/render/src/placed.rs b/crates/render/src/placed.rs new file mode 100644 index 0000000..9d38555 --- /dev/null +++ b/crates/render/src/placed.rs @@ -0,0 +1,117 @@ +//! Laid-out text and click areas of a slide, in slide coordinates, without drawing — for exports +//! that put real text over a raster (PDF text layer, links) and for text extraction. + +use kurbo::{Affine, Rect}; +use slidecraft_model::resolve::{self, Ctx}; +use slidecraft_model::text::{Hyperlink, TextBody}; +use slidecraft_model::{Presentation, Shape, ShapeKind}; +use slidecraft_text::{Opts, TextLayout}; + +use crate::{SlideFields, shape_geometry}; + +/// One shape's text as drawn on the slide. +pub struct PlacedText { + pub layout: TextLayout, + /// Shape-local → slide points (text rotation included). + pub transform: Affine, + pub body: TextBody, +} + +/// A clickable area: a shape action or a text hyperlink. +pub struct PlacedLink { + /// Slide-space bounding box. + pub rect: Rect, + pub link: Hyperlink, +} + +#[derive(Default)] +pub struct Placed { + pub texts: Vec, + pub links: Vec, +} + +/// Text and links of slide `index` (master and layout graphics first, as rendered). +pub fn place_slide(pres: &Presentation, index: usize) -> Placed { + let mut out = Placed::default(); + let Some(slide) = pres.slides.get(index) else { return out }; + let Some(rctx) = Ctx::for_slide(pres, slide) else { return out }; + let fields = + SlideFields { num: pres.slide_number(index), date: pres.header_footer.date_text.clone(), footer: pres.header_footer.footer_text.clone() }; + let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout); + if show_master { + let mctx = Ctx::for_master(pres, rctx.master); + for s in rctx.master.shapes.iter().filter(|s| s.ph.is_none()) { + place(&mut out, &mctx, &fields, s, Affine::IDENTITY, 0); + } + } + if show_layout && let Some(l) = rctx.layout { + let lctx = Ctx::for_layout(pres, rctx.master, l); + for s in l.shapes.iter().filter(|s| s.ph.is_none()) { + place(&mut out, &lctx, &fields, s, Affine::IDENTITY, 0); + } + } + for s in &slide.shapes { + place(&mut out, &rctx, &fields, s, Affine::IDENTITY, 0); + } + out +} + +fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent: Affine, depth: usize) { + if depth > 32 || s.hidden { + return; + } + let x = resolve::xfrm(rctx, s); + if !x.is_finite() { + return; + } + let m = parent * x.affine(); + if let Some(link) = &s.click { + out.links.push(PlacedLink { rect: m.transform_rect_bbox(Rect::new(0.0, 0.0, x.w, x.h)), link: link.clone() }); + } + match &s.kind { + ShapeKind::Group { children, child } => { + let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h); + let gm = parent * slidecraft_geom::group_child_affine(&x, ch); + for c in children { + place(out, rctx, fields, c, gm, depth + 1); + } + } + ShapeKind::Table(_) | ShapeKind::Chart(_) | ShapeKind::Ink { .. } => {} + _ => { + let Some(body) = s.text.as_ref().filter(|b| !b.is_empty()) else { return }; + let tr = shape_geometry(s, x.w, x.h).text_rect; + let layout = slidecraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false }); + let m = if layout.rotation != 0.0 { + let c = tr.center().to_vec2(); + m * Affine::translate(c) * Affine::rotate(layout.rotation.to_radians()) * Affine::translate(-c) + } else { + m + }; + // Text hyperlinks: one box per line segment of a linked run. + for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) { + let Some(link) = run_link(body, run.para, run.chars.0) else { continue }; + let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue }; + let x0 = run.glyphs.first().map(|g| g.1).unwrap_or(0.0); + let last = run.glyphs.last().map(|g| g.1).unwrap_or(x0); + let x1 = line.caret_x.get(run.chars.1.saturating_sub(line.start)).copied().unwrap_or(last).max(last); + let r = Rect::new(x0, line.top, x1, line.bottom); + out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link }); + } + out.texts.push(PlacedText { layout, transform: m, body: body.clone() }); + } + } +} + +/// The hyperlink of the model run containing character `at` of paragraph `para`. +fn run_link(body: &TextBody, para: usize, at: usize) -> Option { + let p = body.paragraphs.get(para)?; + let mut pos = 0; + for r in &p.runs { + let n = r.char_len(); + if at < pos + n.max(1) { + return r.props.link.clone(); + } + pos += n; + } + None +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4db2d34..b13b7ab 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -332,8 +332,9 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { } "export" => { ui.label("Export the presentation as:"); - let fmt = d.get("fmt", "png"); + let fmt = d.get("fmt", "pdf"); for (l, f) in [ + ("PDF document (.pdf)", "pdf"), ("PNG images (current slide)", "png"), ("PNG images (all slides)", "pngAll"), ("JPEG image (current slide)", "jpeg"), @@ -344,10 +345,57 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { d.fields.insert("fmt".into(), f.into()); } } + let pdf_layout = d.get("pdfLayout", "slides"); + if fmt == "pdf" { + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.label("Print layout:"); + egui::ComboBox::from_id_salt("pdf_layout") + .selected_text(match pdf_layout.as_str() { + "notes" => "Notes Pages", + "h1" => "Handouts (1 slide per page)", + "h2" => "Handouts (2 slides per page)", + "h3" => "Handouts (3 slides per page)", + "h4" => "Handouts (4 slides per page)", + "h6" => "Handouts (6 slides per page)", + "h9" => "Handouts (9 slides per page)", + _ => "Full Page Slides", + }) + .show_ui(ui, |ui| { + for (k, l) in [ + ("slides", "Full Page Slides"), + ("notes", "Notes Pages"), + ("h1", "Handouts (1 slide per page)"), + ("h2", "Handouts (2 slides per page)"), + ("h3", "Handouts (3 slides per page)"), + ("h4", "Handouts (4 slides per page)"), + ("h6", "Handouts (6 slides per page)"), + ("h9", "Handouts (9 slides per page)"), + ] { + if ui.selectable_label(pdf_layout == k, l).clicked() { + d.fields.insert("pdfLayout".into(), k.into()); + } + } + }); + }); + let mut hidden = d.get("pdfHidden", "false") == "true"; + if ui.checkbox(&mut hidden, "Include hidden slides").changed() { + d.fields.insert("pdfHidden".into(), hidden.to_string()); + } + } + let pdf_params = { + let (layout, per) = match pdf_layout.as_str() { + "notes" => ("notes", 0), + l if l.starts_with('h') => ("handouts", l[1..].parse().unwrap_or(6)), + _ => ("slides", 0), + }; + json!({"layout": layout, "perPage": per, "includeHidden": d.get("pdfHidden", "false") == "true"}) + }; let (ok, cancel) = buttons(ui, "Export"); if ok { let name = app.session.active().map(|s| s.title()).unwrap_or_else(|| "Presentation".into()); let (ext, all) = match fmt.as_str() { + "pdf" => ("pdf", false), "pngAll" => ("png", true), "jpeg" => ("jpg", false), "pptx" => ("pptx", false), @@ -357,7 +405,13 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let suggested = format!("{name}.{ext}"); if let Some(pick) = app.services.pick_save.as_mut() { if let Some(path) = pick(&suggested) { - let r = app.session.execute("file.export", &json!({"path": path, "all": all})); + let mut params = json!({"path": path, "all": all}); + if ext == "pdf" + && let (Some(o), Some(extra)) = (params.as_object_mut(), pdf_params.as_object()) + { + o.extend(extra.clone()); + } + let r = app.session.execute("file.export", ¶ms); match r { Ok(_) => app.set_status(format!("Exported {path}")), Err(e) => app.set_status(e.to_string()), @@ -373,6 +427,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ); if ext == "png" { img.to_png() } else { img.to_jpeg(92) } } + "pdf" => slidecraft_pdf_bytes(&s.doc, &pdf_params), other => slidecraft_engine::cmd::file::save_bytes(&s.doc, other).unwrap_or_default(), }; dl(&suggested, &bytes); @@ -750,10 +805,10 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { if ui.button("Open…").clicked() { let _ = app.run("app.openDialog", json!({})); } - if ui.button("Open the sample deck").clicked() { - if let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) { - app.set_status(e.to_string()); - } + if ui.button("Open the sample deck").clicked() + && let Err(e) = slidecraft_engine::sample::open_sample(&mut app.session) + { + app.set_status(e.to_string()); } }); if !app.ui.recent.is_empty() { @@ -780,3 +835,8 @@ pub fn start_screen(app: &mut SlideApp, ui: &mut Ui) { }); }); } + +/// PDF bytes with the export dialog's options (web: downloaded instead of written). +fn slidecraft_pdf_bytes(doc: &slidecraft_model::Presentation, params: &serde_json::Value) -> Vec { + slidecraft_engine::cmd::file::pdf_bytes(doc, params).unwrap_or_default() +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 41953e4..f65fcd6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -532,10 +532,10 @@ impl SlideApp { for e in events { match e { egui::Event::Text(t) => { - if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) { - if let Err(e) = self.session.type_text(&t) { - self.set_status(e.to_string()); - } + if self.session.active().is_some_and(|d| d.selection.text.is_some() || d.selection.shapes.len() == 1) + && let Err(e) = self.session.type_text(&t) + { + self.set_status(e.to_string()); } } egui::Event::Copy => { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index cbf8895..645e9d5 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -645,8 +645,7 @@ pub fn shapes_gallery(ui: &mut Ui) -> Option<&'static str> { /// Draw a preset's outline (gallery icons). pub fn paint_preset(p: &egui::Painter, r: Rect, name: &str, fill: Color32, stroke: Color32) { - let line = slidecraft_geom::preset::is_line_like(name); - let (w, h) = if line { (r.width() as f64, r.height() as f64) } else { (r.width() as f64, r.height() as f64) }; + let (w, h) = (r.width() as f64, r.height() as f64); let Some(g) = slidecraft_geom::preset::build(name, w, h, &[]) else { return }; for sp in &g.paths { let mut pts: Vec = vec![]; diff --git a/crates/ui-egui/src/show.rs b/crates/ui-egui/src/show.rs index 8a3cb77..e09d137 100644 --- a/crates/ui-egui/src/show.rs +++ b/crates/ui-egui/src/show.rs @@ -295,11 +295,8 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) { let cur_step = show.state.step; let next_auto = show.timeline(&doc).step_is_auto(cur_step); let n_steps = show.timeline(&doc).steps(); - if show.autoplay && cur_step < n_steps && idle > 0.3 { - actions.push(show.state.next(&doc)); - } else if next_auto && cur_step < n_steps { - actions.push(show.state.next(&doc)); - } else if !show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended { + let step_due = cur_step < n_steps && ((show.autoplay && idle > 0.3) || next_auto); + if step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended) { actions.push(show.state.next(&doc)); } else if show.preview_only && cur_step >= n_steps && idle > 0.8 { keep = false; diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index a61af43..dd26dd8 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -186,10 +186,11 @@ pub fn outline_pane(app: &mut SlideApp, ui: &mut Ui) { if resp.gained_focus() { let _ = app.run("slide.go", json!({"index": i})); } - if resp.lost_focus() && v != cur_title { - if let Some(tid) = title_shape { - let _ = app.run("text.set", json!({"id": tid, "text": v})); - } + if resp.lost_focus() + && v != cur_title + && let Some(tid) = title_shape + { + let _ = app.run("text.set", json!({"id": tid, "text": v})); } if !resp.has_focus() { ui.data_mut(|d| d.remove::(id)); diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index c77a452..aa21913 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -38,7 +38,7 @@ pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage { let w = img.width as usize; let h = img.height as usize; let mut px = Vec::with_capacity(w * h); - for c in img.pixels.chunks_exact(4) { + for c in img.pixels.as_chunks::<4>().0 { let (r, g, b, a) = (c[0], c[1], c[2], c[3]); if grayscale { let y = ((r as u32 * 77 + g as u32 * 150 + b as u32 * 29) >> 8) as u8; diff --git a/docs/parity.md b/docs/parity.md index 60dfbe1..e8d53d6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,18 +11,18 @@ Each row is scored **D** (done), **P** (partial: works but lacks options, UI or Live Presentations), add-ins and VBA macros. -**Weighted breadth parity: 73%** over 187 features. +**Weighted breadth parity: 74%** over 187 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 72 | 55 | 15 | 2 | 87% | +| P0 | 72 | 55 | 16 | 1 | 88% | | P1 | 65 | 32 | 23 | 10 | 67% | | P2 | 50 | 11 | 11 | 28 | 33% | | Area | Parity | |---|---| | Application shell | 71% | -| Files | 54% | +| Files | 59% | | Slides | 95% | | Views | 90% | | Masters & themes | 80% | @@ -44,7 +44,7 @@ Live Presentations), add-ins and VBA macros. | Automation (✱) | 100% | | Platforms (✱) | 25% | -Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Open PowerPoint .pptx, Save as PowerPoint .pptx, Autosave and crash recovery, Export to PDF, Format background (solid, gradient, picture, pattern), Connectors (straight, elbow, curved), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. +Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Open PowerPoint .pptx, Save as PowerPoint .pptx, Autosave and crash recovery, Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format background (solid, gradient, picture, pattern), Connectors (straight, elbow, curved), Gradient fill (linear, radial, rectangular, path), Format Shape pane, Edit chart data, Crop (and crop to shape, aspect ratio), Insert audio (any common format), Audio/video playback in the show, Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. ## Rows @@ -70,7 +70,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Files | Save as template | P1 | D | | Files | Autosave and crash recovery | P0 | P | | Files | Document properties | P2 | D | -| Files | Export to PDF | P0 | M | +| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P | | Files | Export slides as PNG/JPEG | P0 | D | | Files | Export outline / RTF | P2 | P | | Files | Export video (MP4) / animated GIF | P2 | M | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 7f62ac7..5180294 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -42,7 +42,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("format", Class::Layer(2)), ("render", Class::Layer(3)), ("pptx", Class::Layer(3)), - ("engine", Class::Layer(4)), + ("pdf", Class::Layer(4)), + ("engine", Class::Layer(5)), ("ui-egui", Class::Layer(6)), ("mcp", Class::Layer(6)), ("testkit", Class::Testkit), @@ -255,7 +256,7 @@ mod tests { #[test] fn upward_dependency_flagged() { let v = check(&[c("slidecraft-model", &[("slidecraft-engine", Normal, true)])]); - assert!(matches!(v[..], [Violation::Upward { from: 1, to: 4, .. }])); + assert!(matches!(v[..], [Violation::Upward { from: 1, to: 5, .. }])); } #[test] @@ -285,7 +286,7 @@ mod tests { fn ui_crates_below_l6_flagged() { for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] { let v = check(&[c("slidecraft-engine", &[(dep, Normal, false)])]); - assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 4, .. }]), "{dep}"); + assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}"); } assert!(check(&[c("slidecraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty()); assert!(check(&[c("slidecraft-mcp", &[("winit", Normal, false)])]).is_empty());