G4: System Layout spaces every text object in whole points

Squashed from #3412 onto main.
This commit is contained in:
bflatastic
2026-10-22 00:54:43 +07:00
parent 675b759a37
commit adbe0566a6
15 changed files with 199 additions and 19 deletions
@@ -441,6 +441,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(C, "the text is empty"));
}
let mut t = TextObject::point(Point::ZERO, text, super::super::create::new_type_style(s, &Value::Null));
t.system_layout = s.doc()?.doc.setup.system_layout;
crate::cmd::typecmd::refresh_bounds(&mut t);
let n = Node::new(NodeId(1), NodeKind::Text(Box::new(t)));
let count = s.load_clipboard(Clipboard { nodes: vec![n], ..Default::default() }, p)?;
+1
View File
@@ -506,6 +506,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
t.area.fit = new_area_fit(s, a, "text.create")?;
}
t.system_layout = s.doc()?.doc.setup.system_layout;
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
+1
View File
@@ -353,6 +353,7 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
};
let mut text = TextObject::point(Point::ZERO, &label, style);
text.para.justify = vectorcraft_doc::Justify::Center;
text.system_layout = doc.setup.system_layout;
super::typecmd::refresh_bounds(&mut text);
// Centre the label's box on its anchor point, along the line.
let bounds = text.cached_bounds.unwrap_or_else(|| text.estimate_bounds());
+1
View File
@@ -373,6 +373,7 @@ impl Gen<'_> {
let style = label_style(self.styles, role);
let mut t = TextObject::point(at, s, style);
t.para.justify = justify;
t.system_layout = self.d.setup.system_layout;
refresh_bounds(&mut t);
Arc::new(Node::new(self.d.alloc_id(), NodeKind::Text(Box::new(t))))
}
+1
View File
@@ -228,6 +228,7 @@ pub(super) fn area_text(story: Story, frame: Rect, setup: &vectorcraft_doc::DocS
t.set_paragraph_styles(story.paras);
}
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::from_origin_size(Point::ORIGIN, frame.size())) };
t.system_layout = setup.system_layout;
crate::cmd::typecmd::refresh_bounds(&mut t);
t
}
+4 -1
View File
@@ -862,6 +862,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: false,
fractional_widths: true,
cached_bounds: None,
cached_baselines: Vec::new(),
@@ -870,6 +871,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
if !on_path {
t.area.fit = super::create::new_area_fit(s, p, C)?;
}
t.system_layout = s.doc()?.doc.setup.system_layout;
if bool_or(p, "placeholder", false) {
super::typemenu::fill_with_placeholder(&mut t);
} else {
@@ -1085,7 +1087,8 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
fractional_widths: true,
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
cached_bounds: None,
cached_baselines: Vec::new(),
};
+9 -5
View File
@@ -205,7 +205,7 @@ pub(crate) fn with_threads(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
}
/// A closed path converted to an empty area-type frame with `style`.
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node> {
fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle, system_layout: bool) -> Option<Node> {
let NodeKind::Path { path, .. } = &n.kind else { return None };
if !path.subpaths.first().is_some_and(|s| s.closed) {
return None;
@@ -213,6 +213,7 @@ fn frame_from_path(n: &Node, style: vectorcraft_doc::CharStyle) -> Option<Node>
let mut t = TextObject::point(vectorcraft_geom::Point::ZERO, "", style);
t.kind = TextKind::Area { frame: path.clone() };
t.xf = vectorcraft_geom::Affine::IDENTITY;
t.system_layout = system_layout;
refresh_bounds(&mut t);
Some(Node::new(n.id, NodeKind::Text(Box::new(t))))
}
@@ -222,7 +223,8 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let d = &s.doc()?.doc;
let style = ids.iter().find_map(|i| text_of(d, *i)).map(|t| t.first_style()).unwrap_or_default();
let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone()).is_some()));
let system = d.setup.system_layout;
let ok = ids.iter().all(|i| text_of(d, *i).is_some() || d.node(*i).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some()));
if ids.len() < 2 || !ok || !ids.iter().any(|i| text_of(d, *i).is_some()) {
return Err(bad(C, "select area type and closed paths (at least one area type object)"));
}
@@ -231,7 +233,7 @@ fn create(s: &mut Session, _: &Value) -> Result<Value> {
for id in &ids {
if text_of(d, *id).is_none() {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
let f = frame_from_path(&n, style.clone()).ok_or_else(|| bad(C, "not a closed path"))?;
let f = frame_from_path(&n, style.clone(), d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?;
*d.node_mut(*id).ok_or(EngineError::NoNode(*id))? = f;
}
}
@@ -271,7 +273,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
(Some(v), None, None) => {
let to = checked_id(s, v, C)?;
let parent = d.parent_of(to).and_then(|i| d.node(i));
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone()).is_some());
let system = d.setup.system_layout;
let frame = text_of(d, to).is_some() || d.node(to).is_some_and(|n| frame_from_path(n, style.clone(), system).is_some());
if to == from || !frame || parent.is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) {
return Err(bad(C, "`to` must be a closed path, area type or type on a path other than `from`"));
}
@@ -316,7 +319,8 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
None => {
if text_of(d, to).is_none() {
let n = d.node(to).cloned().ok_or(EngineError::NoNode(to))?;
*d.node_mut(to).ok_or(EngineError::NoNode(to))? = frame_from_path(&n, style).ok_or_else(|| bad(C, "not a closed path"))?;
*d.node_mut(to).ok_or(EngineError::NoNode(to))? =
frame_from_path(&n, style, d.setup.system_layout).ok_or_else(|| bad(C, "not a closed path"))?;
}
to
}
+83
View File
@@ -52,6 +52,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
optical_margin_alignment
),
cmd!(
"type.systemLayout",
"System Layout",
[],
None,
"{on?: bool} turn System Layout on or off for the document (on omitted: the opposite of the document's setting) and every text object in it. On, each character that takes any space advances by a whole number of points, at least one, so small type does not run together; a character with no width stays at none. Off (the default, and what a document from before it reads as), spacing uses fractions of a point. New point, area and path type inherits the document's setting → {on}",
has_doc,
system_layout
),
cmd!(
"type.fractionalWidths",
"Fractional Widths",
@@ -155,6 +164,31 @@ fn optical_margin_alignment(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"on": on, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
}
/// Character panel menu › System Layout: set `on` (without it, the opposite of the document's
/// setting) on the document and every text object, in one undo step. Off is the default.
fn system_layout(s: &mut Session, p: &Value) -> Result<Value> {
let current = s.doc()?.doc.setup.system_layout;
let on = p.get("on").and_then(Value::as_bool).unwrap_or(!current);
s.edit("System Layout", |d, _| {
d.setup.system_layout = on;
let mut ids = vec![];
d.walk(|n| {
if matches!(n.kind, NodeKind::Text(_)) {
ids.push(n.id);
}
});
for id in ids {
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else {
continue;
};
t.system_layout = on;
refresh_bounds(t);
}
Ok(())
})?;
Ok(json!({ "on": on }))
}
/// Character panel menu › Fractional Widths: set `on` (without it, the opposite of the first
/// object's setting) on every selected text object and every frame of their threads, in one undo
/// step. On is the default.
@@ -1167,6 +1201,55 @@ mod area_tests {
assert!(!serde_json::from_value::<TextObject>(old).unwrap().optical_margin);
}
/// Character panel menu › System Layout: `type.systemLayout` sets the document and every text
/// object in one undo step. New type inherits it. Off by default and in a document from before
/// the flag, and saved only when on. On, each positive advance is a whole number of points.
#[test]
fn system_layout_sets_the_document_and_every_text_object() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 40, "size": 13, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setStyle", &json!({"id": id, "tracking": 50})).unwrap();
let other = s.execute("text.create", &json!({"x": 10, "y": 80, "text": "Yo"})).unwrap()["id"].as_u64().unwrap();
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let setup = |s: &Session| s.doc().unwrap().doc.setup.clone();
let advances = |s: &Session| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).glyphs.iter().map(|g| g.advance).collect::<Vec<_>>()
};
assert!(!setup(&s).system_layout);
assert!(!text(&s, id).system_layout && !text(&s, other).system_layout);
assert!(serde_json::to_value(setup(&s)).unwrap().get("systemLayout").is_none());
assert!(serde_json::to_value(text(&s, id)).unwrap().get("systemLayout").is_none());
let fractional = advances(&s);
assert!(fractional.iter().any(|a| (a - a.round()).abs() > 1e-6), "{fractional:?}");
assert_eq!(s.execute("type.systemLayout", &json!({})).unwrap()["on"], true);
assert!(setup(&s).system_layout);
assert!(text(&s, id).system_layout && text(&s, other).system_layout);
let whole = advances(&s);
assert!(whole.iter().all(|a| *a >= 1.0 && (a - a.round()).abs() < 1e-9), "{whole:?}");
assert!(fractional.iter().zip(&whole).any(|(a, b)| (a - b).abs() > 0.05), "{fractional:?} {whole:?}");
assert_eq!(serde_json::to_value(setup(&s)).unwrap()["systemLayout"], true);
assert_eq!(serde_json::to_value(text(&s, id)).unwrap()["systemLayout"], true);
// New type inherits the document's setting.
let made = s.execute("text.create", &json!({"x": 10, "y": 120, "text": "Z"})).unwrap()["id"].as_u64().unwrap();
assert!(text(&s, made).system_layout);
// A boolean given as text is ignored, so the setting toggles off.
assert_eq!(s.execute("type.systemLayout", &json!({"on": "no"})).unwrap()["on"], false);
assert!(!setup(&s).system_layout && !text(&s, id).system_layout && !text(&s, made).system_layout);
s.execute("edit.undo", &json!({})).unwrap();
assert!(setup(&s).system_layout && text(&s, id).system_layout);
let back = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
assert!(back.setup.system_layout);
let NodeKind::Text(t) = &back.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert!(t.system_layout);
let mut old = serde_json::to_value(setup(&s)).unwrap();
old.as_object_mut().unwrap().remove("systemLayout");
assert!(!serde_json::from_value::<vectorcraft_doc::DocSetup>(old).unwrap().system_layout);
}
/// Character panel menu › Fractional Widths: `type.fractionalWidths` sets or toggles the flag
/// on the selected type in one undo step. New type and a document from before the flag are on,
/// and the flag is saved only when off. Off, each positive advance is a whole number of points.