mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-28 03:37:00 -06:00
M7: area type vertical alignment
Area Type Options > Align: Top / Center / Bottom / Justify (AreaOptions.verticalAlign, LayoutOptions.vertical_align). Each row/column cell is aligned on its own; rectangular cells shift their lines, other frames and text wrap reflow until settled without losing text. Justify shares the leftover space equally between line gaps. Area Type Options shows Align and First Baseline as dropdowns (the parameter dialog's choices table).
This commit is contained in:
@@ -83,7 +83,7 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
|
||||
pub use style_libs::StyleLibrary;
|
||||
pub use text::{
|
||||
AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, FirstBaseline, Justify, LeadingModel, Mojikumi, ParaDirection, ParaStyle,
|
||||
PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, WrapShape,
|
||||
PathAlign, PathEffect, ScriptMetrics, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
|
||||
};
|
||||
pub use vectorcraft_color as color;
|
||||
pub use vectorcraft_geom as geom;
|
||||
|
||||
+39
-1
@@ -532,6 +532,34 @@ pub enum FirstBaseline {
|
||||
Fixed,
|
||||
}
|
||||
|
||||
/// Area Type Options "Align" (vertical): where the lines of each row/column sit in it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VerticalAlign {
|
||||
/// Lines start at the top of the cell (the default).
|
||||
#[default]
|
||||
Top,
|
||||
/// The block of lines is centred in the cell.
|
||||
Center,
|
||||
/// The last line's descent touches the cell bottom.
|
||||
Bottom,
|
||||
/// The first line stays at the top, the last one moves to the bottom and the space left over
|
||||
/// is shared equally between the lines (no paragraph spacing limit).
|
||||
Justify,
|
||||
}
|
||||
|
||||
impl VerticalAlign {
|
||||
pub const ALL: [VerticalAlign; 4] = [VerticalAlign::Top, VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify];
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
VerticalAlign::Top => "top",
|
||||
VerticalAlign::Center => "center",
|
||||
VerticalAlign::Bottom => "bottom",
|
||||
VerticalAlign::Justify => "justify",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text Wrap Options of a wrap object (Object → Text Wrap).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -634,11 +662,21 @@ pub struct AreaOptions {
|
||||
pub first_baseline: FirstBaseline,
|
||||
/// Minimum first-baseline offset in points.
|
||||
pub first_baseline_min: f64,
|
||||
/// Vertical alignment of the lines in each row/column.
|
||||
pub vertical_align: VerticalAlign,
|
||||
}
|
||||
|
||||
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 }
|
||||
Self {
|
||||
rows: 1,
|
||||
columns: 1,
|
||||
gutter: 18.0,
|
||||
inset: 0.0,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
vertical_align: VerticalAlign::Top,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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} 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} 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",
|
||||
has_selection,
|
||||
area_options
|
||||
),
|
||||
|
||||
@@ -339,3 +339,38 @@ fn the_type_widget_converts_point_and_area_type() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_align_the_lines_vertically() {
|
||||
use vectorcraft_doc::VerticalAlign;
|
||||
let mut s = session();
|
||||
let id = area(&mut s, 40.0, 40.0, 200.0, 300.0, "Short story");
|
||||
sel(&mut s, &[id]);
|
||||
assert_eq!(s.execute("text.areaOptions", &json!({})).unwrap()["verticalAlign"], "top");
|
||||
let top = layout(&s, id).lines[0].baseline;
|
||||
let before = text(&s, id).cached_bounds;
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("text.areaOptions", &json!({"verticalAlign": "bottom"})).unwrap();
|
||||
assert_eq!(r["verticalAlign"], "bottom");
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Bottom);
|
||||
let l = layout(&s, id);
|
||||
let last = l.lines.last().unwrap();
|
||||
assert!((last.baseline + last.descent - 300.0).abs() < 1e-6);
|
||||
assert!(l.lines[0].baseline > top + 200.0);
|
||||
assert_ne!(text(&s, id).cached_bounds, before, "the cached bounds follow the text");
|
||||
assert_eq!((undo_len(&s), last_step(&s).as_str()), (n + 1, "Area Type Options"));
|
||||
s.execute("text.areaOptions", &json!({"verticalAlign": "center"})).unwrap();
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
|
||||
// Unknown values are refused and change nothing.
|
||||
for bad in [json!({"verticalAlign": "middle"}), json!({"verticalAlign": 3}), json!({"verticalAlign": null})] {
|
||||
assert!(s.execute("text.areaOptions", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Center);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id).area.vertical_align, VerticalAlign::Top);
|
||||
assert_eq!(layout(&s, id).lines[0].baseline, top);
|
||||
assert_eq!(text(&s, id).cached_bounds, before);
|
||||
assert!(cmd::find_command("text.areaOptions").unwrap().params.contains("verticalAlign?: top|center|bottom|justify"));
|
||||
}
|
||||
|
||||
@@ -43,6 +43,31 @@ fn opts(version: u32, compress: bool) -> SaveOptions {
|
||||
SaveOptions { version, compress, ..SaveOptions::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_vertical_alignment_round_trips() {
|
||||
let d = rich_doc();
|
||||
let text = String::from_utf8(save(&d, false)).unwrap();
|
||||
assert!(text.contains("\"verticalAlign\":\"center\""), "the fixture's area type is centred");
|
||||
let back = load(text.as_bytes()).unwrap();
|
||||
assert_eq!(doc_json(&back), doc_json(&d));
|
||||
let area = |d: &Document| {
|
||||
let mut found = None;
|
||||
d.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& matches!(t.kind, vectorcraft_doc::TextKind::Area { .. })
|
||||
{
|
||||
found = Some(t.area.vertical_align);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
};
|
||||
assert_eq!(area(&back), vectorcraft_doc::VerticalAlign::Center);
|
||||
// Files from before the option open top-aligned.
|
||||
let old = text.replacen("\"verticalAlign\":\"center\",", "", 1).replacen(",\"verticalAlign\":\"center\"", "", 1);
|
||||
assert!(!old.contains("verticalAlign"));
|
||||
assert_eq!(area(&load(old.as_bytes()).unwrap()), vectorcraft_doc::VerticalAlign::Top);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compressed_files_load() {
|
||||
let d = rich_doc();
|
||||
|
||||
@@ -68,6 +68,20 @@ fn wrapped_lines_get_one_tspan_each() {
|
||||
assert!(!export(&d, &ExportOptions::default()).contains("text-anchor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertically_aligned_area_type_exports_where_it_is_drawn() {
|
||||
let top = area_doc(vec![run("alpha beta", style(12.0))], 200.0, Justify::Left);
|
||||
let mut centred = top.clone();
|
||||
let id = centred.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = centred.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.area.vertical_align = vectorcraft_doc::VerticalAlign::Center;
|
||||
}
|
||||
let y = |d: &Document| attr(tspans(&export(d, &ExportOptions::default()))[0], "y").unwrap();
|
||||
let (yt, yc) = (y(&top), y(¢red));
|
||||
// The 160 pt frame holds one 12 pt line: centring moves it down about (160 - 14.4) / 2.
|
||||
assert!(yc - yt > 60.0 && yc - yt < 80.0, "{yt} → {yc}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_text_writes_spacing_shift_and_scale() {
|
||||
let shifted = CharStyle { baseline_shift: 4.0, ..style(12.0) };
|
||||
|
||||
@@ -115,6 +115,14 @@ pub fn rich_session() -> Session {
|
||||
let tx = id_of(&exec(&mut s, "text.create", json!({"x": 250, "y": 300, "text": "VectorCraft", "size": 30})));
|
||||
// A non-default paragraph composer, so format round trips cover it.
|
||||
exec(&mut s, "text.setFormat", json!({"ids": [tx.0], "composer": "singleLine"}));
|
||||
// Area type with non-default Area Type Options (vertical alignment, inset).
|
||||
let area = id_of(&exec(
|
||||
&mut s,
|
||||
"text.create",
|
||||
json!({"x": 250, "y": 340, "text": "Centred area type", "size": 12, "area": {"width": 120, "height": 50}}),
|
||||
));
|
||||
select(&mut s, &[area]);
|
||||
exec(&mut s, "text.areaOptions", json!({"verticalAlign": "center", "inset": 2}));
|
||||
exec(&mut s, "layer.new", json!({"name": "Top"}));
|
||||
let d = rect(&mut s, 350.0, 200.0, 80.0, 80.0);
|
||||
let e = ellipse(&mut s, 380.0, 230.0, 80.0, 80.0);
|
||||
|
||||
+136
-6
@@ -13,7 +13,7 @@ use crate::composer::{Breakpoint, Params, compose};
|
||||
use crate::fontdb::FontDb;
|
||||
use crate::hyphen::hyphen_points;
|
||||
use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
|
||||
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout, VerticalAlign};
|
||||
|
||||
const EPS: f64 = 1e-6;
|
||||
|
||||
@@ -127,6 +127,7 @@ pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
|
||||
inset: a.inset,
|
||||
first_baseline: a.first_baseline,
|
||||
first_baseline_min: a.first_baseline_min,
|
||||
vertical_align: a.vertical_align,
|
||||
..LayoutOptions::default()
|
||||
};
|
||||
layout_with(db, t, &opts)
|
||||
@@ -183,9 +184,12 @@ pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLay
|
||||
} else {
|
||||
t.wrap.clone()
|
||||
};
|
||||
let regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
let mut regions = Region::cells(&(to_lines * frame.to_bezpath()), opts, &wrap);
|
||||
cx.out.frames = regions.iter().map(|r| line_xf.transform_rect_bbox(r.cell)).collect();
|
||||
flow(&mut cx, ¶s, &t.para, Some(®ions));
|
||||
if opts.vertical_align != VerticalAlign::Top {
|
||||
align_vertically(&mut cx, ¶s, &t.para, &mut regions);
|
||||
}
|
||||
}
|
||||
TextKind::OnPath { path, .. } => on_path(&mut cx, ¶s, t, path),
|
||||
}
|
||||
@@ -416,6 +420,125 @@ fn finish_bounds(out: &mut TextLayout) {
|
||||
out.bounds = b.unwrap_or_default();
|
||||
}
|
||||
|
||||
/// Space left in each cell after flowing top-aligned: (space above the first line's ascent, space
|
||||
/// below the last line's descent, number of distinct baselines). Cells without lines are `None`.
|
||||
fn cell_space(out: &TextLayout, regions: &[Region]) -> Vec<Option<(f64, f64, usize)>> {
|
||||
regions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ri, r)| {
|
||||
let mut top = f64::INFINITY;
|
||||
let mut bottom = f64::NEG_INFINITY;
|
||||
let mut baselines: Vec<f64> = vec![];
|
||||
for l in out.lines.iter().filter(|l| l.region == ri) {
|
||||
top = top.min(l.baseline - l.ascent);
|
||||
bottom = bottom.max(l.baseline + l.descent);
|
||||
if !baselines.iter().any(|b| (b - l.baseline).abs() < 1e-6) {
|
||||
baselines.push(l.baseline);
|
||||
}
|
||||
}
|
||||
if baselines.is_empty() || !top.is_finite() || !bottom.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// Never negative: a full (or overflowing) cell stays where top alignment put it.
|
||||
Some(((top - r.top()).max(0.0), (r.bottom() - bottom).max(0.0), baselines.len()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Area Type Options "Align" other than Top: move each cell's lines down. Rectangular cells
|
||||
/// without text wrap shift their lines (centre: half the space left below the last line;
|
||||
/// bottom: all of it; justify: the first line stays, each further line gets an equal share of it).
|
||||
/// Other frames give lines different widths at different heights, so the text flows again with
|
||||
/// each cell's lines started lower (or spaced wider) until it settles, at most eight passes; a
|
||||
/// pass that would push text out of the frame is undone and retried with half the step.
|
||||
fn align_vertically(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: &mut [Region]) {
|
||||
let align = cx.opts.vertical_align;
|
||||
let plain = regions.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty());
|
||||
if plain {
|
||||
let space = cell_space(&cx.out, regions);
|
||||
// Each distinct baseline's index within its cell, for justify.
|
||||
let mut seen: Vec<Vec<f64>> = vec![vec![]; regions.len()];
|
||||
let offs: Vec<f64> = cx
|
||||
.out
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let Some(Some((_, below, n))) = space.get(l.region).copied() else { return 0.0 };
|
||||
match align {
|
||||
VerticalAlign::Top => 0.0,
|
||||
VerticalAlign::Center => below * 0.5,
|
||||
VerticalAlign::Bottom => below,
|
||||
VerticalAlign::Justify => {
|
||||
let Some(s) = seen.get_mut(l.region) else { return 0.0 };
|
||||
let k = s.iter().position(|b| (b - l.baseline).abs() < 1e-6).unwrap_or_else(|| {
|
||||
s.push(l.baseline);
|
||||
s.len() - 1
|
||||
});
|
||||
if n > 1 { below * k as f64 / (n - 1) as f64 } else { 0.0 }
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for (l, d) in cx.out.lines.iter_mut().zip(&offs) {
|
||||
l.baseline += d;
|
||||
}
|
||||
for g in &mut cx.out.glyphs {
|
||||
let d = offs.get(g.line).copied().unwrap_or(0.0);
|
||||
if d != 0.0 {
|
||||
let m = Affine::translate((0.0, d));
|
||||
g.outline.apply_affine(m);
|
||||
g.xf = m * g.xf;
|
||||
g.origin.y += d;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let laid_out = |out: &TextLayout| out.lines.last().map_or(0, |l| l.end);
|
||||
// How much of the measured space a pass takes up: halved after a pass that lost text.
|
||||
let mut step = 1.0;
|
||||
for _ in 0..8 {
|
||||
let space = cell_space(&cx.out, regions);
|
||||
let mut moved = false;
|
||||
let saved: Vec<(f64, f64)> = regions.iter().map(|r| (r.shift, r.gap)).collect();
|
||||
for (r, s) in regions.iter_mut().zip(&space) {
|
||||
let Some((above, below, n)) = *s else { continue };
|
||||
let (shift, gap) = match align {
|
||||
VerticalAlign::Top => (r.shift, r.gap),
|
||||
VerticalAlign::Center => (r.shift + (below - above) * 0.5 * step, r.gap),
|
||||
VerticalAlign::Bottom => (r.shift + below * step, r.gap),
|
||||
VerticalAlign::Justify if n > 1 => (r.shift, r.gap + below * step / (n - 1) as f64),
|
||||
VerticalAlign::Justify => (r.shift, r.gap),
|
||||
};
|
||||
let (shift, gap) = (shift.clamp(0.0, r.cell.height().max(0.0)), gap.clamp(0.0, r.cell.height().max(0.0)));
|
||||
if (shift - r.shift).abs() > 0.25 || (gap - r.gap).abs() > 0.01 {
|
||||
moved = true;
|
||||
}
|
||||
r.shift = shift;
|
||||
r.gap = gap;
|
||||
}
|
||||
if !moved {
|
||||
return;
|
||||
}
|
||||
let before = laid_out(&cx.out);
|
||||
let prev = (std::mem::take(&mut cx.out.glyphs), std::mem::take(&mut cx.out.lines), std::mem::replace(&mut cx.out.overflow, false));
|
||||
flow(cx, paras, para, Some(regions));
|
||||
if laid_out(&cx.out) < before {
|
||||
// Text no longer fits (lines got narrower, or flow around a wrap object): undo the
|
||||
// pass and try a smaller step.
|
||||
(cx.out.glyphs, cx.out.lines, cx.out.overflow) = prev;
|
||||
for (r, (shift, gap)) in regions.iter_mut().zip(saved) {
|
||||
r.shift = shift;
|
||||
r.gap = gap;
|
||||
}
|
||||
step *= 0.5;
|
||||
if step < 0.1 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cell of a flattened area-type frame (the whole frame, or one row/column of it).
|
||||
struct Region {
|
||||
/// The cell (frame bounds, or a grid cell of them).
|
||||
@@ -427,6 +550,10 @@ struct Region {
|
||||
rect: bool,
|
||||
/// Text Wrap shapes: (polygons, offset, invert).
|
||||
wraps: Vec<(Vec<Vec<Point>>, f64, bool)>,
|
||||
/// Vertical alignment: how far below the top-aligned position the first line starts.
|
||||
shift: f64,
|
||||
/// Vertical justification: extra space added between consecutive lines.
|
||||
gap: f64,
|
||||
}
|
||||
|
||||
/// Flattened closed polygons of a path.
|
||||
@@ -529,7 +656,7 @@ impl Region {
|
||||
let x0 = bbox.x0 + c as f64 * (cw + gutter);
|
||||
let y0 = bbox.y0 + r as f64 * (rh + gutter);
|
||||
let cell = if rows * cols == 1 { bbox } else { Rect::new(x0, y0, x0 + cw, y0 + rh) };
|
||||
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone() });
|
||||
out.push(Region { cell, inset: opts.inset.max(0.0), polys: polys.clone(), rect, wraps: wraps.clone(), shift: 0.0, gap: 0.0 });
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -674,9 +801,9 @@ impl Pen<'_> {
|
||||
loop {
|
||||
let r = regions.get(self.ri)?;
|
||||
let mut baseline = match self.prev {
|
||||
None if self.model == LeadingModel::EmBoxTop => r.top() + est.top.max(self.fb_min),
|
||||
None => r.top() + est.first_baseline(self.fb, self.fb_min),
|
||||
Some(b) => self.next_baseline(b, est),
|
||||
None if self.model == LeadingModel::EmBoxTop => r.top() + r.shift + est.top.max(self.fb_min),
|
||||
None => r.top() + r.shift + est.first_baseline(self.fb, self.fb_min),
|
||||
Some(b) => self.next_baseline(b, est) + r.gap,
|
||||
};
|
||||
loop {
|
||||
if baseline + est.desc > r.bottom() + 0.01 {
|
||||
@@ -1205,6 +1332,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
|
||||
glyph_start,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) },
|
||||
region: if regions.is_some() { pen.ri } else { 0 },
|
||||
});
|
||||
pen.settled(baseline, m);
|
||||
// Further spans of this line band share the settled baseline.
|
||||
@@ -1281,6 +1409,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
glyph_start: 0,
|
||||
glyph_end: 0,
|
||||
avail: (0.0, 0.0),
|
||||
region: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -1365,6 +1494,7 @@ fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, path: &Path
|
||||
glyph_start: 0,
|
||||
glyph_end: cx.out.glyphs.len(),
|
||||
avail: (0.0, ap.len()),
|
||||
region: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
pub use vectorcraft_doc::FirstBaseline;
|
||||
pub use vectorcraft_doc::{FirstBaseline, VerticalAlign};
|
||||
|
||||
/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`].
|
||||
pub use vectorcraft_doc::Composer;
|
||||
@@ -50,6 +50,8 @@ pub struct LayoutOptions {
|
||||
pub first_baseline: FirstBaseline,
|
||||
/// Minimum first-baseline offset in points.
|
||||
pub first_baseline_min: f64,
|
||||
/// Vertical alignment of the lines in each row/column (Area Type Options "Align").
|
||||
pub vertical_align: VerticalAlign,
|
||||
/// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`).
|
||||
pub composer: Option<Composer>,
|
||||
pub features: OtFeatures,
|
||||
@@ -64,6 +66,7 @@ impl Default for LayoutOptions {
|
||||
inset: 0.0,
|
||||
first_baseline: FirstBaseline::Ascent,
|
||||
first_baseline_min: 0.0,
|
||||
vertical_align: VerticalAlign::Top,
|
||||
composer: None,
|
||||
features: OtFeatures::default(),
|
||||
}
|
||||
@@ -124,6 +127,8 @@ pub struct LineInfo {
|
||||
/// Horizontal span available to the line (frame span minus indents; the content extent for
|
||||
/// point type). Used for hit testing across columns.
|
||||
pub avail: (f64, f64),
|
||||
/// Area type: index of the frame cell ([`TextLayout::frames`]) the line sits in (0 otherwise).
|
||||
pub region: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -457,6 +457,197 @@ fn rows_and_columns_flow_in_order() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- vertical alignment ----------
|
||||
|
||||
fn valign(a: VerticalAlign) -> LayoutOptions {
|
||||
LayoutOptions { vertical_align: a, ..Default::default() }
|
||||
}
|
||||
|
||||
/// (space above the first line's ascent, space below the last line's descent) in `0..h`.
|
||||
fn block_space(l: &TextLayout, h: f64) -> (f64, f64) {
|
||||
let (first, last) = (l.lines.first().unwrap(), l.lines.last().unwrap());
|
||||
(first.baseline - first.ascent, h - (last.baseline + last.descent))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_center_bottom_and_justify_in_a_rectangle() {
|
||||
let t = area("First line\nSecond line\nThird line", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
let (above, below) = block_space(&top, 300.0);
|
||||
assert!(above.abs() < 1e-6 && below > 200.0);
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let (a, b) = block_space(¢er, 300.0);
|
||||
assert!((a - b).abs() < 1e-6 && a > 100.0, "{a} {b}");
|
||||
// The model field reaches layout().
|
||||
let mut tc = t.clone();
|
||||
tc.area.vertical_align = VerticalAlign::Center;
|
||||
assert!((layout(db(), &tc).lines[0].baseline - center.lines[0].baseline).abs() < 1e-9);
|
||||
// Lines, glyph origins, outlines and transforms move together.
|
||||
let d = center.lines[0].baseline - top.lines[0].baseline;
|
||||
for (g, h) in center.glyphs.iter().zip(&top.glyphs) {
|
||||
assert!((g.origin.y - h.origin.y - d).abs() < 1e-9);
|
||||
assert!((g.xf.translation().y - h.xf.translation().y - d).abs() < 1e-9);
|
||||
if !h.outline.elements().is_empty() {
|
||||
assert!((g.outline.bounding_box().y0 - h.outline.bounding_box().y0 - d).abs() < 1e-6);
|
||||
}
|
||||
}
|
||||
assert!(center.bounds.y0 > top.bounds.y0 + 100.0);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
let (a, b) = block_space(&bottom, 300.0);
|
||||
assert!(b.abs() < 1e-6 && a > 200.0);
|
||||
// Justify: the first line stays, the last one reaches the bottom, gaps are equal.
|
||||
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
|
||||
let (a, b) = block_space(&just, 300.0);
|
||||
assert!(a.abs() < 1e-6 && b.abs() < 1e-6, "{a} {b}");
|
||||
let gaps: Vec<f64> = just.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect();
|
||||
assert!(gaps.len() == 2 && (gaps[0] - gaps[1]).abs() < 1e-6 && gaps[0] > 100.0);
|
||||
// A single line justifies to the top.
|
||||
let one = area("Alone", style(12.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
assert_eq!(layout_with(db(), &one, &valign(VerticalAlign::Justify)).lines[0].baseline, layout(db(), &one).lines[0].baseline);
|
||||
// Inset: the space is measured inside it.
|
||||
let inset = layout_with(db(), &t, &LayoutOptions { inset: 10.0, vertical_align: VerticalAlign::Bottom, ..Default::default() });
|
||||
assert!(block_space(&inset, 290.0).1.abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_leaves_overflowing_text_where_it_is() {
|
||||
let text = COPY.repeat(4);
|
||||
let t = area(&text, style(12.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
assert!(top.overflow);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &t, &valign(a));
|
||||
assert!(l.overflow);
|
||||
assert_eq!(l.lines.len(), top.lines.len());
|
||||
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
// A full cell has less than a line of space left: the lines barely move.
|
||||
assert!(l.lines[0].baseline - top.lines[0].baseline < top.lines[0].ascent + top.lines[0].descent);
|
||||
assert!(block_space(&l, 100.0).1 >= -1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_acts_on_each_column_on_its_own() {
|
||||
// Enough text for column 1 and a few lines of column 2.
|
||||
let t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 420.0, 120.0), Justify::Left);
|
||||
let opts = |a| LayoutOptions { columns: 2, gutter: 20.0, vertical_align: a, ..Default::default() };
|
||||
let top = layout_with(db(), &t, &opts(VerticalAlign::Top));
|
||||
let col = |l: &TextLayout, c: usize| l.lines.iter().filter(|li| li.region == c).cloned().collect::<Vec<_>>();
|
||||
assert!(!top.overflow && !col(&top, 0).is_empty() && !col(&top, 1).is_empty());
|
||||
assert!(col(&top, 1).iter().all(|li| li.x0 >= 220.0 - 1e-6));
|
||||
let bottom = layout_with(db(), &t, &opts(VerticalAlign::Bottom));
|
||||
for c in 0..2 {
|
||||
let last = col(&bottom, c).last().cloned().unwrap();
|
||||
assert!((last.baseline + last.descent - 120.0).abs() < 1e-6, "column {c} ends at the bottom");
|
||||
}
|
||||
let shift = |c: usize| col(&bottom, c)[0].baseline - col(&top, c)[0].baseline;
|
||||
assert!(shift(1) > shift(0) + 20.0, "the short column moves further: {} {}", shift(0), shift(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_in_a_circle_reflows_and_stays_inside() {
|
||||
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
|
||||
let t = area_path("Centred text in a round frame flows again at its new height.", style(12.0), &circle, Justify::Left);
|
||||
let top = layout(db(), &t);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &t, &valign(a));
|
||||
assert!(!l.overflow, "{a:?}");
|
||||
assert_eq!(l.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
let moved = if a == VerticalAlign::Justify {
|
||||
l.lines.last().unwrap().baseline - top.lines.last().unwrap().baseline
|
||||
} else {
|
||||
l.lines[0].baseline - top.lines[0].baseline
|
||||
};
|
||||
assert!(moved > 20.0, "{a:?} {moved}");
|
||||
for g in l.glyphs.iter().filter(|g| !g.outline.elements().is_empty()) {
|
||||
let b = g.outline.bounding_box();
|
||||
for p in [Point::new(b.x0, b.y0), Point::new(b.x1, b.y1)] {
|
||||
assert!((p - Point::new(150.0, 150.0)).hypot() <= 121.0, "{a:?} {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let c = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let (above, below) = block_space(&c, 270.0);
|
||||
assert!((above - 30.0 - below).abs() < 10.0, "roughly centred: {above} {below}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_with_text_wrap_flows_again_around_the_object() {
|
||||
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, 300.0, 400.0), Justify::Left);
|
||||
let obstacle = Rect::new(100.0, 250.0, 200.0, 330.0).to_path(0.1);
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&obstacle), wrap: Default::default() }];
|
||||
let top = layout(db(), &t);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
assert!(!bottom.overflow);
|
||||
assert_eq!(bottom.lines.last().unwrap().end, top.lines.last().unwrap().end);
|
||||
let last = bottom.lines.last().unwrap();
|
||||
assert!(400.0 - (last.baseline + last.descent) < 15.0, "near the bottom: {}", last.baseline);
|
||||
// Lines beside the object leave room for it (offset 6 pt).
|
||||
for li in bottom.lines.iter().filter(|li| li.baseline + li.descent > 244.0 && li.baseline - li.ascent < 336.0) {
|
||||
assert!(li.x1 <= 94.0 + 1e-6 || li.x0 >= 206.0 - 1e-6, "{li:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertical_align_on_vertical_type_moves_columns_along_the_block_axis() {
|
||||
// Vertical type: the block axis runs right to left, so "bottom" is the frame's left edge.
|
||||
let mut t = area("Tate\nYoko", style(20.0), Rect::new(0.0, 0.0, 200.0, 100.0), Justify::Left);
|
||||
t.vertical = true;
|
||||
let top = layout(db(), &t);
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
assert!(top.bounds.x1 > 190.0 && top.bounds.x0 > 100.0, "{:?}", top.bounds);
|
||||
assert!(bottom.bounds.x0.abs() < 0.5, "{:?}", bottom.bounds);
|
||||
assert!((center.bounds.x0 - (200.0 - center.bounds.x1)).abs() < 0.5, "{:?}", center.bounds);
|
||||
// Glyphs stay inside the frame; lines keep line-space coordinates.
|
||||
for g in &bottom.glyphs {
|
||||
assert!(g.origin.x >= -1e-6 && g.origin.x <= 200.0 + 1e-6);
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-to-Top leading (em box top to em box top): the first line hangs from the frame's top and a
|
||||
/// line's leading is the space below it. Vertical alignment measures the space from the lines that
|
||||
/// model placed, keeps their spacing when it shifts them, and justify adds its share on top of it.
|
||||
#[test]
|
||||
fn vertical_align_with_top_to_top_leading() {
|
||||
use vectorcraft_doc::LeadingModel;
|
||||
let st = |size: f64| CharStyle { size, leading: Some(size * 1.5), ..style(size) };
|
||||
let mut t = area("", st(40.0), Rect::new(0.0, 0.0, 200.0, 300.0), Justify::Left);
|
||||
t.runs = vec![TextRun { text: "大\n".into(), style: st(40.0) }, TextRun { text: "小\n中".into(), style: st(20.0) }];
|
||||
t.para.leading_model = LeadingModel::EmBoxTop;
|
||||
let top = layout(db(), &t);
|
||||
assert_eq!(top.lines.len(), 3);
|
||||
assert!((top.lines[0].baseline - 0.88 * 40.0).abs() < 0.01, "first em box at the top: {}", top.lines[0].baseline);
|
||||
let gaps = |l: &TextLayout| l.lines.windows(2).map(|w| w[1].baseline - w[0].baseline).collect::<Vec<_>>();
|
||||
let bottom = layout_with(db(), &t, &valign(VerticalAlign::Bottom));
|
||||
assert!(block_space(&bottom, 300.0).1.abs() < 1e-6, "{:?}", block_space(&bottom, 300.0));
|
||||
for (a, b) in gaps(&bottom).iter().zip(gaps(&top)) {
|
||||
assert!((a - b).abs() < 1e-6, "spacing kept: {a} {b}");
|
||||
}
|
||||
let center = layout_with(db(), &t, &valign(VerticalAlign::Center));
|
||||
let d = center.lines[0].baseline - top.lines[0].baseline;
|
||||
assert!((d - block_space(&top, 300.0).1 * 0.5).abs() < 1e-6, "{d}");
|
||||
let just = layout_with(db(), &t, &valign(VerticalAlign::Justify));
|
||||
assert!((just.lines[0].baseline - top.lines[0].baseline).abs() < 1e-9, "the first line stays");
|
||||
assert!(block_space(&just, 300.0).1.abs() < 1e-6);
|
||||
let extra: Vec<f64> = gaps(&just).iter().zip(gaps(&top)).map(|(a, b)| a - b).collect();
|
||||
assert!(extra.len() == 2 && (extra[0] - extra[1]).abs() < 1e-6 && extra[0] > 50.0, "{extra:?}");
|
||||
// A round frame reflows with Top-to-Top leading too, without losing text.
|
||||
let circle = Circle::new((150.0, 150.0), 120.0).to_path(0.1);
|
||||
let mut round = area_path("", st(20.0), &circle, Justify::Left);
|
||||
round.runs = t.runs.clone();
|
||||
round.para.leading_model = LeadingModel::EmBoxTop;
|
||||
let rtop = layout(db(), &round);
|
||||
for a in [VerticalAlign::Center, VerticalAlign::Bottom, VerticalAlign::Justify] {
|
||||
let l = layout_with(db(), &round, &valign(a));
|
||||
assert!(!l.overflow, "{a:?}");
|
||||
assert_eq!(l.lines.last().unwrap().end, rtop.lines.last().unwrap().end);
|
||||
// The 40 pt line starts low in the circle already, so centring moves it a little.
|
||||
let want = if a == VerticalAlign::Center { 1.0 } else { 20.0 };
|
||||
assert!(l.lines.last().unwrap().baseline > rtop.lines.last().unwrap().baseline + want, "{a:?}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- OpenType ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,6 +38,8 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
match (command, key) {
|
||||
("type.pathOptions", "effect") => Some(crate::menus::PATH_EFFECTS),
|
||||
("type.pathOptions", "alignToPath") => Some(PATH_ALIGN),
|
||||
("text.areaOptions", "firstBaseline") => Some(FIRST_BASELINE),
|
||||
("text.areaOptions", "verticalAlign") => Some(VERTICAL_ALIGN),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -45,6 +47,13 @@ fn choices(command: &str, key: &str) -> Option<form::Choices> {
|
||||
/// Type on a Path Options › Align to Path.
|
||||
const PATH_ALIGN: form::Choices = &[("Ascender", "ascender"), ("Descender", "descender"), ("Center", "center"), ("Baseline", "baseline")];
|
||||
|
||||
/// Area Type Options › First Baseline.
|
||||
const FIRST_BASELINE: form::Choices =
|
||||
&[("Ascent", "ascent"), ("Cap Height", "capHeight"), ("x Height", "xHeight"), ("Leading", "leading"), ("Fixed", "fixed")];
|
||||
|
||||
/// Area Type Options › Align (vertical alignment of the lines in each row/column).
|
||||
const VERTICAL_ALIGN: form::Choices = &[("Top", "top"), ("Center", "center"), ("Bottom", "bottom"), ("Justify", "justify")];
|
||||
|
||||
/// Closes before running, so a dialog the command opens stays open.
|
||||
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
let cmd = d.str("__command");
|
||||
@@ -59,3 +68,43 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
app.run(&cmd, params)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{NodeKind, VerticalAlign};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn area_type_options_choices_match_the_params_doc() {
|
||||
let doc = vectorcraft_engine::find_command("text.areaOptions").unwrap().params;
|
||||
for key in ["firstBaseline", "verticalAlign"] {
|
||||
let values: Vec<&str> = choices("text.areaOptions", key).unwrap().iter().map(|(_, v)| *v).collect();
|
||||
assert!(doc.contains(&format!("{key}?: {}", values.join("|"))), "{key}: {values:?}");
|
||||
}
|
||||
assert!(choices("text.areaOptions", "firstBaselineMin").is_none(), "a distance, not a choice");
|
||||
assert!(choices("text.areaOptions", "inset").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn area_type_options_dialog_sets_the_vertical_alignment() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 20, "text": "Hello", "area": {"width": 200, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
app.run("select.set", json!({"ids": [id]})).unwrap();
|
||||
crate::menus::invoke(&mut app, "text.areaOptions", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("verticalAlign"), "top");
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::dialogs::show(&mut app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("verticalAlign".into(), json!("center"));
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
let NodeKind::Text(t) = &app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.area.vertical_align, VerticalAlign::Center);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user