mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -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.
218 lines
8.3 KiB
Rust
218 lines
8.3 KiB
Rust
//! Sessions and documents in known states.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
|
||
use vectorcraft_engine::Session;
|
||
use vectorcraft_geom::{PathData, Rect, shapes};
|
||
|
||
/// Execute a command and panic with context on error.
|
||
#[track_caller]
|
||
pub fn exec(s: &mut Session, id: &str, params: Value) -> Value {
|
||
match s.execute(id, ¶ms) {
|
||
Ok(v) => v,
|
||
Err(e) => panic!("`{id}` {params} failed: {e}"),
|
||
}
|
||
}
|
||
|
||
/// The `id` field of a creation command's result.
|
||
#[track_caller]
|
||
pub fn id_of(v: &Value) -> NodeId {
|
||
NodeId(v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}")))
|
||
}
|
||
|
||
/// The created date (Unix seconds) of every fixture document: File Info dates come from the clock
|
||
/// otherwise, and documents made a second apart would not compare equal.
|
||
pub const CREATED: i64 = 1_767_225_600;
|
||
|
||
/// A session with one new `w`×`h` document, dated [`CREATED`].
|
||
pub fn session_with(w: f64, h: f64) -> Session {
|
||
let mut s = Session::new();
|
||
exec(&mut s, "file.new", json!({"width": w, "height": h, "created": CREATED}));
|
||
s
|
||
}
|
||
|
||
/// A session with one new 400×300 document.
|
||
pub fn session() -> Session {
|
||
session_with(400.0, 300.0)
|
||
}
|
||
|
||
pub fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
id_of(&exec(s, "shape.rectangle", json!({"x": x, "y": y, "width": w, "height": h})))
|
||
}
|
||
pub fn ellipse(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||
id_of(&exec(s, "shape.ellipse", json!({"x": x, "y": y, "width": w, "height": h})))
|
||
}
|
||
pub fn star(s: &mut Session, cx: f64, cy: f64, r1: f64, r2: f64) -> NodeId {
|
||
id_of(&exec(s, "shape.star", json!({"cx": cx, "cy": cy, "radius1": r1, "radius2": r2, "points": 5})))
|
||
}
|
||
pub fn select(s: &mut Session, ids: &[NodeId]) {
|
||
exec(s, "select.set", json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}));
|
||
}
|
||
|
||
/// The three standard fixture states for sweeping commands.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Fixture {
|
||
/// A new document with nothing in it.
|
||
Empty,
|
||
/// One rectangle, selected.
|
||
Single,
|
||
/// Several overlapping objects of different kinds (paths, live shapes, text, a group), three selected.
|
||
Multi,
|
||
}
|
||
|
||
impl Fixture {
|
||
pub const ALL: [Fixture; 3] = [Fixture::Empty, Fixture::Single, Fixture::Multi];
|
||
|
||
pub fn session(self) -> Session {
|
||
let mut s = session();
|
||
match self {
|
||
Fixture::Empty => {}
|
||
Fixture::Single => {
|
||
rect(&mut s, 50.0, 50.0, 100.0, 60.0);
|
||
}
|
||
Fixture::Multi => {
|
||
let a = rect(&mut s, 40.0, 40.0, 120.0, 80.0);
|
||
let b = ellipse(&mut s, 100.0, 60.0, 90.0, 90.0);
|
||
let c = star(&mut s, 200.0, 150.0, 50.0, 25.0);
|
||
let t = id_of(&exec(&mut s, "text.create", json!({"x": 20, "y": 250, "text": "Hi", "size": 24})));
|
||
let p = id_of(&exec(
|
||
&mut s,
|
||
"path.create",
|
||
json!({"anchors": [{"x": 250, "y": 30}, {"x": 330, "y": 60, "in": [300, 20], "out": [360, 100]}, {"x": 300, "y": 140}], "closed": false}),
|
||
));
|
||
let g1 = rect(&mut s, 300.0, 200.0, 40.0, 40.0);
|
||
let g2 = ellipse(&mut s, 320.0, 220.0, 40.0, 40.0);
|
||
select(&mut s, &[g1, g2]);
|
||
exec(&mut s, "object.group", json!({}));
|
||
let _ = (t, p);
|
||
select(&mut s, &[a, b, c]);
|
||
}
|
||
}
|
||
s
|
||
}
|
||
}
|
||
|
||
/// A document touching most node kinds and appearance features, built through commands (so its
|
||
/// history is non-trivial). Selection is left empty.
|
||
pub fn rich_session() -> Session {
|
||
let mut s = session_with(500.0, 400.0);
|
||
let a = rect(&mut s, 20.0, 20.0, 150.0, 100.0);
|
||
exec(
|
||
&mut s,
|
||
"paint.setFill",
|
||
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}], "angle": 30}}),
|
||
);
|
||
let b = ellipse(&mut s, 100.0, 60.0, 120.0, 120.0);
|
||
exec(&mut s, "transparency.set", json!({"opacity": 60, "blend": "Multiply"}));
|
||
exec(&mut s, "stroke.set", json!({"weight": 4, "dash": [6, 3], "cap": "round"}));
|
||
let c = star(&mut s, 300.0, 100.0, 60.0, 30.0);
|
||
exec(&mut s, "effect.apply", json!({"effect": "distort.roughen", "params": {"size": 3, "detail": 5, "seed": 7}}));
|
||
let l = id_of(&exec(&mut s, "shape.line", json!({"x1": 20, "y1": 300, "x2": 200, "y2": 350})));
|
||
exec(&mut s, "stroke.set", json!({"weight": 3, "endArrow": "Arrow"}));
|
||
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
|
||
// A non-default paragraph composer, so format round trips cover it.
|
||
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
|
||
exec(&mut s, "layer.new", json!({"name": "Top"}));
|
||
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
|
||
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
|
||
select(&mut s, &[d, e]);
|
||
exec(&mut s, "object.compoundPath.make", json!({}));
|
||
let clip = ellipse(&mut s, 30.0, 150.0, 80.0, 80.0);
|
||
let inner = rect(&mut s, 10.0, 170.0, 140.0, 30.0);
|
||
exec(&mut s, "paint.setFill", json!({"color": "#22aa44"}));
|
||
select(&mut s, &[inner, clip]);
|
||
exec(&mut s, "object.clippingMask.make", json!({}));
|
||
let _ = (a, b, c, l);
|
||
exec(&mut s, "select.none", json!({}));
|
||
s
|
||
}
|
||
|
||
/// Build documents directly (without the engine), e.g. for render tests.
|
||
pub struct DocBuilder {
|
||
pub doc: Document,
|
||
pub layer: NodeId,
|
||
}
|
||
|
||
impl DocBuilder {
|
||
pub fn new(w: f64, h: f64) -> Self {
|
||
let doc = Document::new(w, h);
|
||
let layer = doc.layers[0].id;
|
||
Self { doc, layer }
|
||
}
|
||
pub fn alloc(&mut self) -> NodeId {
|
||
self.doc.alloc_id()
|
||
}
|
||
/// A path node (not inserted).
|
||
pub fn path_node(&mut self, path: PathData, appearance: Appearance) -> Node {
|
||
let id = self.alloc();
|
||
Node::path(id, path, appearance)
|
||
}
|
||
/// A filled, unstroked rectangle node (not inserted).
|
||
pub fn rect_node(&mut self, r: Rect, fill: Color) -> Node {
|
||
self.path_node(shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0))
|
||
}
|
||
/// Append `node` at the top of the current layer.
|
||
pub fn add(&mut self, node: Node) -> NodeId {
|
||
let l = self.layer;
|
||
self.doc.insert(Some(l), usize::MAX, node).expect("insert")
|
||
}
|
||
/// Add a filled rectangle; `f` may tweak the node first.
|
||
pub fn rect(&mut self, r: Rect, fill: Color, f: impl FnOnce(&mut Node)) -> NodeId {
|
||
let mut n = self.rect_node(r, fill);
|
||
f(&mut n);
|
||
self.add(n)
|
||
}
|
||
/// Add any path with an appearance; `f` may tweak the node first.
|
||
pub fn path(&mut self, path: PathData, appearance: Appearance, f: impl FnOnce(&mut Node)) -> NodeId {
|
||
let mut n = self.path_node(path, appearance);
|
||
f(&mut n);
|
||
self.add(n)
|
||
}
|
||
/// Add a group of `children` (bottom first). With `clip`, the first child is the clipping path.
|
||
pub fn group(&mut self, mut children: Vec<Node>, clip: bool) -> NodeId {
|
||
if clip && let Some(NodeKind::Path { clipping, .. }) = children.first_mut().map(|n| &mut n.kind) {
|
||
*clipping = true;
|
||
}
|
||
let id = self.alloc();
|
||
let mut g = Node::group(id, children.into_iter().map(Arc::new).collect());
|
||
if let NodeKind::Group { clip: c, .. } = &mut g.kind {
|
||
*c = clip;
|
||
}
|
||
self.add(g)
|
||
}
|
||
/// Add a new layer and make it current.
|
||
pub fn layer(&mut self, name: &str) -> NodeId {
|
||
self.layer = self.doc.add_layer(Some(name));
|
||
self.layer
|
||
}
|
||
pub fn build(self) -> Document {
|
||
self.doc
|
||
}
|
||
}
|
||
|
||
/// All non-layer nodes of a document in paint order.
|
||
pub fn art_nodes(doc: &Document) -> Vec<&Node> {
|
||
let mut v = Vec::new();
|
||
doc.walk(|n| {
|
||
if !n.is_layer() {
|
||
v.push(n)
|
||
}
|
||
});
|
||
v
|
||
}
|
||
|
||
/// Ids of the top-level art (direct children of layers), bottom first.
|
||
pub fn top_level_ids(doc: &Document) -> Vec<NodeId> {
|
||
doc.layers.iter().flat_map(|l| l.children().into_iter().flatten().map(|n| n.id)).collect()
|
||
}
|
||
|
||
/// Every node id (layers included), paint order.
|
||
pub fn all_ids(doc: &Document) -> Vec<NodeId> {
|
||
let mut v = Vec::new();
|
||
doc.walk(|n| v.push(n.id));
|
||
v
|
||
}
|