mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Use the shared PDF writer and report font embedding failures (#708)
Use the pinned shared PDF writer with explicit font embedding errors, preserving app-owned rendering and current main. All seven local gates and fresh Ubuntu/FreeBSD checks passed on the exact integration tree; paired native/WASM PDF bytes, structure, text and pixels match. Original contributor commits are preserved.
This commit is contained in:
@@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
|
||||
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
|
||||
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
|
||||
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
|
||||
|
||||
| Asset | Author | Source | Licence | SHA-256 | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
|
||||
|
||||
Generated
+85
-28
@@ -605,6 +605,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
@@ -814,6 +820,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1082,15 +1097,18 @@ version = "0.7.0"
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1586,12 +1604,6 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -2005,7 +2017,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
@@ -2014,9 +2025,6 @@ name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -2024,26 +2032,13 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-write"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-syntax",
|
||||
"pdf-writer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2430,15 +2425,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"hayro-write",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
@@ -2588,6 +2581,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2698,6 +2701,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3095,6 +3107,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
@@ -4615,7 +4633,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4630,7 +4648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4758,6 +4776,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
|
||||
+5
-2
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -1175,17 +1175,40 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// A covering fallback accepted by the caller (for example, an interface that cannot use
|
||||
/// colour or very large faces). Rejected faces do not stop the search for another face.
|
||||
/// A policy-specific miss is not stored as a general document-font miss.
|
||||
pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) {
|
||||
return self.loaded_fallback_filtered(c, exclude, &accept);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
self.loaded_fallback_filtered(c, exclude, &|_| true)
|
||||
}
|
||||
|
||||
fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn system_fallback(&self, c: char) -> bool {
|
||||
self.system_fallback_filtered(c, &|_| true, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
@@ -1196,7 +1219,7 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
@@ -1229,7 +1252,9 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> = 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<Cell> = 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}");
|
||||
}
|
||||
|
||||
+11
-4
@@ -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"] }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# DeckCraft PDF export
|
||||
|
||||
DeckCraft uses `craft-pdf` for writer construction, metadata, fallible font preparation and
|
||||
final serialization. Slide rasterization, the selectable text overlay, font discovery and
|
||||
shaping, hyperlinks, bookmarks, notes and handout layout remain app-owned. The existing
|
||||
renderer also resolves and rasterizes WMF/EMF pictures before export.
|
||||
|
||||
Only the shared `write` feature is enabled, with default features disabled. Krilla retains
|
||||
its raster-image and simple-text features, including notes/footer shaping. PDF embedding is
|
||||
disabled: DeckCraft does not use it. The production exporter has no Hayro parser, interpreter,
|
||||
image-decoder, fallback-font or CMap dependencies. Tests use `hayro-syntax` 0.7.2 directly,
|
||||
with only `std` enabled, to inspect the generated PDFs.
|
||||
|
||||
The exporter runs inside a native unwind guard. Writer failures return `PdfError::Write`.
|
||||
A font that cannot be prepared for embedding now produces an error instead of silently
|
||||
omitting its selectable text or notes. The shared Krilla 0.8.2 patch avoids a second panic
|
||||
in page/surface cleanup while unwinding and permanently poisons a document after cleanup
|
||||
failure, so a retained writer cannot serialize it. Successful serialization paths are
|
||||
unchanged. Native unwind guards cannot catch WASM aborts or process-wide allocation failures.
|
||||
|
||||
## Building and validation
|
||||
|
||||
Rust 1.95 or newer is required. The workspace pins `craft-pdf` 0.1.0 and its Krilla 0.8.2
|
||||
patch to `https://github.com/storytold/craft-libs` revision
|
||||
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Both packages resolve from that immutable Git source;
|
||||
a sibling checkout is unnecessary. Publish and review this shared-library prerequisite
|
||||
before publishing the app migration, so fresh CI and release builds can fetch it.
|
||||
|
||||
Run `cargo test -p deckcraft-pdf` for export contracts and `cargo xtask ci` for all workspace
|
||||
gates. PDF contract tests also run with `wasm-bindgen-test-runner` on
|
||||
`wasm32-unknown-unknown`. The `pdf-deps` gate verifies the two writer source pins,
|
||||
retained Krilla image/text features and the development-only parser boundary.
|
||||
|
||||
This migration does not add vector slide artwork or change text-shaping/ToUnicode policy.
|
||||
+54
-30
@@ -8,8 +8,13 @@
|
||||
//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3,
|
||||
//! 4, 6 or 9 slides per page).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action as ClickAction;
|
||||
@@ -19,7 +24,6 @@ use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use krilla::destination::XyzDestination;
|
||||
use krilla::geom::{Point, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::outline::{Outline, OutlineNode};
|
||||
use krilla::page::PageSettings;
|
||||
@@ -37,6 +41,12 @@ pub enum PdfError {
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<write::WriteError> for PdfError {
|
||||
fn from(error: write::WriteError) -> Self {
|
||||
Self::Write(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PdfError>;
|
||||
|
||||
/// What each page shows.
|
||||
@@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect {
|
||||
|
||||
/// Export `pres` as PDF bytes.
|
||||
pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
write::guard(|| export_inner(pres, opts))
|
||||
}
|
||||
|
||||
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let slides: Vec<usize> = match &opts.slides {
|
||||
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
|
||||
None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(),
|
||||
@@ -125,16 +139,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
if slides.is_empty() {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
let mut pdf = krilla::Document::new();
|
||||
let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone());
|
||||
let mut meta = Metadata::new().creator("DeckCraft".into()).producer("DeckCraft".into());
|
||||
if !title.trim().is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
if !pres.props.author.trim().is_empty() {
|
||||
meta = meta.authors(vec![pres.props.author.clone()]);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata {
|
||||
creator: "DeckCraft".into(),
|
||||
producer: "DeckCraft".into(),
|
||||
title: (!title.trim().is_empty()).then_some(title),
|
||||
authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] },
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = Writer::new(Default::default(), metadata);
|
||||
let pdf = writer.document_mut();
|
||||
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
|
||||
@@ -174,16 +188,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
{
|
||||
let mut s = page.surface();
|
||||
for (i, b) in v {
|
||||
ex.slide(&mut s, *i, *b);
|
||||
ex.slide(&mut s, *i, *b)?;
|
||||
annots.extend(ex.links(*i, *b, &page_of));
|
||||
}
|
||||
match opts.layout {
|
||||
PageLayout::Notes => {
|
||||
if let Some((i, _)) = v.first() {
|
||||
let notes = pres.slides[*i].notes_text();
|
||||
ex.paragraphs(&mut s, ¬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<Vec<u8>> {
|
||||
ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1));
|
||||
}
|
||||
}
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
|
||||
}
|
||||
PageLayout::Slides => {}
|
||||
}
|
||||
@@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
}
|
||||
page.finish();
|
||||
}
|
||||
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
|
||||
writer.finish().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points.
|
||||
@@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec<Rect>
|
||||
struct Exporter<'a> {
|
||||
pres: &'a Presentation,
|
||||
opts: &'a PdfOptions,
|
||||
fonts: HashMap<u32, Option<Font>>,
|
||||
fonts: HashMap<u32, Font>,
|
||||
}
|
||||
|
||||
fn tf(a: Affine) -> Transform {
|
||||
@@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill {
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option<Font> {
|
||||
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
|
||||
if let Some(font) = self.fonts.get(&face.id()) {
|
||||
return Ok(font.clone());
|
||||
}
|
||||
let font = write::font(face.data(), face.index(), None)?;
|
||||
self.fonts.insert(face.id(), font.clone());
|
||||
Ok(font)
|
||||
}
|
||||
|
||||
/// Slide `i` drawn into `b` (page points).
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) {
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> {
|
||||
let pres = self.pres;
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let k = b.width() / sw;
|
||||
@@ -432,14 +451,15 @@ impl Exporter<'_> {
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
self.text_layer(s, &pt, view, false);
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invisible glyphs exactly over the rasterised text.
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
@@ -457,7 +477,7 @@ impl Exporter<'_> {
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(font) = self.font(&run.face) else { continue };
|
||||
let font = self.font(&run.face)?;
|
||||
let n = run.glyphs.len();
|
||||
let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n);
|
||||
let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2);
|
||||
@@ -486,6 +506,7 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.pop();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
|
||||
@@ -516,15 +537,15 @@ impl Exporter<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
fn ui_font(&mut self) -> Option<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
fn ui_font(&mut self) -> Result<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular");
|
||||
let f = self.font(&face)?;
|
||||
Some((f, face))
|
||||
Ok((f, face))
|
||||
}
|
||||
|
||||
/// Wrapped plain text (notes).
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) {
|
||||
let Some((font, face)) = self.ui_font() else { return };
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> {
|
||||
let (font, face) = self.ui_font()?;
|
||||
let k = size / face.upem.max(1.0);
|
||||
let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::<f64>();
|
||||
let lh = size * 1.3;
|
||||
@@ -554,6 +575,7 @@ impl Exporter<'_> {
|
||||
y += lh;
|
||||
}
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
@@ -578,21 +600,23 @@ impl Exporter<'_> {
|
||||
|
||||
/// A notes or handout page's header, date, footer and page number from its master; just the
|
||||
/// page number when the deck has no such master.
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) {
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let Some(m) = master else { return self.footer(s, pw, ph, n) };
|
||||
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
|
||||
// wrapping, alignment and mixed formatting as the master has them.
|
||||
let pres = self.pres;
|
||||
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
|
||||
for pt in deckcraft_render::place_master_texts(pres, m, n) {
|
||||
self.text_layer(s, &pt, view, true);
|
||||
self.text_layer(s, &pt, view, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
|
||||
let Some((font, _)) = self.ui_font() else { return };
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let (font, _) = self.ui_font()?;
|
||||
s.set_fill(Some(grey(0x60, 1.0)));
|
||||
s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto);
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+216
-5
@@ -24,33 +24,49 @@ fn opts(layout: PageLayout) -> PdfOptions {
|
||||
PdfOptions { layout, dpi: 48.0, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn exports_slides_notes_and_handouts() {
|
||||
let p = deck(5);
|
||||
for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] {
|
||||
let bytes = export(&p, &opts(layout)).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"), "{layout:?}");
|
||||
assert!(bytes.len() > 1000);
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let (pages, size) = match layout {
|
||||
PageLayout::Slides => (5, (960.0, 540.0)),
|
||||
PageLayout::Notes => (5, (612.0, 792.0)),
|
||||
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
|
||||
_ => (1, (612.0, 792.0)),
|
||||
};
|
||||
assert_eq!(pdf.pages().len(), pages);
|
||||
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn empty_range_is_an_error() {
|
||||
let p = deck(1);
|
||||
let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) };
|
||||
assert!(matches!(export(&p, &o), Err(PdfError::NoPages)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn hidden_slides_skipped_unless_asked() {
|
||||
let mut p = deck(2);
|
||||
Arc::make_mut(&mut p.slides[1]).hidden = true;
|
||||
let a = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert!(b.len() > a.len());
|
||||
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
|
||||
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
|
||||
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn handout_boxes_fit_the_paper() {
|
||||
for n in [1, 2, 3, 4, 6, 9] {
|
||||
let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0);
|
||||
@@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn metadata_retains_values_and_omits_blank_fields() {
|
||||
let mut p = deck(1);
|
||||
p.props.title = " Deck title ".into();
|
||||
p.props.author = " Author ".into();
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
|
||||
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
|
||||
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert!(pdf.metadata().creation_date.is_none());
|
||||
p.props.author = " ".into();
|
||||
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert!(pdf.metadata().title.is_none());
|
||||
assert!(pdf.metadata().author.is_none());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn text_layer_links_and_bookmarks_survive_serialization() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
let mut p = deck(2);
|
||||
let last = p.slides[1].id;
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
|
||||
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
|
||||
}
|
||||
for text_layer in [true, false] {
|
||||
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
|
||||
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font");
|
||||
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
|
||||
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
|
||||
assert!(first.get::<Dict<'_>>(b"Next").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
fn font_without_head() -> Vec<u8> {
|
||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||
let mut offset = 0;
|
||||
while offset + old.len() <= bytes.len() {
|
||||
if bytes[offset..offset + old.len()] == *old {
|
||||
bytes[offset..offset + old.len()].copy_from_slice(new);
|
||||
offset += old.len();
|
||||
} else {
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
for i in 0..tables {
|
||||
let offset = 12 + i * 16;
|
||||
if &bytes[offset..offset + 4] == b"head" {
|
||||
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let db = FontDb::global();
|
||||
assert_eq!(db.add_font(font_without_head()), 1);
|
||||
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
|
||||
let mut p = deck(1);
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = TextBody::from_text("Text that must not disappear silently");
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("BadPDF".into());
|
||||
run.props.size = Some(12.0);
|
||||
}
|
||||
}
|
||||
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
|
||||
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"));
|
||||
// Master text remains visible even when the optional slide text layer is off.
|
||||
// It must propagate the same embedding error through notes and handout pages.
|
||||
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
|
||||
header.text = p.slides[0].shapes[0].text.clone();
|
||||
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
|
||||
let mut p = deck(1);
|
||||
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
|
||||
if layout == PageLayout::Notes {
|
||||
p.notes_master = master;
|
||||
} else {
|
||||
p.handout_master = master;
|
||||
}
|
||||
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
|
||||
}
|
||||
// The failed export leaves no poisoned writer state behind.
|
||||
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
assert_eq!(face.family, "Ubuntu");
|
||||
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
|
||||
let mut body = TextBody::from_text(text);
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
}
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
|
||||
let placed = deckcraft_render::place_slide(&p, 0);
|
||||
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
|
||||
assert!(!runs.is_empty());
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
|
||||
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
|
||||
let cmap = std::str::from_utf8(&cmap).unwrap();
|
||||
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
|
||||
// content's text-showing operators through that map, not document metadata.
|
||||
let mut mapping = HashMap::<u16, String>::new();
|
||||
let mut in_char_map = false;
|
||||
for line in cmap.lines() {
|
||||
if line.ends_with("beginbfchar") {
|
||||
in_char_map = true;
|
||||
} else if line == "endbfchar" {
|
||||
in_char_map = false;
|
||||
} else if in_char_map {
|
||||
let parts: Vec<_> = line.split_whitespace().collect();
|
||||
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
|
||||
let value = parts[1].trim_matches(['<', '>']);
|
||||
let units: Vec<_> = value
|
||||
.as_bytes()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
|
||||
.collect();
|
||||
mapping.insert(cid, String::from_utf16(&units).unwrap());
|
||||
}
|
||||
}
|
||||
let mut extracted = String::new();
|
||||
let mut append = |bytes: &[u8]| {
|
||||
assert!(bytes.len().is_multiple_of(2));
|
||||
for pair in bytes.as_chunks::<2>().0 {
|
||||
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
|
||||
}
|
||||
};
|
||||
let mut operations = page.typed_operations();
|
||||
while let Some(operation) = operations.next() {
|
||||
match operation {
|
||||
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
|
||||
TypedInstruction::ShowTexts(values) => {
|
||||
for value in values.0.iter::<Object<'_>>() {
|
||||
if let Object::String(value) = value {
|
||||
append(value.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
/// on the page and room left for the notes.
|
||||
#[test]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace};
|
||||
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
|
||||
const MAX_FACE_BYTES: usize = 128 << 20;
|
||||
|
||||
fn accepts_face(byte_len: usize, color: bool) -> bool {
|
||||
!color && byte_len <= MAX_FACE_BYTES
|
||||
}
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
@@ -53,7 +57,7 @@ pub fn faces_for_text(
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
@@ -69,7 +73,13 @@ impl UiFonts {
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
faces_for_text(
|
||||
text,
|
||||
&self.faces,
|
||||
&mut self.seen,
|
||||
|c| f.has_glyph(&font, c),
|
||||
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
|
||||
)
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
@@ -168,6 +178,14 @@ mod tests {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_face_policy_rejects_colour_and_oversize_faces() {
|
||||
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected");
|
||||
assert!(!accepts_face(1, true), "even a small colour face is still rejected");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, true));
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/
|
||||
BIN
Binary file not shown.
+5
-1
@@ -8,6 +8,7 @@ mod bench;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod pdf_deps;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -19,9 +20,10 @@ usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
|
||||
pdf-deps verify pinned writer sources and the production feature boundary
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
bench [--save] [DECK...]
|
||||
time open, render, save and export of the sample (and DECKs) in release, and
|
||||
@@ -37,6 +39,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user