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:
haven
2026-10-08 14:28:02 -07:00
parent 8d0cede84a
commit d2bb95daab
13 changed files with 524 additions and 13 deletions
+136 -6
View File
@@ -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, &paras, &t.para, Some(&regions));
if opts.vertical_align != VerticalAlign::Top {
align_vertically(&mut cx, &paras, &t.para, &mut regions);
}
}
TextKind::OnPath { path, .. } => on_path(&mut cx, &paras, 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,
});
}
+6 -1
View File
@@ -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)]
+191
View File
@@ -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(&center, 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]