mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 02:27:08 -06:00
fix(pdf): reconcile writer with notes and handout masters (PDFW)
Preserve notes and handout master export, visible text and native parallel compression while propagating shared font errors through each export path. Keep the dependency check accurate for native and WASM feature resolution. PR #708.
This commit is contained in:
+24
-3
@@ -67,18 +67,24 @@ fn glob(pat: &str, s: &str) -> bool {
|
||||
None => pat == s,
|
||||
Some((head, tail)) => {
|
||||
let Some(rest) = s.strip_prefix(head) else { return false };
|
||||
(0..=rest.len()).take_while(|&i| i == 0 || !rest[..i].contains('/')).any(|i| rest.is_char_boundary(i) && glob(tail, &rest[i..]))
|
||||
let ends = rest.char_indices().map(|(i, _)| i).chain(std::iter::once(rest.len()));
|
||||
ends.take_while(|&i| !rest[..i].contains('/')).any(|i| glob(tail, &rest[i..]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters).
|
||||
fn parse_ls_files(out: &[u8]) -> Vec<String> {
|
||||
out.split(|&b| b == 0).filter(|n| !n.is_empty()).map(|n| String::from_utf8_lossy(n).into_owned()).collect()
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let out = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||||
.args(["ls-files", "--cached", "--others", "--exclude-standard", "-z"])
|
||||
.output()
|
||||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||||
let files: Vec<String> = String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect();
|
||||
let files: Vec<String> = parse_ls_files(&out.stdout).into_iter().filter(|l| root.join(l).exists()).collect();
|
||||
let md = std::fs::read_to_string(root.join("ATTRIBUTION.md")).map_err(|e| format!("ATTRIBUTION.md: {e}"))?;
|
||||
let miss = missing(&files, &md);
|
||||
if miss.is_empty() {
|
||||
@@ -104,4 +110,19 @@ mod tests {
|
||||
assert!(missing(&["assets/app-icon/hicolor/16x16/apps/x.png".into()], md).is_empty());
|
||||
assert_eq!(missing(&["assets/app-icon/hicolor/a/b/apps/x.png".into()], md).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glob_handles_multibyte_names() {
|
||||
assert!(glob("assets/*/x.png", "assets/żółć/x.png"));
|
||||
assert!(glob("assets/*.png", "assets/日本語.png"));
|
||||
assert!(!glob("assets/*/x.png", "assets/ż/ó/x.png"));
|
||||
assert!(missing(&["assets/żółć/x.png".into()], "`assets/*/x.png`").is_empty());
|
||||
assert_eq!(missing(&["assets/é.png".into()], "`assets/*.svg`"), vec!["assets/é.png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_files_output_is_nul_separated_and_unquoted() {
|
||||
let names = parse_ls_files("assets/zażółć.png\0docs/images/a b\nc.png\0".as_bytes());
|
||||
assert_eq!(names, vec!["assets/zażółć.png".to_string(), "docs/images/a b\nc.png".into()]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `cargo xtask bench`: the performance baseline. Builds `deckcraft-cli` in release, runs its
|
||||
//! `bench` on the built-in sample (and any decks given), and compares each step with the saved
|
||||
//! baseline (`docs/benchmarks/baseline.json`) when there is one; `--save` writes a new baseline.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A step this many times slower than the baseline is flagged.
|
||||
const SLOWER: f64 = 1.5;
|
||||
|
||||
pub fn run(root: &Path, args: &[&str]) -> Result<(), String> {
|
||||
let save = args.contains(&"--save");
|
||||
let decks: Vec<&str> = args.iter().copied().filter(|a| !a.starts_with("--")).collect();
|
||||
let mut build = super::cargo();
|
||||
build.args(["build", "--release", "-q", "-p", "deckcraft-cli"]);
|
||||
super::run(build, "cargo build --release -p deckcraft-cli")?;
|
||||
let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root.join("target"), Into::into);
|
||||
let cli = target.join("release").join(if cfg!(windows) { "deckcraft-cli.exe" } else { "deckcraft-cli" });
|
||||
let mut results = vec![];
|
||||
let inputs: Vec<Option<&str>> = std::iter::once(None).chain(decks.iter().map(|d| Some(*d))).collect();
|
||||
for input in inputs {
|
||||
let mut c = Command::new(&cli);
|
||||
c.arg("bench").arg("--json");
|
||||
match input {
|
||||
Some(d) => c.arg(d),
|
||||
None => c.arg("--sample"),
|
||||
};
|
||||
let out = c.output().map_err(|e| format!("{}: {e}", cli.display()))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("bench {}: {}", input.unwrap_or("--sample"), String::from_utf8_lossy(&out.stderr).trim()));
|
||||
}
|
||||
let v: Value = serde_json::from_slice(&out.stdout).map_err(|e| format!("bench output: {e}"))?;
|
||||
results.push(v);
|
||||
}
|
||||
let baseline_path = root.join("docs/benchmarks/baseline.json");
|
||||
let baseline: Option<Value> = std::fs::read(&baseline_path).ok().and_then(|b| serde_json::from_slice(&b).ok());
|
||||
let mut slower = 0;
|
||||
for r in &results {
|
||||
let file = r["file"].as_str().unwrap_or("?");
|
||||
println!("\n{file}: {} slides", r["slides"]);
|
||||
let base = baseline.as_ref().and_then(|b| b["results"].as_array()?.iter().find(|x| x["file"] == r["file"]).cloned());
|
||||
for step in r["steps"].as_array().into_iter().flatten() {
|
||||
let name = step["step"].as_str().unwrap_or("?");
|
||||
let ms = step["medianMs"].as_f64().unwrap_or(0.0);
|
||||
let was = base.as_ref().and_then(|b| b["steps"].as_array()?.iter().find(|s| s["step"] == step["step"])?["medianMs"].as_f64());
|
||||
let cmp = match was {
|
||||
Some(w) if w > 0.5 => {
|
||||
let k = ms / w;
|
||||
if k > SLOWER {
|
||||
slower += 1;
|
||||
}
|
||||
format!(" {k:>5.2}× baseline{}", if k > SLOWER { " SLOWER" } else { "" })
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
println!(" {name:<24} {ms:>9.1} ms{cmp}");
|
||||
}
|
||||
if let Some(p) = r["peakRssMiB"].as_f64() {
|
||||
println!(" peak memory {p:>9.1} MiB");
|
||||
}
|
||||
}
|
||||
if save {
|
||||
if let Some(dir) = baseline_path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let v = json!({"note": "cargo xtask bench --save; times depend on the machine, compare on the same one", "results": results});
|
||||
std::fs::write(&baseline_path, serde_json::to_vec_pretty(&v).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
println!("\nwrote {}", baseline_path.display());
|
||||
} else if baseline.is_none() {
|
||||
println!("\nno baseline yet: `cargo xtask bench --save` writes docs/benchmarks/baseline.json");
|
||||
}
|
||||
if slower > 0 { Err(format!("{slower} step(s) more than {SLOWER}× slower than the baseline")) } else { Ok(()) }
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
//! invoked through `std::process::Command`.
|
||||
|
||||
mod assets;
|
||||
mod bench;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
@@ -24,6 +25,9 @@ commands:
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
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
|
||||
compare with docs/benchmarks/baseline.json (--save writes it)
|
||||
parity recompute the PowerPoint feature-parity summary in docs/parity-checklist.md
|
||||
ico <out.ico> <in.png>...
|
||||
pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh)
|
||||
@@ -40,6 +44,7 @@ fn main() -> ExitCode {
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
Some("parity") => parity::run(&root()),
|
||||
Some("bench") => bench::run(&root(), &rest),
|
||||
Some("ico") => ico::run(&rest),
|
||||
Some("version") => version::run(&root(), &rest),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
|
||||
+70
-4
@@ -20,7 +20,43 @@ pub fn run(root: &Path) -> Result<(), String> {
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
validate(&metadata)?;
|
||||
println!("PDF writer: pinned craft-pdf/Krilla; write only; parser assertions are development-only");
|
||||
// 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(())
|
||||
}
|
||||
|
||||
@@ -37,7 +73,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "simple-text"]),
|
||||
("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)?;
|
||||
@@ -82,6 +118,25 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
||||
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::*;
|
||||
@@ -99,12 +154,23 @@ mod tests {
|
||||
"resolve":{"nodes":[
|
||||
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
|
||||
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
|
||||
{"id":"backend", "features":["default","raster-images","simple-text"], "deps":[]},
|
||||
{"id":"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());
|
||||
@@ -124,7 +190,7 @@ mod tests {
|
||||
for (node, features) in [
|
||||
(1, json!([])),
|
||||
(1, json!(["write", "write-pdf"])),
|
||||
(2, json!(["default", "raster-images", "simple-text", "pdf"])),
|
||||
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
|
||||
(2, json!(["raster-images"])),
|
||||
(3, json!(["std", "images"])),
|
||||
] {
|
||||
|
||||
Reference in New Issue
Block a user