Use the shared PDF writer and report font embedding failures (#708)

Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
This commit is contained in:
skylar taylor-barrick
2026-10-14 21:20:34 -06:00
committed by GitHub
19 changed files with 872 additions and 78 deletions
+14
View File
@@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
## PDF writer colour profiles
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
| Asset | Author | Source | Licence | SHA-256 | Used for |
|---|---|---|---|---|---|
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
Generated
+85 -28
View File
@@ -605,6 +605,12 @@ dependencies = [
"wayland-client",
]
[[package]]
name = "cast"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.6.0"
@@ -814,6 +820,15 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "craft-pdf"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"krilla",
"thiserror 2.0.21",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@@ -1082,15 +1097,18 @@ version = "0.7.0"
name = "deckcraft-pdf"
version = "0.7.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
"deckcraft-model",
"deckcraft-render",
"deckcraft-text",
"hayro-syntax",
"krilla",
"kurbo",
"log",
"serde",
"thiserror 2.0.21",
"wasm-bindgen-test",
]
[[package]]
@@ -1586,12 +1604,6 @@ 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"
@@ -2005,7 +2017,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
dependencies = [
"fearless_simd 1.1.0",
"hayro-ccitt",
]
@@ -2014,9 +2025,6 @@ 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"
@@ -2024,26 +2032,13 @@ 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"
@@ -2430,15 +2425,13 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
dependencies = [
"base64",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"hayro-write",
"image-webp",
"imagesize",
"indexmap",
@@ -2588,6 +2581,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "minicov"
version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
dependencies = [
"cc",
"walkdir",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -2698,6 +2701,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -3095,6 +3107,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "oorandom"
version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "open"
version = "5.4.4"
@@ -4615,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4630,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
dependencies = [
"bytemuck",
"fearless_simd 0.4.1",
"fearless_simd",
"guillotiere",
"log",
"peniko",
@@ -4758,6 +4776,45 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "wasm-bindgen-test"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
dependencies = [
"async-trait",
"cast",
"js-sys",
"libm",
"minicov",
"nu-ansi-term",
"num-traits",
"oorandom",
"serde",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-bindgen-test-macro",
"wasm-bindgen-test-shared",
]
[[package]]
name = "wasm-bindgen-test-macro"
version = "0.3.79"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "wasm-bindgen-test-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
[[package]]
name = "wayland-backend"
version = "0.3.17"
+5 -2
View File
@@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
version = "0.7.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/deckcraft"
[workspace.dependencies]
@@ -57,7 +57,7 @@ harfrust = "0.13"
vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
krilla = { version = "0.8", features = ["pdf"] }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
i_overlay = "9"
quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -93,3 +93,6 @@ opt-level = 3
lto = "thin"
codegen-units = 16
debug = 0
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
+8
View File
@@ -72,3 +72,11 @@ Code dependencies
https://github.com/pdeljanov/Symphonia.
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
FilmCraft (same owner, MIT OR Apache-2.0).
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
and rejection of serialization after failed cleanup. Its upstream licence files are
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
and hashes are in ATTRIBUTION.md.
+3
View File
@@ -97,6 +97,9 @@ Today it can:
## Getting started
PDF export uses the shared `craft-pdf` writer with import features disabled. See
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
```sh
git clone https://github.com/storytold/deckcraft
cd deckcraft
+2
View File
@@ -134,6 +134,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
+28 -3
View File
@@ -1175,17 +1175,40 @@ impl FontDb {
None
}
/// A covering fallback accepted by the caller (for example, an interface that cannot use
/// colour or very large faces). Rejected faces do not stop the search for another face.
/// A policy-specific miss is not stored as a general document-font miss.
pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) {
return Some(f);
}
#[cfg(not(target_arch = "wasm32"))]
if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) {
return self.loaded_fallback_filtered(c, exclude, &accept);
}
None
}
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
self.loaded_fallback_filtered(c, exclude, &|_| true)
}
fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
let faces = self.read_faces();
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
order.into_iter().find(|f| f.covers(c)).cloned()
order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()
}
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn system_fallback(&self, c: char) -> bool {
self.system_fallback_filtered(c, &|_| true, true)
}
#[cfg(not(target_arch = "wasm32"))]
fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool {
if c.is_control() || c.is_whitespace() {
return false;
}
@@ -1196,7 +1219,7 @@ impl FontDb {
return false;
}
}
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
// Another thread may have loaded a face for `c` (faces are only ever added), or found
@@ -1229,7 +1252,9 @@ impl FontDb {
return true;
}
}
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
if remember_miss {
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
}
false
}
+29
View File
@@ -226,6 +226,35 @@ mod tests {
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
}
#[test]
fn filtered_fallback_continues_after_a_rejected_loaded_face() {
let db = FontDb::with_font_dirs(Vec::new());
db.set_system_fallback(false);
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1);
let first = db.fallback_for('A', u32::MAX).unwrap();
let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY };
let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap();
assert_eq!(fallback.family, accepted_family);
assert!(fallback.covers('A'));
assert_ne!(fallback.id(), first.id());
assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged");
assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() {
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
let db = FontDb::with_font_dirs(vec![dir]);
let first = db.fallback_for('A', u32::MAX).unwrap();
assert_ne!(first.family, "DeckCraft Legacy");
assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none());
assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs");
let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap();
assert_eq!(accepted.family, "DeckCraft Legacy");
assert!(accepted.covers('A'));
}
#[test]
fn concurrent_lookups_all_find_an_installed_family() {
// Threads resolving an installed family nobody had loaded yet used to fall back to
+11 -4
View File
@@ -3,7 +3,7 @@ name = "deckcraft-pdf"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "DeckCraft PDF export (krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
description = "DeckCraft PDF export (craft-pdf/Krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
[lints]
workspace = true
@@ -13,13 +13,20 @@ deckcraft-model = { workspace = true }
deckcraft-fonts = { workspace = true }
deckcraft-text = { workspace = true }
deckcraft-render = { workspace = true }
krilla = { workspace = true }
craft-pdf = { workspace = true }
kurbo = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
[dev-dependencies]
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
# Off the web build, krilla compresses each slide's artwork on a background thread while the next
# slide renders (the same bytes, in parallel).
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
# backend as craft-pdf; this dependency only enables its native compression feature.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
krilla = { workspace = true, features = ["rayon"] }
krilla = { version = "=0.8.2", features = ["rayon"] }
+34
View File
@@ -0,0 +1,34 @@
# DeckCraft PDF export
DeckCraft uses `craft-pdf` for writer construction, metadata, fallible font preparation and
final serialization. Slide rasterization, the selectable text overlay, font discovery and
shaping, hyperlinks, bookmarks, notes and handout layout remain app-owned. The existing
renderer also resolves and rasterizes WMF/EMF pictures before export.
Only the shared `write` feature is enabled, with default features disabled. Krilla retains
its raster-image and simple-text features, including notes/footer shaping. PDF embedding is
disabled: DeckCraft does not use it. The production exporter has no Hayro parser, interpreter,
image-decoder, fallback-font or CMap dependencies. Tests use `hayro-syntax` 0.7.2 directly,
with only `std` enabled, to inspect the generated PDFs.
The exporter runs inside a native unwind guard. Writer failures return `PdfError::Write`.
A font that cannot be prepared for embedding now produces an error instead of silently
omitting its selectable text or notes. The shared Krilla 0.8.2 patch avoids a second panic
in page/surface cleanup while unwinding and permanently poisons a document after cleanup
failure, so a retained writer cannot serialize it. Successful serialization paths are
unchanged. Native unwind guards cannot catch WASM aborts or process-wide allocation failures.
## Building and validation
Rust 1.95 or newer is required. The workspace pins `craft-pdf` 0.1.0 and its Krilla 0.8.2
patch to `https://github.com/storytold/craft-libs` revision
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Both packages resolve from that immutable Git source;
a sibling checkout is unnecessary. Publish and review this shared-library prerequisite
before publishing the app migration, so fresh CI and release builds can fetch it.
Run `cargo test -p deckcraft-pdf` for export contracts and `cargo xtask ci` for all workspace
gates. PDF contract tests also run with `wasm-bindgen-test-runner` on
`wasm32-unknown-unknown`. The `pdf-deps` gate verifies the two writer source pins,
retained Krilla image/text features and the development-only parser boundary.
This migration does not add vector slide artwork or change text-shaping/ToUnicode policy.
+54 -30
View File
@@ -8,8 +8,13 @@
//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3,
//! 4, 6 or 9 slides per page).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
use std::collections::HashMap;
use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
use deckcraft_fonts::FontDb;
use deckcraft_model::Presentation;
use deckcraft_model::text::Action as ClickAction;
@@ -19,7 +24,6 @@ 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;
@@ -37,6 +41,12 @@ pub enum PdfError {
Write(String),
}
impl From<write::WriteError> for PdfError {
fn from(error: write::WriteError) -> Self {
Self::Write(error.to_string())
}
}
pub type Result<T> = std::result::Result<T, PdfError>;
/// What each page shows.
@@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect {
/// Export `pres` as PDF bytes.
pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
write::guard(|| export_inner(pres, opts))
}
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
let slides: Vec<usize> = 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(),
@@ -125,16 +139,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
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("DeckCraft".into()).producer("DeckCraft".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 metadata = Metadata {
creator: "DeckCraft".into(),
producer: "DeckCraft".into(),
title: (!title.trim().is_empty()).then_some(title),
authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] },
..Default::default()
};
let mut writer = Writer::new(Default::default(), metadata);
let pdf = writer.document_mut();
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() };
@@ -174,16 +188,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
{
let mut s = page.surface();
for (i, b) in v {
ex.slide(&mut s, *i, *b);
ex.slide(&mut s, *i, *b)?;
annots.extend(ex.links(*i, *b, &page_of));
}
match opts.layout {
PageLayout::Notes => {
if let Some((i, _)) = v.first() {
let notes = pres.slides[*i].notes_text();
ex.paragraphs(&mut s, &notes, notes_frames(pres, opts.paper).1, 12.0);
ex.paragraphs(&mut s, &notes, notes_frames(pres, opts.paper).1, 12.0)?;
}
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1));
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?;
}
PageLayout::Handouts { per_page } => {
if per_page == 3 {
@@ -191,7 +205,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1));
}
}
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
}
PageLayout::Slides => {}
}
@@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
}
page.finish();
}
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
writer.finish().map_err(Into::into)
}
/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points.
@@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec<Rect>
struct Exporter<'a> {
pres: &'a Presentation,
opts: &'a PdfOptions,
fonts: HashMap<u32, Option<Font>>,
fonts: HashMap<u32, Font>,
}
fn tf(a: Affine) -> Transform {
@@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill {
}
impl Exporter<'_> {
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option<Font> {
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
if let Some(font) = self.fonts.get(&face.id()) {
return Ok(font.clone());
}
let font = write::font(face.data(), face.index(), None)?;
self.fonts.insert(face.id(), font.clone());
Ok(font)
}
/// Slide `i` drawn into `b` (page points).
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) {
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> {
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;
@@ -432,14 +451,15 @@ impl Exporter<'_> {
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view, false);
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
}
Ok(())
}
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
@@ -457,7 +477,7 @@ impl Exporter<'_> {
if text.trim().is_empty() {
continue;
}
let Some(font) = self.font(&run.face) else { continue };
let font = self.font(&run.face)?;
let n = run.glyphs.len();
let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n);
let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2);
@@ -486,6 +506,7 @@ impl Exporter<'_> {
s.set_fill(None);
s.pop();
}
Ok(())
}
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
@@ -516,15 +537,15 @@ impl Exporter<'_> {
out
}
fn ui_font(&mut self) -> Option<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
fn ui_font(&mut self) -> Result<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular");
let f = self.font(&face)?;
Some((f, face))
Ok((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 };
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> {
let (font, face) = self.ui_font()?;
let k = size / face.upem.max(1.0);
let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::<f64>();
let lh = size * 1.3;
@@ -554,6 +575,7 @@ impl Exporter<'_> {
y += lh;
}
s.set_fill(None);
Ok(())
}
/// Ruled lines for handwritten notes (3-per-page handouts).
@@ -578,21 +600,23 @@ impl Exporter<'_> {
/// A notes or handout page's header, date, footer and page number from its master; just the
/// page number when the deck has no such master.
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) {
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> {
let Some(m) = master else { return self.footer(s, pw, ph, n) };
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
// wrapping, alignment and mixed formatting as the master has them.
let pres = self.pres;
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
for pt in deckcraft_render::place_master_texts(pres, m, n) {
self.text_layer(s, &pt, view, true);
self.text_layer(s, &pt, view, true)?;
}
Ok(())
}
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
let Some((font, _)) = self.ui_font() else { return };
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> {
let (font, _) = self.ui_font()?;
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);
Ok(())
}
}
+216 -5
View File
@@ -24,33 +24,49 @@ fn opts(layout: PageLayout) -> PdfOptions {
PdfOptions { layout, dpi: 48.0, ..Default::default() }
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), 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);
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let (pages, size) = match layout {
PageLayout::Slides => (5, (960.0, 540.0)),
PageLayout::Notes => (5, (612.0, 792.0)),
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
_ => (1, (612.0, 792.0)),
};
assert_eq!(pdf.pages().len(), pages);
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
}
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), 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]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), 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());
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
}
#[test]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), 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);
@@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() {
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn metadata_retains_values_and_omits_blank_fields() {
let mut p = deck(1);
p.props.title = " Deck title ".into();
p.props.author = " Author ".into();
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
assert!(pdf.metadata().creation_date.is_none());
p.props.author = " ".into();
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
assert!(pdf.metadata().title.is_none());
assert!(pdf.metadata().author.is_none());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn text_layer_links_and_bookmarks_survive_serialization() {
use deckcraft_model::text::{Action, Hyperlink};
use hayro_syntax::object::{Array, Dict};
let mut p = deck(2);
let last = p.slides[1].id;
let slide = Arc::make_mut(&mut p.slides[0]);
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
}
for text_layer in [true, false] {
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font");
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
assert!(first.get::<Dict<'_>>(b"Next").is_some());
}
}
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
/// embedding. No font bytes or vendor assets are stored as test fixtures.
fn font_without_head() -> Vec<u8> {
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
let mut offset = 0;
while offset + old.len() <= bytes.len() {
if bytes[offset..offset + old.len()] == *old {
bytes[offset..offset + old.len()].copy_from_slice(new);
offset += old.len();
} else {
offset += 1;
}
}
}
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
for i in 0..tables {
let offset = 12 + i * 16;
if &bytes[offset..offset + 4] == b"head" {
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
}
}
bytes
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let db = FontDb::global();
assert_eq!(db.add_font(font_without_head()), 1);
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
let mut p = deck(1);
let slide = Arc::make_mut(&mut p.slides[0]);
let mut body = TextBody::from_text("Text that must not disappear silently");
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("BadPDF".into());
run.props.size = Some(12.0);
}
}
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"));
// Master text remains visible even when the optional slide text layer is off.
// It must propagate the same embedding error through notes and handout pages.
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
header.text = p.slides[0].shapes[0].text.clone();
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
let mut p = deck(1);
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
if layout == PageLayout::Notes {
p.notes_master = master;
} else {
p.handout_master = master;
}
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
assert!(matches!(error, PdfError::Write(_)));
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
}
// The failed export leaves no poisoned writer state behind.
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
assert_eq!(face.family, "Ubuntu");
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
let mut body = TextBody::from_text(text);
for paragraph in &mut body.paragraphs {
for run in &mut paragraph.runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
}
let mut p = deck(1);
Arc::make_mut(&mut p.slides[0]).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
let placed = deckcraft_render::place_slide(&p, 0);
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
assert!(!runs.is_empty());
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
let cmap = std::str::from_utf8(&cmap).unwrap();
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
// content's text-showing operators through that map, not document metadata.
let mut mapping = HashMap::<u16, String>::new();
let mut in_char_map = false;
for line in cmap.lines() {
if line.ends_with("beginbfchar") {
in_char_map = true;
} else if line == "endbfchar" {
in_char_map = false;
} else if in_char_map {
let parts: Vec<_> = line.split_whitespace().collect();
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
let value = parts[1].trim_matches(['<', '>']);
let units: Vec<_> = value
.as_bytes()
.as_chunks::<4>()
.0
.iter()
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
.collect();
mapping.insert(cid, String::from_utf16(&units).unwrap());
}
}
let mut extracted = String::new();
let mut append = |bytes: &[u8]| {
assert!(bytes.len().is_multiple_of(2));
for pair in bytes.as_chunks::<2>().0 {
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
}
};
let mut operations = page.typed_operations();
while let Some(operation) = operations.next() {
match operation {
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
TypedInstruction::ShowTexts(values) => {
for value in values.0.iter::<Object<'_>>() {
if let Object::String(value) = value {
append(value.as_bytes());
}
}
}
_ => {}
}
}
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
/// on the page and room left for the notes.
#[test]
+5 -2
View File
@@ -954,8 +954,11 @@ fn symbol_font_characters_draw_in_the_sym_font() {
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
let symbol = "Symbol";
if !FontDb::global().has_family(symbol) {
eprintln!("skipped: no Symbol font installed");
let db = FontDb::global();
// Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap.
// This integration test needs a face that can draw the symbol code used by the document.
if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') {
eprintln!("skipped: no installed Symbol face covers U+F0DE");
return;
}
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
+20 -2
View File
@@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace};
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
const MAX_FACE_BYTES: usize = 128 << 20;
fn accepts_face(byte_len: usize, color: bool) -> bool {
!color && byte_len <= MAX_FACE_BYTES
}
/// Faces borrowed for the interface, and the characters already looked at.
#[derive(Default)]
pub struct UiFonts {
@@ -53,7 +57,7 @@ pub fn faces_for_text(
continue;
}
let Some(f) = lookup(c) else { continue };
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) {
continue;
}
out.push(f);
@@ -69,7 +73,13 @@ impl UiFonts {
}
let font = egui::FontId::proportional(13.0);
let new = ctx.fonts_mut(|f| {
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
faces_for_text(
text,
&self.faces,
&mut self.seen,
|c| f.has_glyph(&font, c),
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
)
});
if !new.is_empty() {
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
@@ -168,6 +178,14 @@ mod tests {
FontDb::global().face(family, "Regular")
}
#[test]
fn interface_face_policy_rejects_colour_and_oversize_faces() {
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected");
assert!(!accepts_face(1, true), "even a small colour face is still rejected");
assert!(!accepts_face(MAX_FACE_BYTES + 1, true));
}
/// One face for a whole string, looked up once per character, never for characters the
/// interface already draws, and never twice.
#[test]
+7 -1
View File
@@ -19,7 +19,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
| L4 | `pdf` | PDF export through krilla |
| L4 | `pdf` | App-owned export layout and rendering through the shared `craft-pdf` writer |
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
@@ -94,3 +94,9 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs |
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text
shaping for notes and footers; slide layout, rasterization, font selection, text mapping,
links and outlines remain app-owned. PDF embedding and all Hayro production dependencies
are disabled. Native builds enable parallel compression on the same pinned Krilla backend;
WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency.
+3
View File
@@ -139,3 +139,6 @@ plan above.
| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain |
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
PDF writer consolidation preserves the current raster-artwork export and its selectable
text layer, notes and handouts. It does not advance vector-PDF or printing parity.
+116
View File
@@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
http://creativecommons.org/publicdomain/zero/1.0/
+5 -1
View File
@@ -8,6 +8,7 @@ mod bench;
mod ico;
mod layers;
mod parity;
mod pdf_deps;
mod stats;
mod version;
@@ -19,9 +20,10 @@ usage: cargo xtask <command>
commands:
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
pdf-deps verify pinned writer sources and the production feature boundary
layers enforce the crate dependency layering (plan/architecture.md §3)
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
bench [--save] [DECK...]
time open, render, save and export of the sample (and DECKs) in release, and
@@ -37,6 +39,7 @@ fn main() -> ExitCode {
let result = match args.first().map(String::as_str) {
Some("assets") => assets::run(&root()),
Some("layers") => cmd_layers(),
Some("pdf-deps") => pdf_deps::run(&root()),
Some("wasm") => cmd_wasm(),
Some("ci") => cmd_ci(),
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
@@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo fmt --all -- --check")
}),
),
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
(
"clippy",
Box::new(|| {
+227
View File
@@ -0,0 +1,227 @@
//! Check the immutable writer sources and keep PDF reading out of production exports.
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use std::process::Command;
use serde_json::Value;
const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
pub fn run(root: &Path) -> Result<(), String> {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args(["metadata", "--locked", "--format-version", "1"])
.output()
.map_err(|e| format!("cargo metadata: {e}"))?;
if !output.status.success() {
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
validate(&metadata)?;
// Metadata merges target features even with --filter-platform. Check Cargo's
// selected feature graph separately so native compression never enables threads on WASM.
let output =
Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?;
if !output.status.success() {
return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr)));
}
let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?;
let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?;
for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] {
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
.current_dir(root)
.args([
"tree",
"--locked",
"--target",
target,
"--edges",
"normal,build",
"--package",
"deckcraft-pdf",
"--depth",
"2",
"--no-dedupe",
"--prefix",
"none",
"--format",
"{p}|{f}",
])
.output()
.map_err(|e| format!("cargo tree ({target}): {e}"))?;
if !output.status.success() {
return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr)));
}
validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?;
}
println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only");
Ok(())
}
fn validate(metadata: &Value) -> Result<(), String> {
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
let nodes = metadata.get("resolve").and_then(|r| r.get("nodes")).and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
let one_package = |name: &str| -> Result<&Value, String> {
let matches: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect();
if matches.len() != 1 {
return Err(format!("{name}: expected one resolved package, found {}", matches.len()));
}
matches.first().copied().ok_or_else(|| format!("missing {name}"))
};
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
for (name, version, expected_source, expected_features) in [
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]),
("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]),
] {
let package = one_package(name)?;
if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(expected_source) {
return Err(format!("{name}: expected {version} from {expected_source}; got {package}"));
}
let id = package.get("id").and_then(Value::as_str).ok_or_else(|| format!("{name}: missing package id"))?;
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("{name}: no resolved node"))?;
let features = node.get("features").and_then(Value::as_array).ok_or_else(|| format!("{name}: no resolved features"))?;
let actual: BTreeSet<_> = features.iter().filter_map(Value::as_str).collect();
let expected: BTreeSet<_> = expected_features.into_iter().collect();
if actual != expected {
return Err(format!("{name}: expected features {expected:?}; got {features:?}"));
}
}
// Metadata includes development dependencies. Follow only normal/build edges from
// the production exporter so a parser accidentally promoted into it cannot hide there.
let by_id: HashMap<_, _> = packages.iter().filter_map(|p| p.get("id").and_then(Value::as_str).map(|id| (id, p))).collect();
let root = one_package("deckcraft-pdf")?.get("id").and_then(Value::as_str).ok_or("deckcraft-pdf has no id")?;
let mut pending = vec![root];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let package = by_id.get(id).ok_or_else(|| format!("unresolved package {id}"))?;
let name = package.get("name").and_then(Value::as_str).ok_or("resolved package has no name")?;
if name == "hayro" || name.starts_with("hayro-") {
return Err(format!("unexpected production reader dependency {name}"));
}
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("no resolved node for {id}"))?;
for dep in node.get("deps").and_then(Value::as_array).ok_or_else(|| format!("no dependency edges for {id}"))? {
let kinds = dep.get("dep_kinds").and_then(Value::as_array).ok_or("dependency has no kinds")?;
if kinds.is_empty() {
return Err("dependency has no kinds".into());
}
if kinds.iter().any(|k| k.get("kind").and_then(Value::as_str) != Some("dev")) {
pending.push(dep.get("pkg").and_then(Value::as_str).ok_or("dependency has no package id")?);
}
}
}
Ok(())
}
fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> {
let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]);
if parallel {
expected.insert("rayon");
}
let mut found = false;
for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) {
found = true;
let actual: BTreeSet<_> = features.split(',').collect();
if actual != expected {
return Err(format!("{package}: expected features {expected:?}; got {actual:?}"));
}
}
if !found {
return Err("missing Krilla in the exporter's target dependency graph".into());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn metadata() -> Value {
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
json!({
"packages": [
{"id":"app", "name":"deckcraft-pdf"},
{"id":"writer", "name":"craft-pdf", "version":"0.1.0", "source":source},
{"id":"backend", "name":"krilla", "version":"0.8.2", "source":source},
{"id":"parser", "name":"hayro-syntax", "version":"0.7.2", "source":REGISTRY}
],
"resolve":{"nodes":[
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
{"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]},
{"id":"parser", "features":["std"], "deps":[]}
]}
})
}
#[test]
fn parallel_compression_is_required_only_on_native_targets() {
let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n";
let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n";
assert!(validate_target_features(serial, false).is_ok());
assert!(validate_target_features(parallel, true).is_ok());
assert!(validate_target_features(serial, true).is_err());
assert!(validate_target_features(parallel, false).is_err());
assert!(validate_target_features("", false).is_err());
}
#[test]
fn rejects_unpinned_or_duplicate_writer_sources() {
assert!(validate(&metadata()).is_ok());
for source in [Value::Null, json!(REGISTRY), json!("git+https://github.com/storytold/craft-libs#other")] {
let mut m = metadata();
m["packages"][1]["source"] = source;
assert!(validate(&m).is_err());
}
let mut m = metadata();
let duplicate = m["packages"][2].clone();
m["packages"].as_array_mut().unwrap().push(duplicate);
assert!(validate(&m).is_err());
}
#[test]
fn rejects_import_features_and_missing_text_shaping() {
for (node, features) in [
(1, json!([])),
(1, json!(["write", "write-pdf"])),
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
(2, json!(["raster-images"])),
(3, json!(["std", "images"])),
] {
let mut m = metadata();
m["resolve"]["nodes"][node]["features"] = features;
assert!(validate(&m).is_err());
}
}
#[test]
fn rejects_dev_parser_promoted_to_production_directly_or_transitively() {
for kind in [Value::Null, json!("build")] {
let mut m = metadata();
m["resolve"]["nodes"][0]["deps"][1]["dep_kinds"][0]["kind"] = kind;
assert!(validate(&m).is_err());
}
let mut m = metadata();
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"parser", "dep_kinds":[{"kind":null}]}]);
assert!(validate(&m).is_err());
}
#[test]
fn rejects_incomplete_dependency_metadata() {
let mut m = metadata();
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"missing", "dep_kinds":[{"kind":null}]}]);
assert!(validate(&m).is_err());
let mut m = metadata();
m["resolve"] = Value::Null;
assert!(validate(&m).is_err());
let mut m = metadata();
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
assert!(validate(&m).is_err());
}
}