mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
M7: area type auto size and shrink text to fit
Area Type Options gain a fit: none, autoHeight (Illustrator's Auto Size:
the frame's height follows the text after every edit, resolved in
refresh_bounds inside the edit so it shares the edit's undo step) and
shrinkText {minPercent} (beyond Illustrator: overflowing text scales its
sizes, leading and baseline shifts down at layout time by the largest
factor, in 0.1 pt steps of the first run, that fits). The Auto Size New
Area Type preference now applies. text.areaOptions and document.inspect
report overflow and the shrink factor; the Area Type Options dialog
offers fit and first baseline as dropdowns.
This commit is contained in:
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Text",
|
||||
[],
|
||||
None,
|
||||
"{x, y, text, vertical?: bool = false, size?: pt, font?: family, style?, color?, area?: {width, height}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id}",
|
||||
"{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\" (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",
|
||||
has_doc,
|
||||
text_create
|
||||
),
|
||||
@@ -415,10 +415,12 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let w = f64_or(a, "width", 200.0);
|
||||
let h = f64_or(a, "height", 100.0);
|
||||
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")?;
|
||||
}
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
// 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)
|
||||
@@ -471,6 +473,13 @@ pub(crate) fn new_type_alignment(s: &mut Session, p: &Value, cmd: &str, t: &mut
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The fit new area type gets: `fit`/`fitMinPercent` from `p` (as `text.areaOptions` takes
|
||||
/// them), else Auto Size when the "Auto Size New Area Type" preference is on.
|
||||
pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str) -> Result<vectorcraft_doc::AreaFit> {
|
||||
let default = if s.prefs.auto_size_area_type { vectorcraft_doc::AreaFit::AutoHeight } else { vectorcraft_doc::AreaFit::None };
|
||||
Ok(super::typecmd::fit_param(p, default, c)?.unwrap_or(default))
|
||||
}
|
||||
|
||||
/// The character style new type gets: `size`, `font`, `style` and `color` from `p`, else the
|
||||
/// defaults, filled with the current fill unless that is None or white (black then).
|
||||
pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle {
|
||||
|
||||
@@ -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?, 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?, 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}",
|
||||
has_doc,
|
||||
create_in_path
|
||||
),
|
||||
@@ -472,6 +472,9 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
super::create::new_type_alignment(s, p, C, &mut t)?;
|
||||
if !on_path {
|
||||
t.area.fit = super::create::new_area_fit(s, p, C)?;
|
||||
}
|
||||
if bool_or(p, "placeholder", false) {
|
||||
super::typemenu::fill_with_placeholder(&mut t);
|
||||
} else {
|
||||
|
||||
@@ -93,6 +93,16 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
continue;
|
||||
}
|
||||
vectorcraft_text::edit::normalize(&mut story);
|
||||
if live.len() >= 2 {
|
||||
// Threaded frames keep their size: Auto Size is off for them (as in Illustrator).
|
||||
for id in &live {
|
||||
if text_of(doc, *id).is_some_and(|t| t.area.fit == vectorcraft_doc::AreaFit::AutoHeight)
|
||||
&& let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind)
|
||||
{
|
||||
t.area.fit = vectorcraft_doc::AreaFit::None;
|
||||
}
|
||||
}
|
||||
}
|
||||
let frames: Vec<TextObject> = live.iter().filter_map(|id| text_of(doc, *id).cloned()).collect();
|
||||
let refs: Vec<&TextObject> = frames.iter().collect();
|
||||
let parts = vectorcraft_text::thread::distribute(vectorcraft_text::FontDb::global(), &refs, &story);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_doc::{AreaFit, Document, Justify, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, PathData, Vec2};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
@@ -48,7 +48,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Area Type Options…",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (none given: query) → the first object's options",
|
||||
"{ids?, width?: pt, height?: pt, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt, verticalAlign?: top|center|bottom|justify, fit?: none|autoHeight|shrinkText, fitMinPercent?: 10..100} set the selected area type's options; width and height size the type area from its top-left corner, along the type's own axes, and the text reflows at its size (with fit autoHeight, Auto Size, the frame's height follows the text after every edit and height is ignored; setting the height by hand turns it off); fit shrinkText scales overflowing text down (size, leading, baseline shift) by the largest factor down to fitMinPercent % (default 50) that makes it fit (none given: query) → the first object's options, plus overflow (the text doesn't fit its frame) and fitScale (Shrink Text's factor, 1 unshrunk)",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
@@ -97,13 +97,94 @@ fn orientation(s: &mut Session, p: &Value, vertical: bool) -> Result<Value> {
|
||||
Ok(json!({"vertical": vertical, "ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit.
|
||||
/// Recompute the layout caches (bounds and baselines) after a text edit. Every engine text edit ends here (typing
|
||||
/// through `text.editRange` too), inside its `Session::edit`, so this is where Auto Size area type
|
||||
/// ([`vectorcraft_doc::AreaFit::AutoHeight`]) fits its frame to the text: part of the same undo
|
||||
/// step as the edit.
|
||||
pub(crate) fn refresh_bounds(t: &mut TextObject) {
|
||||
auto_height(t);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
t.cached_bounds = Some(lay.bounds);
|
||||
t.cached_baselines = lay.baselines();
|
||||
}
|
||||
|
||||
/// The text-space bounds of area type's frame, when Auto Size can fit it: a rectangle (one
|
||||
/// subpath, as large as its bounds) of horizontal type in one row, with some height.
|
||||
fn auto_height_frame(t: &TextObject) -> Option<vectorcraft_geom::Rect> {
|
||||
use vectorcraft_geom::Shape;
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &t.kind else { return None };
|
||||
if t.area.fit != vectorcraft_doc::AreaFit::AutoHeight || t.vertical || t.area.rows > 1 || frame.subpaths.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let b = frame.bounds()?;
|
||||
let area = frame.to_bezpath().area().abs();
|
||||
let finite = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite());
|
||||
(finite && b.height() > 1e-6 && b.width() > 1e-6 && (area - b.area()).abs() <= b.area() * 1e-6).then_some(b)
|
||||
}
|
||||
|
||||
/// Auto Size: move the bottom of a rectangular area type frame to just below its last line (plus
|
||||
/// the inset), so the frame's height follows the text. Text in several columns gets the least
|
||||
/// height (to 0.01 pt) at which it fits. True when the frame changed.
|
||||
fn auto_height(t: &mut TextObject) -> bool {
|
||||
use vectorcraft_geom::Shape;
|
||||
let Some(b) = auto_height_frame(t) else { return false };
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Lay out in a frame as tall as the canvas allows: everything the width lets through flows.
|
||||
let tall = (crate::MAX_COORD - b.y0).min(1.0e5);
|
||||
if tall <= 1.0 {
|
||||
return false;
|
||||
}
|
||||
let mut probe = t.clone();
|
||||
let with_size = |probe: &mut TextObject, w: f64, h: f64| {
|
||||
if let vectorcraft_doc::TextKind::Area { frame } = &mut probe.kind {
|
||||
*frame = PathData::from_bezpath(&vectorcraft_geom::Rect::new(b.x0, b.y0, b.x0 + w, b.y0 + h).to_path(0.1));
|
||||
}
|
||||
};
|
||||
// Columns: all the text in one column as wide as each of them is surely tall enough.
|
||||
let cols = t.area.columns.max(1);
|
||||
let col_w = if cols > 1 { ((b.width() - t.area.gutter.max(0.0) * (cols - 1) as f64) / cols as f64).max(1.0) } else { b.width() };
|
||||
with_size(&mut probe, col_w, tall);
|
||||
probe.area.columns = 1;
|
||||
let lay = vectorcraft_text::layout(db, &probe);
|
||||
let inset = t.area.inset.max(0.0);
|
||||
let Some(bottom) = lay.lines.iter().map(|l| l.baseline + l.descent).filter(|y| y.is_finite()).reduce(f64::max) else { return false };
|
||||
// The layout's fit test is `baseline + descent <= bottom + 0.01`: the last line fits exactly.
|
||||
let mut h = (bottom + inset - b.y0).clamp(1.0, tall);
|
||||
if cols > 1 {
|
||||
// The least height (to 0.01 pt) at which the columns hold the text.
|
||||
probe.area.columns = cols;
|
||||
let fits = |probe: &mut TextObject, h: f64| {
|
||||
with_size(probe, b.width(), h);
|
||||
!vectorcraft_text::layout(db, probe).overflow
|
||||
};
|
||||
if !fits(&mut probe, h) {
|
||||
return false;
|
||||
}
|
||||
let (mut lo, mut hi) = (1.0, h);
|
||||
for _ in 0..24 {
|
||||
if hi - lo <= 0.01 {
|
||||
break;
|
||||
}
|
||||
let mid = (lo + hi) * 0.5;
|
||||
if fits(&mut probe, mid) { hi = mid } else { lo = mid }
|
||||
}
|
||||
h = hi;
|
||||
}
|
||||
if (h - b.height()).abs() <= 1e-6 {
|
||||
return false;
|
||||
}
|
||||
// Scale the frame about its top edge, keeping its anchors (and their order) as they are.
|
||||
let a = Affine::translate((0.0, b.y0)) * Affine::scale_non_uniform(1.0, h / b.height()) * Affine::translate((0.0, -b.y0));
|
||||
let vectorcraft_doc::TextKind::Area { frame } = &mut t.kind else { return false };
|
||||
let mut next = frame.clone();
|
||||
next.transform(a);
|
||||
if next.bounds().is_none_or(|nb| ![nb.x0, nb.y0, nb.x1, nb.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD)) {
|
||||
return false;
|
||||
}
|
||||
*frame = next;
|
||||
true
|
||||
}
|
||||
|
||||
/// Text objects among `ids` and their descendants.
|
||||
fn text_ids(d: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
let mut out = vec![];
|
||||
@@ -292,10 +373,18 @@ pub(crate) fn reshape_area_with(t: &mut TextObject, f: impl FnOnce(&mut TextObje
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let height = |t: &TextObject| auto_height_frame(t).map(|b| b.height());
|
||||
let was = height(t);
|
||||
if !(f(t) && within(t)) {
|
||||
t.kind = before;
|
||||
return false;
|
||||
}
|
||||
// Setting the height by hand turns Auto Size off (as in Illustrator); a new width keeps it.
|
||||
if let (Some(a), Some(b)) = (was, height(t))
|
||||
&& (a - b).abs() > 1e-6
|
||||
{
|
||||
t.area.fit = vectorcraft_doc::AreaFit::None;
|
||||
}
|
||||
refresh_bounds(t);
|
||||
true
|
||||
}
|
||||
@@ -334,6 +423,60 @@ fn size_area(t: &mut TextObject, w: Option<f64>, h: Option<f64>) -> bool {
|
||||
reshape_area_with(t, |t| t.transform_area(a))
|
||||
}
|
||||
|
||||
/// The fit `p` asks for (`fit`: none|autoHeight|shrinkText or `{"shrinkText": {"minPercent"}}`,
|
||||
/// `fitMinPercent`), starting from `cur`. None when `p` sets neither (or only `fitMinPercent`
|
||||
/// while the fit isn't Shrink Text).
|
||||
pub(crate) fn fit_param(p: &Value, cur: AreaFit, c: &str) -> Result<Option<AreaFit>> {
|
||||
let min = p.get("fitMinPercent").and_then(Value::as_f64);
|
||||
let cur_min = match cur {
|
||||
AreaFit::ShrinkText { min_percent } => Some(min_percent),
|
||||
_ => None,
|
||||
};
|
||||
let fit = match p.get("fit") {
|
||||
None | Some(Value::Null) => match (cur, min) {
|
||||
(AreaFit::ShrinkText { .. }, Some(m)) => AreaFit::ShrinkText { min_percent: m },
|
||||
_ => return Ok(None),
|
||||
},
|
||||
Some(Value::String(id)) => {
|
||||
AreaFit::parse(id, min.or(cur_min)).ok_or_else(|| bad(c, format!("fit must be none|autoHeight|shrinkText, got {id}")))?
|
||||
}
|
||||
Some(v) => match serde_json::from_value::<AreaFit>(v.clone()).map_err(|e| bad(c, format!("bad fit: {e}")))? {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: min.unwrap_or(min_percent) },
|
||||
f => f,
|
||||
},
|
||||
};
|
||||
Ok(Some(match fit {
|
||||
AreaFit::ShrinkText { min_percent } => AreaFit::ShrinkText { min_percent: AreaFit::clamp_percent(min_percent) },
|
||||
f => f,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Area type's options as `text.areaOptions` reports them: the Area Type Options with `fit` as
|
||||
/// its id and `fitMinPercent` beside it, the frame's `width` and `height`, and whether the text
|
||||
/// `overflow`s its frame at `fitScale` (Shrink Text to Fit's factor; 1 unshrunk).
|
||||
pub(crate) fn area_options_json(t: &TextObject) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("fit".into(), json!(t.area.fit.id()));
|
||||
let min = match t.area.fit {
|
||||
AreaFit::ShrinkText { min_percent } => min_percent,
|
||||
_ => AreaFit::DEFAULT_MIN_PERCENT,
|
||||
};
|
||||
o.insert("fitMinPercent".into(), json!(min));
|
||||
if let Some((w, h, _)) = area_size(t) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
o.insert("overflow".into(), json!(lay.overflow));
|
||||
o.insert("fitScale".into(), json!(lay.fit_scale));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The [`vectorcraft_doc::AreaOptions`] keys `text.areaOptions` merges as they are.
|
||||
const AREA_KEYS: [&str; 7] = ["rows", "columns", "gutter", "inset", "firstBaseline", "firstBaselineMin", "verticalAlign"];
|
||||
|
||||
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.areaOptions";
|
||||
let ids: Vec<NodeId> = {
|
||||
@@ -343,34 +486,35 @@ fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let first = *ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let options = |s: &Session| -> Result<Value> {
|
||||
let t = area_text(s.doc()?.doc.node(first)).ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
|
||||
let mut v = serde_json::to_value(&t.area).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
if let (Some(o), Some((w, h, _))) = (v.as_object_mut(), area_size(t)) {
|
||||
o.insert("width".into(), json!(w));
|
||||
o.insert("height".into(), json!(h));
|
||||
}
|
||||
Ok(v)
|
||||
area_options_json(t)
|
||||
};
|
||||
let mut v = options(s)?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some();
|
||||
let cur = area_text(s.doc()?.doc.node(first)).map(|t| t.area.clone()).unwrap_or_default();
|
||||
let fit = fit_param(p, cur.fit, C)?;
|
||||
let mut v = serde_json::to_value(&cur).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let size = |k: &str| p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| x.clamp(1.0, 100_000.0));
|
||||
let (w, mut h) = (size("width"), size("height"));
|
||||
let mut changed = w.is_some() || h.is_some() || fit.is_some();
|
||||
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
|
||||
for (k, val) in src {
|
||||
if o.contains_key(k) && !matches!(k.as_str(), "ids" | "width" | "height") {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
for (k, val) in src.iter().filter(|(k, _)| AREA_KEYS.contains(&k.as_str())) {
|
||||
o.insert(k.clone(), val.clone());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return Ok(v);
|
||||
return options(s);
|
||||
}
|
||||
let mut opts: vectorcraft_doc::AreaOptions = serde_json::from_value(v).map_err(|e| bad(C, e.to_string()))?;
|
||||
opts.rows = opts.rows.clamp(1, 100);
|
||||
opts.columns = opts.columns.clamp(1, 100);
|
||||
opts.gutter = opts.gutter.clamp(0.0, 10_000.0);
|
||||
opts.inset = opts.inset.clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = opts.first_baseline_min.clamp(0.0, 10_000.0);
|
||||
let finite = |x: f64| if x.is_finite() { x } else { 0.0 };
|
||||
opts.gutter = finite(opts.gutter).clamp(0.0, 10_000.0);
|
||||
opts.inset = finite(opts.inset).clamp(0.0, 10_000.0);
|
||||
opts.first_baseline_min = finite(opts.first_baseline_min).clamp(0.0, 10_000.0);
|
||||
opts.fit = fit.unwrap_or(cur.fit);
|
||||
if opts.fit == AreaFit::AutoHeight {
|
||||
// Auto Size sets the height (Illustrator's dialog turns the Height field off).
|
||||
h = None;
|
||||
}
|
||||
s.edit("Area Type Options", |d, _| {
|
||||
for id in &ids {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
|
||||
Reference in New Issue
Block a user