PDFW2: adopt shared PDF writer with explicit embedding errors

This commit is contained in:
skylar taylor-barrick
2026-10-13 13:39:33 -06:00
parent 7d5dd85209
commit c536c6e905
15 changed files with 695 additions and 68 deletions
+14
View File
@@ -56,3 +56,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 -29
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"
@@ -1078,15 +1093,17 @@ version = "0.6.1"
name = "deckcraft-pdf"
version = "0.6.1"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
"deckcraft-model",
"deckcraft-render",
"deckcraft-text",
"krilla",
"hayro-syntax",
"kurbo",
"log",
"serde",
"thiserror 2.0.21",
"wasm-bindgen-test",
]
[[package]]
@@ -1582,12 +1599,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"
@@ -2001,7 +2012,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",
]
@@ -2010,9 +2020,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"
@@ -2020,26 +2027,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"
@@ -2426,15 +2420,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",
@@ -2583,6 +2575,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"
@@ -2693,6 +2695,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"
@@ -3090,6 +3101,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"
@@ -4619,7 +4636,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",
@@ -4634,7 +4651,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",
@@ -4762,6 +4779,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.6.1"
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.38"
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
@@ -27,3 +27,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
+1
View File
@@ -129,6 +129,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 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 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
+8 -2
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,8 +13,14 @@ 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"
+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.
+51 -28
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.
@@ -88,6 +98,10 @@ impl Default for PdfOptions {
/// 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(),
@@ -95,16 +109,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() };
@@ -146,7 +160,7 @@ 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 {
@@ -154,9 +168,9 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
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, &notes, area, 12.0);
ex.paragraphs(&mut s, &notes, area, 12.0)?;
}
ex.footer(&mut s, pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1));
ex.footer(&mut s, pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?;
}
PageLayout::Handouts { per_page } => {
if per_page == 3 {
@@ -164,7 +178,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.footer(&mut s, pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
ex.footer(&mut s, pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
}
PageLayout::Slides => {}
}
@@ -175,7 +189,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)
}
/// Slide boxes for a handout page (portrait paper, half-inch-ish margins).
@@ -211,7 +225,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 {
@@ -228,12 +242,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;
@@ -273,14 +292,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);
self.text_layer(s, &pt, view)?;
}
let _ = sh;
}
Ok(())
}
/// Invisible glyphs exactly over the rasterised text.
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine) {
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine) -> Result<()> {
for run in &pt.layout.runs {
if run.glyphs.is_empty() || run.size <= 0.0 {
continue;
@@ -298,7 +318,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 char_starts: Vec<usize> = text.char_indices().map(|(b, _)| b).chain(std::iter::once(text.len())).collect();
let one_to_one = char_starts.len() - 1 == n;
@@ -325,6 +345,7 @@ impl Exporter<'_> {
s.set_fill(None);
s.pop();
}
Ok(())
}
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
@@ -355,15 +376,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;
@@ -393,6 +414,7 @@ impl Exporter<'_> {
y += lh;
}
s.set_fill(None);
Ok(())
}
/// Ruled lines for handwritten notes (3-per-page handouts).
@@ -415,11 +437,12 @@ impl Exporter<'_> {
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 };
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(())
}
}
+195 -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);
@@ -66,3 +82,177 @@ 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 });
}
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"));
// 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().chunks_exact(4).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.chunks_exact(2) {
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);
}
+6 -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 (226 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, 27 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 |
@@ -80,3 +80,8 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|---|---|---|
| 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. Generated-PDF assertions use a std-only parser as a development dependency.
+3
View File
@@ -111,3 +111,6 @@ plan above.
|---|---|---|
| 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
@@ -7,6 +7,7 @@ mod assets;
mod ico;
mod layers;
mod parity;
mod pdf_deps;
mod stats;
mod version;
@@ -18,9 +19,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`)
parity recompute the PowerPoint feature-parity summary in docs/parity-checklist.md
ico <out.ico> <in.png>...
@@ -33,6 +35,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")),
@@ -156,6 +159,7 @@ fn cmd_ci() -> Result<(), String> {
run(c, "cargo fmt --all -- --check")
}),
),
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
(
"clippy",
Box::new(|| {
+161
View File
@@ -0,0 +1,161 @@
//! 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)?;
println!("PDF writer: pinned craft-pdf/Krilla; write only; 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", "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(())
}
#[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","simple-text"], "deps":[]},
{"id":"parser", "features":["std"], "deps":[]}
]}
})
}
#[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", "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());
}
}