Fix #1471: making and restyling type reports text that doesn't fit its frame

text.create, text.createInPath, text.setText and text.setStyle answer with
overflow: [id…] for the area type and type on a path whose text doesn't fit (left
out when all fits), and MCP add_text with overflow: true. add_text takes area
type's fit and fitMinPercent.
This commit is contained in:
bflatastic
2026-10-13 03:27:13 +07:00
parent 214312f883
commit 8b8fd1476b
7 changed files with 95 additions and 11 deletions
+4 -2
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}; 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), 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
),
@@ -502,7 +502,9 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
// Lays the text out (and sizes an Auto Size frame).
super::typecmd::refresh_bounds(&mut t);
}
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
let made = add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)?;
let id = made.get("id").and_then(Value::as_u64).map(NodeId);
Ok(super::typecmd::with_overflow(s, id.as_slice(), made))
}
/// The paragraph attributes new type gets (the Type tools, `text.create`): aligned to the start of
+2 -2
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}",
"{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)}",
has_doc,
create_in_path
),
@@ -568,7 +568,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
sel.set([id]);
Ok(id)
})?;
Ok(json!({"id": id.0}))
Ok(super::typecmd::with_overflow(s, &[id], json!({"id": id.0})))
}
/// What `type.step` changes.
+20 -4
View File
@@ -39,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Text",
[],
None,
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style)",
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_text
),
@@ -75,7 +75,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Character",
[],
None,
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text)",
"{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour, features?: [\"dlig\", \"-liga\", …] OpenType, start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
has_doc,
set_style
),
@@ -203,6 +203,22 @@ pub(crate) fn refresh_bounds_of(d: &mut Document, ids: &[NodeId]) {
}
}
/// `result` with `overflow: [id…]`: the area type and type on a path among `ids` whose text
/// doesn't fit (the red overflow "+"; its lines past the frame aren't drawn). Left out when all of
/// it fits. The commands that make type or change what it holds answer with it, so the call that
/// pushed text out of its frame says so itself.
pub(crate) fn with_overflow(s: &Session, ids: &[NodeId], mut result: Value) -> Value {
let Ok(st) = s.doc() else { return result };
let db = vectorcraft_text::FontDb::global();
let fits = |t: &TextObject| matches!(t.kind, vectorcraft_doc::TextKind::Point) || !vectorcraft_text::layout(db, t).overflow;
let over = |id: &&NodeId| matches!(st.doc.node(**id).map(|n| &n.kind), Some(NodeKind::Text(t)) if !fits(t));
let over: Vec<u64> = ids.iter().filter(over).map(|i| i.0).collect();
if !over.is_empty() {
result["overflow"] = json!(over);
}
result
}
/// Text objects among `ids` and their descendants.
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
let mut out = vec![];
@@ -373,7 +389,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
}
fn justify_param(v: &str) -> Option<Justify> {
@@ -501,7 +517,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
}
/// Reshape area type's frame with `f` ([`TextObject::transform_area`],
+28
View File
@@ -240,3 +240,31 @@ fn auto_height_follows_the_leading_model_and_character_alignment() {
};
assert!(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).overflow, "tight");
}
/// #1471: the commands that make type or change what it holds say which text no longer fits, so
/// the call that pushed it out of its frame doesn't read as plain success.
#[test]
fn making_and_restyling_type_reports_its_overflow() {
let mut s = session();
// A headline in a frame lower than its line draws nothing.
let low = s.execute("text.create", &json!({"x": 0, "y": 20, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 60}})).unwrap();
assert_eq!(low["overflow"], json!([low["id"]]), "{low}");
// One that fits says nothing, nor does point type.
let fits = s.execute("text.create", &json!({"x": 0, "y": 200, "text": "NEON DRIVE", "size": 60, "area": {"width": 400, "height": 90}})).unwrap();
assert!(fits.get("overflow").is_none(), "{fits}");
let point = s.execute("text.create", &json!({"x": 0, "y": 400, "text": "NEON DRIVE", "size": 60})).unwrap();
assert!(point.get("overflow").is_none(), "{point}");
// Growing the type pushes it out; of the two restyled, only the area type is named.
let ids = json!([fits["id"], point["id"]]);
let grown = s.execute("text.setStyle", &json!({"ids": ids, "size": 96})).unwrap();
assert_eq!((&grown["ids"], &grown["overflow"]), (&ids, &json!([fits["id"]])), "{grown}");
let back = s.execute("text.setStyle", &json!({"ids": ids, "size": 60})).unwrap();
assert!(back.get("overflow").is_none(), "{back}");
// More words than the frame holds.
let long = s.execute("text.setText", &json!({"id": fits["id"], "text": STORY})).unwrap();
assert_eq!(long["overflow"], json!([fits["id"]]), "{long}");
// Type flowed into a path too small for it.
let frame = s.execute("shape.rectangle", &json!({"x": 500, "y": 300, "width": 60, "height": 20})).unwrap();
let inside = s.execute("text.createInPath", &json!({"path": frame["id"], "mode": "area", "text": STORY, "size": 12, "fit": "none"})).unwrap();
assert_eq!(inside["overflow"], json!([inside["id"]]), "{inside}");
}