diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index dd734bd0..3299a639 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`: | BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text | Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular. + +## PDF writer colour profiles + +`craft-pdf` is used with only its `write` feature and default features disabled. Krilla +0.8.2 embeds the following profiles; these were already reachable through the direct Krilla +exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary +is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`. + +| Asset | Author | Source | Licence | SHA-256 | Used for | +|---|---|---|---|---|---| +| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles | +| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles | +| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles | +| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles | diff --git a/Cargo.lock b/Cargo.lock index 45c61525..e2bc7965 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" @@ -1082,15 +1097,18 @@ version = "0.7.0" name = "deckcraft-pdf" version = "0.7.0" dependencies = [ + "craft-pdf", "deckcraft-fonts", "deckcraft-model", "deckcraft-render", "deckcraft-text", + "hayro-syntax", "krilla", "kurbo", "log", "serde", "thiserror 2.0.21", + "wasm-bindgen-test", ] [[package]] @@ -1586,12 +1604,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" -[[package]] -name = "fearless_simd" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" - [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -2005,7 +2017,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9" dependencies = [ - "fearless_simd 1.1.0", "hayro-ccitt", ] @@ -2014,9 +2025,6 @@ name = "hayro-jpeg2000" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1" -dependencies = [ - "fearless_simd 0.4.1", -] [[package]] name = "hayro-syntax" @@ -2024,26 +2032,13 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27" dependencies = [ - "flate2", "hayro-jbig2", "hayro-jpeg2000", - "memchr", "rustc-hash 2.1.3", "smallvec", "zune-jpeg", ] -[[package]] -name = "hayro-write" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a" -dependencies = [ - "flate2", - "hayro-syntax", - "pdf-writer", -] - [[package]] name = "hermit-abi" version = "0.5.3" @@ -2430,15 +2425,13 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33" +source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8" dependencies = [ "base64", "bumpalo", "flate2", "float-cmp", "gif", - "hayro-write", "image-webp", "imagesize", "indexmap", @@ -2588,6 +2581,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2698,6 +2701,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -3095,6 +3107,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.4" @@ -4615,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" dependencies = [ "bytemuck", - "fearless_simd 0.4.1", + "fearless_simd", "guillotiere", "log", "peniko", @@ -4630,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", - "fearless_simd 0.4.1", + "fearless_simd", "guillotiere", "log", "peniko", @@ -4758,6 +4776,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe" + [[package]] name = "wayland-backend" version = "0.3.17" diff --git a/Cargo.toml b/Cargo.toml index c7d6c036..c9cb8182 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" -rust-version = "1.90" +rust-version = "1.95" repository = "https://github.com/storytold/deckcraft" [workspace.dependencies] @@ -57,7 +57,7 @@ harfrust = "0.13" vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } -krilla = { version = "0.8", features = ["pdf"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] } i_overlay = "9" quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } @@ -93,3 +93,6 @@ opt-level = 3 lto = "thin" codegen-units = 16 debug = 0 + +[patch.crates-io] +krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" } diff --git a/NOTICE b/NOTICE index 4fc19273..79a0babb 100644 --- a/NOTICE +++ b/NOTICE @@ -72,3 +72,11 @@ Code dependencies https://github.com/pdeljanov/Symphonia. - The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from FilmCraft (same owner, MIT OR Apache-2.0). + +- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under + MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery + and rejection of serialization after failed cleanup. Its upstream licence files are + retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram + (https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile + licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources + and hashes are in ATTRIBUTION.md. diff --git a/README.md b/README.md index 54b7aac4..82903b44 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 44b9c4f1..8ea976e6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,8 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | +| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) | +| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production | | 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) | | 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) | | 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) | diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 44d9395f..03267384 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -1175,17 +1175,40 @@ impl FontDb { None } + /// A covering fallback accepted by the caller (for example, an interface that cannot use + /// colour or very large faces). Rejected faces do not stop the search for another face. + /// A policy-specific miss is not stored as a general document-font miss. + pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option> { + if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) { + return Some(f); + } + #[cfg(not(target_arch = "wasm32"))] + if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) { + return self.loaded_fallback_filtered(c, exclude, &accept); + } + None + } + fn loaded_fallback(&self, c: char, exclude: u32) -> Option> { + self.loaded_fallback_filtered(c, exclude, &|_| true) + } + + fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option> { let faces = self.read_faces(); let mut order: Vec<&Arc> = faces.iter().filter(|f| f.id != exclude).collect(); order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32)); - order.into_iter().find(|f| f.covers(c)).cloned() + order.into_iter().find(|f| f.covers(c) && accept(f)).cloned() } /// Load a system font covering `c` (preferred fallback families first, then any cataloged /// file under 40 MB). Returns true if one was loaded. Misses are remembered. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn system_fallback(&self, c: char) -> bool { + self.system_fallback_filtered(c, &|_| true, true) + } + + #[cfg(not(target_arch = "wasm32"))] + fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool { if c.is_control() || c.is_whitespace() { return false; } @@ -1196,7 +1219,7 @@ impl FontDb { return false; } } - let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c)); + let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f)); let cataloged: Vec = self.read_catalog().iter().map(|e| e.family.clone()).collect(); let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner()); // Another thread may have loaded a face for `c` (faces are only ever added), or found @@ -1229,7 +1252,9 @@ impl FontDb { return true; } } - self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + if remember_miss { + self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c); + } false } diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs index a68fd986..a9f0c050 100644 --- a/crates/fonts/src/lib.rs +++ b/crates/fonts/src/lib.rs @@ -226,6 +226,35 @@ mod tests { assert!(!shape(&f, "Hello", &[], |c| c).is_empty()); } + #[test] + fn filtered_fallback_continues_after_a_rejected_loaded_face() { + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1); + let first = db.fallback_for('A', u32::MAX).unwrap(); + let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY }; + let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap(); + assert_eq!(fallback.family, accepted_family); + assert!(fallback.covers('A')); + assert_ne!(fallback.id(), first.id()); + assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged"); + assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded"); + } + + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() { + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures"); + let db = FontDb::with_font_dirs(vec![dir]); + let first = db.fallback_for('A', u32::MAX).unwrap(); + assert_ne!(first.family, "DeckCraft Legacy"); + assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none()); + assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs"); + let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap(); + assert_eq!(accepted.family, "DeckCraft Legacy"); + assert!(accepted.covers('A')); + } + #[test] fn concurrent_lookups_all_find_an_installed_family() { // Threads resolving an installed family nobody had loaded yet used to fall back to diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index f1d0dc83..e3e37192 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -10,7 +10,7 @@ use std::io::{Cursor, Read, Write}; use std::sync::Arc; use deckcraft_model::text::{Paragraph, TextBody}; -use deckcraft_model::{LayoutType, Presentation, defaults}; +use deckcraft_model::{LayoutType, Presentation, ShapeKind, defaults}; pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip"; /// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read. @@ -505,6 +505,13 @@ pub fn slides_to_outline(p: &Presentation) -> String { out.push('\n'); } } + if let ShapeKind::Table(t) = &sh.kind { + for row in &t.rows { + let cells: Vec = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect(); + out.push_str(&cells.join("\t")); + out.push('\n'); + } + } } } out @@ -830,3 +837,32 @@ mod tests { } } } + +/// #814: slides_to_outline includes table cell text, which it used to skip. +#[test] +fn outline_includes_table_text() { + use deckcraft_model::table::{Cell, Row, Table}; + use deckcraft_model::{Shape, ShapeKind, Xfrm}; + let mut p = defaults::new_presentation(None); + let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap(); + let mut s = defaults::new_slide(&mut p, layout).expect("slide"); + s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title())); + let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() }; + body.text = Some(TextBody::from_text("Text box body")); + s.shapes.push(body); + + let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() }; + for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] { + let cells: Vec = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect(); + table.rows.push(Row { height: 30.0, cells }); + } + let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() }; + tbl.kind = ShapeKind::Table(table); + s.shapes.push(tbl); + p.slides = vec![std::sync::Arc::new(s)]; + + let out = slides_to_outline(&p); + assert!(out.contains("Text box body"), "{out}"); + assert!(out.contains("Cell A1\tCell B1"), "{out}"); + assert!(out.contains("Cell A2\tCell B2"), "{out}"); +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 24298af0..03a373ac 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,13 +13,20 @@ deckcraft-model = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } deckcraft-render = { workspace = true } -krilla = { workspace = true } +craft-pdf = { workspace = true } kurbo = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } log = { workspace = true } +[dev-dependencies] +hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + # Off the web build, krilla compresses each slide's artwork on a background thread while the next -# slide renders (the same bytes, in parallel). +# slide renders (the same bytes, in parallel). The root patch selects the same pinned +# backend as craft-pdf; this dependency only enables its native compression feature. [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -krilla = { workspace = true, features = ["rayon"] } +krilla = { version = "=0.8.2", features = ["rayon"] } 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 5f213e09..3faec84d 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. @@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect { /// Export `pres` as PDF bytes. pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { + 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(), @@ -125,16 +139,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() }; @@ -174,16 +188,16 @@ 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 { PageLayout::Notes => { if let Some((i, _)) = v.first() { let notes = pres.slides[*i].notes_text(); - ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0); + ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0)?; } - ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1)); + ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?; } PageLayout::Handouts { per_page } => { if per_page == 3 { @@ -191,7 +205,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1)); } } - ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1)); + ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?; } PageLayout::Slides => {} } @@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result> { } page.finish(); } - pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}"))) + writer.finish().map_err(Into::into) } /// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points. @@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec struct Exporter<'a> { pres: &'a Presentation, opts: &'a PdfOptions, - fonts: HashMap>, + fonts: HashMap, } fn tf(a: Affine) -> Transform { @@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill { } impl Exporter<'_> { - fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option { - 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; @@ -432,14 +451,15 @@ impl Exporter<'_> { if self.opts.text_layer { let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k); for pt in deckcraft_render::place_slide(pres, i).texts { - self.text_layer(s, &pt, view, false); + self.text_layer(s, &pt, view, false)?; } let _ = sh; } + Ok(()) } /// Invisible glyphs exactly over the rasterised text. - fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) { + fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> { for run in &pt.layout.runs { if run.glyphs.is_empty() || run.size <= 0.0 { continue; @@ -457,7 +477,7 @@ impl Exporter<'_> { if text.trim().is_empty() { continue; } - let Some(font) = self.font(&run.face) else { continue }; + let font = self.font(&run.face)?; let n = run.glyphs.len(); let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n); let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2); @@ -486,6 +506,7 @@ impl Exporter<'_> { s.set_fill(None); s.pop(); } + Ok(()) } fn links(&self, i: usize, b: Rect, page_of: &HashMap) -> Vec { @@ -516,15 +537,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; @@ -554,6 +575,7 @@ impl Exporter<'_> { y += lh; } s.set_fill(None); + Ok(()) } /// Ruled lines for handwritten notes (3-per-page handouts). @@ -578,21 +600,23 @@ impl Exporter<'_> { /// A notes or handout page's header, date, footer and page number from its master; just the /// page number when the deck has no such master. - fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) { + fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> { let Some(m) = master else { return self.footer(s, pw, ph, n) }; // Laid out by the text engine in the placeholders, scaled from the notes size to the page: // wrapping, alignment and mixed formatting as the master has them. let pres = self.pres; let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0)); for pt in deckcraft_render::place_master_texts(pres, m, n) { - self.text_layer(s, &pt, view, true); + self.text_layer(s, &pt, view, true)?; } + Ok(()) } - fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) { - let Some((font, _)) = self.ui_font() else { return }; + fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> { + let (font, _) = self.ui_font()?; s.set_fill(Some(grey(0x60, 1.0))); s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto); s.set_fill(None); + Ok(()) } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f78b2bf9..fd858884 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); @@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() { } } +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn metadata_retains_values_and_omits_blank_fields() { + let mut p = deck(1); + p.props.title = " Deck title ".into(); + p.props.author = " Author ".into(); + let bytes = export(&p, &opts(PageLayout::Slides)).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice())); + assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice())); + assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice())); + assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice())); + assert!(pdf.metadata().creation_date.is_none()); + p.props.author = " ".into(); + let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + assert!(pdf.metadata().title.is_none()); + assert!(pdf.metadata().author.is_none()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn text_layer_links_and_bookmarks_survive_serialization() { + use deckcraft_model::text::{Action, Hyperlink}; + use hayro_syntax::object::{Array, Dict}; + let mut p = deck(2); + let last = p.slides[1].id; + let slide = Arc::make_mut(&mut p.slides[0]); + for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) { + shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }); + } + for text_layer in [true, false] { + let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap(); + let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap(); + let page = &pdf.pages()[0]; + let annotations = page.raw().get::>(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")); + // Master text remains visible even when the optional slide text layer is off. + // It must propagate the same embedding error through notes and handout pages. + let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, ""); + header.text = p.slides[0].shapes[0].text.clone(); + for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] { + let mut p = deck(1); + let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() })); + if layout == PageLayout::Notes { + p.notes_master = master; + } else { + p.handout_master = master; + } + let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err(); + assert!(matches!(error, PdfError::Write(_))); + assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}"); + } + // The failed export leaves no poisoned writer state behind. + assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok()); +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] +#[cfg_attr(not(target_arch = "wasm32"), test)] +fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() { + use deckcraft_model::{Shape, ShapeId, Xfrm}; + use hayro_syntax::content::ops::TypedInstruction; + use hayro_syntax::object::{Dict, Object, Stream}; + + let text = "Café Ελληνικά Привет"; + let face = FontDb::global().face("Ubuntu", "Regular"); + assert_eq!(face.family, "Ubuntu"); + assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::>()); + 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() + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap()) + .collect(); + mapping.insert(cid, String::from_utf16(&units).unwrap()); + } + } + let mut extracted = String::new(); + let mut append = |bytes: &[u8]| { + assert!(bytes.len().is_multiple_of(2)); + for pair in bytes.as_chunks::<2>().0 { + extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap()); + } + }; + let mut operations = page.typed_operations(); + while let Some(operation) = operations.next() { + match operation { + TypedInstruction::ShowText(value) => append(value.0.as_bytes()), + TypedInstruction::ShowTexts(values) => { + for value in values.0.iter::>() { + if let Object::String(value) = value { + append(value.as_bytes()); + } + } + } + _ => {} + } + } + assert_eq!(extracted, text); +} + /// Notes pages and handouts print on the chosen paper, landscape included, with every slide box /// on the page and room left for the notes. #[test] diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index eb347aca..a9d936cd 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -954,8 +954,11 @@ fn symbol_font_characters_draw_in_the_sym_font() { // U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from // whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle). let symbol = "Symbol"; - if !FontDb::global().has_family(symbol) { - eprintln!("skipped: no Symbol font installed"); + let db = FontDb::global(); + // Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap. + // This integration test needs a face that can draw the symbol code used by the document. + if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') { + eprintln!("skipped: no installed Symbol face covers U+F0DE"); return; } let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0); diff --git a/crates/ui-egui/src/uifonts.rs b/crates/ui-egui/src/uifonts.rs index 4672cbe0..f2212a9d 100644 --- a/crates/ui-egui/src/uifonts.rs +++ b/crates/ui-egui/src/uifonts.rs @@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace}; /// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese). const MAX_FACE_BYTES: usize = 128 << 20; +fn accepts_face(byte_len: usize, color: bool) -> bool { + !color && byte_len <= MAX_FACE_BYTES +} + /// Faces borrowed for the interface, and the characters already looked at. #[derive(Default)] pub struct UiFonts { @@ -53,7 +57,7 @@ pub fn faces_for_text( continue; } let Some(f) = lookup(c) else { continue }; - if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) { + if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) { continue; } out.push(f); @@ -69,7 +73,13 @@ impl UiFonts { } let font = egui::FontId::proportional(13.0); let new = ctx.fonts_mut(|f| { - faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX)) + faces_for_text( + text, + &self.faces, + &mut self.seen, + |c| f.has_glyph(&font, c), + |c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())), + ) }); if !new.is_empty() { log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::>().join(", ")); @@ -168,6 +178,14 @@ mod tests { FontDb::global().face(family, "Regular") } + #[test] + fn interface_face_policy_rejects_colour_and_oversize_faces() { + assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted"); + assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected"); + assert!(!accepts_face(1, true), "even a small colour face is still rejected"); + assert!(!accepts_face(MAX_FACE_BYTES + 1, true)); + } + /// One face for a whole string, looked up once per character, never for characters the /// interface already draws, and never twice. #[test] diff --git a/docs/architecture.md b/docs/architecture.md index 6766dca6..31bac443 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 (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) | | L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app | | apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering | @@ -94,3 +94,9 @@ macOS, Windows, Linux, FreeBSD and web artifacts. | 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs | | 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor | | 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 | + +The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text +shaping for notes and footers; slide layout, rasterization, font selection, text mapping, +links and outlines remain app-owned. PDF embedding and all Hayro production dependencies +are disabled. Native builds enable parallel compression on the same pinned Krilla backend; +WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ff4c455..caf868de 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -139,3 +139,6 @@ plan above. | 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain | | 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha | | 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history | + +PDF writer consolidation preserves the current raster-artwork export and its selectable +text layer, notes and handouts. It does not advance vector-PDF or printing parity. 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/table.pptx b/table.pptx new file mode 100644 index 00000000..525702fd Binary files /dev/null and b/table.pptx differ diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fefa20bf..edb1331e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -8,6 +8,7 @@ mod bench; mod ico; mod layers; mod parity; +mod pdf_deps; mod stats; mod version; @@ -19,9 +20,10 @@ usage: cargo xtask commands: assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md + pdf-deps verify pinned writer sources and the production feature boundary layers enforce the crate dependency layering (plan/architecture.md §3) wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates - ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure) + ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure) stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) bench [--save] [DECK...] time open, render, save and export of the sample (and DECKs) in release, and @@ -37,6 +39,7 @@ fn main() -> ExitCode { let result = match args.first().map(String::as_str) { Some("assets") => assets::run(&root()), Some("layers") => cmd_layers(), + Some("pdf-deps") => pdf_deps::run(&root()), Some("wasm") => cmd_wasm(), Some("ci") => cmd_ci(), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), @@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> { run(c, "cargo fmt --all -- --check") }), ), + ("pdf-deps", Box::new(|| pdf_deps::run(&root()))), ( "clippy", Box::new(|| { diff --git a/xtask/src/pdf_deps.rs b/xtask/src/pdf_deps.rs new file mode 100644 index 00000000..6680f240 --- /dev/null +++ b/xtask/src/pdf_deps.rs @@ -0,0 +1,227 @@ +//! Check the immutable writer sources and keep PDF reading out of production exports. + +use std::collections::{BTreeSet, HashMap}; +use std::path::Path; +use std::process::Command; + +use serde_json::Value; + +const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8"; +const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; + +pub fn run(root: &Path) -> Result<(), String> { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args(["metadata", "--locked", "--format-version", "1"]) + .output() + .map_err(|e| format!("cargo metadata: {e}"))?; + if !output.status.success() { + return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr))); + } + let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?; + validate(&metadata)?; + // Metadata merges target features even with --filter-platform. Check Cargo's + // selected feature graph separately so native compression never enables threads on WASM. + let output = + Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?; + if !output.status.success() { + return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr))); + } + let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?; + let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?; + for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(root) + .args([ + "tree", + "--locked", + "--target", + target, + "--edges", + "normal,build", + "--package", + "deckcraft-pdf", + "--depth", + "2", + "--no-dedupe", + "--prefix", + "none", + "--format", + "{p}|{f}", + ]) + .output() + .map_err(|e| format!("cargo tree ({target}): {e}"))?; + if !output.status.success() { + return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr))); + } + validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?; + } + println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only"); + Ok(()) +} + +fn validate(metadata: &Value) -> Result<(), String> { + let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?; + let nodes = metadata.get("resolve").and_then(|r| r.get("nodes")).and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?; + let one_package = |name: &str| -> Result<&Value, String> { + let matches: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect(); + if matches.len() != 1 { + return Err(format!("{name}: expected one resolved package, found {}", matches.len())); + } + matches.first().copied().ok_or_else(|| format!("missing {name}")) + }; + let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); + for (name, version, expected_source, expected_features) in [ + ("craft-pdf", "0.1.0", source.as_str(), vec!["write"]), + ("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]), + ("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]), + ] { + let package = one_package(name)?; + if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(expected_source) { + return Err(format!("{name}: expected {version} from {expected_source}; got {package}")); + } + let id = package.get("id").and_then(Value::as_str).ok_or_else(|| format!("{name}: missing package id"))?; + let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("{name}: no resolved node"))?; + let features = node.get("features").and_then(Value::as_array).ok_or_else(|| format!("{name}: no resolved features"))?; + let actual: BTreeSet<_> = features.iter().filter_map(Value::as_str).collect(); + let expected: BTreeSet<_> = expected_features.into_iter().collect(); + if actual != expected { + return Err(format!("{name}: expected features {expected:?}; got {features:?}")); + } + } + // Metadata includes development dependencies. Follow only normal/build edges from + // the production exporter so a parser accidentally promoted into it cannot hide there. + let by_id: HashMap<_, _> = packages.iter().filter_map(|p| p.get("id").and_then(Value::as_str).map(|id| (id, p))).collect(); + let root = one_package("deckcraft-pdf")?.get("id").and_then(Value::as_str).ok_or("deckcraft-pdf has no id")?; + let mut pending = vec![root]; + let mut seen = BTreeSet::new(); + while let Some(id) = pending.pop() { + if !seen.insert(id) { + continue; + } + let package = by_id.get(id).ok_or_else(|| format!("unresolved package {id}"))?; + let name = package.get("name").and_then(Value::as_str).ok_or("resolved package has no name")?; + if name == "hayro" || name.starts_with("hayro-") { + return Err(format!("unexpected production reader dependency {name}")); + } + let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("no resolved node for {id}"))?; + for dep in node.get("deps").and_then(Value::as_array).ok_or_else(|| format!("no dependency edges for {id}"))? { + let kinds = dep.get("dep_kinds").and_then(Value::as_array).ok_or("dependency has no kinds")?; + if kinds.is_empty() { + return Err("dependency has no kinds".into()); + } + if kinds.iter().any(|k| k.get("kind").and_then(Value::as_str) != Some("dev")) { + pending.push(dep.get("pkg").and_then(Value::as_str).ok_or("dependency has no package id")?); + } + } + } + Ok(()) +} + +fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> { + let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]); + if parallel { + expected.insert("rayon"); + } + let mut found = false; + for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) { + found = true; + let actual: BTreeSet<_> = features.split(',').collect(); + if actual != expected { + return Err(format!("{package}: expected features {expected:?}; got {actual:?}")); + } + } + if !found { + return Err("missing Krilla in the exporter's target dependency graph".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn metadata() -> Value { + let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); + json!({ + "packages": [ + {"id":"app", "name":"deckcraft-pdf"}, + {"id":"writer", "name":"craft-pdf", "version":"0.1.0", "source":source}, + {"id":"backend", "name":"krilla", "version":"0.8.2", "source":source}, + {"id":"parser", "name":"hayro-syntax", "version":"0.7.2", "source":REGISTRY} + ], + "resolve":{"nodes":[ + {"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]}, + {"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]}, + {"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]}, + {"id":"parser", "features":["std"], "deps":[]} + ]} + }) + } + + #[test] + fn parallel_compression_is_required_only_on_native_targets() { + let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n"; + let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n"; + assert!(validate_target_features(serial, false).is_ok()); + assert!(validate_target_features(parallel, true).is_ok()); + assert!(validate_target_features(serial, true).is_err()); + assert!(validate_target_features(parallel, false).is_err()); + assert!(validate_target_features("", false).is_err()); + } + + #[test] + fn rejects_unpinned_or_duplicate_writer_sources() { + assert!(validate(&metadata()).is_ok()); + for source in [Value::Null, json!(REGISTRY), json!("git+https://github.com/storytold/craft-libs#other")] { + let mut m = metadata(); + m["packages"][1]["source"] = source; + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + let duplicate = m["packages"][2].clone(); + m["packages"].as_array_mut().unwrap().push(duplicate); + assert!(validate(&m).is_err()); + } + + #[test] + fn rejects_import_features_and_missing_text_shaping() { + for (node, features) in [ + (1, json!([])), + (1, json!(["write", "write-pdf"])), + (2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])), + (2, json!(["raster-images"])), + (3, json!(["std", "images"])), + ] { + let mut m = metadata(); + m["resolve"]["nodes"][node]["features"] = features; + assert!(validate(&m).is_err()); + } + } + + #[test] + fn rejects_dev_parser_promoted_to_production_directly_or_transitively() { + for kind in [Value::Null, json!("build")] { + let mut m = metadata(); + m["resolve"]["nodes"][0]["deps"][1]["dep_kinds"][0]["kind"] = kind; + assert!(validate(&m).is_err()); + } + let mut m = metadata(); + m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"parser", "dep_kinds":[{"kind":null}]}]); + assert!(validate(&m).is_err()); + } + + #[test] + fn rejects_incomplete_dependency_metadata() { + let mut m = metadata(); + m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"missing", "dep_kinds":[{"kind":null}]}]); + assert!(validate(&m).is_err()); + let mut m = metadata(); + m["resolve"] = Value::Null; + assert!(validate(&m).is_err()); + let mut m = metadata(); + m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax"); + assert!(validate(&m).is_err()); + } +}