diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9ca8e5e8..f0d6d822 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -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 | diff --git a/Cargo.lock b/Cargo.lock index 4044fce6..e91dbf00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index b8596156..03d475c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/NOTICE b/NOTICE index fb209cfc..1ebe2acd 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/README.md b/README.md index f25aef2d..cffc5de7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 06e2efd2..d75a8a03 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 50f9adbd..11cf931c 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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" diff --git a/crates/pdf/README.md b/crates/pdf/README.md new file mode 100644 index 00000000..a3402a0c --- /dev/null +++ b/crates/pdf/README.md @@ -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. diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 97eb641b..753a212b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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 for PdfError { + fn from(error: write::WriteError) -> Self { + Self::Write(error.to_string()) + } +} + pub type Result = std::result::Result; /// 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> { + write::guard(|| export_inner(pres, opts)) +} + +fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result> { let slides: Vec = match &opts.slides { Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(), None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(), @@ -95,16 +109,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { 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> { { 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> { if let Some((i, b)) = v.first() { let notes = pres.slides[*i].notes_text(); let area = Rect::new(72.0, b.y1 + 36.0, pw - 72.0, ph - 54.0); - ex.paragraphs(&mut s, ¬es, area, 12.0); + ex.paragraphs(&mut s, ¬es, 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> { 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> { } 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 struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, - fonts: HashMap>, + fonts: HashMap, } 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 { - 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 { + 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 = 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) -> Vec { @@ -355,15 +376,15 @@ impl Exporter<'_> { out } - fn ui_font(&mut self) -> Option<(Font, std::sync::Arc)> { + fn ui_font(&mut self) -> Result<(Font, std::sync::Arc)> { 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::(); 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(()) } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 305f1457..39dc33cb 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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::>(b"Annots").unwrap(); + assert_eq!(annotations.iter::>().count(), 2); + let resources = page.raw().get::>(b"Resources").unwrap(); + let fonts = resources.get::>(b"Font"); + assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer); + let catalog = pdf.xref().get::>(pdf.xref().root_id()).unwrap(); + let outlines = catalog.get::>(b"Outlines").unwrap(); + let first = outlines.get::>(b"First").unwrap(); + assert!(first.get::>(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 { + 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::>()); + 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::>(b"Resources").unwrap(); + let fonts = resources.get::>(b"Font").unwrap(); + assert_eq!(fonts.len(), 1); + let font = fonts.get::>(fonts.keys().next().unwrap()).unwrap(); + let cmap = font.get::>(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::::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::>() { + if let Object::String(value) = value { + append(value.as_bytes()); + } + } + } + _ => {} + } + } + assert_eq!(extracted, text); +} diff --git a/docs/architecture.md b/docs/architecture.md index 2849f870..bd52ab44 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..c1bc627e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/licenses/pdf-writer/LICENSE-ICC.txt b/licenses/pdf-writer/LICENSE-ICC.txt new file mode 100644 index 00000000..550e190e --- /dev/null +++ b/licenses/pdf-writer/LICENSE-ICC.txt @@ -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/ \ No newline at end of file diff --git a/xtask/src/main.rs b/xtask/src/main.rs index e64a0d42..ad869af2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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 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 ... @@ -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(|| { diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 00000000..565b785e --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -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()); + } +}