mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
Open and Place read Affinity documents natively (.af from Affinity 3, .afdesign/.afphoto/.afpub from Affinity 1 and 2): layers and sublayers, groups, artboards with their names and backgrounds, Publisher pages side by side, curves with live corners, parametric shapes, compound shapes, clipping by nesting, vector and pixel masks, solid and gradient fills, strokes, fill layers, artistic and frame text, placed images (their original JPEG/PNG), pixel layers, embedded documents as their cached picture, opacity, visibility, locking and blend modes, at the document's resolution in points. Unsupported content (effects, adjustments, brushes, master pages, special shapes, Affinity-only blend modes...) is listed in the import warning, one line per kind. A file whose native data can't be read opens as its embedded preview with the reason, and that preview cannot be placed or used as a template or library. Save never writes back over the Affinity file and there is no Affinity export. The document semantics were fitted to the thumbnails Affinity embeds in 176 public documents. `cargo xtask corpus --affinity` fetches 22 public CC0/MIT/Apache-2.0 documents at pinned commits, sha256-verified; the new engine test renders each and compares it with Affinity's own thumbnail (0-4.4 of 255 mean difference, per-file ceilings). import_fuzz mutates a synthetic native document's stream and container through open, render and export; the fuzz crate gains a stream target. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GQPh8UDgMyARR5Hat9UchC
260 lines
10 KiB
Rust
260 lines
10 KiB
Rust
//! Workspace tooling: `cargo xtask <command>`.
|
||
//!
|
||
//! Pure Rust (std + serde_json). External tools (`cargo`, `curl`, `tar`) are
|
||
//! invoked through `std::process::Command`.
|
||
|
||
mod affinity_corpus;
|
||
mod assets;
|
||
mod brands;
|
||
mod bundle;
|
||
mod ico;
|
||
mod layers;
|
||
mod sha256;
|
||
mod stats;
|
||
mod version;
|
||
|
||
use std::path::{Path, PathBuf};
|
||
use std::process::{Command, ExitCode};
|
||
|
||
const USAGE: &str = "\
|
||
usage: cargo xtask <command>
|
||
|
||
commands:
|
||
assets check that every icon/image/font/asset is attributed in ASSETS.md
|
||
brands check that user-visible text (labels, params docs, MCP, docs, packaging) names no other vendor's products
|
||
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, brands, layers, wasm (stops at first failure)
|
||
corpus [--download] [--affinity]
|
||
show where test corpora live; --download fetches PngSuite into corpus/pngsuite,
|
||
--affinity the pinned public Affinity documents into corpus/affinity
|
||
bundle build dist/VectorCraft.app (macOS) with assets/app-icon/vectorcraft.icns
|
||
ico <out.ico> <png>...
|
||
pack PNGs into a Windows .ico (used by packaging/icons.sh)
|
||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||
";
|
||
|
||
fn main() -> ExitCode {
|
||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||
let result = match args.first().map(String::as_str) {
|
||
Some("assets") => assets::run(&root()),
|
||
Some("brands") => brands::run(&root()),
|
||
Some("layers") => cmd_layers(),
|
||
Some("wasm") => cmd_wasm(),
|
||
Some("ci") => cmd_ci(),
|
||
Some("corpus") if rest.contains(&"--affinity") => affinity_corpus::fetch(),
|
||
Some("corpus") => cmd_corpus(rest.contains(&"--download")),
|
||
Some("bundle") => bundle::run(&root()),
|
||
Some("ico") => ico::run(&rest),
|
||
Some("version") => version::run(&root(), &rest),
|
||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||
Some("-h" | "--help" | "help") | None => {
|
||
print!("{USAGE}");
|
||
Ok(())
|
||
}
|
||
Some(other) => Err(format!("unknown command `{other}`\n\n{USAGE}")),
|
||
};
|
||
match result {
|
||
Ok(()) => ExitCode::SUCCESS,
|
||
Err(e) => {
|
||
eprintln!("error: {e}");
|
||
ExitCode::FAILURE
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Workspace root (parent of the xtask crate).
|
||
pub fn root() -> PathBuf {
|
||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
|
||
}
|
||
|
||
/// Tracked and untracked (not ignored) files that exist, relative to `root`.
|
||
pub fn repo_files(root: &Path) -> Result<Vec<String>, String> {
|
||
let out = Command::new("git")
|
||
.current_dir(root)
|
||
.args(["ls-files", "--cached", "--others", "--exclude-standard"])
|
||
.output()
|
||
.map_err(|e| format!("git ls-files: {e}"))?;
|
||
if !out.status.success() {
|
||
return Err(format!("git ls-files failed: {}", String::from_utf8_lossy(&out.stderr).trim()));
|
||
}
|
||
Ok(String::from_utf8_lossy(&out.stdout).lines().filter(|l| root.join(l).exists()).map(str::to_owned).collect())
|
||
}
|
||
|
||
pub fn cargo() -> Command {
|
||
let mut c = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into()));
|
||
c.current_dir(root());
|
||
c
|
||
}
|
||
|
||
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
|
||
eprintln!("$ {what}");
|
||
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
|
||
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
|
||
}
|
||
|
||
pub fn metadata() -> Result<serde_json::Value, String> {
|
||
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
|
||
if !out.status.success() {
|
||
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
|
||
}
|
||
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
|
||
}
|
||
|
||
fn cmd_layers() -> Result<(), String> {
|
||
let crates = layers::from_metadata(&metadata()?)?;
|
||
println!("Dependency layering (plan/architecture.md §3)\n");
|
||
println!("{:<28} {:<14} workspace deps", "crate", "layer");
|
||
for c in &crates {
|
||
let ws: Vec<String> = c
|
||
.deps
|
||
.iter()
|
||
.filter(|d| d.workspace)
|
||
.map(|d| {
|
||
let k = match d.kind {
|
||
layers::DepKind::Normal => "",
|
||
layers::DepKind::Dev => " (dev)",
|
||
layers::DepKind::Build => " (build)",
|
||
};
|
||
format!("{}{k}", layers::short_name(&d.name))
|
||
})
|
||
.collect();
|
||
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
|
||
}
|
||
let violations = layers::check(&crates);
|
||
println!();
|
||
if violations.is_empty() {
|
||
println!("OK: {} crates, no layering violations.", crates.len());
|
||
Ok(())
|
||
} else {
|
||
println!("{} violation(s):", violations.len());
|
||
for v in &violations {
|
||
println!(" - {v}");
|
||
}
|
||
Err(format!("{} layering violation(s)", violations.len()))
|
||
}
|
||
}
|
||
|
||
/// Workspace packages that must build for wasm32: all L0–L5 crates plus
|
||
/// the egui shell.
|
||
fn wasm_set() -> Result<Vec<String>, String> {
|
||
let crates = layers::from_metadata(&metadata()?)?;
|
||
Ok(crates
|
||
.into_iter()
|
||
.filter(|c| match layers::classify(&c.name) {
|
||
Some(layers::Class::Layer(l)) => l <= 5 || layers::short_name(&c.name) == "ui-egui",
|
||
Some(layers::Class::Standalone) => true,
|
||
_ => false,
|
||
})
|
||
.map(|c| c.name)
|
||
.collect())
|
||
}
|
||
|
||
fn cmd_wasm() -> Result<(), String> {
|
||
let set = wasm_set()?;
|
||
let mut results = Vec::new();
|
||
for pkg in &set {
|
||
let mut c = cargo();
|
||
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
|
||
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
|
||
results.push((pkg.clone(), ok));
|
||
}
|
||
println!("\nwasm32-unknown-unknown check:");
|
||
for (p, ok) in &results {
|
||
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
|
||
}
|
||
let failed = results.iter().filter(|r| !r.1).count();
|
||
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
|
||
}
|
||
|
||
fn cmd_ci() -> Result<(), String> {
|
||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||
let steps: Vec<Step> = vec![
|
||
(
|
||
"fmt",
|
||
Box::new(|| {
|
||
let mut c = cargo();
|
||
c.args(["fmt", "--all", "--", "--check"]);
|
||
run(c, "cargo fmt --all -- --check")
|
||
}),
|
||
),
|
||
(
|
||
"clippy",
|
||
Box::new(|| {
|
||
let mut c = cargo();
|
||
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
|
||
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
|
||
}),
|
||
),
|
||
(
|
||
"test",
|
||
Box::new(|| {
|
||
let mut c = cargo();
|
||
c.args(["test", "--workspace"]);
|
||
run(c, "cargo test --workspace")
|
||
}),
|
||
),
|
||
("assets", Box::new(|| assets::run(&root()))),
|
||
("brands", Box::new(|| brands::run(&root()))),
|
||
("layers", Box::new(cmd_layers)),
|
||
("wasm", Box::new(cmd_wasm)),
|
||
];
|
||
let mut done = Vec::new();
|
||
for (name, f) in &steps {
|
||
eprintln!("\n=== ci: {name} ===");
|
||
if let Err(e) = f() {
|
||
println!("\nCI summary:");
|
||
for d in &done {
|
||
println!(" ok {d}");
|
||
}
|
||
println!(" FAIL {name}: {e}");
|
||
for (n, _) in steps.iter().skip(done.len() + 1) {
|
||
println!(" skip {n}");
|
||
}
|
||
return Err(format!("ci failed at `{name}`"));
|
||
}
|
||
done.push(*name);
|
||
}
|
||
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
|
||
Ok(())
|
||
}
|
||
|
||
const PNGSUITE_URL: &str = "http://www.schaik.com/pngsuite/PngSuite-2017jul19.tgz";
|
||
|
||
fn cmd_corpus(download: bool) -> Result<(), String> {
|
||
let corpus = root().join("corpus");
|
||
println!(
|
||
"Test corpora live under {} (git-ignored, never committed).
|
||
Tests that use a corpus skip cleanly when it is absent.
|
||
|
||
corpus/pngsuite/ PngSuite (public domain) — vectorcraft-codecs compares every file
|
||
against the `image` crate. Fetch: cargo xtask corpus --download
|
||
corpus/psd/ PSD samples from MIT/BSD projects (ag-psd, psd-tools test data).
|
||
Copy files in manually; licences must be MIT/BSD/CC0.
|
||
corpus/tiff/ libtiff pics (optional)
|
||
corpus/exr/ OpenEXR sample images (optional)
|
||
corpus/raw/ raw.pixls.us samples, CC0 (optional)
|
||
corpus/affinity/ public Affinity documents (CC0/MIT/Apache-2.0) at pinned commits, sha256
|
||
verified: oracles for the Affinity reader. Fetch: cargo xtask corpus --affinity
|
||
",
|
||
corpus.display()
|
||
);
|
||
if !download {
|
||
return Ok(());
|
||
}
|
||
let dest = corpus.join("pngsuite");
|
||
std::fs::create_dir_all(&dest).map_err(|e| format!("create {}: {e}", dest.display()))?;
|
||
let tgz = corpus.join("PngSuite-2017jul19.tgz");
|
||
let mut curl = Command::new("curl");
|
||
curl.args(["-fsSL", "-o"]).arg(&tgz).arg(PNGSUITE_URL);
|
||
run(curl, &format!("curl {PNGSUITE_URL}"))?;
|
||
let mut tar = Command::new("tar");
|
||
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
|
||
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
|
||
let _ = std::fs::remove_file(&tgz);
|
||
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
|
||
println!("PngSuite: {n} PNG files in {}", dest.display());
|
||
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
|
||
}
|