Robustness: VectorCraft never crashes — no panics in shipped code

Shipped code no longer unwraps, expects or panics: every one of the 124
unwrap()/expect()/panic!/unreachable! sites outside tests now handles the
failure (Result/Option, ?, let-else, defaults) and reports errors to the user.

- Lints: workspace clippy denies unwrap_used, expect_used, panic, todo,
  unimplemented and unreachable; clippy.toml allows them in tests, and
  integration tests, examples and testkit opt out explicitly.
- Safety net (vectorcraft_engine::guard): a panic in a top-level command rolls
  the document back and returns EngineError::Internal; a panicking tool is
  reset; the MCP server answers with an internal error and keeps serving; the
  UI frame and each control request are guarded.
- APIs that hid failures now return them: Rendered::to_png/to_jpeg/to_webp
  return Result (JPEG/WebP errors used to write empty files),
  FontDb::face returns Option, Region::top returns Option.
- Fuzzing: engine/tests/import_fuzz.rs feeds garbage, hostile and mutated SVG
  and PDF through import → render → export (30k cases locally, no panics).
- Docs: AGENTS.md/CLAUDE.md rule, docs/development.md "Robustness" section,
  README pointer and a PR template checklist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-05 06:28:59 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent 0f241aac9f
commit 3e323bfa2a
89 changed files with 1041 additions and 303 deletions
+7 -6
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])?;
+10 -3
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 {
+12 -6
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();
+2 -1
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, .. })
+55 -34
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 {
+1
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))),
})
+26 -20
View File
@@ -231,7 +231,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 +635,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 +685,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 +704,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 +778,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 +792,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 +907,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;
@@ -952,7 +957,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).
+4 -1
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;
+3 -3
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)?;
+4 -4
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 => {
+4 -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())
+2 -2
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))?;
+21 -8
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;
+14 -6
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())
+3 -6
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();
+4 -2
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());
+1 -1
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()
}
+5 -2
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();
}
}
}
+7 -10
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)
}
+1 -1
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 {
+1 -1
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;
+46
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()));
}
}
+41 -3
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> {
+29
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"));
}
+21 -2
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)
}
+2
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};
+285
View File
@@ -0,0 +1,285 @@
//! Untrusted files never crash the app: garbage, truncated, mutated and hostile SVG and PDF input
//! must import 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 vectorcraft_doc::Document;
use vectorcraft_testkit::catch_quiet;
use vectorcraft_testkit::fixtures::rich_session;
/// 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",
]);
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(""))),
];
(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",
]);
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(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
#[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(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
#[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())?;
}
}
+2
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;
+2
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;