mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
Illustrator's Every-line Composer balances the breaks of the whole
paragraph for left/centre/right aligned text too, not only justified
text. VectorCraft only composed justified area text; ragged text was
always broken greedily.
- Model: ParaStyle.composer (vectorcraft_doc::Composer, re-exported by
vectorcraft-text), Every-line by default like Illustrator, saved only
when Single-line. layout() reads it from the object;
LayoutOptions.composer is now an Option override for tests.
- Composer: the justified gate is gone. Ragged lines never shrink (a
line that doesn't fit ends the node), badness is 100*r^3 with r = the
line's leftover space over a rag zone of 1/6 of the width (finite for
lines without spaces); the last line is free unless shorter than 1/3
of the width (measured in zones of 1/3), so a lone short word on the
last line pulls a word down. Hyphens cost 25 in ragged text (50 in
justified). Knuth-Plass fitness classes with adjacency demerits
(justified 10000, ragged 1000). Lines past the last width change
share one node key, active nodes are capped (4096) and paragraphs
over 200k glyphs break single-line, so work stays bounded; ragged
tries a tolerance of one rag zone, then any fitting breaks, then
falls back to greedy.
- text.setFormat {composer: "singleLine"|"everyLine"}; the Paragraph
panel menu's composer items work with a check mark (Reset Panel goes
back to Every-line).
- Tests: ragged Every-line moves "or" down in "Destroy all creatures
with toughness X or less." where greedy leaves "less." alone; the rag
of long paragraphs is no worse than greedy (sum of squared leftover
and of neighbour differences); Single-line is greedy; the 10k-char
layout budget covers ragged text; command test with undo; native
format round trip (rich_session's text is now Single-line).
No existing test needed changing: the justified tests and
hyphenated_layout_adds_hyphens (ragged, now Every-line) still pass.
Auto alignment (start of the paragraph direction) is ragged; a wrapped
line's Mojikumi flush opening bracket gives its half em back to the
composer, as in the line loop; tests cover Auto in both directions.
Layout of 10k chars, release: justified Every-line 5.6 ms, ragged
Every-line 5.8 ms, ragged Single-line 4.8 ms.
280 lines
13 KiB
Rust
280 lines
13 KiB
Rust
//! Panel-support commands (`cmd/{panelcmds,gradient,appearance,stroke,swatch,style}.rs`).
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_color::{GradientKind, Paint};
|
|
use vectorcraft_doc::{AppearanceItem, NodeKind};
|
|
|
|
use super::*;
|
|
|
|
fn session() -> Session {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
|
s
|
|
}
|
|
|
|
fn rect(s: &mut Session) -> NodeId {
|
|
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap();
|
|
NodeId(r["id"].as_u64().unwrap())
|
|
}
|
|
|
|
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
|
s.doc().unwrap().doc.node(id).unwrap().clone()
|
|
}
|
|
|
|
#[test]
|
|
fn edit_gradient_converts_and_keeps_stops() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute(
|
|
"paint.editGradient",
|
|
&json!({"stroke": false, "kind": "radial", "stops": [
|
|
{"offset": 1.0, "color": "#0000ff"},
|
|
{"offset": 0.0, "color": "#ff0000", "opacity": 50, "midpoint": 0.3},
|
|
{"offset": 0.5, "color": "#00ff00"}]}),
|
|
)
|
|
.unwrap();
|
|
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!("not a gradient") };
|
|
assert_eq!(g.gradient.kind, GradientKind::Radial);
|
|
assert_eq!(g.gradient.stops.len(), 3);
|
|
assert_eq!(g.gradient.stops[0].color.to_hex(), "#ff0000");
|
|
assert!((g.gradient.stops[0].opacity - 0.5).abs() < 1e-6);
|
|
assert!((g.gradient.stops[0].midpoint - 0.3).abs() < 1e-6);
|
|
// One undo step.
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert!(node(&s, id).appearance.fill_paint().color().is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn edit_gradient_angle_reverse_and_errors() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute("paint.editGradient", &json!({"stroke": false, "angle": 450})).unwrap();
|
|
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
|
assert!((g.angle - 90.0).abs() < 1e-9);
|
|
s.execute("paint.editGradient", &json!({"stroke": false, "reverse": true})).unwrap();
|
|
let Paint::Gradient(g) = node(&s, id).appearance.fill_paint() else { panic!() };
|
|
assert_eq!(g.gradient.stops[0].color.to_hex(), "#000000");
|
|
assert!(s.execute("paint.editGradient", &json!({"stops": [{"offset": 0, "color": "#fff"}]})).is_err());
|
|
assert!(s.execute("paint.editGradient", &json!({"kind": "conic"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn appearance_duplicate_and_move() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
let n0 = node(&s, id).appearance.items.len();
|
|
s.execute("appearance.duplicateItem", &json!({"index": 0})).unwrap();
|
|
assert_eq!(node(&s, id).appearance.items.len(), n0 + 1);
|
|
let last = node(&s, id).appearance.items.len() - 1;
|
|
s.execute("appearance.moveItem", &json!({"from": last, "to": 0})).unwrap();
|
|
assert!(matches!(node(&s, id).appearance.items[0], AppearanceItem::Stroke(_)));
|
|
assert!(s.execute("appearance.duplicateItem", &json!({"index": 99})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn artboards_reorder_and_duplicate() {
|
|
let mut s = session();
|
|
s.execute("artboard.new", &json!({"name": "Second"})).unwrap();
|
|
let r = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
|
|
assert_eq!(r["index"], 2);
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!(d.artboards.len(), 3);
|
|
assert!(d.artboards[2].rect.x0 >= d.artboards[1].rect.x1);
|
|
s.execute("artboard.reorder", &json!({"index": 1, "to": 0})).unwrap();
|
|
assert_eq!(s.doc().unwrap().doc.artboards[0].name, "Second");
|
|
}
|
|
|
|
/// The artboards' left edges and names.
|
|
fn boards(s: &Session) -> Vec<(f64, String)> {
|
|
s.doc().unwrap().doc.artboards.iter().map(|a| (a.rect.x0, a.name.clone())).collect()
|
|
}
|
|
|
|
/// The left edge of object `id`.
|
|
fn left(s: &Session, id: u64) -> f64 {
|
|
s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap().x0
|
|
}
|
|
|
|
fn ids(v: &serde_json::Value) -> Vec<u64> {
|
|
v.as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
|
}
|
|
|
|
/// Duplicate Artboards (#446) copies the art on the artboard too, as the Artboard tool's Move/Copy
|
|
/// Artwork with Artboard says, in one undo step.
|
|
#[test]
|
|
fn duplicate_artboard_copies_its_art() {
|
|
let mut s = session();
|
|
let r = rect(&mut s);
|
|
let out = s.execute("artboard.duplicate", &json!({"index": 0})).unwrap();
|
|
assert_eq!(out["index"], 1);
|
|
let copies = ids(&out["ids"]);
|
|
assert_eq!(copies.len(), 1);
|
|
assert_ne!(copies[0], r.0);
|
|
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (820.0, "Artboard 1 copy".into())]);
|
|
assert_eq!(left(&s, copies[0]), 830.0, "the copy sits on the new artboard as the original on its own");
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!((d.artboards.len(), d.layers[0].children().unwrap().len()), (1, 1), "one undo step");
|
|
// Off (the tool's option or the param): the artboard alone.
|
|
s.set_tool_options(Some("artboard"), json!({"moveArt": false}).as_object().unwrap());
|
|
assert_eq!(s.execute("artboard.duplicate", &json!({})).unwrap()["ids"], json!([]));
|
|
assert_eq!(ids(&s.execute("artboard.duplicate", &json!({"index": 0, "art": true})).unwrap()["ids"]).len(), 1);
|
|
}
|
|
|
|
/// With the Artboard tool, Copy and Paste copy its artboard with the art on it (#446): Paste places
|
|
/// it right of the last artboard, Paste in Place where it was, also in another document; each paste
|
|
/// is one undo step, and the tool takes the pasted artboard.
|
|
#[test]
|
|
fn artboard_tool_copy_and_paste_artboards_with_their_art() {
|
|
let mut s = session();
|
|
let r = rect(&mut s);
|
|
s.execute("artboard.new", &json!({"x": 0, "y": 700, "width": 100, "height": 100, "name": "Small"})).unwrap();
|
|
s.execute("select.none", &json!({})).unwrap();
|
|
// Nothing selected: Copy is off, until the Artboard tool is chosen.
|
|
let copy_enabled = |s: &Session| (crate::find_command("edit.copy").unwrap().enabled)(s).is_ok();
|
|
assert!(!copy_enabled(&s));
|
|
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
|
assert!(copy_enabled(&s));
|
|
assert_eq!(s.execute("edit.copy", &json!({})).unwrap()["copied"], 1, "artboard 1 and its rectangle");
|
|
let out = s.execute("edit.paste", &json!({"center": [5, 5]})).unwrap();
|
|
assert_eq!(out["artboard"], 2);
|
|
let pasted = ids(&out["ids"]);
|
|
assert_eq!(left(&s, pasted[0]), 830.0, "beside the artboards, not at the view centre");
|
|
assert_eq!(boards(&s)[2], (820.0, "Artboard 1 copy".into()));
|
|
assert_eq!(s.tool_options()["active"], 2);
|
|
let st = s.doc().unwrap();
|
|
assert_eq!(st.selection.in_paint_order(&st.doc), vec![NodeId(pasted[0])]);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
assert_eq!(boards(&s).len(), 2);
|
|
assert!(s.doc().unwrap().doc.node(NodeId(pasted[0])).is_none(), "one undo step");
|
|
// In place: on top of the original.
|
|
let out = s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
|
assert_eq!(left(&s, ids(&out["ids"])[0]), 10.0);
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!(d.artboards[2].rect, d.artboards[0].rect);
|
|
// Into another document.
|
|
s.execute("file.new", &json!({"width": 300, "height": 300})).unwrap();
|
|
let out = s.execute("edit.paste", &json!({})).unwrap();
|
|
assert_eq!(boards(&s), vec![(0.0, "Artboard 1".into()), (320.0, "Artboard 1 copy".into())]);
|
|
let n = s.doc().unwrap().doc.node(NodeId(ids(&out["ids"])[0])).unwrap().geometric_bounds().unwrap();
|
|
assert_eq!((n.x0, n.width()), (330.0, 100.0));
|
|
// Paste on All Artboards pastes the art alone.
|
|
let out = s.execute("edit.pasteOnAllArtboards", &json!({})).unwrap();
|
|
assert_eq!((out["artboard"].clone(), ids(&out["ids"]).len(), boards(&s).len()), (json!(null), 2, 2));
|
|
// The clipboard still holds the artboard for the next document, and the original is untouched.
|
|
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
|
assert!(s.doc().unwrap().doc.node(r).is_some());
|
|
}
|
|
|
|
/// With the Artboard tool, Cut takes its artboard and the art on it away in one undo step (#446),
|
|
/// never the only artboard; pasting it back keeps its name.
|
|
#[test]
|
|
fn artboard_tool_cut_removes_the_artboard_and_its_art() {
|
|
let mut s = session();
|
|
let r = rect(&mut s);
|
|
let outside = s.execute("shape.rectangle", &json!({"x": 2000, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
|
s.select_tool("artboard", ViewInfo::default()).unwrap();
|
|
assert!(s.execute("edit.cut", &json!({})).is_err(), "the only artboard");
|
|
s.execute("artboard.new", &json!({"x": 1000, "y": 0, "width": 100, "height": 100})).unwrap();
|
|
s.set_tool_option("active", &json!(0));
|
|
s.execute("edit.cut", &json!({})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!((d.artboards.len(), d.node(r).is_none(), d.node(NodeId(outside)).is_some()), (1, true, true));
|
|
s.execute("edit.pasteInPlace", &json!({})).unwrap();
|
|
assert_eq!(boards(&s), vec![(1000.0, "Artboard 2".into()), (0.0, "Artboard 1".into())]);
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert_eq!((d.artboards.len(), d.node(r).is_some()), (2, true), "the cut is one undo step");
|
|
}
|
|
|
|
#[test]
|
|
fn swatch_groups_duplicate_sort() {
|
|
let mut s = session();
|
|
s.execute("swatch.new", &json!({"name": "Zed", "color": "#123456"})).unwrap();
|
|
s.execute("swatch.new", &json!({"name": "Alpha", "color": "#654321"})).unwrap();
|
|
let r = s.execute("swatch.newGroup", &json!({"name": "Mine", "swatches": ["Zed"], "colors": ["#ff0000"]})).unwrap();
|
|
assert_eq!(r["name"], "Mine");
|
|
let d = &s.doc().unwrap().doc;
|
|
let g = d.swatch_groups.iter().find(|g| g.name == "Mine").unwrap();
|
|
assert_eq!(g.swatches.len(), 2);
|
|
assert!(!d.swatches.iter().any(|x| x.name == "Zed"));
|
|
let r = s.execute("swatch.duplicate", &json!({"name": "Alpha"})).unwrap();
|
|
assert_eq!(r["name"], "Alpha copy");
|
|
s.execute("swatch.sortByName", &json!({})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert!(d.swatches[0].name.starts_with('['));
|
|
let names: Vec<_> = d.swatches.iter().skip(1).map(|x| x.name.to_lowercase()).collect();
|
|
let mut sorted = names.clone();
|
|
sorted.sort();
|
|
assert_eq!(names, sorted);
|
|
}
|
|
|
|
#[test]
|
|
fn graphic_style_delete_duplicate() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute("graphicStyle.new", &json!({"name": "S1"})).unwrap();
|
|
assert_eq!(s.execute("graphicStyle.duplicate", &json!({"name": "S1"})).unwrap()["name"], "S1 copy");
|
|
s.execute("graphicStyle.delete", &json!({"name": "S1"})).unwrap();
|
|
let d = &s.doc().unwrap().doc;
|
|
assert!(d.graphic_styles.iter().all(|g| g.name != "S1"));
|
|
assert!(s.execute("graphicStyle.delete", &json!({"name": "nope"})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn text_set_format() {
|
|
let mut s = session();
|
|
let r = s.execute("text.create", &json!({"x": 10, "y": 100, "text": "Hello"})).unwrap();
|
|
let id = NodeId(r["id"].as_u64().unwrap());
|
|
s.execute(
|
|
"text.setFormat",
|
|
&json!({"ids": [id.0], "kerning": 20, "baselineShift": 3, "hScale": 120, "rotation": 370, "underline": true, "spaceBefore": 6, "hyphenate": true}),
|
|
)
|
|
.unwrap();
|
|
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
|
let st = t.first_style();
|
|
assert_eq!(st.kerning, Some(20.0));
|
|
assert_eq!(st.baseline_shift, 3.0);
|
|
assert_eq!(st.h_scale, 120.0);
|
|
assert!((st.rotation - 10.0).abs() < 1e-9);
|
|
assert!(st.underline);
|
|
assert_eq!(t.para.space_before, 6.0);
|
|
assert!(t.para.hyphenate);
|
|
s.execute("text.setFormat", &json!({"ids": [id.0], "kerning": "auto"})).unwrap();
|
|
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
|
assert_eq!(t.first_style().kerning, None);
|
|
assert!(s.execute("text.setFormat", &json!({"ids": [id.0]})).is_err());
|
|
// Paragraph composer: Every-line by default, undoable.
|
|
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
|
|
s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "singleLine"})).unwrap();
|
|
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
|
assert_eq!(t.para.composer, vectorcraft_doc::Composer::SingleLine);
|
|
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": "best"})).is_err());
|
|
assert!(s.execute("text.setFormat", &json!({"ids": [id.0], "composer": 1})).is_err());
|
|
s.execute("edit.undo", &json!({})).unwrap();
|
|
let NodeKind::Text(t) = node(&s, id).kind else { panic!() };
|
|
assert_eq!(t.para.composer, vectorcraft_doc::Composer::EveryLine);
|
|
}
|
|
|
|
#[test]
|
|
fn stroke_advanced_scale_swap_flip() {
|
|
let mut s = session();
|
|
let id = rect(&mut s);
|
|
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
|
s.execute("stroke.set", &json!({"startArrow": "Triangle", "profile": "taperEnd"})).unwrap();
|
|
s.execute("stroke.setAdvanced", &json!({"arrowScale": [50, 200]})).unwrap();
|
|
s.execute("stroke.setAdvanced", &json!({"swapArrows": true, "flipProfile": "along"})).unwrap();
|
|
let n = node(&s, id);
|
|
let st = n.appearance.stroke().unwrap();
|
|
assert_eq!(st.start_arrow, None);
|
|
assert_eq!(st.end_arrow, Some(vectorcraft_doc::Arrowhead::Triangle));
|
|
assert_eq!(st.arrow_scale, (200.0, 50.0));
|
|
assert_eq!(st.profile.as_ref().unwrap().points, vectorcraft_doc::WidthProfile::taper_start().points);
|
|
assert!(s.execute("stroke.setAdvanced", &json!({})).is_err());
|
|
assert!(s.execute("stroke.setAdvanced", &json!({"flipProfile": "sideways"})).is_err());
|
|
}
|