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:
parent
4d1eb16424
commit
f522e6bd3d
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¤t).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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Reference in New Issue