mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 09:20:56 -06:00
Merge main into feat/plugin-call-commands
This commit is contained in:
@@ -914,3 +914,34 @@ fn read_path_for_a_named_importer_stops_at_the_limit() {
|
||||
assert!(e.to_string().contains("larger than") && e.to_string().contains("drawing.tuv"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #3084, #2096: Export for Screens writes JPGs a strict reader opens, in every colour model and at
|
||||
/// the qualities and scales the reports used. A four-component (CMYK) file has no JFIF segment,
|
||||
/// which describes only one- and three-component images (#3140); RGB and gray files keep it.
|
||||
#[test]
|
||||
fn export_for_screens_jpgs_open_in_every_colour_model() {
|
||||
let mut s = session(100.0, 80.0, 1);
|
||||
s.execute("paint.setFill", &json!({"color": "#cc3300"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 40})).unwrap();
|
||||
for (model, components) in [("rgb", 3), ("cmyk", 4), ("gray", 1)] {
|
||||
for quality in [100, 80, 50] {
|
||||
let r = s
|
||||
.execute(
|
||||
"document.exportForScreens",
|
||||
&json!({"formats": [{"format": "jpg"}, {"format": "jpg", "scale": 2}], "settings": {"jpg": {"colorModel": model, "quality": quality}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2, "{model} {quality}: {r}");
|
||||
for (f, width) in files.iter().zip([100, 200]) {
|
||||
let bytes = b64(f);
|
||||
let what = format!("{model} q{quality} {}", f["name"]);
|
||||
assert_eq!(bytes.get(..2), Some(&[0xFF, 0xD8][..]), "{what}: starts with SOI");
|
||||
let jfif = bytes.get(2..4) == Some(&[0xFF, 0xE0][..]);
|
||||
assert_eq!(jfif, components != 4, "{what}: JFIF only on one- and three-component files");
|
||||
let image = image::load_from_memory(&bytes).unwrap_or_else(|e| panic!("{what}: {e}"));
|
||||
assert_eq!(image.width(), width, "{what}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
|
||||
@@ -157,6 +157,7 @@ pub struct BuiltinSymbolLibrary {
|
||||
pub const BUILTIN: &[BuiltinSymbolLibrary] = &[
|
||||
BuiltinSymbolLibrary { id: "basic-shapes", name: "Basic Shapes", make: basic_shapes },
|
||||
BuiltinSymbolLibrary { id: "ui-frames", name: "UI Frames", make: ui_frames },
|
||||
BuiltinSymbolLibrary { id: "map-markers", name: "Map Markers", make: map_markers },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -221,6 +222,33 @@ fn ui_frames() -> SymbolLibrary {
|
||||
SymbolLibrary { name: "UI Frames".into(), doc }
|
||||
}
|
||||
|
||||
/// Map Markers: pin, flag, crosshair and checkpoint for map/HUD art (original, no 9-slice).
|
||||
fn map_markers() -> SymbolLibrary {
|
||||
let c = Point::ZERO;
|
||||
let pin = [(0.0, 22.0), (-9.0, 4.0), (-12.0, -6.0), (-8.0, -16.0), (0.0, -20.0), (8.0, -16.0), (12.0, -6.0), (9.0, 4.0)];
|
||||
let flag = [(-16.0, 22.0), (-12.0, 22.0), (-12.0, -8.0), (18.0, -14.0), (-12.0, -20.0), (-12.0, -22.0), (-16.0, -22.0)];
|
||||
let pin: Vec<Point> = pin.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let flag: Vec<Point> = flag.iter().map(|&(x, y)| Point::new(x, y)).collect();
|
||||
let markers = [
|
||||
("Pin", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&pin, true)), "#e74c3c"),
|
||||
("Flag", vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::polyline(&flag, true)), "#3498db"),
|
||||
("Crosshair", shapes::star(c, 20.0, 5.0, 4, 0.0), "#2c3e50"),
|
||||
("Checkpoint", shapes::polygon(c, 18.0, 4, 0.0), "#27ae60"),
|
||||
];
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (name, path, hex) in markers {
|
||||
let fill = Color::from_hex(hex).unwrap_or(Color::BLACK);
|
||||
let edge = fill.lerp(&Color::BLACK, 0.35);
|
||||
let id = doc.alloc_id();
|
||||
let art = Node::path(id, path, Appearance::basic(Paint::solid(fill), Paint::solid(edge), 1.25));
|
||||
let Ok((art, size, _)) = normalize(art) else { continue };
|
||||
doc.symbols.push(Symbol { dynamic: false, name: name.into(), art: Arc::new(art) });
|
||||
set_natural_size(&mut doc, name, Some(size));
|
||||
}
|
||||
doc.title = "Map Markers".into();
|
||||
SymbolLibrary { name: "Map Markers".into(), doc }
|
||||
}
|
||||
|
||||
/// An instance of symbol `name` (what the resource gathering follows).
|
||||
fn instance(name: &str) -> Node {
|
||||
Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::IDENTITY })
|
||||
|
||||
@@ -115,6 +115,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
reshape_path
|
||||
),
|
||||
cmd!(
|
||||
"text.setPathHandle",
|
||||
"Move Type Path Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)",
|
||||
has_doc,
|
||||
set_path_handle
|
||||
),
|
||||
cmd!(
|
||||
"text.fitHeadline",
|
||||
"Fit Headline",
|
||||
@@ -1268,6 +1277,35 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setPathHandle";
|
||||
let id = id_param(p, "id").ok_or_else(|| bad(C, "missing id"))?;
|
||||
let si = super::draw2::usize_opt(p, "subpath", C)?.unwrap_or(0);
|
||||
let ai = p.get("anchor").and_then(Value::as_u64).map(index).ok_or_else(|| bad(C, "missing anchor"))?;
|
||||
let out = str_param(p, "which") != Some("in");
|
||||
let pos = vectorcraft_geom::Point::new(f64_req(p, "x", C)?, f64_req(p, "y", C)?);
|
||||
let independent = switch_or(p, "independent", false, C)?;
|
||||
let node = s.doc()?.doc.node(id);
|
||||
let on_path = matches!(node.map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, vectorcraft_doc::TextKind::OnPath { .. }));
|
||||
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not type on a path outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Path", |doc, _| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| {
|
||||
t.type_path().and_then(|p| p.bounds()).is_some_and(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.abs() <= crate::MAX_COORD))
|
||||
};
|
||||
if !(t.set_path_handle((si, ai), out, pos, independent) && within(t)) {
|
||||
t.kind = before;
|
||||
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod area_tests;
|
||||
|
||||
|
||||
@@ -2081,3 +2081,35 @@ fn fit_min_percent_for_several_frames_keeps_each_frames_fit() {
|
||||
assert_eq!(fit(&s, plain), vectorcraft_doc::AreaFit::None, "the plain frame doesn't start shrinking");
|
||||
assert_eq!(fit(&s, shrink), vectorcraft_doc::AreaFit::ShrinkText { min_percent: 30.0 });
|
||||
}
|
||||
|
||||
/// #3565: `text.setPathHandle` moves a direction handle of type on a path's path, in document
|
||||
/// space, as `path.setHandle` moves a path's: a smooth anchor's other handle turns with it at
|
||||
/// its length, `independent` leaves it and makes the anchor a corner; one undo step.
|
||||
#[test]
|
||||
fn set_path_handle_reshapes_type_on_a_path() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let path =
|
||||
s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let text = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap();
|
||||
let id = NodeId(text["id"].as_u64().unwrap());
|
||||
let first = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => *t.type_path().unwrap().subpaths[0].anchors.first().unwrap(),
|
||||
_ => panic!("not text"),
|
||||
};
|
||||
let smooth = first(&s).kind == vectorcraft_geom::AnchorKind::Smooth;
|
||||
s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "out", "x": 100, "y": 250})).unwrap();
|
||||
let a = first(&s);
|
||||
assert!((a.h_out.x - 100.0).abs() < 1e-6 && (a.h_out.y - 250.0).abs() < 1e-6, "{a:?}");
|
||||
if smooth {
|
||||
assert!((a.h_in.x - 100.0).abs() < 1e-6 && (a.h_in.y - 350.0).abs() < 1e-6, "smooth: the in handle turns with it at its length: {a:?}");
|
||||
}
|
||||
s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 0, "which": "in", "x": 60, "y": 300, "independent": true})).unwrap();
|
||||
let b = first(&s);
|
||||
assert_eq!(b.h_out, a.h_out, "independent: the other handle stays");
|
||||
assert_eq!(b.kind, vectorcraft_geom::AnchorKind::Corner);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(first(&s), a, "one undo step each");
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor");
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user