Merge pull request #30 from storytold/harden/no-panics

Robustness: VectorCraft never crashes — no panics in shipped code
This commit is contained in:
BFlat 2026-10-05 06:50:04 +07:00 committed by GitHub
commit 39ce7b0e01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
128 changed files with 1250 additions and 354 deletions

17
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,17 @@
## What and why
<!-- What does this change, and why? Link the issue if there is one. -->
## How it was tested
<!-- Tests added, and how you checked the behaviour (CLI, MCP, screenshots). -->
## Checklist
- [ ] `cargo xtask ci` passes (fmt, clippy, tests, assets, brands, layers, wasm).
- [ ] No panics in shipped code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!` or
`unimplemented!` outside tests, and no unchecked indexing on data from files, parameters or
messages. Errors are returned as `Result` and shown to the user
([why and how](https://github.com/storytold/vectorcraft/blob/main/docs/development.md#robustness-vectorcraft-never-crashes)).
- [ ] Clean-room: nothing copied from Adobe products or GPL/AGPL code ([AGENTS.md](https://github.com/storytold/vectorcraft/blob/main/AGENTS.md)).
- [ ] Every new asset has a row in `ASSETS.md`.

View File

@ -24,11 +24,19 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).

View File

@ -24,11 +24,19 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The ArtCraft logos in `docs/brand/` are trademarks, not open source, under `docs/brand/LICENSE-brand.txt`: never modify them or use them outside VectorCraft.
- Prefer art generated in code for defaults (swatches, brushes, symbols, patterns, cursors). If the provenance of an asset is unclear, don't add it.
- Reference screenshots of Illustrator stay local under the gitignored `plan/` and are never committed, published or used as assets.
- **Never crash: no panics in shipped code.** A crash loses the user's unsaved work. Files, pasted data, MCP and control messages, and command params are all untrusted input.
- Fallible work returns `Result` (or `Option`); callers handle it or propagate it with `?`. Failures reach the user as an error message (`EngineError` for commands, the crate's own error type or `String` below the engine), never as an abort.
- Workspace clippy lints ban these outside tests, so `cargo xtask ci` fails on them: `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`. Use instead: `?`, `.ok_or(…)?`, `let … else { return … }`, `if let`, `unwrap_or` / `unwrap_or_default` / `unwrap_or_else`, `.first()` / `.last()` / `.split_last()`, and a real error for a match arm you believe is impossible.
- Don't panic implicitly either: use `.get(i)` instead of `v[i]` or `&s[a..b]` when the index comes from data. Don't divide integers by something that can be zero, cap sizes and counts read from input (allocations, images, loops), and check that floats are finite before casting them or looping on them.
- Don't silence errors just to satisfy the lint: use `let _ =` or `.ok()` only where ignoring the failure is the right behaviour, with a comment saying why.
- Tests may unwrap and panic (`#[test]`, `#[cfg(test)]`, `tests/`, `testkit`). Integration-test files start with an `#![allow(…)]` for that.
- `vectorcraft_engine::guard` is the last line of defence. Every entry point (commands, tool events, MCP requests, the UI frame, control requests) catches a panic, rolls the document back and reports an internal error. It exists for bugs and doesn't replace `Result`: wasm aborts on panic, so the web app has no safety net.
- Untrusted input is fuzzed (`engine/tests/import_fuzz.rs`, `engine/tests/command_sweep.rs`, `format/tests/prop_format.rs`, `mcp/tests/protocol_props.rs`). Extend these when you add an importer, a parser or a protocol method.
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. Tools emit commands (Begin/Preview/Commit). UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The control channel and MCP reach all of them.
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
## Running and looking at the app
- `cargo run --release -p vectorcraft -- --control 7979 [file.svg|file.vectorcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app).

View File

@ -63,7 +63,14 @@ unsafe_code = "deny"
[workspace.lints.clippy]
dbg_macro = "warn"
todo = "warn"
# VectorCraft never crashes: shipped code returns `Result`/`Option` instead of panicking (see
# AGENTS.md › Robustness). Tests may unwrap (clippy.toml).
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
unreachable = "deny"
[profile.dev.package."*"]
opt-level = 2

View File

@ -173,8 +173,9 @@ milestones, and honest time-to-parity estimates.
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
the UI can be swapped without touching the engine.
Agent and contributor rules (clean-room, the asset policy, quality gates) are in
[`AGENTS.md`](AGENTS.md). Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
Agent and contributor rules (clean-room, the asset policy, no panics in shipped code, quality gates) are in
[`AGENTS.md`](AGENTS.md); [`docs/development.md`](docs/development.md#robustness-vectorcraft-never-crashes)
explains how VectorCraft avoids crashing. Every bundled asset is listed with its licence in [`ASSETS.md`](ASSETS.md).
## The Crafting Apps

View File

@ -23,9 +23,9 @@ impl Rng {
}
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
pub fn synthetic(n: usize) -> Document {
pub fn synthetic(n: usize) -> Result<Document, String> {
let mut d = Document::new(1600.0, 1200.0);
let l = d.layers[0].id;
let l = d.layers.first().map(|l| l.id);
let mut r = Rng(42);
for i in 0..n {
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
@ -43,9 +43,9 @@ pub fn synthetic(n: usize) -> Document {
if i % 3 == 1 {
node.opacity = 0.8;
}
d.insert(Some(l), usize::MAX, node).expect("insert");
d.insert(l, usize::MAX, node).map_err(|e| format!("building the synthetic document: {e}"))?;
}
d
Ok(d)
}
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
@ -57,7 +57,7 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
})
.collect();
v.sort_by(f64::total_cmp);
v[v.len() / 2]
v.get(v.len() / 2).copied().unwrap_or(0.0)
}
/// 1-minute load average (macOS / Linux), if available.
@ -87,7 +87,7 @@ pub fn run(args: &[String]) -> Result<(), String> {
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
}
let t = Instant::now();
let doc = synthetic(n);
let doc = synthetic(n)?;
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
let mut rows: Vec<(&str, f64, f64)> = vec![];
@ -123,18 +123,31 @@ pub fn run(args: &[String]) -> Result<(), String> {
let mut bytes = vec![];
rows.push(("save .vectorcraft", median_ms(3, || bytes = vectorcraft_format::save_file(&doc)), 300.0));
rows.push(("open .vectorcraft", median_ms(3, || drop(vectorcraft_format::load(&bytes).expect("load"))), 300.0));
let mut failed = None;
let open = median_ms(3, || {
if let Err(e) = vectorcraft_format::load(&bytes) {
failed = Some(format!("open .vectorcraft: {e}"));
}
});
rows.push(("open .vectorcraft", open, 300.0));
let svg_opts = vectorcraft_svg::ExportOptions::default();
rows.push(("export SVG", median_ms(3, || drop(vectorcraft_svg::export(&doc, &svg_opts))), 500.0));
// Pathfinder Unite on 1,000 overlapping paths.
let unite = median_ms(3, || {
let mut s = vectorcraft_engine::Session::new();
s.add_document(synthetic(1000), None);
s.execute("select.all", &json!({})).expect("select");
s.execute("object.pathfinder.unite", &json!({})).expect("unite");
let r = synthetic(1000).and_then(|d| {
s.add_document(d, None);
s.execute("select.all", &json!({})).and_then(|_| s.execute("object.pathfinder.unite", &json!({}))).map_err(|e| e.to_string())
});
if let Err(e) = r {
failed = Some(format!("Pathfinder Unite: {e}"));
}
});
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
if let Some(e) = failed {
return Err(e);
}
let mut over = 0;
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");

View File

@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};

View File

@ -1,4 +1,6 @@
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;

View File

@ -1,4 +1,6 @@
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};

View File

@ -4,12 +4,12 @@
//! cross-compile from macOS or Linux still links, unless `VECTORCRAFT_REQUIRE_WINRES=1` turns it into
//! an error (for release builds).
fn main() {
fn main() -> Result<(), String> {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=../../assets/app-icon/vectorcraft.ico");
println!("cargo:rerun-if-env-changed=VECTORCRAFT_REQUIRE_WINRES");
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
return Ok(());
}
let mut res = winresource::WindowsResource::new();
res.set_icon("../../assets/app-icon/vectorcraft.ico")
@ -21,8 +21,9 @@ fn main() {
.set("InternalName", "vectorcraft");
if let Err(e) = res.compile() {
if std::env::var_os("VECTORCRAFT_REQUIRE_WINRES").is_some() {
panic!("embedding Windows resources failed: {e}");
return Err(format!("embedding Windows resources failed: {e}"));
}
println!("cargo:warning=vectorcraft.exe built without icon/version resources: {e}");
}
Ok(())
}

5
clippy.toml Normal file
View File

@ -0,0 +1,5 @@
# Tests may unwrap, expect and panic: a failing assertion is the point of a test. Shipped code may
# not (workspace lints in Cargo.toml; AGENTS.md › Robustness).
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true

View File

@ -83,10 +83,9 @@ pub(crate) fn sweep(t: &Track, nib: &Nib) -> Vec<SubPath> {
};
let half = std::f64::consts::FRAC_PI_2;
let mut poly = left.clone();
let (pe, te) = *s.last().unwrap();
let (Some(&(pe, te)), Some(&(ps, ts))) = (s.last(), s.first()) else { return Vec::new() };
cap(pe, te, half, -half, &mut poly);
poly.extend(right.iter().rev());
let (ps, ts) = s[0];
cap(ps, ts, -half, -3.0 * half, &mut poly);
vec![SubPath::polyline(&poly, true)]
}

View File

@ -325,7 +325,11 @@ pub fn unique_name(lib: &[Brush], base: &str) -> String {
if !lib.iter().any(|b| b.name == base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !lib.iter().any(|b| &b.name == n)).unwrap()
// At most `lib.len()` names are taken, so one of the first `lib.len() + 1` candidates is free.
(2..=lib.len() + 2)
.map(|i| format!("{base} {i}"))
.find(|n| !lib.iter().any(|b| &b.name == n))
.unwrap_or_else(|| format!("{base} {}", lib.len() + 2))
}
// ---------- geometry ----------

View File

@ -267,7 +267,7 @@ fn bristle_makes_translucent_strands() {
#[test]
fn tints_colorization_uses_stroke_colour() {
let b = art_brush(rect_art(10.0, 4.0), ArtScale::Stretch);
let BrushKind::Art(mut a) = b.kind else { unreachable!() };
let BrushKind::Art(mut a) = b.kind else { panic!("not an art brush") };
a.colorization = Colorization::Tints;
let b = Brush { name: "t".into(), kind: BrushKind::Art(a) };
let out = stroke_pieces(&b, &line(0.0, 0.0, 50.0, 0.0), &stroke(1.0));

View File

@ -76,11 +76,11 @@ impl Track {
corner.push(a.dot(b) < cos_limit);
tangents.push(unit(a + b));
}
(Some(a), None) | (None, Some(a)) => {
(a, b) => {
// An end of an open track (both `None` only for a track without segments).
corner.push(false);
tangents.push(a);
tangents.push(a.or(b).unwrap_or(Vec2::new(1.0, 0.0)));
}
(None, None) => unreachable!(),
}
}
Some(Track { verts: pts, cum, closed, dirs, tangents, corner })

View File

@ -110,8 +110,10 @@ impl Gradient {
return (mix(&a.color, &b.color, v), a.opacity + (b.opacity - a.opacity) * v);
}
}
let l = stops.last().unwrap();
(l.color, l.opacity)
match stops.last() {
Some(l) => (l.color, l.opacity),
None => (Color::BLACK, 1.0),
}
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {

View File

@ -386,7 +386,7 @@ mod tests {
for h in Harmony::ALL {
assert!(h.apply(base).iter().all(|c| matches!(c, Color::Cmyk { .. })), "{}", h.label());
}
let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { unreachable!() };
let Color::Cmyk { c, m, y, .. } = Harmony::Complementary.apply(base)[1] else { panic!("a CMYK complement") };
assert!(c > m && c > y, "the complement of red is a cyan: {c} {m} {y}");
let g = Guide::new(Color::gray(0.4), Harmony::Triad, &GuideOptions::default());
assert!(g.grid.iter().flatten().all(|c| matches!(c, Color::Gray { .. })));

View File

@ -516,7 +516,7 @@ mod tests {
*t = 0.5;
true
});
let Paint::Gradient(g) = g else { unreachable!() };
let Paint::Gradient(g) = g else { panic!("a gradient") };
assert!(g.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.5));
}

View File

@ -48,7 +48,7 @@ pub struct SwatchGroup {
}
fn solid(name: &str, hex: &str) -> Swatch {
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap()), global: false, spot: false }
Swatch { name: name.into(), paint: Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK)), global: false, spot: false }
}
/// The default swatches of a document in colour `model`: [`base_swatches`] with their RGB colours
@ -125,7 +125,10 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
name: name.into(),
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
kind,
stops: vec![GradientStop::new(0.0, Color::from_hex(a).unwrap()), GradientStop::new(1.0, Color::from_hex(b).unwrap())],
stops: vec![
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
],
}))),
global: false,
spot: false,
@ -180,4 +183,16 @@ mod tests {
let black = s.iter().find(|w| w.name == "Black").and_then(|w| w.paint.color());
assert_eq!(black, Some(Color::cmyk(0.0, 0.0, 0.0, 1.0)));
}
#[test]
fn default_swatches_parse() {
// `solid`/`grad` fall back to black or white on a bad literal instead of panicking; make sure
// none falls back.
let src = include_str!("swatch.rs");
let hexes: Vec<&str> = src.split('"').filter(|t| t.starts_with('#') && t.len() == 7).collect();
assert!(hexes.len() > 30);
for h in hexes {
assert!(Color::from_hex(h).is_some(), "{h}");
}
}
}

View File

@ -241,8 +241,7 @@ impl WidthProfile {
return (w[0].1 + (w[1].1 - w[0].1) * u, w[0].2 + (w[1].2 - w[0].2) * u);
}
}
let l = p.last().unwrap();
(l.1, l.2)
p.last().map_or((1.0, 1.0), |l| (l.1, l.2))
}
/// The built-in profiles, in menu order. "uniform" is the plain stroke (no profile).
pub const PRESETS: [ProfilePreset; 7] = [
@ -271,13 +270,13 @@ impl WidthProfile {
}
/// The lens profile (thin ends, full width in the middle).
pub fn lens() -> Self {
Self::preset("lens").expect("built-in")
Self::preset("lens").unwrap_or_default()
}
pub fn taper_end() -> Self {
Self::preset("taperEnd").expect("built-in")
Self::preset("taperEnd").unwrap_or_default()
}
pub fn taper_start() -> Self {
Self::preset("taperStart").expect("built-in")
Self::preset("taperStart").unwrap_or_default()
}
/// The (left, right) factors just before and just after `t`: they differ only at a
/// discontinuous point (two points at `t`, within `1e-7`).
@ -1024,7 +1023,7 @@ mod tests {
a.rebase_gradients(Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(100.0, 0.0, 300.0, 100.0));
let start = |p: Paint| match p {
Paint::Gradient(g) => g.geom.unwrap().start,
_ => unreachable!(),
_ => panic!("a gradient"),
};
// The fill's start stays on the left edge; the stroke's on its inflated box's (−5 → 95).
assert_eq!((start(a.fill_paint()), start(a.stroke_paint())), (Point::new(100.0, 0.0), Point::new(95.0, 0.0)));

View File

@ -615,8 +615,11 @@ impl Document {
pub fn remove(&mut self, id: NodeId) -> Result<Arc<Node>, DocError> {
let parent = self.parent_of(id);
let path = self.index_path(id).ok_or(DocError::NoNode(id))?;
let idx = *path.last().unwrap();
let idx = *path.last().ok_or(DocError::NoNode(id))?;
let ch = self.children_mut(parent)?;
if idx >= ch.len() {
return Err(DocError::NoNode(id));
}
Ok(ch.remove(idx))
}
@ -719,8 +722,7 @@ impl Document {
n.id = self.alloc_id();
if let Some(ch) = n.children_mut() {
let old: Vec<Arc<Node>> = std::mem::take(ch);
let fresh: Vec<Arc<Node>> = old.iter().map(|c| Arc::new(self.reid(c))).collect();
*n.children_mut().unwrap() = fresh;
*ch = old.iter().map(|c| Arc::new(self.reid(c))).collect();
}
n
}
@ -781,7 +783,7 @@ impl Document {
fn default_graphic_styles() -> Vec<GraphicStyle> {
use vectorcraft_color::{Color, Paint};
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap());
let solid = |hex| Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK));
[
GraphicStyle::new(DEFAULT_GRAPHIC_STYLE, Appearance::default_art()),
GraphicStyle::new("Black Outline", Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 1.0)),

View File

@ -466,11 +466,7 @@ impl Spine {
if pts.len() < 2 {
return None;
}
let mut cum = vec![0.0];
for w in pts.windows(2) {
cum.push(cum.last().unwrap() + w[0].distance(w[1]));
}
Some(Self { pts, cum })
Some(Self { cum: cumulative_lengths(&pts), pts })
}
pub fn length(&self) -> f64 {
*self.cum.last().unwrap_or(&0.0)
@ -639,6 +635,18 @@ fn poly_len(c: &CubicBez) -> f64 {
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
}
/// Running length along a polyline: `0, |p0p1|, |p0p1| + |p1p2|, …` (one entry per point).
fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
let mut total = 0.0;
let mut cum = Vec::with_capacity(pts.len());
cum.push(0.0);
for w in pts.windows(2) {
total += w[0].distance(w[1]);
cum.push(total);
}
cum
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
@ -662,7 +670,9 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
res.last_mut().unwrap().h_out = f(sub.p1);
if let Some(last) = res.last_mut() {
last.h_out = f(sub.p1);
}
if j + 1 < k {
res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() });
} else {
@ -673,8 +683,9 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
}
if let Some(h) = pending_in
&& sp.closed
&& let Some(first) = res.first_mut()
{
res[0].h_in = h;
first.h_in = h;
}
let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect();
subs.push(SubPath::new(anchors, sp.closed));
@ -704,11 +715,8 @@ fn resample(poly: &[Point], m: usize) -> Vec<Point> {
if poly.len() == 1 {
return vec![poly[0]; m];
}
let mut cum = vec![0.0];
for w in poly.windows(2) {
cum.push(cum.last().unwrap() + w[0].distance(w[1]));
}
let total = *cum.last().unwrap();
let cum = cumulative_lengths(poly);
let total = cum.last().copied().unwrap_or(0.0);
let mut out = Vec::with_capacity(m);
let mut i = 0;
for k in 0..m {
@ -749,7 +757,10 @@ impl Coons {
let r = path.bounds()?;
let tol = (r.width() + r.height()).max(1e-6) / 2000.0;
let mut poly = flatten_subpath(sp, tol);
if poly.len() > 1 && poly[0].distance(*poly.last().unwrap()) < 1e-9 {
if poly.len() > 1
&& let (Some(a), Some(b)) = (poly.first(), poly.last())
&& a.distance(*b) < 1e-9
{
poly.pop();
}
if poly.len() < 3 {

View File

@ -1132,7 +1132,7 @@ mod tests {
let fresh = || Node::path(NodeId(2), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(grad(), grad(), 2.0));
let geoms = |n: &Node| match (n.appearance.fill_paint(), n.appearance.stroke_paint()) {
(Paint::Gradient(f), Paint::Gradient(s)) => (f.geom, s.geom),
_ => unreachable!(),
_ => panic!("gradient fill and stroke"),
};
let mut n = fresh();
n.transform(Affine::translate((10.0, 5.0)) * Affine::scale(2.0), true);

View File

@ -163,10 +163,10 @@ mod tests {
let style = CharStyle { fill: spot, stroke: k, stroke_width: 1.0, ..CharStyle::default() };
let mut t = Node::new(NodeId(2), NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "Hi", style))));
OverprintBlack::default().apply(&mut t, &["Ink".to_string()]);
let NodeKind::Text(tx) = &t.kind else { unreachable!() };
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
assert!(!tx.runs[0].style.overprint_fill && tx.runs[0].style.overprint_stroke);
OverprintBlack { spot: true, ..Default::default() }.apply(&mut t, &["Ink".to_string()]);
let NodeKind::Text(tx) = &t.kind else { unreachable!() };
let NodeKind::Text(tx) = &t.kind else { panic!("type") };
assert!(tx.runs[0].style.overprint_fill);
}

View File

@ -112,8 +112,11 @@ pub fn pathfinder_children(group: &Node, hook: Option<OutlineHook>) -> Option<Ve
if shape_mode && lv.len() > 1 {
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(geometry).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, sources.len() as u64));
sources.push(lv.pop().expect("non-empty"));
let key = sources.len() as u64;
if let Some(top) = lv.pop() {
shapes.push(po::Shape::new(po::unite_all(&refs), FillRule::NonZero, key));
sources.push(top);
}
} else {
for l in lv {
if let Some((p, r)) = geometry(&l) {

View File

@ -32,7 +32,7 @@ fn contours(bp: &BezPath) -> Vec<Vec<Point>> {
c.pop();
}
}
_ => unreachable!("flattened"),
_ => panic!("flattened"),
});
out
}

View File

@ -131,7 +131,7 @@ pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
}
}
xs.sort_by(f64::total_cmp);
let mut spans: Vec<(f64, f64)> = xs.chunks_exact(2).map(|s| (s[0] - overlap, s[1] + overlap)).collect();
let mut spans: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|s| (s[0] - overlap, s[1] + overlap)).collect();
if row % 2 == 1 {
spans.reverse();
}

View File

@ -248,11 +248,12 @@ fn brush_apply(s: &mut Session, p: &Value) -> Result<Value> {
if n.appearance.stroke().is_none_or(|st| st.paint.is_none()) {
n.appearance.set_stroke(stroke.clone());
}
let st = n.appearance.stroke_mut().expect("stroke");
if st.width <= 0.0 {
st.width = 1.0;
if let Some(st) = n.appearance.stroke_mut() {
if st.width <= 0.0 {
st.width = 1.0;
}
st.brush = Some(name.clone());
}
st.brush = Some(name.clone());
}
Ok(())
})?;
@ -515,7 +516,7 @@ fn unique_symbol_name(d: &Document, base: &str) -> String {
if !taken(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap()
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
fn instances_of(d: &Document, name: Option<&str>) -> Vec<NodeId> {
@ -676,7 +677,7 @@ fn symbol_edit(s: &mut Session, p: &Value) -> Result<Value> {
let first = *ids.first().ok_or_else(|| EngineError::Other("select a symbol instance".into()))?;
let name = match &s.doc()?.doc.node(first).map(|n| n.kind.clone()) {
Some(NodeKind::SymbolInstance { symbol, .. }) => symbol.clone(),
_ => unreachable!(),
_ => return Err(EngineError::Other("select a symbol instance".into())),
};
let out = s.edit("Edit Symbol", |d, sel| {
let out = break_links(d, &[first])?;

View File

@ -308,7 +308,7 @@ fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
new_nodes.push(shape_node(d, rest, Some(leaf)));
}
}
if !erase && i == style_src {
if !erase && Some(i) == style_src {
let mut m = shape_node(d, union.clone(), Some(leaf));
if let Some(f) = &fill {
m.appearance.set_fill(f.clone());
@ -399,7 +399,7 @@ fn build_children(d: &mut Document, sources: Vec<Arc<Node>>) -> Result<(Vec<Arc<
let nfaces = regs.len();
for r in regs {
let id = d.alloc_id();
let fill = styles[r.top()].0.clone();
let fill = r.top().and_then(|t| styles.get(t)).map_or(Paint::None, |s| s.0.clone());
let mut n = Node::path(id, r.path, fill_only(fill));
n.name = Some(FACE_NAME.into());
children.push(Arc::new(n));
@ -680,7 +680,14 @@ fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Value> {
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
let id = s.edit(label, |d, sel| {
let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
let mut image = if is_trace_group(&src) { (**src.children().unwrap().first().unwrap()).clone() } else { src.clone() };
let mut image = if is_trace_group(&src) {
src.children()
.and_then(|c| c.first())
.map(|c| (**c).clone())
.ok_or_else(|| EngineError::Other("Image Trace: the traced image is missing".into()))?
} else {
src.clone()
};
image.visible = true;
let mut children: Vec<Arc<Node>> = vec![];
if !expand {

View File

@ -131,6 +131,15 @@ pub(crate) fn to_gray(c: Color) -> Color {
c.in_model(Model::Gray)
}
/// Grayscale ink amount (0 = white, 1 = black) of a colour.
fn gray_level(c: Color) -> f32 {
match to_gray(c) {
Color::Gray { k } => k,
// `in_model(Gray)` always yields Gray.
_ => 0.0,
}
}
/// What a recolouring pass applies to a colour that can link to a swatch (a solid colour or a
/// gradient stop): `(colour, link, tint)`, true when it changed them.
pub(crate) type LinkMap<'a> = dyn Fn(&mut Color, &mut Option<String>, &mut f32) -> bool + 'a;
@ -416,10 +425,7 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
}
"gray" => {
let dgray = g("gray");
Box::new(move |c| {
let Color::Gray { k } = to_gray(c) else { unreachable!() };
Color::gray(cl(k + dgray))
})
Box::new(move |c| Color::gray(cl(gray_level(c) + dgray)))
}
"global" => {
// Each linked colour moves to the shifted tint of its global swatch's current colour.
@ -506,8 +512,8 @@ fn blend(s: &mut Session, order: BlendOrder) -> Result<Value> {
BlendOrder::Horizontal => ids.sort_by(|a, b| center(a).x.total_cmp(&center(b).x)),
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(&center(b).y)),
}
let first = d.node(ids[0]).and_then(fill_color).unwrap_or_default();
let last = d.node(*ids.last().unwrap()).and_then(fill_color).unwrap_or_default();
let first = ids.first().and_then(|id| d.node(*id)).and_then(fill_color).unwrap_or_default();
let last = ids.last().and_then(|id| d.node(*id)).and_then(fill_color).unwrap_or_default();
// Ends in one model blend in it; mixed ones give colours in the document's model.
let model = if first.model() == last.model() { first.model() } else { d.color_mode.model() };
let n = ids.len();

View File

@ -356,7 +356,8 @@ fn liquify(s: &mut Session, p: &Value) -> Result<Value> {
let doc = &s.doc()?.doc;
let leaves = if roots.is_empty() {
// Nothing selected: everything the brush sweeps over.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap();
// No dabs sweep nothing.
let sweep = dab_pts.iter().map(|c| prm.brush_bounds(*c)).reduce(|a, b| a.union(b)).unwrap_or(vectorcraft_geom::Rect::ZERO);
let mut all = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Path { guide: false, .. })

View File

@ -347,12 +347,13 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
}
// A closed path's last and first runs meet at anchor 0.
if sp.closed && runs.len() >= 2 {
let first_starts = runs[0][0].0 == 0 && runs[0][0].1 == 0.0;
let last = runs.last().unwrap();
let last_ends = last[last.len() - 1].0 == nseg - 1 && last[last.len() - 1].2 == 1.0;
let first_starts = runs.first().and_then(|r| r.first()).is_some_and(|s| s.0 == 0 && s.1 == 0.0);
let last_ends = runs.last().and_then(|r| r.last()).is_some_and(|s| s.0 + 1 == nseg && s.2 == 1.0);
if first_starts && last_ends {
let first = runs.remove(0);
runs.last_mut().unwrap().extend(first);
if let Some(last) = runs.last_mut() {
last.extend(first);
}
}
}
let out = runs
@ -363,10 +364,10 @@ fn cut_subpath(sp: &SubPath, remove: &dyn Fn(Point) -> bool) -> Option<Vec<SubPa
for &(i, t0, t1) in r {
let c = sp.segment(i).subsegment(t0..t1);
let (h1, h2) = if sp.segment_is_line(i) { (c.p0, c.p3) } else { (c.p1, c.p2) };
if anchors.is_empty() {
anchors.push(Anchor::corner(c.p0));
match anchors.last_mut() {
Some(a) => a.h_out = h1,
None => anchors.push(Anchor { h_out: h1, ..Anchor::corner(c.p0) }),
}
anchors.last_mut().unwrap().h_out = h1;
anchors.push(Anchor { p: c.p3, h_in: h2, h_out: c.p3, kind: AnchorKind::Corner });
}
for a in &mut anchors {
@ -419,16 +420,20 @@ fn path_kind(path: PathData) -> NodeKind {
/// corners) and simplify it to `tol`.
fn fit_freehand(pts: &[Point], tol: f64, closed: bool) -> SubPath {
let min = (tol * 0.3).max(0.05);
let mut kept: Vec<Point> = vec![pts[0]];
for &p in &pts[1..] {
if kept.last().unwrap().distance(p) >= min {
let (Some(&p0), Some(&pn)) = (pts.first(), pts.last()) else { return SubPath::new(vec![], closed) };
let mut kept: Vec<Point> = vec![p0];
for &p in pts.iter().skip(1) {
if kept.last().is_none_or(|k| k.distance(p) >= min) {
kept.push(p);
}
}
if let (Some(&l), true) = (pts.last(), kept.len() > 1) {
*kept.last_mut().unwrap() = l;
} else if kept.len() == 1 && pts.len() > 1 {
kept.push(pts[pts.len() - 1]);
// The last sample always ends the stroke.
if kept.len() > 1 {
if let Some(k) = kept.last_mut() {
*k = pn;
}
} else if pts.len() > 1 {
kept.push(pn);
}
let n = kept.len();
let marked: Vec<(Point, bool)> = (0..n)
@ -465,25 +470,34 @@ fn freehand(s: &mut Session, p: &Value) -> Result<Value> {
if at_start {
sp.reverse();
}
let end = sp.anchors.last().unwrap().p;
pts[0] = end;
let (Some(first), Some(end)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p)) else {
return Err(bad(C, "path has no open end"));
};
let Some(p0) = pts.first_mut() else { return Err(bad(C, "needs points")) };
*p0 = end;
if pts.len() < 2 {
return Ok(json!({ "id": id.0 }));
}
let first = sp.anchors[0].p;
let close_tol = (tol * 4.0).max(6.0);
let closing = sp.anchors.len() >= 2 && pts.last().unwrap().distance(first) <= close_tol;
if closing {
*pts.last_mut().unwrap() = first;
let closing = sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol);
if closing && let Some(l) = pts.last_mut() {
*l = first;
}
let fit = fit_freehand(&pts, tol, false);
let mut add = fit.anchors;
sp.anchors.last_mut().unwrap().h_out = add[0].h_out;
add.remove(0);
if let (Some(last), Some(a0)) = (sp.anchors.last_mut(), add.first()) {
last.h_out = a0.h_out;
}
if !add.is_empty() {
add.remove(0);
}
sp.anchors.extend(add);
if closing && sp.anchors.len() > 2 {
let l = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = l.h_in;
if closing
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
sp.closed = true;
}
for a in &mut sp.anchors {
@ -900,8 +914,10 @@ fn smooth_subpath(sp: &SubPath, near: &dyn Fn(Point) -> bool, tol: f64) -> Optio
m.push(false);
open.closed = false;
let mut res = smooth_open(&open.anchors, &m, tol);
let l = res.pop().unwrap();
res[0].h_in = l.h_in;
let l = res.pop()?;
if let Some(r0) = res.first_mut() {
r0.h_in = l.h_in;
}
return Some(SubPath::new(res, true));
}
Some(SubPath::new(smooth_open(&sp.anchors, &marked, tol), false))
@ -1029,8 +1045,10 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
if ida == idb && sa == sb {
let path = path_mut(d, ida)?;
let sp = &mut path.subpaths[sa];
if merge && sp.anchors.len() > 2 {
let l = sp.anchors.pop().unwrap();
if merge
&& sp.anchors.len() > 2
&& let Some(l) = sp.anchors.pop()
{
let d0 = mid - sp.anchors[0].p;
sp.anchors[0].translate(d0);
sp.anchors[0].h_in = l.h_in + (mid - l.p);
@ -1048,10 +1066,12 @@ fn join_scrub(s: &mut Session, p: &Value) -> Result<Value> {
if !startb {
y.reverse();
}
if merge {
if merge
&& !y.anchors.is_empty()
&& let Some(last) = x.anchors.last_mut()
{
let yf = y.anchors.remove(0);
let lp = x.anchors.last().unwrap().p;
let last = x.anchors.last_mut().unwrap();
let lp = last.p;
last.translate(mid - lp);
last.h_out = yf.h_out + (mid - yf.p);
*last = Anchor::with_handles(last.p, last.h_in, last.h_out);
@ -1117,8 +1137,9 @@ fn blob(s: &mut Session, p: &Value) -> Result<Value> {
let mut inputs: Vec<(&PathData, FillRule)> = vec![(&region, FillRule::NonZero)];
inputs.extend(merge_ids.iter().map(|(_, pd, r)| (pd, *r)));
let merged = po::unite_all(&inputs);
let keep = merge_ids.last().unwrap().0;
let others: Vec<NodeId> = merge_ids[..merge_ids.len() - 1].iter().map(|m| m.0).collect();
let Some(((keep, _, _), rest)) = merge_ids.split_last() else { return add_node(s, "Blob Brush", path_kind(region), ap, None) };
let keep = *keep;
let others: Vec<NodeId> = rest.iter().map(|m| m.0).collect();
s.edit("Blob Brush", |d, sel| {
*path_mut(d, keep)? = merged;
for id in others {

View File

@ -130,6 +130,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
"webp" => img.to_webp(),
_ => img.to_jpeg(o.quality.unwrap_or(90)),
}
.map_err(EngineError::Other)?
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
})

View File

@ -272,7 +272,7 @@ pub fn writable_format(format_param: Option<&str>, path: Option<&str>) -> std::r
let f = match (format_param, path.map(extension).filter(|e| !e.is_empty())) {
(Some(f), _) => format(f).ok_or_else(|| format!("unknown format `{f}` (see document.formats)"))?,
(None, Some(e)) => format(&e).ok_or_else(|| format!("unknown extension `.{e}`: pass `format` (see document.formats)"))?,
(None, None) => format("png").expect("png is in FORMATS"),
(None, None) => format("png").ok_or("no PNG encoder")?,
};
if f.write { Ok(f) } else { Err(format!("{} files can be opened but not written (see document.formats)", f.label)) }
}

View File

@ -133,7 +133,7 @@ pub struct FlattenOptions {
impl Default for FlattenOptions {
fn default() -> Self {
Self::preset("medium").expect("built-in preset")
Self::builtin("medium")
}
}
@ -155,12 +155,17 @@ impl FlattenOptions {
pub fn preset(name: &str) -> Option<Self> {
let key = name.trim().to_ascii_lowercase();
let id = Self::PRESETS.into_iter().find(|id| key == *id || Self::preset_label(id).is_some_and(|l| l.eq_ignore_ascii_case(&key)))?;
Some(Self::builtin(id))
}
/// The built-in preset `id` (one of [`Self::PRESETS`]; anything else is low).
fn builtin(id: &str) -> Self {
let (balance, line_art_ppi, gradient_ppi, clip_complex_regions, anti_alias) = match id {
"high" => (100.0, 1200.0, 300.0, true, false),
"medium" => (75.0, 300.0, 300.0, false, true),
_ => (75.0, 300.0, 150.0, false, true),
};
Some(Self {
Self {
balance,
line_art_ppi,
gradient_ppi,
@ -170,7 +175,7 @@ impl FlattenOptions {
anti_alias,
preserve_alpha: false,
preserve_overprints: true,
})
}
}
/// The built-in presets, finest first.
@ -231,7 +236,7 @@ fn flatten(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("Flatten Transparency: select objects to flatten".into()));
}
let src = s.doc()?.doc.clone();
let plan = plan(&src, &roots, &o, true);
let plan = plan(&src, &roots, &o, true)?;
let options = serde_json::to_value(&o).unwrap_or_default();
if plan.flat.is_empty() && !plan.kept.iter().any(|id| src.node(*id).is_some_and(|n| kept_work(n, &o))) {
return Ok(json!({ "ids": roots.iter().map(|i| i.0).collect::<Vec<_>>(), "rasterized": 0, "vector": 0, "options": options }));
@ -635,8 +640,8 @@ impl FlattenReport {
}
/// What flattening `roots` of `doc` with `o` would do.
pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> FlattenReport {
let plan = plan(doc, roots, o, false);
pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> Result<FlattenReport> {
let plan = plan(doc, roots, o, false)?;
let mut r = FlattenReport { transparent: plan.transparent, ..Default::default() };
// Every object under `id` (but a compound path's own pieces), visible ones only.
fn visit(n: &Node, f: &mut dyn FnMut(&Node)) {
@ -685,7 +690,7 @@ pub fn report(doc: &Document, roots: &[NodeId], o: &FlattenOptions) -> FlattenRe
});
}
r.affected.sort_by_key(|id| doc.index_path(*id));
r
Ok(r)
}
impl Session {
@ -704,7 +709,7 @@ impl Session {
let layers: Vec<u64> = self.doc()?.doc.layers.iter().map(|l| l.id.0).collect();
target_roots(self, &json!({ "ids": layers }))?
};
let r = report(&self.doc()?.doc, &roots, &o);
let r = report(&self.doc()?.doc, &roots, &o)?;
Ok((o, r))
}
}
@ -778,8 +783,8 @@ struct Raster {
}
/// The flattening of `roots` with `o`; `render` renders the images (the preview only needs where
/// they go).
fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Plan {
/// they go). Fails only when an image can't be encoded.
fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> Result<Plan> {
let tmp = isolated_doc(src, roots.iter().filter_map(|id| src.node(*id).cloned()).collect());
// Geometry effects first: what is left on the art are raster effects.
let baked = effects::bake_document(&tmp).unwrap_or(tmp);
@ -792,19 +797,24 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> P
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
for g in overlapping(&reaches) {
let ids: Vec<NodeId> = g.iter().map(|&i| roots[i]).collect();
let flat = g.iter().any(|&i| see_through[i]).then(|| {
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
let group = g.iter().any(|&i| see_through[i]).then(|| {
let plain: Vec<&Node> = g.iter().map(|&i| &plain[i]).collect();
let (regions, area) = flatten_group(&plain, o, cmyk)?;
let raster = area.as_ref().filter(|_| render).map(|a| render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o));
Some(Flat { roots: ids.clone(), regions, area, raster })
flatten_group(&plain, o, cmyk)
});
match flat.flatten() {
Some(f) => out.flat.push(f),
None => out.kept.extend(ids),
}
let Some((regions, area)) = group.flatten() else {
out.kept.extend(ids);
continue;
};
let raster = match area.as_ref().filter(|_| render) {
Some(a) => {
let art: Vec<Node> = g.iter().map(|&i| art[i].clone()).collect();
Some(render_raster(&baked, &art, a.rect, a.ppi, a.clip.clone(), o)?)
}
None => None,
};
out.flat.push(Flat { roots: ids, regions, area, raster });
}
out
Ok(out)
}
/// The regions and the image area replacing one group of overlapping objects (`plain`: the art as
@ -902,8 +912,8 @@ fn region_fill(elems: &[Elem], sources: &[usize], o: &FlattenOptions, cmyk: bool
/// `art` rendered over `rect` at `ppi`: over white with the white the art doesn't cover taken out
/// again (so blend modes see the white page), or over nothing with Preserve Alpha. The image and
/// its `clip` reach a pixel further.
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Raster {
/// its `clip` reach a pixel further. Fails when the image can't be encoded.
fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Option<PathData>, o: &FlattenOptions) -> Result<Raster> {
let mut scale = ppi / 72.0;
scale = scale.min((MAX_PIXELS / (rect.width() * rect.height()).max(1.0)).sqrt());
let px = 1.0 / scale;
@ -928,7 +938,7 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
})
.collect();
let cover = r.render_region(&isolated_doc(doc, cover), region, scale, false);
for (px, c) in img.pixels.chunks_exact_mut(4).zip(cover.pixels.chunks_exact(4)) {
for (px, c) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(cover.pixels.as_chunks::<4>().0) {
let k = c[3];
let k2 = if o.anti_alias || k == 0 || k == 255 {
k
@ -952,7 +962,8 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
}
}
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
Raster { png: img.to_png(), width: img.width, height: img.height, xf, clip }
let png = img.to_png().map_err(|e| EngineError::Other(format!("Flatten Transparency: {e}")))?;
Ok(Raster { png, width: img.width, height: img.height, xf, clip })
}
/// `n` at full opacity with Normal blending throughout (objects, fills and strokes).
@ -978,6 +989,8 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
let (mut vector, mut rasterized) = (0, 0);
let mut ids = vec![];
for f in plan.flat {
// A group always has objects.
let Some(&top) = f.roots.last() else { continue };
let mut children = vec![];
// The image goes under the regions, reaching a pixel past its own: their edges then fall on
// matching pixels instead of on what is behind the group.
@ -995,7 +1008,6 @@ fn apply(d: &mut Document, sel: &mut vectorcraft_doc::Selection, plan: Plan, o:
children.push(Arc::new(n));
vector += 1;
}
let top = *f.roots.last().expect("a group has objects");
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let g = Node::group(d.alloc_id(), children);
ids.push(d.insert(par, idx + 1, g)?);

View File

@ -126,7 +126,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
for r in &mut t.runs {
if matches(&r.style) {
// An explicit style, else the closest the new family has to the old one.
let style = ts.clone().unwrap_or_else(|| db.face(&tf, &r.style.font_style).style.clone());
let style = ts
.clone()
.or_else(|| db.face(&tf, &r.style.font_style).map(|f| f.style.clone()))
.unwrap_or_else(|| r.style.font_style.clone());
r.style.font_family = tf.clone();
r.style.font_style = style;
changed = true;

View File

@ -381,8 +381,9 @@ fn edit_gradient(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(m) = mode
&& ids.is_empty()
&& p.get("ids").is_none()
&& let Some(st) = s.new_art_stroke_mut()
{
s.new_art_stroke_mut().gradient_mode = m;
st.gradient_mode = m;
}
// The last gradient is the one the first object now shows (the defaults' without objects).
let shown = match ids.first() {

View File

@ -463,7 +463,7 @@ fn blend_replace_spine(s: &mut Session, _: &Value) -> Result<Value> {
&& !blends.iter().any(|b| st.doc.ancestry(*id).unwrap_or_default().contains(b))
})
.ok_or_else(|| EngineError::Other("Replace Spine: select a blend and a path".into()))?;
let b = *blends.first().unwrap();
let Some(&b) = blends.first() else { return Err(EngineError::Other("Replace Spine: select a blend and a path".into())) };
s.edit("Replace Spine", |d, sel| {
let spine = d.node(path).and_then(|n| n.path_data().cloned()).ok_or(EngineError::NoNode(path))?;
d.remove(path)?;
@ -576,9 +576,9 @@ fn env_make_top(s: &mut Session, p: &Value) -> Result<Value> {
if roots.len() < 2 {
return Err(bad(C, "select the content and a path on top"));
}
let top = *roots.last().unwrap();
let Some((&top, content)) = roots.split_last() else { return Err(bad(C, "select the content and a path on top")) };
let content = content.to_vec();
let path = s.doc()?.doc.node(top).and_then(outline_of).filter(|p| p.bounds().is_some()).ok_or_else(|| bad(C, "the top object must be a path"))?;
let content = roots[..roots.len() - 1].to_vec();
let id = s.edit("Make Envelope", |d, sel| {
let id = wrap(d, sel, &content, |id, content| envelope(id, content, EnvelopeKind::TopObject { path }))?;
d.remove(top)?;

View File

@ -323,7 +323,7 @@ fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, white:
}
let mut r = vectorcraft_render::Renderer::new();
let img = r.render_region(doc, region, scale, white);
Ok((img.to_png(), img.width, img.height))
Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height))
}
fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
@ -370,7 +370,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result<Value> {
fn has_image(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().unwrap();
let st = s.active().ok_or("no document open")?;
if st.selection.objects.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_)))) {
Ok(())
} else {
@ -389,7 +389,7 @@ fn object_mosaic(s: &mut Session, p: &Value) -> Result<Value> {
.find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_))))
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { unreachable!() };
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
let blob = st.doc.images.get(&im.key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| bad(C, "the image has no embedded pixels"))?;
let raster = vectorcraft_trace::Raster::decode(&blob.bytes).map_err(|e| bad(C, e.to_string()))?;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(10).clamp(1, 1000) as u32;
@ -464,7 +464,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result<Value> {
.find(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Image(_))))
.ok_or_else(|| bad(C, "select an image"))?;
let node = st.doc.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let NodeKind::Image(im) = &node.kind else { unreachable!() };
let NodeKind::Image(im) = &node.kind else { return Err(bad(C, "select an image")) };
let ib = node.geometric_bounds().ok_or_else(|| bad(C, "image has no bounds"))?;
let rect = match p.get("rect").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {

View File

@ -120,12 +120,12 @@ pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
let st = s.active().ok_or("no document open")?;
if st.selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
let st = s.active().ok_or("no document open")?;
if st.selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())

View File

@ -155,9 +155,9 @@ impl Session {
st
}
/// The template's top stroke for an edit (`stroke.set`): the template becomes the user's own
/// (an inherited one as new art takes it now), with a stroke to edit.
pub(crate) fn new_art_stroke_mut(&mut self) -> &mut StrokeLayer {
/// The template for an edit (`stroke.set`): it becomes the user's own (an inherited one as new
/// art takes it now), with a stroke to edit.
fn new_art_template_mut(&mut self) -> &mut Appearance {
let own = self.template().map(Cow::into_owned);
if self.basic_only() {
(self.paint.opacity, self.paint.blend, self.paint.style) = (1.0, BlendMode::Normal, None);
@ -167,7 +167,13 @@ impl Session {
if t.stroke().is_none() {
t.set_stroke(Paint::None);
}
t.stroke_mut().expect("a stroke was just made")
t
}
/// The template's top stroke for an edit (see [`Session::new_art_template_mut`], which always
/// gives it one).
pub(crate) fn new_art_stroke_mut(&mut self) -> Option<&mut StrokeLayer> {
self.new_art_template_mut().stroke_mut()
}
/// A graphic style clicked with nothing selected: new art takes it (`add`: its fills, strokes
@ -175,8 +181,7 @@ impl Session {
pub(crate) fn new_art_style(&mut self, g: &GraphicStyle, add: bool) {
let ap = if g.unit_box { g.appearance.clone() } else { unplace_all(g.appearance.clone()) };
if add {
let _ = self.new_art_stroke_mut();
let t = self.paint.appearance.as_mut().expect("made by new_art_stroke_mut");
let t = self.new_art_template_mut();
t.items.extend(ap.items);
t.effects.extend(ap.effects);
self.paint.style = None;

View File

@ -511,7 +511,7 @@ fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
for id in &ids {
d.remove(*id)?;
}
let ch = d.node_mut(cid).unwrap().children_mut().unwrap();
let ch = d.node_mut(cid).and_then(|n| n.children_mut()).ok_or(EngineError::NoNode(cid))?;
for mut p in paths {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default();
@ -586,7 +586,7 @@ pub(super) fn release_clip(d: &mut Document, id: NodeId) {
fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let top = *ids.last().unwrap();
let Some(&top) = ids.last() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Make Clipping Mask", |d, sel| {
make_clipping_path(d, top)?;
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;

View File

@ -283,6 +283,11 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({ "changed": changed }))
}
/// First and last anchor points of a subpath.
fn end_points(sp: &SubPath) -> Option<(Point, Point)> {
Some((sp.anchors.first()?.p, sp.anchors.last()?.p))
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
match ids.as_slice() {
@ -292,9 +297,12 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
// Merge coincident end points.
if sp.anchors.len() > 2 && sp.anchors[0].p.distance(sp.anchors.last().unwrap().p) < 1e-6 {
let last = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = last.h_in;
if sp.anchors.len() > 2
&& end_points(sp).is_some_and(|(f, l)| f.distance(l) < 1e-6)
&& let Some(last) = sp.anchors.pop()
&& let Some(first) = sp.anchors.first_mut()
{
first.h_in = last.h_in;
}
sp.closed = true;
Ok(())
@ -310,21 +318,26 @@ fn join(s: &mut Session, _: &Value) -> Result<Value> {
};
let mut x = pa.subpaths[sa].clone();
let mut y = pb.subpaths[sb].clone();
let (xf, xl) = (x.anchors[0].p, x.anchors.last().unwrap().p);
let (yf, yl) = (y.anchors[0].p, y.anchors.last().unwrap().p);
let (Some((xf, xl)), Some((yf, yl))) = (end_points(&x), end_points(&y)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
// Pick the closest pair of ends.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap();
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap_or((0.0, false, false));
if rx {
x.reverse();
}
if ry {
y.reverse();
}
if x.anchors.last().unwrap().p.distance(y.anchors[0].p) < 1e-6 {
if let (Some(xe), Some(ys)) = (x.anchors.last().map(|a| a.p), y.anchors.first().map(|a| a.p))
&& xe.distance(ys) < 1e-6
{
let first = y.anchors.remove(0);
x.anchors.last_mut().unwrap().h_out = first.h_out;
if let Some(l) = x.anchors.last_mut() {
l.h_out = first.h_out;
}
}
x.anchors.extend(y.anchors);
pa.subpaths[sa] = x;

View File

@ -324,11 +324,14 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
if lv.is_empty() {
continue;
}
if shape_mode && lv.len() > 1 {
if shape_mode
&& lv.len() > 1
&& let Some(top) = lv.last()
{
// A group acts as one shape (the union of its leaves) with its top leaf's style.
let parts: Vec<(PathData, FillRule)> = lv.iter().filter_map(node_path).collect();
let refs: Vec<(&PathData, FillRule)> = parts.iter().map(|(p, r)| (p, *r)).collect();
push(po::unite_all(&refs), FillRule::NonZero, style_of(lv.last().unwrap()));
push(po::unite_all(&refs), FillRule::NonZero, style_of(top));
} else {
for l in &lv {
if let Some((p, r)) = node_path(l) {
@ -349,8 +352,10 @@ fn run_pf(s: &mut Session, op: PathfinderOp) -> Result<Value> {
}
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let new_node = if shape_mode {
let r = results.into_iter().find(|r| !r.path.is_empty()).unwrap();
shape_node_styled(d, r.path, &styles[r.key as usize])
let r =
results.into_iter().find(|r| !r.path.is_empty()).ok_or_else(|| EngineError::Other(format!("{label} produced an empty result")))?;
let style = styles.get(r.key as usize).ok_or_else(|| EngineError::Other(format!("{label}: lost track of a shape's style")))?;
shape_node_styled(d, r.path, style)
} else {
let mut children = vec![];
for r in results {
@ -418,8 +423,11 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
if made.is_empty() {
continue;
}
let new = if made.len() == 1 && !matches!(n.kind, NodeKind::Group { .. }) {
made.pop().unwrap()
let new = if made.len() == 1
&& !matches!(n.kind, NodeKind::Group { .. })
&& let Some(one) = made.pop()
{
one
} else {
let gid = d.alloc_id();
Node::group(gid, made.into_iter().map(Arc::new).collect())

View File

@ -212,11 +212,11 @@ fn unique_pattern_name(d: &Document, base: &str) -> String {
if d.pattern(base).is_none() && d.swatch(base).is_none() {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap()
(2..).map(|i| format!("{base} {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| base.to_string())
}
fn next_pattern_name(d: &Document) -> String {
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap()
(1..).map(|i| format!("New Pattern {i}")).find(|n| d.pattern(n).is_none() && d.swatch(n).is_none()).unwrap_or_else(|| "New Pattern".to_string())
}
/// Add pattern `def` and its swatch.
@ -633,11 +633,8 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
return Ok(json!({ "id": r.0 }));
}
let roots = roots(s, p)?;
if roots.is_empty() {
return Err(EngineError::Other("select the art to repeat".into()));
}
let Some(&top) = roots.last() else { return Err(EngineError::Other("select the art to repeat".into())) };
let id = s.edit(label, |d, sel| {
let top = *roots.last().unwrap();
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let nodes: Vec<Arc<Node>> = roots.iter().filter_map(|id| d.node(*id).cloned()).map(Arc::new).collect();
let id = d.alloc_id();

View File

@ -94,19 +94,21 @@ pub(crate) fn effect_image(out: &mut Document, whole: &Node, knockout: Option<&N
let mut img = render(out, vec![whole.clone()], region, scale);
if let Some(bare) = knockout {
let obj = render(out, vec![bare.clone()], region, scale);
for (px, o) in img.pixels.chunks_exact_mut(4).zip(obj.pixels.chunks_exact(4)) {
for (px, o) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(obj.pixels.as_chunks::<4>().0) {
let keep = 1.0 - o[3] as f32 / 255.0;
for c in px.iter_mut() {
*c = (*c as f32 * keep).round() as u8;
}
}
}
// Can't encode: keep the object as it is (vector, effects ignored) rather than fail the export.
let png = img.to_png().ok()?;
let id = out.alloc_id();
let mut key = format!("raster-effect-{}", id.0);
while out.images.contains_key(&key) {
key.push('+');
}
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(img.to_png()) });
out.images.insert(key.clone(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) });
let xf = Affine::translate(region.origin().to_vec2()) * Affine::scale(1.0 / scale);
let mut image = Node::new(id, NodeKind::Image(ImageObject { key, width: img.width, height: img.height, xf, link: None }));
image.name = Some("Raster effect".into());

View File

@ -155,7 +155,7 @@ fn step(s: &mut Session, dir: i64) -> Result<Value> {
if j < 0 || j >= n as i64 {
return ok();
}
let id = st.doc.children(par).unwrap()[j as usize].id;
let Some(id) = st.doc.children(par).and_then(|c| c.get(j as usize)).map(|n| n.id) else { return ok() };
s.select(|_, sel| sel.set([id]))?;
ok()
}

View File

@ -217,8 +217,8 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
}
};
// With nothing selected the Stroke panel sets up the next object drawn.
if new_art {
set(s.new_art_stroke_mut());
if new_art && let Some(st) = s.new_art_stroke_mut() {
set(st);
}
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
if index.is_none()
@ -360,8 +360,11 @@ fn stroke_advanced(s: &mut Session, p: &Value) -> Result<Value> {
}
};
// As stroke.set: with nothing selected, for the next object drawn.
if no_targets(&ids, p) && (scale.is_some() || swap || flip.is_some()) {
arrows_and_profile(s.new_art_stroke_mut());
if no_targets(&ids, p)
&& (scale.is_some() || swap || flip.is_some())
&& let Some(st) = s.new_art_stroke_mut()
{
arrows_and_profile(st);
}
edit_items(s, &ids, item, C, "Stroke", false, |n, index| {
let Some(st) = n.appearance.stroke_at_mut(index) else { return Ok(()) };

View File

@ -231,8 +231,11 @@ impl CharChange {
// Keep the style when the new family has it, else its closest match.
if self.style.is_none() {
let styles = vectorcraft_text::FontDb::global().styles(f);
if !styles.is_empty() && !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style)) {
st.font_style = vectorcraft_text::FontDb::global().face(f, &st.font_style).style.clone();
if !styles.is_empty()
&& !styles.iter().any(|s| s.eq_ignore_ascii_case(&st.font_style))
&& let Some(face) = vectorcraft_text::FontDb::global().face(f, &st.font_style)
{
st.font_style = face.style.clone();
}
}
}

View File

@ -141,19 +141,16 @@ fn with_attrs<T: Serialize + DeserializeOwned>(base: &T, attrs: &Map<String, Val
}
/// `cur` after its style changed from `old` to `new`: attributes still equal to the old style's
/// value (or not set by it) take the new value; overrides stay.
fn restyle<T: Serialize + DeserializeOwned>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
let mut m = match serde_json::to_value(cur) {
Ok(Value::Object(m)) => m,
_ => return serde_json::from_value(serde_json::to_value(cur).unwrap_or_default()).unwrap_or_else(|_| unreachable!()),
};
/// value (or not set by it) take the new value; overrides stay. Unchanged if the style doesn't
/// round-trip through JSON.
fn restyle<T: Serialize + DeserializeOwned + Clone>(cur: &T, old: &Map<String, Value>, new: &Map<String, Value>) -> T {
let Ok(Value::Object(mut m)) = serde_json::to_value(cur) else { return cur.clone() };
for (k, v) in new {
if old.get(k).is_none_or(|o| m.get(k) == Some(o)) {
m.insert(k.clone(), v.clone());
}
}
serde_json::from_value(Value::Object(m.clone()))
.unwrap_or_else(|_| serde_json::from_value(serde_json::to_value(cur).unwrap_or_default()).expect("round trip"))
serde_json::from_value(Value::Object(m)).unwrap_or_else(|_| cur.clone())
}
/// Every text object id in the document.
@ -276,7 +273,7 @@ fn new(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let base = if kind == Kind::Char { "Character Style" } else { "Paragraph Style" };
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).expect("free name"),
None => (1..).map(|i| format!("{base} {i}")).find(|n| kind.attrs(d, n).is_none()).unwrap_or_else(|| base.to_string()),
};
if name.trim().is_empty() || kind.attrs(d, &name).is_some() {
return Err(bad(&c, format!("a style named `{name}` already exists")));
@ -387,7 +384,7 @@ fn duplicate(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
let copy = (1..)
.map(|i| if i == 1 { format!("{name} copy") } else { format!("{name} copy {i}") })
.find(|n| kind.attrs(d, n).is_none())
.expect("free name");
.unwrap_or_else(|| format!("{name} copy"));
new(s, &json!({ "name": copy, "attrs": attrs }), kind)
}

View File

@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
fn has_thread_selection(s: &Session) -> std::result::Result<(), String> {
has_selection(s)?;
let st = s.active().unwrap();
let st = s.active().ok_or("no document open")?;
if st.selection.objects.iter().any(|id| st.doc.text_threads.iter().any(|t| t.contains(id))) {
Ok(())
} else {

View File

@ -250,7 +250,7 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.area.clone(),
_ => unreachable!(),
_ => return Err(bad(C, "select area type (text in a frame)")),
};
let mut v = serde_json::to_value(&current).map_err(|e| EngineError::Other(e.to_string()))?;
let mut changed = false;

View File

@ -0,0 +1,46 @@
//! Last line of defence against bugs: frontends never crash on a panic.
//!
//! Code must not panic (see `AGENTS.md` › Robustness); fallible work returns `Result`. Should a bug
//! panic anyway, the entry points ([`crate::Session::execute`], tool events, the MCP server, the UI
//! frame and the control channel) catch it with [`catch_panic`], roll back what they can and report
//! an error instead of taking the app (and the user's unsaved work) down.
//!
//! On wasm the panic strategy is `abort`, so this only helps native builds; the rule against
//! panicking code is what protects the web app.
use std::any::Any;
use std::panic::{AssertUnwindSafe, catch_unwind};
/// Runs `f`, turning a panic into `Err(message)`. The default panic hook still prints the message
/// and location to stderr. Callers must leave their state consistent after an `Err` (roll back, or
/// rebuild the part `f` was working on).
pub fn catch_panic<T>(f: impl FnOnce() -> T) -> Result<T, String> {
catch_unwind(AssertUnwindSafe(f)).map_err(|payload| {
let msg = panic_message(payload.as_ref());
log::error!("recovered from a panic: {msg}");
msg
})
}
/// The message of a panic payload (`panic!("…")` carries a `&str` or a `String`).
pub fn panic_message(payload: &(dyn Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catch_panic_returns_value_or_message() {
assert_eq!(catch_panic(|| 7), Ok(7));
assert_eq!(catch_panic(|| -> i32 { panic!("boom {}", 1) }), Err("boom 1".to_string()));
assert_eq!(catch_panic(|| -> i32 { std::panic::panic_any(5_u8) }), Err("unknown panic".to_string()));
}
}

View File

@ -7,6 +7,7 @@
#![forbid(unsafe_code)]
pub mod cmd;
pub mod guard;
pub mod inspect;
mod tooling;
@ -39,6 +40,9 @@ pub enum EngineError {
NoNode(NodeId),
#[error("{0}")]
Other(String),
/// A bug: the command panicked. The document was rolled back to its state before the command.
#[error("internal error in `{cmd}`: {msg} (the document was left as it was before; please report this bug)")]
Internal { cmd: String, msg: String },
}
impl From<vectorcraft_doc::DocError> for EngineError {
@ -489,6 +493,8 @@ pub struct Session {
pub(crate) tool: Box<dyn Tool>,
pub(crate) last_view: ViewInfo,
depth: u32,
/// Set when the active tool panicked (see [`guard`]); reported by the next tool event.
tool_panic: Option<EngineError>,
/// Draw Normal / Behind / Inside (toolbar drawing modes).
pub draw_mode: DrawMode,
/// The path new art is drawn inside (Draw Inside).
@ -545,6 +551,7 @@ impl Session {
tool: vectorcraft_tools::create("selection"),
last_view: ViewInfo::default(),
depth: 0,
tool_panic: None,
draw_mode: DrawMode::Normal,
draw_inside: None,
untitled_counter: 0,
@ -654,9 +661,7 @@ impl Session {
if self.depth == 0 {
self.journal_note.clear();
}
self.depth += 1;
let r = (spec.run)(self, params);
self.depth -= 1;
let r = if self.depth == 0 { self.run_guarded(id, |s| (spec.run)(s, params)) } else { self.run_nested(|s| (spec.run)(s, params)) };
let r = r?;
if self.depth == 0 {
self.inherit_new_art();
@ -690,6 +695,39 @@ impl Session {
}
}
fn run_nested(&mut self, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
self.depth += 1;
let r = f(self);
self.depth -= 1;
r
}
/// A top-level command: a panic (a bug) becomes [`EngineError::Internal`] and the active
/// document goes back to how it was before the command, instead of crashing the frontend.
fn run_guarded(&mut self, id: &str, f: impl FnOnce(&mut Self) -> Result<Value>) -> Result<Value> {
let snapshot = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.interaction.clone()));
let active = self.active;
match guard::catch_panic(|| self.run_nested(f)) {
Ok(r) => r,
Err(msg) => {
self.depth = 0;
self.journal_note.clear();
if let Some((uid, doc, selection, interaction)) = snapshot
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
{
st.doc = doc;
st.selection = selection;
st.interaction = interaction;
st.revision += 1;
}
if active.is_some_and(|i| i < self.docs.len()) {
self.active = active;
}
Err(EngineError::Internal { cmd: id.to_string(), msg })
}
}
}
/// Run `f` on a mutable copy of the active document. Outside an interaction this records one
/// undo step labelled `label`; inside one it just mutates (the interaction commits later).
pub fn edit<T>(&mut self, label: &str, f: impl FnOnce(&mut Document, &mut Selection) -> Result<T>) -> Result<T> {

View File

@ -481,3 +481,32 @@ fn shaper_turns_rough_strokes_into_live_shapes_and_scribbles_delete() {
stroke(&mut s, &zig);
assert!(s.doc().unwrap().doc.node(id).is_none());
}
#[test]
fn a_panicking_command_rolls_back_instead_of_crashing() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 100.0, 50.0);
let before = s.doc().unwrap().doc.clone();
let undo_steps = s.doc().unwrap().history.undo.len();
// A bug halfway through a command: the document was already changed in place, inside a
// nested command.
let r = s.run_guarded("test.panic", |s| {
let st = s.doc_mut()?;
Arc::make_mut(&mut st.doc).remove(a)?;
st.selection = Selection::default();
s.run_nested(|_| panic!("boom"))
});
match r {
Err(EngineError::Internal { cmd, msg }) => assert_eq!((cmd.as_str(), msg.as_str()), ("test.panic", "boom")),
other => panic!("expected an internal error, got {other:?}"),
}
let st = s.doc().unwrap();
assert!(Arc::ptr_eq(&st.doc, &before), "document rolled back");
assert_eq!(st.selection.objects, vec![a]);
assert_eq!(st.history.undo.len(), undo_steps);
assert_eq!(s.depth, 0);
// The session keeps working (and journaling top-level commands).
let b = rect(&mut s, 200.0, 10.0, 50.0, 50.0);
assert!(s.doc().unwrap().doc.node(b).is_some());
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("shape.rectangle"));
}

View File

@ -116,7 +116,7 @@ fn type_fills_go_above_or_below_the_characters() {
// A new fill on type copies its characters' fill (it has none of its own).
run(&mut s, "appearance.addFill", json!({}));
let n = node(&s, t);
let NodeKind::Text(tx) = &n.kind else { unreachable!() };
let NodeKind::Text(tx) = &n.kind else { panic!("type") };
assert_eq!(n.appearance.fill_paint(), tx.runs[0].style.fill);
run(&mut s, "appearance.moveItem", json!({"from": "contents", "to": 1}));
assert_eq!(node(&s, t).appearance.contents_at(), 1);

View File

@ -38,7 +38,13 @@ fn render(s: &Session) -> vectorcraft_render::Rendered {
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
a.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
.count()
}
fn expandable(s: &Session) -> bool {

View File

@ -108,7 +108,14 @@ fn assert_regions_match(s: &Session, before: &Rendered, group: u64, tol: u8, wha
/// Share of pixels whose colour moved by more than 8/255.
fn changed(a: &Rendered, b: &Rendered) -> f64 {
let n = a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8)).count();
let n = a
.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 8))
.count();
n as f64 / (a.pixels.len() / 4) as f64
}

View File

@ -76,7 +76,7 @@ fn the_focal_point_round_trips_through_params_and_the_native_format() {
s.execute("paint.setGradientGeom", &json!({"focal": [170, 190], "aspect": 80})).unwrap();
let want = geom(&s, id);
// Gradient params (what swatches and agents read) carry it, and give it back.
let Paint::Gradient(g) = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() else { unreachable!() };
let Paint::Gradient(g) = s.doc().unwrap().doc.node(id).unwrap().appearance.fill_paint() else { panic!("a gradient fill") };
let params = vectorcraft_tools::params::gradient_params(&g);
assert_eq!(params["focal"], json!([170.0, 190.0]));
s.execute("paint.setFill", &json!({"gradient": {"kind": "linear"}})).unwrap();

View File

@ -69,7 +69,13 @@ fn mean_diff(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered)
/// Pixels (of 200×200) whose colour differs by more than `tol` in any channel.
fn differing(a: &vectorcraft_render::Rendered, b: &vectorcraft_render::Rendered, tol: u8) -> usize {
a.pixels.chunks_exact(4).zip(b.pixels.chunks_exact(4)).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol)).count()
a.pixels
.as_chunks::<4>()
.0
.iter()
.zip(b.pixels.as_chunks::<4>().0)
.filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > tol))
.count()
}
fn white_black() -> Value {

View File

@ -219,7 +219,7 @@ fn every_builtin_style_paints_visibly_on_a_rectangle() {
run(&mut s, "graphicStyle.addFromLibrary", json!({"library": b.id, "name": g.name, "apply": true}));
assert_eq!(doc(&s).node(id).unwrap().graphic_style, doc(&s).graphic_style(&g.name).map(|x| x.id), "{}", g.name);
let img = renderer.render_region(doc(&s), Rect::new(0.0, 0.0, 100.0, 100.0), 0.5, true);
let painted = img.pixels.chunks_exact(4).filter(|p| p[..3] != [255, 255, 255]).count();
let painted = img.pixels.as_chunks::<4>().0.iter().filter(|p| p[..3] != [255, 255, 255]).count();
assert!(painted > 40, "{} / {} paints {painted} pixels", b.id, g.name);
}
}

View File

@ -109,7 +109,7 @@ fn override_character_color_clears_the_run_fill() {
let text = |s: &mut Session, y: f64| id_of(&run(s, "text.create", json!({"x": 10, "y": y, "text": "Hi", "size": 40})));
let runs = |s: &Session, id: NodeId| match &s.doc().unwrap().doc.node(id).unwrap().kind {
NodeKind::Text(t) => t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())).collect::<Vec<_>>(),
_ => unreachable!(),
_ => panic!("type"),
};
// On by default: the characters' own colour gives way to the style's fill and stroke.
assert_eq!(run(&mut s, "graphicStyle.setOptions", json!({}))["overrideCharColor"], true);

View File

@ -149,7 +149,7 @@ fn editing_a_global_swatch_recolours_linked_art_in_one_step() {
assert_eq!(fill_of(&s, b), Paint::solid(Color::from_hex("#2a6fb0").unwrap()), "unlinked art keeps its colour");
let text_fill = |s: &Session| match &doc(s).node(t).unwrap().kind {
vectorcraft_doc::NodeKind::Text(tx) => tx.runs[0].style.fill.clone(),
_ => unreachable!(),
_ => panic!("type"),
};
assert_eq!(text_fill(&s), linked("#ff0000", "Brand"), "text runs follow the swatch");
assert_eq!(s.paint.stroke, linked("#ff0000", "Brand"), "so does the default stroke");

View File

@ -4,7 +4,7 @@ use serde_json::Value;
use vectorcraft_geom::Point;
use vectorcraft_tools::{Action, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
use crate::{Result, Session};
use crate::{EngineError, Result, Session};
/// View state the tools need from the frontend.
#[derive(Clone, Copy, Debug)]
@ -86,24 +86,43 @@ impl Session {
raster_sample: self.prefs.eyedropper.sample_size,
preview_bounds: self.prefs.use_preview_bounds,
};
f(self.tool.as_mut(), &cx)
let tool = &mut self.tool;
match crate::guard::catch_panic(|| f(tool.as_mut(), &cx)) {
Ok(r) => r,
Err(msg) => {
// A bug in the tool: start it afresh and drop its half-done drag.
let id = self.tool.id();
self.tool = vectorcraft_tools::create(id);
let _ = self.cancel_interaction();
self.tool_panic = Some(EngineError::Internal { cmd: format!("tool `{id}`"), msg });
R::default()
}
}
}
/// The error of a tool that panicked in the last [`Self::with_tool_cx`] call, if any.
fn take_tool_panic(&mut self) -> Result<()> {
self.tool_panic.take().map_or(Ok(()), Err)
}
/// Feed a pointer event to the active tool. Returns requests for the UI.
pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<Vec<UiRequest>> {
self.last_view = view;
let acts = self.with_tool_cx(view, |t, cx| t.pointer(cx, ev));
self.take_tool_panic()?;
self.apply_actions(acts)
}
pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.key(cx, key, mods));
self.take_tool_panic()?;
self.apply_actions(acts)
}
/// Typed text for the active tool (Type tool).
pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result<Vec<UiRequest>> {
let acts = self.with_tool_cx(view, |t, cx| t.text_input(cx, text));
self.take_tool_panic()?;
self.apply_actions(acts)
}

View File

@ -1,6 +1,8 @@
//! Every-command sweep: each registered command, in three fixture states, with `{}` and with fuzzed
//! junk params, must return Ok or Err (never panic), never leave an interaction open, and never
//! corrupt the tree.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use vectorcraft_engine::{Session, command_specs};

View File

@ -0,0 +1,384 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input,
//! and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
//! (`.vcflattener`) libraries, must load as an error or as a document that then renders and
//! exports, without a panic.
//!
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
/// 64 cases each, unless `PROPTEST_CASES` asks for more.
fn config() -> ProptestConfig {
let mut c = ProptestConfig { failure_persistence: None, ..ProptestConfig::default() };
if std::env::var_os("PROPTEST_CASES").is_none() {
c.cases = 64;
}
c
}
/// Import must not panic; whatever comes back must render and export without panicking either.
fn survive(what: &str, import: impl FnOnce() -> Option<Document>) -> Result<(), TestCaseError> {
let r = catch_quiet(|| {
if let Some(d) = import() {
let mut r = vectorcraft_render::Renderer::new();
if let Some(ab) = d.artboards.first() {
let scale = (256.0 / ab.rect.width().max(ab.rect.height()).max(1.0)).min(1.0);
if vectorcraft_render::raster_size(ab.rect, scale).is_ok() {
let _ = r.render_region(&d, ab.rect, scale, true).to_png();
}
}
let _ = vectorcraft_svg::export(&d, &vectorcraft_svg::ExportOptions::default());
let _ = vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default());
let _ = vectorcraft_format::save_file(&d);
}
});
r.map_err(|msg| TestCaseError::fail(format!("{what}: panicked: {msg}")))
}
fn rich_doc() -> Document {
(*rich_session().doc().unwrap().doc).clone()
}
fn rich_svg() -> String {
vectorcraft_svg::export(&rich_doc(), &vectorcraft_svg::ExportOptions::default())
}
fn rich_pdf() -> Vec<u8> {
vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap()
}
/// Numbers that tend to break arithmetic: zero, negatives, huge, tiny, exponents, junk.
fn arb_num() -> impl Strategy<Value = String> {
prop_oneof![
(-1000.0f64..1000.0).prop_map(|v| format!("{v:.2}")),
Just("0".to_string()),
Just("-0".to_string()),
Just("1e308".to_string()),
Just("-1e308".to_string()),
Just("1e-308".to_string()),
Just("1e15".to_string()),
Just("4294967296".to_string()),
Just("-2147483649".to_string()),
Just("NaN".to_string()),
Just("inf".to_string()),
Just("".to_string()),
Just("1e".to_string()),
Just("--1".to_string()),
]
}
// ---------- SVG ----------
fn arb_svg_attr() -> impl Strategy<Value = String> {
let names = prop::sample::select(vec![
"x",
"y",
"width",
"height",
"rx",
"ry",
"r",
"cx",
"cy",
"x1",
"y1",
"x2",
"y2",
"dx",
"dy",
"font-size",
"stroke-width",
"stroke-dasharray",
"stroke-dashoffset",
"stroke-miterlimit",
"opacity",
"fill-opacity",
"offset",
"startOffset",
"letter-spacing",
"rotate",
"textLength",
"fx",
"fy",
"fr",
"points",
"viewBox",
"transform",
"d",
"fill",
"stroke",
"stop-color",
"mask-type",
"data-vectorcraft-mask",
"href",
]);
let value = prop_oneof![
arb_num(),
prop::collection::vec(arb_num(), 0..7).prop_map(|v| v.join(" ")),
prop::collection::vec(arb_num(), 0..7).prop_map(|v| format!("matrix({})", v.join(" "))),
prop::collection::vec(arb_num(), 0..3).prop_map(|v| format!("rotate({}) scale({})", v.join(" "), v.join(","))),
prop::collection::vec((prop::sample::select(vec!["M", "L", "C", "Q", "A", "Z", "H", "V", "S", "T", "m", "a"]), arb_num()), 0..12)
.prop_map(|v| v.iter().map(|(c, n)| format!("{c}{n} {n}")).collect::<Vec<_>>().join(" ")),
prop::collection::vec(arb_num(), 0..2).prop_map(|v| format!("{}%", v.join(""))),
// Colours (Lab too), mask options and links.
prop::collection::vec(arb_num(), 0..4).prop_map(|v| format!("lab({})", v.join(" "))),
prop::sample::select(vec!["noclip invert", "invert", "noclip", "alpha", "luminance", "url(#e)", "#e", "#zz", "none", "currentColor"])
.prop_map(str::to_string),
];
(names, value).prop_map(|(n, v)| format!(" {n}=\"{v}\""))
}
fn arb_svg_element(depth: u32) -> BoxedStrategy<String> {
let tag = prop::sample::select(vec![
"rect",
"circle",
"ellipse",
"line",
"polyline",
"polygon",
"path",
"text",
"tspan",
"textPath",
"g",
"use",
"image",
"linearGradient",
"radialGradient",
"stop",
"pattern",
"clipPath",
"mask",
"symbol",
"defs",
"svg",
"marker",
"a",
]);
let attrs = || prop::collection::vec(arb_svg_attr(), 0..6).prop_map(|v| v.concat());
let leaf = (tag.clone(), attrs(), "[a-z ]{0,6}").prop_map(|(t, a, txt)| format!("<{t} id=\"e\"{a} href=\"#e\">{txt}</{t}>"));
if depth == 0 {
return leaf.boxed();
}
prop_oneof![
leaf,
(tag, attrs(), prop::collection::vec(arb_svg_element(depth - 1), 0..4)).prop_map(|(t, a, ch)| format!("<{t}{a}>{}</{t}>", ch.concat()))
]
.boxed()
}
/// A character-level edit that keeps the string valid UTF-8 (the importer takes `&str`).
fn mutate_text(src: &str, cut: usize, edits: &[(usize, char)]) -> String {
let mut chars: Vec<char> = src.chars().collect();
for &(i, c) in edits {
if !chars.is_empty() {
let n = chars.len();
chars[i % n] = c;
}
}
chars.truncate(cut.min(chars.len()));
chars.into_iter().collect()
}
proptest! {
#![proptest_config(config())]
#[test]
fn svg_garbage_never_panics(s in ".{0,300}") {
survive("svg garbage", || vectorcraft_svg::import(&s).ok())?;
}
#[test]
fn svg_hostile_markup_never_panics(
root in prop::collection::vec(arb_svg_attr(), 0..4),
body in prop::collection::vec(arb_svg_element(2), 0..6),
) {
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\"{}>{}</svg>", root.concat(), body.concat());
survive(&svg, || vectorcraft_svg::import(&svg).ok())?;
}
#[test]
fn svg_mutated_export_never_panics(
cut in 0usize..20_000,
edits in prop::collection::vec((0usize..20_000, prop::sample::select(vec!['<', '>', '"', '/', '-', '9', 'e', '.', ' ', '#', '%', '&', ';', 'x'])), 0..10),
) {
let svg = mutate_text(&rich_svg(), cut, &edits);
survive("mutated svg", || vectorcraft_svg::import(&svg).ok())?;
}
}
// ---------- PDF ----------
fn handmade_pdf(content: &str, resources: &str, media: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources {resources} >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut out = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![];
for (i, o) in objs.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let xref = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// One content-stream operation with junk operands.
fn arb_pdf_op() -> impl Strategy<Value = String> {
let op = prop::sample::select(vec![
"m", "l", "c", "v", "y", "h", "re", "f", "f*", "F", "S", "s", "B", "B*", "b", "n", "W", "W*", "q", "Q", "cm", "w", "J", "j", "M", "d", "ri",
"i", "gs", "g", "G", "rg", "RG", "k", "K", "cs", "CS", "sc", "SC", "scn", "SCN", "sh", "Do", "BT", "ET", "Tf", "Td", "TD", "Tm", "T*", "Tc",
"Tw", "Tz", "TL", "Tr", "Ts", "Tj", "TJ", "'", "\"", "BMC", "BDC", "EMC", "d0", "d1",
]);
let operand = prop_oneof![
arb_num(),
prop::collection::vec(arb_num(), 0..5).prop_map(|v| format!("[{}]", v.join(" "))),
Just("/F1".to_string()),
Just("/Sh0".to_string()),
Just("/Im0".to_string()),
Just("/GS0".to_string()),
Just("/DeviceRGB".to_string()),
Just("/Pattern".to_string()),
Just("(Hi)".to_string()),
Just("<00ff>".to_string()),
Just("[(a) -250 (b)]".to_string()),
];
(prop::collection::vec(operand, 0..7), op).prop_map(|(args, op)| format!("{} {op}", args.join(" ")))
}
fn arb_resources() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"<< >>".to_string(),
"<< /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>".to_string(),
"<< /ExtGState << /GS0 << /CA 0.5 /ca -3 /BM /Multiply /SMask /None >> >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 1e308 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> >>".to_string(),
"<< /Shading << /Sh0 << /ShadingType 3 /ColorSpace /DeviceRGB /Coords [0 0 0 0 0 -5] /Function << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [] /N -1 >> >> >> >>".to_string(),
"<< /XObject << /Im0 << /Type /XObject /Subtype /Image /Width 4294967295 /Height 0 /BitsPerComponent 8 /ColorSpace /DeviceRGB /Length 3 >> >> >>".to_string(),
"<< /Pattern << /P0 << /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 0 0] /XStep 0 /YStep -0 /Resources << >> >> >> >>".to_string(),
])
}
proptest! {
#![proptest_config(config())]
#[test]
fn pdf_garbage_never_panics(bytes in prop::collection::vec(any::<u8>(), 0..600)) {
let mut b = b"%PDF-1.7\n".to_vec();
b.extend(bytes);
survive("pdf garbage", || vectorcraft_pdf::import(&b).ok())?;
}
#[test]
fn pdf_hostile_content_never_panics(
ops in prop::collection::vec(arb_pdf_op(), 0..40),
resources in arb_resources(),
media in prop::sample::select(vec!["[0 0 100 100]", "[0 0 0 0]", "[0 0 1e308 1e308]", "[100 100 0 0]", "[-5 -5 -1 -1]", "[0 0 14400 14400]", "[]", "[0 0 NaN 5]"]),
) {
let pdf = handmade_pdf(&ops.join("\n"), &resources, media);
survive(&ops.join(" "), || vectorcraft_pdf::import(&pdf).ok())?;
}
#[test]
fn pdf_mutated_export_never_panics(
cut in 0usize..40_000,
flips in prop::collection::vec((0usize..40_000, prop::sample::select(vec![b'0', b'9', b'-', b'.', b' ', b'[', b']', b'<', b'>', b'/', b'(', b'e', 0u8, 0xff])), 0..10),
) {
let mut b = rich_pdf();
for (i, c) in flips {
let n = b.len();
b[i % n] = c;
}
b.truncate(cut.min(b.len()).max(9));
survive("mutated pdf", || vectorcraft_pdf::import(&b).ok())?;
}
}
// ---------- library files ----------
/// The `data` of a library file `cmd` writes for the rich document.
fn saved(cmd: &str, params: Value) -> String {
rich_session().execute(cmd, &params).unwrap()["data"].as_str().unwrap().to_string()
}
/// Library file `data` loaded with `load`; what it holds is then used on the rich document
/// (`then`, given the load's result), which renders and exports.
fn survive_library(what: &str, load: &str, data: &str, then: impl FnOnce(&mut vectorcraft_engine::Session, &Value)) -> Result<(), TestCaseError> {
survive(what, || {
let mut s = rich_session();
let r = s.execute(load, &json!({"data": data, "name": "fuzz"})).ok()?;
then(&mut s, &r);
Some((*s.doc().ok()?.doc).clone())
})
}
fn swatches(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "swatch.library.load", data, |s, r| {
let _ = s.execute("swatch.library.add", &json!({"library": r["library"], "apply": "fill"}));
})
}
fn styles(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "graphicStyle.loadLibrary", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
let _ = s.execute("graphicStyle.addFromLibrary", &json!({"library": r["library"], "apply": true}));
})
}
fn flattener_presets(what: &str, data: &str) -> Result<(), TestCaseError> {
survive_library(what, "flattener.presets.import", data, |s, r| {
let _ = s.execute("select.all", &json!({}));
if let Some(name) = r["imported"].get(0) {
let _ = s.execute("flattener.preview", &json!({"preset": name, "highlight": "allAffected"}));
let _ = s.execute("object.flattenTransparency", &json!({"preset": name, "lineArtPpi": 36, "gradientPpi": 36}));
}
})
}
/// Characters that break JSON and GPL palettes.
fn arb_edit() -> impl Strategy<Value = (usize, char)> {
(0usize..20_000, prop::sample::select(vec!['{', '}', '[', ']', '"', ':', ',', '-', '9', 'e', '.', ' ', '\n', '#', 'n', 'x', '\t']))
}
proptest! {
#![proptest_config(config())]
#[test]
fn library_garbage_never_panics(s in ".{0,300}", head in prop::sample::select(vec!["", "GIMP Palette\n","{\"format\": \"vcswatches\", ", "{\"format\": \"vcstyles\", ", "{\"format\": \"vcflattener\", "])) {
let data = format!("{head}{s}");
swatches("swatch library garbage", &data)?;
styles("style library garbage", &data)?;
flattener_presets("flattener preset garbage", &data)?;
}
#[test]
fn mutated_swatch_libraries_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10), gpl in any::<bool>()) {
let text = saved("swatch.library.save", json!({"format": if gpl { "gpl" } else { "vcswatches" }}));
swatches("mutated swatch library", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn mutated_style_libraries_never_panic(cut in 0usize..20_000, edits in prop::collection::vec(arb_edit(), 0..10)) {
let text = saved("graphicStyle.saveLibrary", json!({}));
styles("mutated style library", &mutate_text(&text, cut, &edits))?;
}
#[test]
fn mutated_flattener_presets_never_panic(cut in 0usize..5_000, edits in prop::collection::vec(arb_edit(), 0..10)) {
let text = saved("flattener.presets.export", json!({"names": ["high", "medium", "low"]}));
flattener_presets("mutated flattener presets", &mutate_text(&text, cut, &edits))?;
}
}

View File

@ -1,5 +1,7 @@
//! Minimal reproductions of engine bugs found by the property tests. Each is `#[ignore]`d with a
//! `BUG:` reason until fixed; remove the attribute when the fix lands.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_engine::Session;

View File

@ -1,5 +1,7 @@
//! Model-based / stateful property tests: random command sequences drawn from the registry, with
//! structural invariants checked after every step and history/serialization invariants at the end.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::json;

View File

@ -1,4 +1,6 @@
//! `.vectorcraft` format: round trips, robustness against garbage, versioning, unknown fields.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;

View File

@ -317,13 +317,13 @@ impl PathData {
PathEl::ClosePath => {
if let Some(mut sp) = cur.take() {
// Merge a closing anchor that duplicates the start.
if sp.anchors.len() > 1 {
let first = sp.anchors[0].p;
let lastp = sp.anchors.last().unwrap().p;
if first.distance(lastp) < 1e-7 {
let l = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = l.h_in;
}
if sp.anchors.len() > 1
&& let (Some(first), Some(lastp)) = (sp.anchors.first().map(|a| a.p), sp.anchors.last().map(|a| a.p))
&& first.distance(lastp) < 1e-7
&& let Some(l) = sp.anchors.pop()
&& let Some(a0) = sp.anchors.first_mut()
{
a0.h_in = l.h_in;
}
sp.closed = true;
for a in &mut sp.anchors {
@ -351,9 +351,10 @@ impl PathData {
return None;
}
let bp = self.to_bezpath();
if self.anchor_count() == 1 {
let p = self.subpaths.iter().find_map(|s| s.anchors.first()).unwrap().p;
return Some(Rect::from_points(p, p));
if self.anchor_count() == 1
&& let Some(a) = self.subpaths.iter().find_map(|s| s.anchors.first())
{
return Some(Rect::from_points(a.p, a.p));
}
Some(bp.bounding_box())
}

View File

@ -1,4 +1,6 @@
//! Geometry property tests: PathData ↔ BezPath, bounds, hit testing, anchors and shapes.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use vectorcraft_geom::hit::{distance_to_outline, fill_contains, stroke_contains};
@ -29,7 +31,7 @@ fn crossing_winding(bp: &BezPath, p: Point) -> i32 {
PathEl::MoveTo(q) => pts.push(vec![q]),
PathEl::LineTo(q) => pts.last_mut().unwrap().push(q),
PathEl::ClosePath => {}
_ => unreachable!(),
other => panic!("unexpected {other:?}"),
});
for poly in pts {
for i in 0..poly.len() {

View File

@ -59,7 +59,9 @@ impl Remote {
if self.conn.is_none() {
self.reconnect()?;
}
let (reader, writer) = self.conn.as_mut().expect("connected");
let Some((reader, writer)) = self.conn.as_mut() else {
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected to the app"));
};
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;

View File

@ -201,7 +201,7 @@ impl Headless {
let scale = p.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.01, 16.0);
vectorcraft_render::raster_size(r, scale)?;
let img = self.renderer.render_region(&doc, r, scale, true);
let png = img.to_png();
let png = img.to_png()?;
match s(p, "path") {
Some(path) => {
std::fs::write(path, &png).map_err(|e| format!("write {path}: {e}"))?;

View File

@ -111,7 +111,10 @@ impl Server {
if !(id.is_string() || id.is_number()) {
return Some(error(Value::Null, INVALID_REQUEST, "`id` must be a string or number"));
}
Some(match self.request(method, &params) {
// A bug that panics fails this request only; the server keeps serving.
let r = vectorcraft_engine::guard::catch_panic(|| self.request(method, &params))
.unwrap_or_else(|msg| Err((INTERNAL_ERROR, format!("internal error in `{method}`: {msg} (please report this bug)"))));
Some(match r {
Ok(r) => response(id, r),
Err((code, m)) => error(id, code, m),
})

View File

@ -516,7 +516,7 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
}
let r = if let Some(path) = a.get("path") {
let mut p = json!({"path": path, "mode": a.get("mode").cloned().unwrap_or(json!("onPath")), "text": text});
for (k, v) in style.as_object().unwrap() {
for (k, v) in style.as_object().into_iter().flatten() {
p[k.as_str()] = v.clone();
}
exec(b, "text.createInPath", p)?
@ -524,7 +524,7 @@ fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
let x = a.get("x").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?;
let y = a.get("y").and_then(Value::as_f64).ok_or("give `x` and `y` (or a `path`)")?;
let mut p = json!({"x": x, "y": y, "text": text});
for (k, v) in style.as_object().unwrap() {
for (k, v) in style.as_object().into_iter().flatten() {
p[k.as_str()] = v.clone();
}
if let Some(c) = a.get("color") {

View File

@ -1,6 +1,8 @@
//! MCP server properties (in-process, headless backend): tool schemas are valid, every tool
//! survives junk arguments, JSON-RPC framing always answers correctly, and random editing through
//! MCP keeps the document valid.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use serde_json::{Value, json};

View File

@ -188,8 +188,9 @@ pub fn remove_redundant_points(path: &PathData, tolerance: f64) -> PathData {
segs.remove(i1);
if sp.closed && i1 == 0 {
// The merged segment now starts the loop.
let last = segs.pop().expect("non-empty");
segs.insert(0, last);
if let Some(last) = segs.pop() {
segs.insert(0, last);
}
}
changed = true;
break;
@ -287,18 +288,22 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
}
}
}
if open.len() == 1 {
let mut sp = open.pop().expect("one");
let n = sp.anchors.len();
if n > 2 && sp.anchors[0].p.distance(sp.anchors[n - 1].p) <= tolerance {
let last = sp.anchors.pop().expect("n > 2");
let f = &mut sp.anchors[0];
if open.len() == 1
&& let Some(mut sp) = open.pop()
{
let ends_meet = sp.anchors.len() > 2 && end_point(&sp, false).zip(end_point(&sp, true)).is_some_and(|(a, b)| a.distance(b) <= tolerance);
if ends_meet
&& let Some(last) = sp.anchors.pop()
&& let Some(f) = sp.anchors.first_mut()
{
*f = Anchor::with_handles(f.p, last.h_in, f.h_out);
} else {
let f = &mut sp.anchors[0];
f.h_in = f.p;
let l = sp.anchors.last_mut().expect("n >= 2");
l.h_out = l.p;
if let Some(f) = sp.anchors.first_mut() {
f.h_in = f.p;
}
if let Some(l) = sp.anchors.last_mut() {
l.h_out = l.p;
}
}
sp.closed = true;
closed.push(sp);
@ -311,8 +316,7 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
for j in (i + 1)..open.len() {
for ei in [false, true] {
for ej in [false, true] {
let pi = if ei { open[i].anchors.last() } else { open[i].anchors.first() }.expect("len>=2").p;
let pj = if ej { open[j].anchors.last() } else { open[j].anchors.first() }.expect("len>=2").p;
let (Some(pi), Some(pj)) = (end_point(&open[i], ei), end_point(&open[j], ej)) else { continue };
let d = pi.distance(pj);
if d < best.0 {
best = (d, i, j, ei, ej);
@ -330,14 +334,18 @@ pub fn join(paths: &[PathData], tolerance: f64) -> PathData {
if ej {
b.reverse();
}
if d <= tolerance {
if d <= tolerance && !b.anchors.is_empty() {
let first = b.anchors.remove(0);
let l = a.anchors.last_mut().expect("len>=2");
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
if let Some(l) = a.anchors.last_mut() {
*l = Anchor::with_handles(l.p, l.h_in, first.h_out);
}
} else {
let l = a.anchors.last_mut().expect("len>=2");
l.h_out = l.p;
b.anchors[0].h_in = b.anchors[0].p;
if let Some(l) = a.anchors.last_mut() {
l.h_out = l.p;
}
if let Some(f) = b.anchors.first_mut() {
f.h_in = f.p;
}
}
a.anchors.extend(b.anchors);
}
@ -369,3 +377,8 @@ pub fn split_into_grid(rect: Rect, rows: usize, cols: usize, gutter: f64) -> Vec
}
out
}
/// The first (`last == false`) or last anchor point of a subpath.
fn end_point(sp: &SubPath, last: bool) -> Option<Point> {
if last { sp.anchors.last() } else { sp.anchors.first() }.map(|a| a.p)
}

View File

@ -112,8 +112,7 @@ fn bern(t: f64) -> [f64; 4] {
/// Solve the 2x2 normal equations for the handle lengths along the fixed tangents.
fn generate(pts: &[Point], u: &[f64], t0: Vec2, t1: Vec2) -> CubicBez {
let p0 = pts[0];
let p3 = *pts.last().unwrap();
let (Some(&p0), Some(&p3)) = (pts.first(), pts.last()) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) };
let dir_in = -t1; // handle at the end points backwards
let (mut c00, mut c01, mut c11, mut x0, mut x1) = (0.0, 0.0, 0.0, 0.0, 0.0);
for (p, &t) in pts.iter().zip(u) {

View File

@ -160,7 +160,7 @@ fn deep_point(bp: &BezPath) -> Option<kurbo::Point> {
_ => {}
});
xs.sort_by(f64::total_cmp);
for w in xs.chunks_exact(2) {
for w in xs.as_chunks::<2>().0 {
let span = w[1] - w[0];
if best.is_none_or(|(b, _)| span > b) {
best = Some((span, kurbo::Point::new((w[0] + w[1]) / 2.0, y)));

View File

@ -57,9 +57,9 @@ pub struct Region {
}
impl Region {
/// Front-most covering shape.
pub fn top(&self) -> usize {
*self.sources.last().expect("regions are never uncovered")
/// Front-most covering shape (`None` only for an uncovered face, which regions never are).
pub fn top(&self) -> Option<usize> {
self.sources.last().copied()
}
/// Does the face contain `p`?
pub fn contains(&self, p: Point) -> bool {
@ -87,23 +87,27 @@ pub fn pathfinder(op: PathfinderOp, shapes: &[Shape]) -> Vec<Shape> {
return Vec::new();
}
let front_key = shapes[n - 1].key;
if op == PathfinderOp::Unite {
let unite = || {
let v: Vec<(&PathData, FillRule)> = shapes.iter().map(|s| (&s.path, s.rule)).collect();
return one(unite_all(&v), front_key);
one(unite_all(&v), front_key)
};
// Unite doesn't need the arrangement.
if op == PathfinderOp::Unite {
return unite();
}
let Some(arr) = arrangement(shapes) else { return Vec::new() };
let p = &arr.tidy;
match op {
PathfinderOp::Unite => unreachable!(),
PathfinderOp::Unite => unite(),
PathfinderOp::MinusFront => one(all_contours_to_path(&arr.contours(|m| m[0] && !m[1..].iter().any(|&b| b)), p), shapes[0].key),
PathfinderOp::MinusBack => one(all_contours_to_path(&arr.contours(|m| m[n - 1] && !m[..n - 1].iter().any(|&b| b)), p), front_key),
PathfinderOp::Intersect => one(all_contours_to_path(&arr.contours(|m| m.iter().all(|&b| b)), p), front_key),
PathfinderOp::Exclude => one(all_contours_to_path(&arr.contours(|m| m.iter().filter(|&&b| b).count() % 2 == 1), p), front_key),
PathfinderOp::Divide => regions_of(&arr)
.into_iter()
.map(|r| {
let key = shapes[r.top()].key;
Shape::new(r.path, FillRule::NonZero, key)
.filter_map(|r| {
let key = shapes.get(r.top()?)?.key;
Some(Shape::new(r.path, FillRule::NonZero, key))
})
.collect(),
PathfinderOp::Trim => (0..n).flat_map(|i| one(all_contours_to_path(&arr.contours(|m| topmost(m) == Some(i)), p), shapes[i].key)).collect(),

View File

@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::f64::consts::PI;
use kurbo::{ParamCurve, Point, Rect, Shape as _};

View File

@ -1,5 +1,7 @@
//! Path-operation properties on concave polygons, ellipses and random smooth curves (complements
//! `props.rs`, which uses rectangles and circles).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;

View File

@ -1,3 +1,5 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::f64::consts::PI;

View File

@ -252,7 +252,7 @@ impl Builder {
if self.stack.len() <= 1 {
return;
}
let f = self.stack.pop().expect("frame");
let Some(f) = self.stack.pop() else { return };
let node = match f.kind {
FrameKind::Root => None,
FrameKind::Skip => {
@ -386,7 +386,7 @@ fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<Gra
}
let mut keep = vec![0usize];
for i in 1..N {
let a = pts[*keep.last().expect("non-empty")];
let a = pts[keep.last().copied().unwrap_or(0)];
let b = pts[i + 1];
let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6);
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max);
@ -596,7 +596,7 @@ impl<'a> Device<'a> for Builder {
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
let rgba: Vec<u8> = match img {
ImageData::Rgb(d) => d.data.chunks_exact(3).zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
ImageData::Rgb(d) => d.data.as_chunks::<3>().0.iter().zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
};
decoded = Some((rgba, w, h, sf));

View File

@ -1,4 +1,6 @@
//! `cargo run --release -p vectorcraft-render --example bench` — render timing on synthetic documents.
// Example (dev tool): unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node};

View File

@ -204,7 +204,7 @@ pub(crate) fn compose(cmy: &[u8], k: &[u8], background: Option<[u8; 4]>) -> Vec<
let mut out = Vec::with_capacity(cmy.len());
// Runs of one colour convert once.
let mut last: Option<([u8; 8], [u8; 4])> = None;
for (p, q) in cmy.chunks_exact(4).zip(k.chunks_exact(4)) {
for (p, q) in cmy.as_chunks::<4>().0.iter().zip(k.as_chunks::<4>().0) {
let key = [p[0], p[1], p[2], p[3], q[0], q[1], q[2], q[3]];
let px = match (&last, &bg) {
_ if p[3] == 0 => [0; 4],

View File

@ -118,7 +118,7 @@ impl Rendered {
/// Un-premultiplied RGBA8 copy.
pub fn to_straight(&self) -> Vec<u8> {
let mut out = self.pixels.clone();
for px in out.chunks_exact_mut(4) {
for px in out.as_chunks_mut::<4>().0 {
let a = px[3] as u32;
if a != 0 && a != 255 {
for c in &mut px[..3] {
@ -129,17 +129,20 @@ impl Rendered {
out
}
/// Encode as PNG.
pub fn to_png(&self) -> Vec<u8> {
pub fn to_png(&self) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()).expect("size");
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).expect("png encode");
buf
let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight())
.ok_or("PNG encoding failed: pixel buffer doesn't match the image size")?;
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).map_err(|e| format!("PNG encoding failed: {e}"))?;
Ok(buf)
}
/// Encode as JPEG (flattened on white) at `quality` 1..=100.
pub fn to_jpeg(&self, quality: u8) -> Vec<u8> {
pub fn to_jpeg(&self, quality: u8) -> Result<Vec<u8>, String> {
let rgba = self.to_straight();
let rgb: Vec<u8> = rgba
.chunks_exact(4)
.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let a = p[3] as u32;
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
@ -148,15 +151,17 @@ impl Rendered {
.collect();
let mut buf = Vec::new();
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
let _ = image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8);
buf
image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8)
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
Ok(buf)
}
/// Encode as lossless WebP.
pub fn to_webp(&self) -> Vec<u8> {
pub fn to_webp(&self) -> Result<Vec<u8>, String> {
let mut buf = Vec::new();
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
let _ = image::ImageEncoder::write_image(enc, &self.to_straight(), self.width, self.height, image::ExtendedColorType::Rgba8);
buf
image::ImageEncoder::write_image(enc, &self.to_straight(), self.width, self.height, image::ExtendedColorType::Rgba8)
.map_err(|e| format!("WebP encoding failed: {e}"))?;
Ok(buf)
}
/// Straight-alpha RGBA at (x, y).
pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] {

View File

@ -247,7 +247,7 @@ pub(crate) fn post(pixels: &mut [u8], opts: &RenderOptions) {
let lut = cms::active().proof_lut(proof);
let mut last_in = [0u8; 4];
let mut last_out = [0u8; 4];
for px in pixels.chunks_exact_mut(4) {
for px in pixels.as_chunks_mut::<4>().0 {
let a = px[3];
if a == 0 {
continue;

View File

@ -188,7 +188,7 @@ fn region_export_png() {
d.insert(Some(l), 0, n).unwrap();
let r = Renderer::new().render_region(&d, d.artboards[0].rect, 2.0, true);
assert_eq!((r.width, r.height), (200, 200));
let png = r.to_png();
let png = r.to_png().unwrap();
assert_eq!(&png[1..4], b"PNG");
}

View File

@ -1,5 +1,7 @@
//! Pixel-sample "golden" tests on small fixtures: gradients, blend modes, clip groups, stroke
//! alignment/dashes/arrowheads, effects, text, and opacity folding vs layer compositing.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
@ -137,7 +139,7 @@ fn blend_ref(mode: BlendMode, b: f64, s: f64) -> f64 {
b + (2.0 * s - 1.0) * (d - b)
}
}
_ => unreachable!(),
other => panic!("unexpected {other:?}"),
}
}
@ -496,7 +498,7 @@ fn png_encoding_roundtrips_pixels() {
b.rect(Rect::new(10.0, 10.0, 60.0, 60.0), Color::rgb(0.2, 0.4, 0.6), |n| n.opacity = 0.5);
let d = b.build();
let r = vectorcraft_render::Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0, false);
let decoded = Image::from_png(&r.to_png()).unwrap();
let decoded = Image::from_png(&r.to_png().unwrap()).unwrap();
assert_eq!(decoded, Image::from_rendered(&r));
}

View File

@ -1,4 +1,6 @@
//! Gradients on type: a vector across the text, and run strokes fitted like object strokes.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_color::Paint;

View File

@ -1,5 +1,7 @@
//! Pixel tests for the shared stroke geometry (`vectorcraft_effects::stroke`) on the canvas:
//! dotted lines (zero-length dashes) and arrowheads (hollow kinds, alignment, compositing).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Document, LineCap, StrokeLayer};

View File

@ -275,7 +275,7 @@ impl Importer {
if !g.filters().is_empty() {
self.warn(format!("filter on {label} ignored"));
}
let children = self.children(g, ts);
let mut children = self.children(g, ts);
let mut n = if let Some(cp) = g.clip_path() {
if cp.clip_path().is_some() {
self.warn(format!("nested clip path on {label} approximated by its outer clip"));
@ -294,8 +294,9 @@ impl Importer {
&& mask.is_none()
&& children.len() == 1
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
&& let Some(only) = children.pop()
{
let mut c = Arc::unwrap_or_clone(children.into_iter().next().unwrap());
let mut c = Arc::unwrap_or_clone(only);
c.opacity *= g.opacity().get();
if g.blend_mode() != usvg::BlendMode::Normal {
c.blend = blend(g.blend_mode());

View File

@ -1,5 +1,7 @@
//! Blend modes and opacity masks in SVG export look as on the canvas: every mode follows the
//! reference formulas when resvg draws the file, and masks take luminance the canvas's way.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::blend::blend_rgb;
use vectorcraft_color::{BlendMode, Color, Paint};

View File

@ -1,6 +1,8 @@
//! The appearance of groups and layers in SVG export: their own fills and strokes are baked into
//! paths painting the members (below or above them as the contents slot says), their geometry
//! effects into the members, and their raster effects filter the whole group.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::sync::Arc;

View File

@ -1,5 +1,7 @@
//! Dashes fitted to corners export as the canvas's filled outlines (a dash array can't fit them);
//! exact dashes stay a `stroke-dasharray`. Checked by rendering the exported SVG with resvg.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Dash, Document, Node, StrokeLayer};

View File

@ -1,5 +1,7 @@
//! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's
//! element, and geometry effects on type are baked through its outlines.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::json;
use vectorcraft_color::{Color, Paint};

View File

@ -1,4 +1,6 @@
//! Radial gradients' focal points as `fx`/`fy`, out and back in.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, Document, Node};

View File

@ -1,6 +1,8 @@
//! Opacity-mask options survive SVG (M3.87): exported masks list the options that differ from the
//! defaults in `data-vectorcraft-mask`, and import restores them and the original mask art (without
//! the backdrop rectangle and inverting filter that draw the options in SVG).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, Node, OpacityMask};

View File

@ -1,5 +1,7 @@
//! SVG export/import properties: never panics, always re-imports, and plain art survives the round
//! trip visually (render comparison with a perceptual tolerance).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use proptest::prelude::*;
use vectorcraft_color::{Color, Paint};

Some files were not shown because too many files have changed in this diff Show More