mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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:
committed by
bflatastic
co-authored by
Claude Opus 5.5
parent
0f241aac9f
commit
3e323bfa2a
@@ -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])?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¢er(b).x)),
|
||||
BlendOrder::Vertical => ids.sort_by(|a, b| center(a).y.total_cmp(¢er(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();
|
||||
|
||||
@@ -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, .. })
|
||||
|
||||
@@ -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![(®ion, 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 {
|
||||
|
||||
@@ -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))),
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut changed = false;
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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())?;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user