Fix #1748: text.create takes justify, so point type can be centred on its point

text.create, text.createInPath and the MCP add_text tool now take a
`justify` (auto|left|center|right|justifyAll, as text.setStyle). Point
type already laid out centred or right-aligned text around its origin;
it just could not be made that way, so scripts measured the bounds and
moved the text. Changing it afterwards stays text.setStyle {justify}.
This commit is contained in:
Gert Pellin authored and bflatastic committed 2026-10-14 03:55:41 +07:00
1 parent d9328c7668
commit ed1e976264
6 files changed
+79 -8

No files matched your search

+7 -3
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create Text",
[],
None,
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height, fit?: none|autoHeight|shrinkText, fitMinPercent?}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on",
has_doc,
text_create
),
@@ -519,12 +519,16 @@ pub(crate) fn new_type_para() -> vectorcraft_doc::ParaStyle {
}
}
/// The leading model and character alignment new type starts with (`t`'s paragraph and runs):
/// `leadingModel` and `charAlign` from `p`, else em box top to top and em box centre while the
/// The paragraph alignment, leading model and character alignment new type starts with (`t`'s
/// paragraph and runs): `justify` from `p` (else as [`new_type_para`] left it), `leadingModel`
/// and `charAlign` from `p`, else em box top to top and em box centre while the
/// interface is in Japanese (#432), else the Roman baseline for both. Noted in the journal, so a
/// replay sets the same whatever the interface.
pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut TextObject) -> Result<()> {
use vectorcraft_doc::{CharAlign, LeadingModel};
if let Some(j) = str_param(p, "justify") {
t.para.justify = super::typecmd::justify_param(j).ok_or_else(|| bad(cmd, "justify must be auto|left|center|right|justifyAll"))?;
}
let japanese = s.japanese_interface();
let leading = match p.get("leadingModel").filter(|v| !v.is_null()) {
Some(v) => match v.as_str() {
+1 -1
View File
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Area / Path Type",
[],
None,
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}",
"{path: id, mode: \"area\"|\"onPath\", text?: \"\", vertical?: bool = false, at?: [x, y] (on-path start: nearest point), size?, font?, style?, color? (as text.create), fit?: none|autoHeight|shrinkText, fitMinPercent? (area: as text.areaOptions; default autoHeight when the autoSizeAreaType preference is on), placeholder?: bool (placeholder text instead, as text.create), justify?, leadingModel?, charAlign? (as text.create)} turn a path into an area-type frame or a type-on-a-path baseline (the path's paint is dropped) → {id, overflow?: [id] (the text doesn't fit; left out when it fits)}",
has_doc,
create_in_path
),
+46 -1
View File
@@ -392,7 +392,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
}
fn justify_param(v: &str) -> Option<Justify> {
pub(crate) fn justify_param(v: &str) -> Option<Justify> {
Some(match v.to_ascii_lowercase().as_str() {
"auto" => Justify::Auto,
"left" => Justify::Left,
@@ -716,6 +716,51 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
mod area_tests {
use super::*;
/// #1748: `text.create` takes `justify`; point type then lines up on its point (centred on
/// it, or ending there), and `text.setStyle` moves it the same way afterwards.
#[test]
fn text_create_justify_anchors_point_type_on_its_point() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
let bounds = |s: &Session, id: &Value| {
let id = NodeId(id.as_u64().unwrap());
let st = s.doc().unwrap();
st.doc.bounds_of(&[id], false).unwrap()
};
let make = |s: &mut Session, extra: Value| {
let mut p = json!({"x": 300, "y": 100, "text": "Centred title", "size": 24});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
s.execute("text.create", &p).unwrap()["id"].clone()
};
let id = make(&mut s, json!({}));
let left = bounds(&s, &id);
assert!(left.width() > 10.0, "{left:?}");
assert!((left.x0 - 300.0).abs() < 1.0, "left (the default) starts at x: {left:?}");
let id = make(&mut s, json!({"justify": "center"}));
let centre = bounds(&s, &id);
assert!((centre.center().x - 300.0).abs() < 0.5, "centred on x: {centre:?}");
assert!((centre.width() - left.width()).abs() < 0.5);
let right = make(&mut s, json!({"justify": "right"}));
let r = bounds(&s, &right);
assert!((r.x1 - 300.0).abs() < 1.0, "ends at x: {r:?}");
let got = |s: &Session, id: &Value| match &s.doc().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind {
NodeKind::Text(t) => t.para.justify,
_ => panic!("not text"),
};
assert_eq!(got(&s, &right), Justify::Right);
// Changed afterwards, it moves to the same place as made centred.
s.execute("text.setStyle", &json!({"id": right, "justify": "center"})).unwrap();
let moved = bounds(&s, &right);
assert!((moved.center().x - 300.0).abs() < 0.5, "{moved:?}");
assert!(s.execute("text.create", &json!({"x": 0, "y": 0, "text": "a", "justify": "middle"})).is_err());
// Type in or on a path takes it too.
let path = s.execute("path.create", &json!({"d": "M50 300 L550 300"})).unwrap()["id"].clone();
let on = s.execute("text.createInPath", &json!({"path": path, "mode": "onPath", "text": "On a path", "justify": "center"})).unwrap();
assert_eq!(got(&s, &on["id"]), Justify::Center);
}
#[test]
fn kinsoku_set_is_set_per_paragraph_and_saved_only_when_not_hard() {
use vectorcraft_doc::Kinsoku;
+21
View File
@@ -328,6 +328,27 @@ fn add_text_on_or_in_a_path_takes_its_color() {
}
}
/// #1748: add_text's `justify` centres point type on (x, y) or ends it there.
#[test]
fn add_text_justify_anchors_point_type_on_x() {
let mut s = server();
let mut ids = Vec::new();
for (n, justify) in [(2, "left"), (3, "center"), (4, "right")] {
let r = call(&mut s, n, "add_text", json!({"text": "Title", "x": 300, "y": 100, "size": 24, "justify": justify}));
assert_eq!(r["isError"], false, "{r}");
ids.push(serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone());
}
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, 5, "inspect_document", json!({})))).unwrap();
let b = |id: &Value| {
let t = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| &c["id"] == id).unwrap();
(t["bounds"]["x"].as_f64().unwrap(), t["bounds"]["width"].as_f64().unwrap())
};
let ((l, lw), (c, cw), (r, rw)) = (b(&ids[0]), b(&ids[1]), b(&ids[2]));
assert!(lw > 10.0 && (l - 300.0).abs() < 1.0, "{l} {lw}");
assert!((c + cw / 2.0 - 300.0).abs() < 0.5, "{c} {cw}");
assert!((r + rw - 300.0).abs() < 1.0, "{r} {rw}");
}
/// save_file says it saves in the document's own format or the one the path's extension picks.
#[test]
fn save_file_describes_the_formats_it_writes() {
+3 -2
View File
@@ -355,7 +355,7 @@ pub fn tool_definitions() -> Vec<Value> {
obj(
json!({
"text": {"type": "string"},
"x": num("Baseline origin x (point/area type: left)"),
"x": num("Baseline origin x (area type: left; point type: where the line starts, or its centre or end with justify center/right)"),
"y": num("First baseline y (area type: top)"),
"width": num("Area type frame width"),
"height": num("Area type frame height"),
@@ -367,6 +367,7 @@ pub fn tool_definitions() -> Vec<Value> {
"size": num("Font size in pt"),
"font": {"type": "string", "description": "Font family"},
"color": paint_schema("Text colour (#rrggbb)"),
"justify": {"type": "string", "enum": ["auto", "left", "center", "right", "justifyAll"], "description": "Paragraph alignment (default auto: the start of the text's direction). Point type lines up on x: center centres each line on it, right ends it there"},
}),
&["text"],
),
@@ -579,7 +580,7 @@ fn select_ids(b: &mut dyn Backend, a: &Args) -> Result<(), String> {
fn add_text(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
let text = a.get("text").and_then(Value::as_str).ok_or("missing string argument `text`")?;
let mut style = json!({});
for k in ["size", "font", "color"] {
for k in ["size", "font", "color", "justify"] {
if let Some(v) = a.get(k) {
style[k] = v.clone();
}
+1 -1
View File
@@ -288,7 +288,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.vctemplate`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, `.eps`, `.dxf`, `.emf`, `.wmf`, PNG, JPEG, GIF, WebP, TIFF, BMP, PSD/PSB (an image opens as a document of its pixel size; a Photoshop file as its merged image). Templates (`.vctemplate`, `.ait`) open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. The reply is `document.open`'s: its `warnings` say what didn't come in as it was (an EPS whose PostScript can't be read opens as its preview, and the warning names the PostScript error, the operator and the procedure). `run_command document.formats` lists the formats. |
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `svgz`, `pdf`, `eps`, `dxf`, `emf`, `wmf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text), `vectorcraft` or `template` (a native template). The tool's `format` enum and `document.formats` list them, generated from the engine's format table. When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds (the reply adds their `bounds`, and reports `format` and the encoder's `warnings` as a whole-document export does); `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `add_text` | `{text, x?, y?, width?, height?, fit?, fitMinPercent?, path?, mode?, pathEffect?, size?, font?, color?, justify?}` | Point type at (x, y) (`justify`: `left`, `center` or `right` starts, centres or ends each line on x); area type with `width`/`height` (`fit`: `none`, `autoHeight`, `shrinkText`, as `text.areaOptions`); or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`); `justify` (also `auto`, `justifyAll`) aligns the paragraphs of every kind, as `text.setStyle` does later. Returns `{id}`, with `overflow: true` when the text doesn't fit its frame or path. |
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |