mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-27 03:06:57 -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;
|
||||
|
||||
Reference in New Issue
Block a user