M7.3: Area Type Options — rows, columns, gutter, inset, first baseline stored on area type, laid out by the text engine; Type → Area Type Options… dialog prefilled from the selection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 10:32:58 -04:00
parent 4d1eb16424
commit f522e6bd3d
8 changed files with 171 additions and 20 deletions

View File

@ -37,7 +37,7 @@ pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, Gradient
pub use node::{ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeId, NodeKind, OpacityMask};
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use selection::{AnchorRef, Selection};
pub use text::{CharStyle, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef};
pub use text::{AreaOptions, CharStyle, FirstBaseline, Justify, ParaStyle, TextKind, TextObject, TextRun, TextStyleDef};
use drawcraft_color::{Swatch, SwatchGroup};
use drawcraft_geom::{Point, Rect};

View File

@ -116,6 +116,42 @@ pub struct ParaStyle {
pub style_name: Option<String>,
}
/// Area Type Options "First Baseline" offset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FirstBaseline {
/// The tallest glyph ascent touches the frame top (Illustrator's default).
#[default]
Ascent,
CapHeight,
XHeight,
/// The first line's leading.
Leading,
/// Exactly `first_baseline_min` below the top.
Fixed,
}
/// Area Type Options: rows and columns, gutters, inset and first baseline of area type.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct AreaOptions {
pub rows: usize,
pub columns: usize,
/// Gutter between rows/columns in points.
pub gutter: f64,
/// Inset from the frame edges in points.
pub inset: f64,
pub first_baseline: FirstBaseline,
/// Minimum first-baseline offset in points.
pub first_baseline_min: f64,
}
impl Default for AreaOptions {
fn default() -> Self {
Self { rows: 1, columns: 1, gutter: 18.0, inset: 0.0, first_baseline: FirstBaseline::Ascent, first_baseline_min: 0.0 }
}
}
/// A named character or paragraph style: the attributes it sets (a subset of [`CharStyle`] or
/// [`ParaStyle`] fields, by their serialized names). Text using it records the name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -152,6 +188,9 @@ pub struct TextObject {
pub runs: Vec<TextRun>,
#[serde(default)]
pub para: ParaStyle,
/// Area Type Options (area type only).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub area: AreaOptions,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@ -164,6 +203,7 @@ impl TextObject {
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun { text: text.into(), style }],
para: ParaStyle::default(),
area: AreaOptions::default(),
cached_bounds: None,
}
}

View File

@ -315,7 +315,14 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
_ => 0.0,
};
let kind = if on_path { TextKind::OnPath { path: path.clone(), start } } else { TextKind::Area { frame: path.clone() } };
let mut t = TextObject { kind, xf: Affine::IDENTITY, runs: vec![TextRun { text, style }], para: Default::default(), cached_bounds: None };
let mut t = TextObject {
kind,
xf: Affine::IDENTITY,
runs: vec![TextRun { text, style }],
para: Default::default(),
area: Default::default(),
cached_bounds: None,
};
refresh_bounds(&mut t);
let id = s.edit(if on_path { "Type on a Path" } else { "Area Type" }, |d, sel| {
let (par, idx, _) = d.position(pid).ok_or(EngineError::NoNode(pid))?;
@ -342,7 +349,14 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
}
let head = edit::slice_runs(&t.runs, 0, end);
let measure = |tr: f64| {
let mut h = TextObject { kind: TextKind::Point, xf: Affine::IDENTITY, runs: head.clone(), para: Default::default(), cached_bounds: None };
let mut h = TextObject {
kind: TextKind::Point,
xf: Affine::IDENTITY,
runs: head.clone(),
para: Default::default(),
area: Default::default(),
cached_bounds: None,
};
for r in &mut h.runs {
r.style.tracking = tr;
}

View File

@ -31,6 +31,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
set_text
),
cmd!(
"text.areaOptions",
"Area Type Options…",
["Type"],
None,
"{ids?, rows?, columns?, gutter?: pt, inset?: pt, firstBaseline?: ascent|capHeight|xHeight|leading|fixed, firstBaselineMin?: pt} set the selected area type's options (none given: query) → the first object's options",
has_selection,
area_options
),
cmd!(
"text.setStyle",
"Character",
@ -207,3 +216,82 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn area_options(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "text.areaOptions";
let ids: Vec<NodeId> = {
let d = &s.doc()?.doc;
text_targets(s, p, C)?
.into_iter()
.filter(|i| matches!(d.node(*i).map(|n| &n.kind), Some(NodeKind::Text(t)) if matches!(t.kind, drawcraft_doc::TextKind::Area { .. })))
.collect()
};
let first = ids.first().ok_or_else(|| bad(C, "select area type (text in a frame)"))?;
let current = match &s.doc()?.doc.node(*first).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => t.area.clone(),
_ => unreachable!(),
};
let mut v = serde_json::to_value(&current).map_err(|e| EngineError::Other(e.to_string()))?;
let mut changed = false;
if let (Some(o), Some(src)) = (v.as_object_mut(), p.as_object()) {
for (k, val) in src {
if o.contains_key(k) && k != "ids" {
o.insert(k.clone(), val.clone());
changed = true;
}
}
}
if !changed {
return Ok(v);
}
let mut opts: drawcraft_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 out = serde_json::to_value(&opts).map_err(|e| EngineError::Other(e.to_string()))?;
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) {
t.area = opts.clone();
refresh_bounds(t);
}
}
Ok(())
})?;
Ok(out)
}
#[cfg(test)]
mod area_tests {
use super::*;
#[test]
fn area_options_columns_change_layout() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let text = "word ".repeat(80);
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": text, "area": {"width": 300, "height": 120}})).unwrap()["id"]
.as_u64()
.unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let q = s.execute("text.areaOptions", &json!({})).unwrap();
assert_eq!((q["rows"].as_u64(), q["columns"].as_u64()), (Some(1), Some(1)));
let lay = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => drawcraft_text::layout(drawcraft_text::FontDb::global(), t),
_ => panic!(),
};
assert_eq!(lay(&s).frames.len(), 1);
let r = s.execute("text.areaOptions", &json!({"columns": 3, "gutter": 12, "inset": 4, "firstBaseline": "capHeight"})).unwrap();
assert_eq!(r["firstBaseline"], "capHeight");
let l = lay(&s);
assert_eq!(l.frames.len(), 3, "three column cells");
// Glyphs land in more than one column.
let xs: Vec<f64> = l.glyphs.iter().map(|g| g.origin.x).collect();
assert!(xs.iter().any(|x| *x > 110.0 + 10.0) && xs.iter().any(|x| *x < 100.0));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(lay(&s).frames.len(), 1);
assert!(s.execute("text.areaOptions", &json!({"firstBaseline": "nope"})).is_err());
}
}

View File

@ -690,7 +690,8 @@ fn text_fallback(im: &mut Importer, svg: &str, w: f64, h: f64, layer_mode: bool)
Some("end") => Justify::Right,
_ => Justify::Left,
};
let mut obj = TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), cached_bounds: None };
let mut obj =
TextObject { kind: TextKind::Point, xf, runs: rb.runs, para: Default::default(), area: Default::default(), cached_bounds: None };
obj.para.justify = justify;
let mut node = im.named(t.attribute("id").unwrap_or(""), NodeKind::Text(Box::new(obj)));
if let Some(o) = ctx.own(t, "opacity").and_then(|o| o.trim().parse::<f32>().ok()) {

View File

@ -53,7 +53,17 @@ impl Ctx<'_> {
/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]).
pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
layout_with(db, t, &LayoutOptions::default())
let a = &t.area;
let opts = LayoutOptions {
rows: a.rows,
columns: a.columns,
gutter: a.gutter,
inset: a.inset,
first_baseline: a.first_baseline,
first_baseline_min: a.first_baseline_min,
..LayoutOptions::default()
};
layout_with(db, t, &opts)
}
/// Lay out a text object with explicit options (area type rows/columns, inset, first baseline,

View File

@ -23,19 +23,7 @@ pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace};
use kurbo::{BezPath, Point, Rect, Vec2};
pub use layout::{layout, layout_with};
/// Area Type Options "First Baseline" offset.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum FirstBaseline {
/// The tallest glyph ascent touches the frame top (Illustrator's default).
#[default]
Ascent,
CapHeight,
XHeight,
/// The first line's leading.
Leading,
/// Exactly `first_baseline_min` below the top.
Fixed,
}
pub use drawcraft_doc::FirstBaseline;
/// Paragraph composer (Paragraph panel menu).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@ -48,7 +36,7 @@ pub enum Composer {
}
/// Layout parameters that the document model doesn't store per object (Area Type Options,
/// composer, OpenType features). [`layout`] uses the defaults.
/// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object.
#[derive(Clone, Debug, PartialEq)]
pub struct LayoutOptions {
/// Area type rows and columns (text flows down each column, then across).

View File

@ -805,7 +805,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
c("Convert To Area Type", "type.convertToAreaType"),
c("Convert To Point Type", "type.convertToPointType"),
todo("Area Type Options…"),
c("Area Type Options…", "text.areaOptions"),
sub(
"Type on a Path",
vec![
@ -1164,6 +1164,16 @@ pub fn invoke(app: &mut DrawcraftApp, id: &str, p: Value) {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Repeat Options", "params": fields}));
return;
}
// Area Type Options: a dialog with the selected area type's current values.
if id == "text.areaOptions" && p.as_object().is_none_or(|o| o.is_empty()) {
match app.session.execute(id, &json!({})) {
Ok(fields) => {
let _ = app.run("ui.paramDialog", json!({"command": id, "label": "Area Type Options", "params": fields}));
}
Err(e) => app.status(e.to_string()),
}
return;
}
if let Err(e) = app.run(id, p) {
app.status(e);
} else if matches!(id, "object.pattern.make" | "object.pattern.edit") {