mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
fuzzing
This commit is contained in:
@@ -5,3 +5,4 @@ plan/
|
||||
/corpus
|
||||
/log
|
||||
.DS_Store
|
||||
/fuzz-out
|
||||
|
||||
Generated
+1
@@ -2904,6 +2904,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"printcraft-automation",
|
||||
"printcraft-cos",
|
||||
"printcraft-engine",
|
||||
"printcraft-render",
|
||||
"serde_json",
|
||||
|
||||
@@ -16,6 +16,7 @@ workspace = true
|
||||
printcraft-render = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
printcraft-engine = { workspace = true }
|
||||
printcraft-cos = { workspace = true }
|
||||
printcraft-automation = { workspace = true }
|
||||
base64 = "0.22"
|
||||
|
||||
|
||||
@@ -268,12 +268,40 @@ fn check_one(args: &[String]) -> Result<(), String> {
|
||||
(status, info.pages.len(), failed.len(), failed.join(" | "), info.warnings.len())
|
||||
}
|
||||
};
|
||||
// `--edit` (fuzzing): also run our own object layer end to end, with no panic safety net,
|
||||
// so a panic in cos shows up as a crash of this process.
|
||||
if args.iter().any(|a| a == "--edit") {
|
||||
edit_round_trip(&std::fs::read(path).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let line = serde_json::json!({ "file": path, "status": status, "pages": pages, "failed_pages": failed, "warnings": warnings,
|
||||
"ms": start.elapsed().as_millis() as u64, "detail": detail.chars().take(400).collect::<String>() });
|
||||
println!("{line}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open with cos, touch every object, edit the catalog, save incrementally and in full (both
|
||||
/// output styles), and reopen each result. Errors are fine; panics, hangs and aborts are bugs.
|
||||
fn edit_round_trip(bytes: &[u8]) {
|
||||
use printcraft_cos::{Document, Object, SaveOptions, write_full, write_incremental};
|
||||
let Ok(mut doc) = Document::open(Arc::new(bytes.to_vec())) else { return };
|
||||
for num in doc.object_numbers().into_iter().take(20_000) {
|
||||
if let Ok(o) = doc.try_get(num)
|
||||
&& let Object::Stream(s) = &*o
|
||||
{
|
||||
let _ = s.decoded();
|
||||
}
|
||||
}
|
||||
if let Some(root) = doc.root() {
|
||||
let _ = doc.update_dict(root, |d| d.set(b"PrintCraftFuzz".to_vec(), Object::Bool(true)));
|
||||
}
|
||||
let classic = SaveOptions { object_streams: false, ..SaveOptions::default() };
|
||||
for out in [write_incremental(&doc, &SaveOptions::default()), write_full(&doc, &SaveOptions::default()), write_full(&doc, &classic)].into_iter().flatten() {
|
||||
if let Ok(again) = Document::open(Arc::new(out)) {
|
||||
let _ = again.root().map(|r| again.get(r));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn short(s: &str) -> String {
|
||||
s.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).take(4).collect::<Vec<_>>().join("-").to_lowercase()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
//! `cargo xtask fuzz`: a mutation fuzzer that runs on stable Rust (M1.10).
|
||||
//!
|
||||
//! Seeds are small PDFs (synthetic ones, plus pdf.js corpus files under 64 KB when `corpus/` is
|
||||
//! present). Each iteration mutates a seed, writes it to `fuzz-out/work/`, and runs
|
||||
//! `printcraft-cli check-one <file> --edit` in a child process with a timeout. That opens,
|
||||
//! inspects, renders and extracts text from every page (the bootstrap renderer), then runs our
|
||||
//! object layer end to end: decode streams, edit, save incrementally and in full, reopen.
|
||||
//!
|
||||
//! A child that dies (panic outside a safety net, abort, stack overflow, signal) is a **crash**,
|
||||
//! and one that runs past the timeout is a **hang**. Each finding is minimized (chunks are
|
||||
//! removed while it still fails the same way) and kept in `fuzz-out/findings/` with a JSON note.
|
||||
//! `fuzz-out/` is git-ignored; findings derived from corpus files must not be committed. Turn
|
||||
//! each real bug into a synthetic regression test instead.
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo xtask fuzz [--time 300] [--iterations N] [--seed 1] [--jobs 8] [--timeout 10]
|
||||
//! ```
|
||||
//! It exits non-zero when it finds a crash (hangs are reported but, like the corpus sweep's
|
||||
//! known renderer hangs, do not fail the run).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::gates::root;
|
||||
|
||||
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str)
|
||||
}
|
||||
|
||||
/// xorshift64*: small, deterministic, good enough for mutation choices.
|
||||
#[derive(Clone)]
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
self.0.wrapping_mul(0x2545_F491_4F6C_DD1D)
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
if n == 0 { 0 } else { (self.next() % n as u64) as usize }
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes that matter to PDF syntax, and tokens worth splicing in.
|
||||
const SYNTAX: &[u8] = b"0123456789 /<>[]()R\n\r%-.+#\\";
|
||||
const TOKENS: &[&[u8]] = &[
|
||||
b" 0 R",
|
||||
b" 99999 0 R",
|
||||
b" -1 ",
|
||||
b" 2147483648 ",
|
||||
b" 1e308 ",
|
||||
b"<<",
|
||||
b">>",
|
||||
b"[",
|
||||
b"]",
|
||||
b"(",
|
||||
b")",
|
||||
b"stream\n",
|
||||
b"\nendstream",
|
||||
b"endobj",
|
||||
b" obj ",
|
||||
b"/Length 0",
|
||||
b"/Length 99999999",
|
||||
b"/Filter /FlateDecode",
|
||||
b"/Filter [/ASCIIHexDecode /LZWDecode]",
|
||||
b"/DecodeParms << /Predictor 15 /Columns 99999 >>",
|
||||
b"/Type /ObjStm /N 99999 /First 0",
|
||||
b"/Type /XRef /W [1 9 1]",
|
||||
b"/Kids [1 0 R]",
|
||||
b"/Parent 1 0 R",
|
||||
b"/Prev 0",
|
||||
b"xref\n0 1\n",
|
||||
b"trailer << /Root 1 0 R >>",
|
||||
b"startxref\n0\n%%EOF",
|
||||
b"/Encrypt << /Filter /Standard /V 5 /R 6 >>",
|
||||
b"/Count -5",
|
||||
b"/MediaBox [0 0 0 0]",
|
||||
b"/Rotate 45",
|
||||
];
|
||||
|
||||
fn mutate(rng: &mut Rng, seed: &[u8], other: &[u8]) -> Vec<u8> {
|
||||
let mut d = seed.to_vec();
|
||||
let rounds = 1 + rng.below(4);
|
||||
for _ in 0..rounds {
|
||||
if d.is_empty() {
|
||||
d.extend_from_slice(b"%PDF-1.7\n");
|
||||
}
|
||||
let at = rng.below(d.len());
|
||||
match rng.below(9) {
|
||||
0 => d[at] ^= 1 << rng.below(8),
|
||||
1 => d[at] = SYNTAX[rng.below(SYNTAX.len())],
|
||||
2 => {
|
||||
let n = 1 + rng.below(64.min(d.len() - at));
|
||||
d.drain(at..at + n);
|
||||
}
|
||||
3 => {
|
||||
let n = 1 + rng.below(256.min(d.len() - at));
|
||||
let chunk = d[at..at + n].to_vec();
|
||||
let to = rng.below(d.len());
|
||||
d.splice(to..to, chunk);
|
||||
}
|
||||
4 => {
|
||||
let t = TOKENS[rng.below(TOKENS.len())];
|
||||
d.splice(at..at, t.iter().copied());
|
||||
}
|
||||
5 => d.truncate(at.max(1)),
|
||||
6 if !other.is_empty() => {
|
||||
let from = rng.below(other.len());
|
||||
let n = 1 + rng.below(512.min(other.len() - from));
|
||||
d.splice(at..at, other[from..from + n].iter().copied());
|
||||
}
|
||||
7 => {
|
||||
// Replace a run of digits with an extreme number.
|
||||
if let Some(start) = d[at..].iter().position(u8::is_ascii_digit).map(|p| p + at) {
|
||||
let end = d[start..].iter().position(|c| !c.is_ascii_digit()).map_or(d.len(), |p| p + start);
|
||||
let v: &[u8] = [&b"0"[..], b"4294967295", b"9223372036854775807", b"65535", b"1"][rng.below(5)];
|
||||
d.splice(start..end, v.iter().copied());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Corrupt compressed data: flip bytes inside a stream body.
|
||||
if let Some(s) = find(&d[at..], b"stream").map(|p| p + at + 7) {
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
if s < d.len() {
|
||||
let i = s + rng.below((d.len() - s).min(512));
|
||||
d[i] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
|
||||
hay.windows(needle.len()).position(|w| w == needle)
|
||||
}
|
||||
|
||||
/// Synthetic seeds, so fuzzing works without the corpus: plain, object-stream and encrypted
|
||||
/// shapes, nested page trees, annotations, forms and inline images.
|
||||
fn synthetic_seeds() -> Vec<Vec<u8>> {
|
||||
let build = |objs: &[&str], trailer: &str| {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offs {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} {trailer} >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
};
|
||||
let content = "BT /F1 24 Tf 20 150 Td (Hello) Tj ET 0 0 1 rg 10 10 50 50 re f q 20 0 0 20 100 100 cm BI /W 2 /H 2 /BPC 8 /CS /G ID \u{0}\u{ff}\u{ff}\u{0} EI Q";
|
||||
let stream = format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len());
|
||||
vec![
|
||||
build(
|
||||
&[
|
||||
"<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [6 0 R] >> /Outlines 7 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R 8 0 R] /Count 2 /MediaBox [0 0 200 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> /Annots [6 0 R] >>",
|
||||
&stream,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Annot /Subtype /Widget /FT /Tx /T (f) /V (v) /Rect [10 10 90 30] /P 3 0 R >>",
|
||||
"<< /Type /Outlines /First 9 0 R /Last 9 0 R /Count 1 >>",
|
||||
"<< /Type /Pages /Parent 2 0 R /Kids [10 0 R] /Count 1 /Rotate 90 >>",
|
||||
"<< /Title (One) /Parent 7 0 R /Dest [3 0 R /Fit] >>",
|
||||
"<< /Type /Page /Parent 8 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>",
|
||||
],
|
||||
"/Root 1 0 R /Info << /Title (Fuzz) >>",
|
||||
),
|
||||
build(&["<< /Type /Catalog /Pages 2 0 R >>", "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 10 10] >>"], "/Root 1 0 R"),
|
||||
]
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<(String, Vec<u8>)> {
|
||||
let mut out: Vec<(String, Vec<u8>)> = synthetic_seeds().into_iter().enumerate().map(|(i, b)| (format!("synthetic-{i}"), b)).collect();
|
||||
let dir = root().join("corpus/pdfjs/test/pdfs");
|
||||
if let Ok(rd) = std::fs::read_dir(&dir) {
|
||||
let mut files: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "pdf")).collect();
|
||||
files.sort();
|
||||
for f in files {
|
||||
if let Ok(b) = std::fs::read(&f)
|
||||
&& b.len() <= 64 * 1024
|
||||
{
|
||||
out.push((f.file_name().unwrap_or_default().to_string_lossy().into_owned(), b));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Outcome {
|
||||
Ok,
|
||||
Crash,
|
||||
Hang,
|
||||
}
|
||||
|
||||
fn run_one(exe: &Path, file: &Path, timeout: Duration) -> (Outcome, String) {
|
||||
let child = Command::new(exe)
|
||||
.args(["check-one", &file.to_string_lossy(), "--dpi", "18", "--edit"])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn();
|
||||
let Ok(mut child) = child else { return (Outcome::Ok, String::new()) };
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
if status.success() {
|
||||
return (Outcome::Ok, String::new());
|
||||
}
|
||||
let mut err = String::new();
|
||||
if let Some(mut e) = child.stderr.take() {
|
||||
use std::io::Read;
|
||||
let _ = e.read_to_string(&mut err);
|
||||
}
|
||||
// Keep the first panic location or abort message: it identifies the bug.
|
||||
let what = err.lines().find(|l| l.contains("panicked at") || l.contains("overflow") || l.contains("fatal")).unwrap_or("").trim().to_string();
|
||||
return (Outcome::Crash, format!("{status}; {what}"));
|
||||
}
|
||||
Ok(None) if start.elapsed() > timeout => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return (Outcome::Hang, format!("over {} s", timeout.as_secs()));
|
||||
}
|
||||
Ok(None) => std::thread::sleep(Duration::from_millis(5)),
|
||||
Err(e) => return (Outcome::Ok, e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove chunks while the input still fails with the same outcome and message.
|
||||
fn minimize(exe: &Path, work: &Path, data: Vec<u8>, outcome: Outcome, what: &str, timeout: Duration) -> Vec<u8> {
|
||||
let mut best = data;
|
||||
let mut chunk = best.len() / 2;
|
||||
let mut tries = 0;
|
||||
let probe = work.join("minimize.pdf");
|
||||
while chunk >= 1 && tries < 300 {
|
||||
let mut i = 0;
|
||||
let mut progressed = false;
|
||||
while i < best.len() && tries < 300 {
|
||||
let mut cand = best.clone();
|
||||
cand.drain(i..(i + chunk).min(cand.len()));
|
||||
tries += 1;
|
||||
if std::fs::write(&probe, &cand).is_err() {
|
||||
break;
|
||||
}
|
||||
let (o, w) = run_one(exe, &probe, timeout);
|
||||
if o == outcome && (outcome == Outcome::Hang || w == what) {
|
||||
best = cand;
|
||||
progressed = true;
|
||||
} else {
|
||||
i += chunk;
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
chunk /= 2;
|
||||
}
|
||||
}
|
||||
let _ = std::fs::remove_file(probe);
|
||||
best
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<()> {
|
||||
let time = Duration::from_secs(flag(args, "--time").unwrap_or("300").parse()?);
|
||||
let iterations: usize = flag(args, "--iterations").map(str::parse).transpose()?.unwrap_or(usize::MAX);
|
||||
let seed: u64 = flag(args, "--seed").unwrap_or("1").parse()?;
|
||||
let jobs: usize = flag(args, "--jobs").map(str::parse).transpose()?.unwrap_or_else(|| std::thread::available_parallelism().map_or(4, |n| n.get()));
|
||||
let timeout = Duration::from_secs(flag(args, "--timeout").unwrap_or("10").parse()?);
|
||||
|
||||
let status = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()))
|
||||
.current_dir(root())
|
||||
.args(["build", "--release", "-q", "-p", "printcraft-cli"])
|
||||
.status()?;
|
||||
if !status.success() {
|
||||
bail!("building printcraft-cli failed");
|
||||
}
|
||||
let exe = root().join("target/release/printcraft-cli");
|
||||
let out = root().join("fuzz-out");
|
||||
let work = out.join("work");
|
||||
let findings = out.join("findings");
|
||||
std::fs::create_dir_all(&work)?;
|
||||
std::fs::create_dir_all(&findings)?;
|
||||
let seeds = Arc::new(seeds());
|
||||
println!("fuzz: {} seeds, {jobs} jobs, {} s, timeout {} s, seed {seed}", seeds.len(), time.as_secs(), timeout.as_secs());
|
||||
|
||||
let done = Arc::new(AtomicUsize::new(0));
|
||||
let found: Arc<Mutex<Vec<(Outcome, String, String)>>> = Arc::default();
|
||||
let start = Instant::now();
|
||||
std::thread::scope(|s| {
|
||||
for job in 0..jobs {
|
||||
let (seeds, done, found, exe, work, findings) = (seeds.clone(), done.clone(), found.clone(), exe.clone(), work.clone(), findings.clone());
|
||||
s.spawn(move || {
|
||||
let mut rng = Rng(seed.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(job as u64 + 1) | 1);
|
||||
let file = work.join(format!("job{job}.pdf"));
|
||||
while start.elapsed() < time {
|
||||
let n = done.fetch_add(1, Ordering::Relaxed);
|
||||
if n >= iterations {
|
||||
break;
|
||||
}
|
||||
let (name, base) = &seeds[rng.below(seeds.len())];
|
||||
let other = &seeds[rng.below(seeds.len())].1;
|
||||
let data = mutate(&mut rng, base, other);
|
||||
if std::fs::write(&file, &data).is_err() {
|
||||
continue;
|
||||
}
|
||||
let (outcome, what) = run_one(&exe, &file, timeout);
|
||||
if outcome == Outcome::Ok {
|
||||
continue;
|
||||
}
|
||||
let key = format!("{outcome:?}:{what}");
|
||||
{
|
||||
let f = found.lock().expect("lock");
|
||||
// One finding per distinct failure (hangs: one per seed).
|
||||
let k = if outcome == Outcome::Hang { format!("Hang:{name}") } else { key.clone() };
|
||||
if f.iter().any(|(o, w, s)| (if *o == Outcome::Hang { format!("Hang:{s}") } else { format!("{o:?}:{w}") }) == k) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let small = minimize(&exe, &work, data.clone(), outcome, &what, timeout);
|
||||
let id = format!("{:?}-{:016x}", outcome, fxhash(&small)).to_lowercase();
|
||||
let _ = std::fs::write(findings.join(format!("{id}.pdf")), &small);
|
||||
let note = serde_json::json!({ "outcome": format!("{outcome:?}"), "detail": what, "seed_file": name, "bytes": small.len(), "original_bytes": data.len() });
|
||||
let _ = std::fs::write(findings.join(format!("{id}.json")), serde_json::to_string_pretty(¬e).unwrap_or_default());
|
||||
println!(" {outcome:?} from {name}: {what} → fuzz-out/findings/{id}.pdf ({} bytes)", small.len());
|
||||
found.lock().expect("lock").push((outcome, what, name.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let n = done.load(Ordering::Relaxed).min(iterations);
|
||||
let found = found.lock().expect("lock");
|
||||
let crashes = found.iter().filter(|f| f.0 == Outcome::Crash).count();
|
||||
let hangs = found.len() - crashes;
|
||||
println!(
|
||||
"fuzz: {n} runs in {:.0} s ({:.1}/s): {crashes} distinct crash(es), {hangs} hang(s)",
|
||||
start.elapsed().as_secs_f64(),
|
||||
n as f64 / start.elapsed().as_secs_f64().max(0.001)
|
||||
);
|
||||
if crashes > 0 {
|
||||
bail!("{crashes} crash(es): see fuzz-out/findings/");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fxhash(b: &[u8]) -> u64 {
|
||||
b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, x| (h ^ u64::from(*x)).wrapping_mul(0x100_0000_01b3))
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::process::ExitCode;
|
||||
|
||||
mod assets;
|
||||
mod demo_pdf;
|
||||
mod fuzz;
|
||||
mod gates;
|
||||
mod layers;
|
||||
mod screenshots;
|
||||
@@ -19,6 +20,7 @@ const COMMANDS: &[(&str, &str, Command)] = &[
|
||||
("assets", "Enforce the asset policy (AGENTS.md §1) against ATTRIBUTION.toml; --write regenerates ATTRIBUTION.md", assets::run),
|
||||
("corpus", "Fetch test corpora into corpus/ (git-ignored): pdf.js test PDFs", gates::corpus),
|
||||
("check", "Robustness sweep over corpus/ with printcraft-cli; fails on crashes or regressions vs xtask/baselines", gates::check),
|
||||
("fuzz", "Mutation fuzzing of open/render/edit/save in child processes; findings in fuzz-out/ (--time 300)", fuzz::run),
|
||||
("text-oracle", "Compare text extraction with pdftotext over corpus/ (word F1; target median ≥ 0.97)", gates::text_oracle),
|
||||
("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run),
|
||||
("demo-pdf", "Build dist/demo/printcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),
|
||||
|
||||
Reference in New Issue
Block a user