Files
bflatasticandClaude Opus 5.5 dd595cf2e8 Fix #260: tate-chu-yoko numbers and upright glyphs keep their own cell in vertical text
In vertical type every character now sits in its own cell down the column:
"10" and "22" in 「10月22日」 stand side by side in one em, centred on the
column, and 月/日 start right after them. Justified (Justify All) columns
no longer split a tate-chu-yoko block, and tracked or justified upright
characters stay on the column's centre line instead of drifting across it.

Root cause: in 0.3.1 (the reporter's build) upright glyphs were placed by
the font's ascent rather than the ideographic em box, so a font with a tall
ascent (Noto Sans CJK JP, 1.16 em) drew each upright character ~0.28 em
down into the next cell, onto the sideways digits; #208 (after 0.3.1)
replaced that with em-box placement and tate-chu-yoko, which this commit
locks in with regression tests. Two defects of the same kind remained:
Justify All added its inter-character gap inside a tate-chu-yoko block
(the block's second digit slid down the column, away from the first), and
upright glyphs turned about the middle of their advance, which tracking and
justification lengthen, instead of the middle of their own cell.

Tests: the issue's layout with bundled glyphs (cells one after the other,
every ink inside its cell and no overlaps, 2-3 digit blocks one em and
centred, single digits and 2026 on their side advancing by their length,
spaces), the issue's string with craft-fonts' Japanese face (skips without
craft-fonts), Justify All with a block, tracking.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:16:44 +07:00

1051 lines
43 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Line breaking and glyph placement for point, area and on-path type.
use std::ops::Range;
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, Rect, Shape, Vec2};
use vectorcraft_doc::{CharStyle, Justify, ParaStyle, PathEffect, TextKind, TextObject};
use crate::composer::{Breakpoint, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{SGlyph, Tcy, cap_x_heights, hyphen_glyph, no_line_end, no_line_start, shape_range, style_metrics};
use crate::{Composer, FirstBaseline, LayoutOptions, LineInfo, OtFeatures, PositionedGlyph, TextLayout};
const EPS: f64 = 1e-6;
struct Ctx<'a> {
on_path: bool,
db: &'a FontDb,
text: &'a str,
runs: Vec<(Range<usize>, &'a CharStyle)>,
default: CharStyle,
opts: &'a LayoutOptions,
/// Vertical type: lines are laid out as horizontal lines in line space, upright glyphs turned
/// a quarter turn back (see [`stands_upright`]); [`TextLayout::line_xf`] then stands the lines
/// up as columns.
vertical: bool,
out: TextLayout,
}
impl Ctx<'_> {
fn style_at(&self, b: usize) -> &CharStyle {
let find = |b: usize| self.runs.iter().find(|(r, _)| r.start <= b && b < r.end).map(|(_, s)| *s);
find(b).or_else(|| b.checked_sub(1).and_then(find)).or_else(|| self.runs.first().map(|(_, s)| *s)).unwrap_or(&self.default)
}
fn shape_para(&self, r: Range<usize>) -> Vec<SGlyph> {
let mut v = Vec::with_capacity(r.len());
shape_range(self.db, self.text, r, &self.runs, &self.opts.features, &mut v);
if self.vertical {
tate_chu_yoko(&mut v, |g| self.style_at(g.byte).size);
// An upright glyph advances at least one em down the column (the vertical advance of
// CJK fonts), centred in it: a narrow mark like § must not overlap its neighbours.
for g in v.iter_mut().filter(|g| g.adv > 0.0 && stands_upright(g)) {
let extra = upright_cell(g) - g.face.advance(g.gid) * g.sx;
g.dx += extra * 0.5;
g.adv += extra;
}
}
v
}
fn emit(&mut self, g: &SGlyph, pre: Affine, origin: Point, angle: f64, advance: f64, line: usize) {
let src = self.db.outline(&g.face, g.gid);
let local = Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx, g.dy - g.bshift)) * Affine::scale_non_uniform(g.sx, g.sy);
let mut m = pre * local;
if self.vertical && self.on_path {
// Vertical path type keeps the baseline path and turns each glyph across it.
m = Affine::rotate_about(std::f64::consts::FRAC_PI_2, origin) * m;
} else if let (true, Some(t)) = (self.vertical, g.tcy) {
// Tate-chu-yoko: the block set across the column, centred on its em of it.
let em = self.style_at(g.byte).size;
let start = origin.x - t.pen;
let centre = Point::new(start + em * 0.5, origin.y - EM_CENTER * em);
let across = Affine::translate((centre.x - t.width * 0.5 + t.ink - origin.x, 0.0));
let squeeze = Affine::translate((centre.x, 0.0)) * Affine::scale_non_uniform(t.squeeze, 1.0) * Affine::translate((-centre.x, 0.0));
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, centre) * squeeze * across * m;
} else if self.vertical && stands_upright(g) {
// Turned about the centre of its em box, which the column's centre line runs through:
// the middle of its own cell, not of its advance (tracking and justification add space
// after the cell, and must not push the glyph off the centre line).
let em = self.style_at(g.byte).size;
let cell = if g.adv > 0.0 { upright_cell(g) } else { advance };
m = Affine::rotate_about(-std::f64::consts::FRAC_PI_2, Point::new(origin.x + cell * 0.5, origin.y - EM_CENTER * em)) * m;
}
// Control characters (tabs) and soft hyphens draw nothing (fonts map them to .notdef).
let outline = if src.elements().is_empty() || g.is_soft_hyphen() || g.ch.is_control() {
BezPath::new()
} else {
let mut p = BezPath::with_capacity(src.elements().len());
for el in src.elements() {
p.push(m * *el);
}
p
};
let font_id = g.face.id();
self.out.glyphs.push(PositionedGlyph {
outline,
run: g.run,
byte: g.byte,
origin,
advance,
len: g.len,
angle,
line,
font_id,
gid: g.gid,
xf: m,
});
}
}
/// Lay out a text object into text-space glyph outlines (default [`LayoutOptions`]).
pub fn layout(db: &FontDb, t: &TextObject) -> TextLayout {
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,
/// composer, OpenType features).
pub fn layout_with(db: &FontDb, t: &TextObject, opts: &LayoutOptions) -> TextLayout {
let text = t.plain_text();
let mut runs = Vec::with_capacity(t.runs.len());
let mut off = 0;
for r in &t.runs {
runs.push((off..off + r.text.len(), &r.style));
off += r.text.len();
}
let mut paras = Vec::new();
let mut s = 0;
for (i, c) in text.char_indices() {
if c == '\n' {
paras.push(s..i);
s = i + 1;
}
}
paras.push(s..text.len());
let vertical = t.vertical;
let is_on_path = matches!(&t.kind, TextKind::OnPath { .. });
let vopts;
let opts = if vertical {
vopts = LayoutOptions { features: OtFeatures { vertical: true, ..opts.features }, ..opts.clone() };
&vopts
} else {
opts
};
let mut cx = Ctx { on_path: is_on_path, db, text: &text, runs, default: CharStyle::default(), opts, vertical, out: TextLayout::default() };
// Vertical type: line space turned a quarter turn clockwise (lines become columns, each next
// one to the left). Point type's anchor is on the first column's centre line. Vertical path
// type stays on its path (each glyph turned across it in `emit`).
let line_xf = match &t.kind {
_ if !vertical => Affine::IDENTITY,
TextKind::OnPath { .. } => Affine::IDENTITY,
TextKind::Point => Affine::translate((-EM_CENTER * cx.style_at(0).size, 0.0)) * QUARTER_TURN,
_ => QUARTER_TURN,
};
match &t.kind {
TextKind::Point => flow(&mut cx, &paras, &t.para, None),
TextKind::Area { frame } => {
let to_lines = line_xf.inverse();
let wrap: Vec<vectorcraft_doc::WrapShape> = if vertical {
t.wrap.iter().map(|w| vectorcraft_doc::WrapShape { path: w.path.transformed(to_lines), ..w.clone() }).collect()
} else {
t.wrap.clone()
};
let 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));
}
TextKind::OnPath { path, start } => on_path(&mut cx, &paras, &t.para, &path.to_bezpath(), *start, path.is_closed(), t.path_effect),
}
if vertical && !is_on_path {
// Glyph origins, outlines and transforms to text space (lines stay in line space).
for g in &mut cx.out.glyphs {
g.outline.apply_affine(line_xf);
g.xf = line_xf * g.xf;
g.origin = line_xf * g.origin;
g.angle += std::f64::consts::FRAC_PI_2;
}
cx.out.vertical = true;
cx.out.line_xf = line_xf;
}
finish_bounds(&mut cx.out);
cx.out
}
/// Tate-chu-yoko: a run of two or three half-width digits (`10`月, `100`年) stands upright across
/// the column as one block one em long, squeezed to the em when wider; longer numbers (`2026`)
/// stay on their side.
fn tate_chu_yoko(g: &mut [SGlyph], size: impl Fn(&SGlyph) -> f64) {
let digits: Vec<bool> = g.iter().map(|g| g.ch.is_ascii_digit()).collect();
let digit = |i: usize| digits.get(i).copied().unwrap_or(false);
let mut i = 0;
while i < g.len() {
if !digit(i) {
i += 1;
continue;
}
let mut end = i;
while digit(end) {
end += 1;
}
if (2..=3).contains(&(end - i)) {
let em = size(&g[i]);
let width: f64 = g[i..end].iter().map(|g| g.adv).sum();
let squeeze = if width > em && width > 0.0 { em / width } else { 1.0 };
let mut ink = 0.0;
for (k, gl) in g[i..end].iter_mut().enumerate() {
let adv = gl.adv;
gl.tcy = Some(Tcy { pen: if k == 0 { 0.0 } else { em }, ink, width, squeeze });
gl.adv = if k == 0 { em } else { 0.0 };
ink += adv;
}
}
i = end;
}
}
/// The length an upright glyph takes down the column before tracking and justification: its
/// advance, at least one em.
fn upright_cell(g: &SGlyph) -> f64 {
(g.face.advance(g.gid) * g.sx).max(g.face.units_per_em() * g.sx)
}
/// Height of the centre of the ideographic em box above the baseline, in ems (the em box runs
/// from 0.12 em below the baseline to 0.88 em above it).
const EM_CENTER: f64 = 0.38;
/// A quarter turn clockwise (y down), exact: line space → text space for vertical type.
const QUARTER_TURN: Affine = Affine::new([0.0, 1.0, -1.0, 0.0, 0.0, 0.0]);
/// Does the glyph stand upright in vertical type? CJK characters and symbols do (their vertical
/// forms come from the font's `vert` feature); Latin letters, digits and other characters lie on
/// their side. Brackets, the long vowel mark and similar marks that need a vertical form lie on
/// their side when the font has none (Unicode's Vertical_Orientation Tr).
fn stands_upright(g: &SGlyph) -> bool {
let c = g.ch as u32;
let upright = matches!(c,
0x00A7 | 0x00A9 | 0x00AE | 0x00B1 | 0x00BC..=0x00BE | 0x00D7 | 0x00F7
| 0x2016 | 0x2020 | 0x2021 | 0x2030 | 0x2031 | 0x203B | 0x203C | 0x2042 | 0x2047..=0x2049 | 0x2051
| 0x20DD..=0x20E0 | 0x20E2..=0x20E4 | 0x2100..=0x2101 | 0x2103..=0x2109 | 0x210F | 0x2113 | 0x2116 | 0x2117
| 0x211E..=0x2123 | 0x2125 | 0x2127 | 0x2129 | 0x212E | 0x2135..=0x213F | 0x2145..=0x214A | 0x214C | 0x214D | 0x214F..=0x2189
| 0x2460..=0x24FF | 0x25A0..=0x27BF | 0x2B12..=0x2B2F | 0x2B50..=0x2B59
| 0x1100..=0x11FF | 0x2E80..=0x303F | 0x3040..=0xA4CF | 0xA960..=0xA97F | 0xAC00..=0xD7FF
| 0xE000..=0xFAFF | 0xFE10..=0xFE1F | 0xFE30..=0xFE4F | 0xFE50..=0xFE6F | 0xFF00..=0xFFE7
| 0x1F000..=0x1FAFF | 0x20000..=0x3FFFF);
if !upright {
return false;
}
let needs_vertical_form = matches!(c,
0x3001 | 0x3002 | 0x3008..=0x3011 | 0x3013..=0x301F | 0x3030 | 0x30A0 | 0x30FC
| 0xFE50..=0xFE52 | 0xFE59..=0xFE5E | 0xFF01 | 0xFF08 | 0xFF09 | 0xFF0C | 0xFF0E | 0xFF1A | 0xFF1B | 0xFF1F
| 0xFF3B | 0xFF3D | 0xFF3F | 0xFF5B..=0xFF60 | 0xFFE3);
// Without a vertical alternate the shaped glyph is the nominal one.
!needs_vertical_form || g.gid != g.face.glyph_for(g.ch)
}
fn finish_bounds(out: &mut TextLayout) {
let xf = out.line_xf;
let mut b: Option<Rect> = None;
let mut add = |r: Rect| b = Some(b.map_or(r, |b| b.union(r)));
for g in &out.glyphs {
if !g.outline.elements().is_empty() {
add(g.outline.bounding_box());
}
}
if !out.on_path {
for l in &out.lines {
add(xf.transform_rect_bbox(Rect::new(l.x0.min(l.x1), l.baseline - l.ascent, l.x0.max(l.x1), l.baseline + l.descent)));
}
}
out.bounds = b.unwrap_or_default();
}
/// 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).
cell: Rect,
/// Inset applied inside the frame edges.
inset: f64,
polys: Vec<Vec<Point>>,
/// The frame is its own bounding rectangle (spans need no polygon intersection).
rect: bool,
/// Text Wrap shapes: (polygons, offset, invert).
wraps: Vec<(Vec<Vec<Point>>, f64, bool)>,
}
/// Flattened closed polygons of a path.
fn polygons(path: &BezPath) -> Vec<Vec<Point>> {
let mut polys: Vec<Vec<Point>> = Vec::new();
kurbo::flatten(path, 0.1, |el| match el {
PathEl::MoveTo(p) => polys.push(vec![p]),
PathEl::LineTo(p) => {
if let Some(v) = polys.last_mut() {
v.push(p);
}
}
_ => {}
});
polys.retain(|p| p.len() >= 3);
polys
}
/// Inside intervals (even-odd) of the horizontal line at `y` through `polys`.
fn poly_intervals(polys: &[Vec<Point>], y: f64) -> Vec<(f64, f64)> {
let mut xs = Vec::new();
for poly in polys {
for i in 0..poly.len() {
let a = poly[i];
let b = poly[(i + 1) % poly.len()];
if (a.y <= y) != (b.y <= y) {
xs.push(a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect()
}
/// Union of sorted-or-not intervals.
fn union_intervals(mut v: Vec<(f64, f64)>) -> Vec<(f64, f64)> {
v.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut out: Vec<(f64, f64)> = Vec::with_capacity(v.len());
for (a, b) in v {
match out.last_mut() {
Some(l) if a <= l.1 => l.1 = l.1.max(b),
_ => out.push((a, b)),
}
}
out
}
/// `a` minus `b` (both unions of disjoint intervals).
fn subtract_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut out = vec![];
for &(mut s, e) in a {
for &(c, d) in b {
if d <= s || c >= e {
continue;
}
if c > s {
out.push((s, c));
}
s = s.max(d);
}
if e > s {
out.push((s, e));
}
}
out
}
/// `a` ∩ `b`.
fn intersect_intervals(a: &[(f64, f64)], b: &[(f64, f64)]) -> Vec<(f64, f64)> {
let mut out = vec![];
for &(s, e) in a {
for &(c, d) in b {
let (x, y) = (s.max(c), e.min(d));
if y > x {
out.push((x, y));
}
}
}
out
}
impl Region {
fn cells(path: &BezPath, opts: &LayoutOptions, wrap: &[vectorcraft_doc::WrapShape]) -> Vec<Region> {
let polys = polygons(path);
let wraps: Vec<(Vec<Vec<Point>>, f64, bool)> =
wrap.iter().map(|w| (polygons(&w.path.to_bezpath()), w.wrap.offset.max(0.0), w.wrap.invert)).filter(|w| !w.0.is_empty()).collect();
let bbox = path.bounding_box();
let rect = polys.len() == 1 && {
let area: f64 = polys[0].iter().zip(polys[0].iter().cycle().skip(1)).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>().abs() * 0.5;
(area - bbox.area()).abs() <= bbox.area() * 1e-6 + 1e-9
};
let (rows, cols) = (opts.rows.max(1), opts.columns.max(1));
let gutter = opts.gutter.max(0.0);
let cw = ((bbox.width() - gutter * (cols - 1) as f64) / cols as f64).max(0.0);
let rh = ((bbox.height() - gutter * (rows - 1) as f64) / rows as f64).max(0.0);
let mut out = Vec::with_capacity(rows * cols);
// Text flows down each column, then across (Illustrator's default "by columns").
for c in 0..cols {
for r in 0..rows {
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
}
fn top(&self) -> f64 {
self.cell.y0 + self.inset
}
fn bottom(&self) -> f64 {
self.cell.y1 - self.inset
}
/// Inside intervals (even-odd) of the horizontal line at `y`, minus the wrap objects (or
/// inside them, for Invert Wrap). Offsets grow each wrap shape vertically and horizontally.
fn intervals(&self, y: f64) -> Vec<(f64, f64)> {
let mut iv = if self.polys.is_empty() || self.rect { vec![(self.cell.x0, self.cell.x1)] } else { poly_intervals(&self.polys, y) };
for (polys, off, invert) in &self.wraps {
let ys = if *off > 0.0 { vec![y - off, y - off * 0.5, y, y + off * 0.5, y + off] } else { vec![y] };
let w = union_intervals(ys.into_iter().flat_map(|y| poly_intervals(polys, y)).map(|(a, b)| (a - off, b + off)).collect());
iv = if *invert { intersect_intervals(&iv, &w) } else { subtract_intervals(&iv, &w) };
}
iv
}
/// Widest horizontal span inside the frame (and the cell) over the band `top..bottom`.
fn span(&self, top: f64, bottom: f64) -> Option<(f64, f64)> {
self.spans(top, bottom).into_iter().max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))
}
/// Every horizontal span inside the frame (and the cell) over the band `top..bottom`, left to
/// right (text wraps on both sides of an object).
fn spans(&self, top: f64, bottom: f64) -> Vec<(f64, f64)> {
let clip = |(a, b): (f64, f64)| {
let (a, b) = (a.max(self.cell.x0) + self.inset, b.min(self.cell.x1) - self.inset);
(b > a).then_some((a, b))
};
let plain = self.polys.is_empty() || self.rect;
if plain && self.wraps.is_empty() {
return (self.cell.width() > 0.0).then_some((self.cell.x0, self.cell.x1)).and_then(clip).into_iter().collect();
}
let fb = if plain {
self.cell
} else {
self.polys.iter().flatten().fold(Rect::new(f64::MAX, f64::MAX, f64::MIN, f64::MIN), |r, p| r.union_pt(*p))
};
let clamp = |y: f64| if plain { y } else { y.clamp(fb.y0 + 1e-4, fb.y1 - 1e-4) };
// Sample the band densely enough for curved frames (circles, blobs).
let samples = 5;
let mut rows: Vec<Vec<(f64, f64)>> =
(0..samples).map(|k| self.intervals(clamp(top + (bottom - top) * k as f64 / (samples - 1) as f64))).collect();
let mid = rows.swap_remove(samples / 2);
if !self.wraps.is_empty() {
// Wrap objects split lines: keep exactly what is free on every sampled row.
let free = rows.iter().fold(mid, |acc, r| intersect_intervals(&acc, r));
return free.into_iter().filter_map(clip).collect();
}
mid.into_iter()
.filter_map(|(mut a, mut b)| {
for o in &rows {
let best =
o.iter().map(|&(c, d)| (a.max(c), b.min(d))).filter(|(c, d)| d > c).max_by(|x, y| (x.1 - x.0).total_cmp(&(y.1 - y.0)))?;
a = best.0;
b = best.1;
}
clip((a, b))
})
.collect()
}
}
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
#[derive(Clone, Copy, Debug, PartialEq)]
struct Metrics {
asc: f64,
desc: f64,
lead: f64,
cap: f64,
xh: f64,
}
impl Metrics {
fn of(g: &SGlyph) -> Self {
Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh }
}
fn max(g: &[SGlyph]) -> Option<Self> {
let mut it = g.iter();
let first = Self::of(it.next()?);
Some(it.fold(first, |m, g| Self {
asc: m.asc.max(g.ascent),
desc: m.desc.max(g.descent),
lead: m.lead.max(g.leading),
cap: m.cap.max(g.cap),
xh: m.xh.max(g.xh),
}))
}
/// Distance from the frame top to the first baseline.
fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 {
let v = match fb {
FirstBaseline::Ascent => self.asc,
FirstBaseline::CapHeight => self.cap,
FirstBaseline::XHeight => self.xh,
FirstBaseline::Leading => self.lead,
FirstBaseline::Fixed => 0.0,
};
v.max(min)
}
}
/// Where the next line goes: the region being filled and the previous baseline in it.
#[derive(Clone)]
struct Pen<'r> {
regions: Option<&'r [Region]>,
ri: usize,
prev: Option<f64>,
pending: f64,
fb: FirstBaseline,
fb_min: f64,
/// Further spans at the current baseline (text wrapping on both sides of an object).
queued: Vec<(f64, f64, f64)>,
}
impl Pen<'_> {
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
/// the frame is full (overflow).
/// The `bool` is true for a further span at the previous line's baseline.
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> {
let Some(regions) = self.regions else {
let b = self.prev.map_or(0.0, |b| b + est.lead + self.pending);
return Some((b, f64::NEG_INFINITY, f64::INFINITY, false));
};
if !self.queued.is_empty() {
let (b, x0, x1) = self.queued.remove(0);
return Some((b, x0, x1, true));
}
loop {
let r = regions.get(self.ri)?;
let mut baseline = match self.prev {
None => r.top() + est.first_baseline(self.fb, self.fb_min),
Some(b) => b + est.lead + self.pending,
};
loop {
if baseline + est.desc > r.bottom() + 0.01 {
break;
}
let fits = |&(a, b): &(f64, f64)| b - a - ind_l - ind_r > est.asc.max(1.0);
if r.wraps.is_empty() {
match r.span(baseline - est.asc, baseline + est.desc) {
Some(s) if fits(&s) => return Some((baseline, s.0, s.1, false)),
_ => baseline += est.lead.max(1.0),
}
} else {
// Wrap objects can split a line: fill every span, left to right.
let mut spans: Vec<(f64, f64)> = r.spans(baseline - est.asc, baseline + est.desc).into_iter().filter(fits).collect();
if spans.is_empty() {
baseline += est.lead.max(1.0);
continue;
}
let (a, b) = spans.remove(0);
self.queued = spans.into_iter().map(|(a, b)| (baseline, a, b)).collect();
return Some((baseline, a, b, false));
}
}
// Next row/column.
self.ri += 1;
self.prev = None;
self.pending = 0.0;
self.queued.clear();
}
}
fn bottom(&self) -> f64 {
self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom())
}
}
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
/// every ½ inch). Right, centre and decimal stops align the text that follows (`rest`, up to the
/// next tab) on the stop.
fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> f64 {
use vectorcraft_doc::TabAlign;
let rel = x - origin;
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t').collect();
let width: f64 = seg.iter().map(|g| g.adv).sum();
let target = |pos: f64, align: TabAlign, on: char| match align {
TabAlign::Left => pos,
TabAlign::Right => pos - width,
TabAlign::Center => pos - width / 2.0,
TabAlign::Decimal => pos - seg.iter().take_while(|g| g.ch != on).map(|g| g.adv).sum::<f64>(),
};
let explicit = tabs.iter().find_map(|t| {
let at = target(t.position, t.align, t.align_on);
(at > rel + 1e-6).then_some(at)
});
let at = explicit.unwrap_or_else(|| {
let last = tabs.iter().map(|t| t.position).fold(0.0, f64::max).max(0.0);
let base = rel.max(last);
((base / vectorcraft_doc::text::DEFAULT_TAB_INTERVAL).floor() + 1.0) * vectorcraft_doc::text::DEFAULT_TAB_INTERVAL
});
(at - rel).max(0.0)
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool) -> (usize, bool) {
if !width.is_finite() {
return (g.len(), false);
}
let mut x = 0.0;
let mut last_break: Option<(usize, bool)> = None;
let mut j = i;
while j < g.len() {
let gl = &g[j];
if j > i && !gl.is_space() && x + gl.adv > width + EPS {
break;
}
x += gl.adv;
if gl.break_after() && kinsoku_allows(g, j) {
if gl.is_soft_hyphen() {
if x + hyphen_glyph(gl).adv <= width + EPS {
last_break = Some((j + 1, true));
}
} else {
last_break = Some((j + 1, false));
}
}
j += 1;
}
if j >= g.len() {
return (g.len(), false);
}
// Hyphenate the word that overflows.
let word_start = last_break.map_or(i, |b| b.0);
if hyphenate && g[j].is_letter() {
let mut we = j;
while we < g.len() && !g[we].is_space() && !g[we].break_after() {
we += 1;
}
let x_ws: f64 = g[i..word_start].iter().map(|g| g.adv).sum();
let pts: Vec<usize> = hyphen_breaks(text, g, word_start, we).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)).collect();
for &k in pts.iter().rev() {
if k <= j && k > i {
let w = x_ws + g[word_start..k].iter().map(|g| g.adv).sum::<f64>() + hyphen_glyph(&g[k - 1]).adv;
if w <= width + EPS {
return (k, true);
}
}
}
}
// Don't separate a cluster's glyphs.
let (mut end, hy) = match last_break {
Some(b) if b.0 > i => b,
_ => (j, false),
};
while end > i + 1 && end < g.len() && g[end].byte == g[end - 1].byte {
end -= 1;
}
(end, hy)
}
/// Does kinsoku allow a line break after glyph `j`? Not after an opening bracket, nor before a
/// closing one, a comma, a full stop, a small kana… (the character is pushed to the next line
/// with the one before it).
fn kinsoku_allows(g: &[SGlyph], j: usize) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch)))
}
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph).
fn kinsoku_between(before: char, after: Option<char>) -> bool {
!no_line_end(before) && after.is_none_or(|c| !no_line_start(c))
}
#[cfg(test)]
#[test]
fn kinsoku_keeps_closing_marks_off_line_starts_and_opening_ones_off_line_ends() {
for (a, b) in [('字', '。'), ('弧', '」'), ('」', '、'), ('ャ', 'ー'), ('カ', 'ッ'), ('「', 'か'), ('(', '雅')] {
assert!(!kinsoku_between(a, Some(b)), "{a}{b}");
}
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
assert!(kinsoku_between(a, Some(b)), "{a}{b}");
}
assert!(kinsoku_between('。', None));
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize) -> Vec<usize> {
// Strip leading/trailing punctuation (quotes, commas).
let (mut a, mut b) = (ws, we);
while a < b && !g[a].is_letter() {
a += 1;
}
while b > a && !g[b - 1].is_letter() {
b -= 1;
}
if b <= a || g[a..b].iter().any(|g| !g.is_letter()) {
return vec![];
}
let (s, e) = (g[a].byte, g[b - 1].byte + g[b - 1].len);
let Some(word) = text.get(s..e) else { return vec![] };
let offs: Vec<usize> = word.char_indices().map(|(o, _)| s + o).collect();
hyphen_points(word)
.into_iter()
.filter_map(|ci| {
let byte = *offs.get(ci)?;
// Only at cluster starts (not inside a ligature).
(a + 1..b).find(|&k| g[k].byte == byte && g[k - 1].byte != byte)
})
.collect()
}
/// Break candidates for the every-line composer.
fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
let mut v = vec![];
let mut ws = 0;
for (j, gl) in g.iter().enumerate() {
let end_of_word = gl.is_space() || gl.break_after();
if end_of_word {
if hyphenate && j > ws {
for k in hyphen_breaks(text, g, ws, j).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j) {
v.push(Breakpoint { end: j + 1, hyphen: hy });
}
ws = j + 1;
}
}
if hyphenate && g.len() > ws {
for k in hyphen_breaks(text, g, ws, g.len()).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1)) {
v.push(Breakpoint { end: k, hyphen: hyphen_glyph(&g[k - 1]).adv });
}
}
v.sort_by_key(|b| b.end);
v.dedup_by_key(|b| b.end);
v
}
/// Every-line composition of paragraph glyphs `sg` if applicable (justified area text with uniform
/// line metrics); `None` falls back to the greedy single-line composer.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) -> Option<Vec<(usize, bool)>> {
let justified = !matches!(para.justify, Justify::Left | Justify::Center | Justify::Right);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 {
return None;
}
let m = Metrics::of(&sg[0]);
if sg.iter().any(|g| Metrics::of(g) != m) {
return None;
}
// Line widths are independent of the breaks when every line has the same metrics.
let total: f64 = sg.iter().map(|g| g.adv).sum();
let mut sim = pen.clone();
let mut widths = vec![];
let mut acc = 0.0;
while widths.len() < sg.len() {
let first = widths.is_empty();
let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent) else { break };
let w = x1 - x0 - ind_l - para.right_indent;
widths.push(w);
acc += w.max(1.0);
sim.prev = Some(b);
sim.pending = 0.0;
if acc > total * 1.6 + 4.0 * w.max(1.0) {
break;
}
}
let last = *widths.last()?;
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
let cands = candidates(cx.text, sg, para.hyphenate);
let justify_last = para.justify == Justify::JustifyAll;
compose(sg, &width, &cands, justify_last, 1.0).or_else(|| compose(sg, &width, &cands, justify_last, 4.0))
}
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Option<&[Region]>) {
let mut pen = Pen { regions, ri: 0, prev: None, pending: 0.0, fb: cx.opts.first_baseline, fb_min: cx.opts.first_baseline_min, queued: vec![] };
'paras: for (pi, pr) in paras.iter().enumerate() {
let sg = cx.shape_para(pr.clone());
let pm = {
let (asc, desc, lead) = style_metrics(cx.db, cx.style_at(pr.start));
let (cap, xh) = cap_x_heights(cx.db, cx.style_at(pr.start));
Metrics { asc, desc, lead, cap, xh }
};
if pi > 0 {
pen.pending += para.space_before;
}
let n = sg.len();
let composed = compose_para(cx, &sg, para, &pen);
let mut li_para = 0;
let mut i = 0;
loop {
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
let (baseline, x0, x1, end, hyph, m) = loop {
let first_in_region = pen.prev.is_none();
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent) else {
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
break 'paras;
};
let width = x1 - x0 - ind_l - para.right_indent;
let (end, hyph) = match composed.as_ref().and_then(|c| c.get(li_para)) {
Some(&(e, h)) if e > i => (e, h),
_ if i < n => break_line(cx.text, &sg, i, width, para.hyphenate),
_ => (n, false),
};
let m = Metrics::max(&sg[i..end]).unwrap_or(pm);
baseline += if same_baseline || (pen.regions.is_none() && first_in_region) {
0.0
} else if first_in_region {
m.first_baseline(pen.fb, pen.fb_min) - est.first_baseline(pen.fb, pen.fb_min)
} else {
m.lead - est.lead
};
if pen.regions.is_some() && baseline + m.desc > pen.bottom() + 0.01 {
// Try the next row/column; overflow if there is none.
pen.ri += 1;
pen.prev = None;
pen.pending = 0.0;
pen.queued.clear();
continue;
}
break (baseline, x0, x1, end, hyph, m);
};
let (ax0, ax1) = if regions.is_some() { (x0 + ind_l, x1 - para.right_indent) } else { (x0, x1) };
let width = ax1 - ax0;
pen.pending = 0.0;
let last_of_para = end >= n;
let mut trimmed = end;
while trimmed > i && sg[trimmed - 1].is_space() {
trimmed -= 1;
}
let hyphen = (hyph && end > i).then(|| hyphen_glyph(&sg[end - 1]));
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv);
let (align, justify) = match para.justify {
Justify::Left => (0, false),
Justify::Center => (1, false),
Justify::Right => (2, false),
Justify::JustifyLeft => (0, !last_of_para),
Justify::JustifyCenter => (1, !last_of_para),
Justify::JustifyRight => (2, !last_of_para),
Justify::JustifyAll => (0, true),
};
let justify = justify && regions.is_some();
let (mut per_space, mut per_gap) = (0.0, 0.0);
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
if justify && (width - w).abs() > EPS {
if spaces > 0 {
// Composed lines may shrink word spaces (never below zero).
per_space =
((width - w) / spaces as f64).max(-sg[i..trimmed].iter().filter(|g| g.is_space()).map(|g| g.adv).fold(f64::MAX, f64::min));
} else if para.justify == Justify::JustifyAll && width > w {
let gaps = sg.get(i + 1..trimmed).map_or(0, |s| s.iter().filter(|g| !g.continues_tcy()).count());
if gaps > 0 {
per_gap = (width - w) / gaps as f64;
}
}
}
let start_x = if justify {
ax0
} else if regions.is_none() {
match align {
0 => ind_l,
1 => (ind_l - para.right_indent - w) * 0.5,
_ => -para.right_indent - w,
}
} else {
match align {
0 => ax0,
1 => ax0 + (width - w) * 0.5,
_ => ax1 - w,
}
};
let li = cx.out.lines.len();
let glyph_start = cx.out.glyphs.len();
let mut x = start_x;
let mut x_end = start_x;
// Tab stops are measured from the frame's left edge (point type: the origin).
let tab_origin = if regions.is_some() { x0 } else { 0.0 };
for (j, g) in sg.iter().enumerate().take(end).skip(i) {
let mut adv = g.adv;
if g.ch == '\t' {
adv = tab_advance(&para.tabs, tab_origin, x, &sg[j + 1..trimmed.max(j + 1)]);
} else if j < trimmed {
if g.is_space() {
adv += per_space;
} else if j + 1 < trimmed && sg.get(j + 1).is_some_and(|next| !next.continues_tcy()) {
// Between glyphs, never inside a tate-chu-yoko block (one cell).
adv += per_gap;
}
}
cx.emit(g, Affine::translate((x, baseline)), Point::new(x, baseline), 0.0, adv, li);
x += adv;
if j + 1 == trimmed {
x_end = x;
}
}
if let Some(h) = &hyphen {
// The hyphen follows the last non-space glyph.
let hx = x_end;
cx.emit(h, Affine::translate((hx, baseline)), Point::new(hx, baseline), 0.0, h.adv, li);
x_end = hx + h.adv;
}
cx.out.lines.push(LineInfo {
baseline,
x0: start_x,
x1: x_end,
ascent: m.asc,
descent: m.desc,
start: if i < n { sg[i].byte } else { pr.start },
end: if last_of_para { pr.end } else { sg[end].byte },
glyph_start,
glyph_end: cx.out.glyphs.len(),
avail: if regions.is_some() { (ax0, ax1) } else { (start_x, x_end) },
});
pen.prev = Some(baseline);
// Further spans of this line band share the settled baseline.
for q in &mut pen.queued {
q.0 = baseline;
}
li_para += 1;
i = end;
if i >= n {
break;
}
}
pen.pending += para.space_after;
}
}
/// Arc-length parameterised path.
struct ArcPath {
segs: Vec<(PathSeg, f64, f64)>,
len: f64,
}
impl ArcPath {
fn new(p: &BezPath) -> Self {
let mut segs = Vec::new();
let mut cum = 0.0;
for s in p.segments() {
let l = s.arclen(1e-4);
if l > 1e-9 {
segs.push((s, cum, l));
cum += l;
}
}
Self { segs, len: cum }
}
/// Point and unit tangent at arc length `s`.
fn at(&self, s: f64) -> (Point, Vec2) {
let s = s.clamp(0.0, self.len);
let i = self.segs.partition_point(|(_, c, _)| *c <= s).saturating_sub(1);
let (seg, c, l) = self.segs[i];
let t = seg.inv_arclen((s - c).min(l), 1e-4).clamp(0.0, 1.0);
let p = seg.eval(t);
let (t0, t1) = ((t - 1e-4).max(0.0), (t + 1e-4).min(1.0));
let d = seg.eval(t1) - seg.eval(t0);
let len = d.hypot();
(p, if len > 1e-12 { d / len } else { Vec2::new(1.0, 0.0) })
}
}
#[allow(clippy::too_many_arguments)]
fn on_path(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, path: &BezPath, start: f64, closed: bool, effect: PathEffect) {
cx.out.on_path = true;
let mut sg = Vec::new();
for pr in paras {
sg.extend(cx.shape_para(pr.clone()));
}
let ap = ArcPath::new(path);
let m = Metrics::max(&sg).map(|m| (m.asc, m.desc)).unwrap_or_else(|| {
let s = style_metrics(cx.db, cx.style_at(0));
(s.0, s.1)
});
let text_len = cx.text.len();
if ap.segs.is_empty() {
cx.out.overflow = !sg.is_empty();
cx.out.lines.push(LineInfo {
baseline: 0.0,
x0: 0.0,
x1: 0.0,
ascent: m.0,
descent: m.1,
start: 0,
end: text_len,
glyph_start: 0,
glyph_end: 0,
avail: (0.0, 0.0),
});
return;
}
let centre = path.bounding_box().center();
let s_start = start.clamp(0.0, 1.0) * ap.len;
let avail = if closed { ap.len } else { ap.len - s_start };
let w: f64 = sg.iter().map(|g| g.adv).sum();
let s0 = match para.justify {
Justify::Center | Justify::JustifyCenter => s_start + ((avail - w) * 0.5).max(0.0),
Justify::Right | Justify::JustifyRight => s_start + (avail - w).max(0.0),
_ => s_start,
};
let mut x = 0.0;
for g in &sg {
let s = s0 + x;
if s + g.adv - s_start > avail + 1e-6 {
cx.out.overflow = true;
break;
}
let mut mid = s + g.adv * 0.5;
if closed {
mid = mid.rem_euclid(ap.len);
}
let (p, dir) = ap.at(mid);
let angle = dir.y.atan2(dir.x);
let half = Affine::translate((-g.adv * 0.5, 0.0));
// Glyph space: x along the advance, y down from the baseline; `pre` maps it onto the path.
let pre = match effect {
PathEffect::Rainbow => Affine::translate(p.to_vec2()) * Affine::rotate(angle) * half,
// x axis along the tangent, y axis stays vertical.
PathEffect::Skew => Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, 0.0, 1.0, 0.0, 0.0]) * half,
// x axis stays horizontal (facing the path's direction), y axis perpendicular to the path.
PathEffect::Ribbon3d => {
let sx = if dir.x < 0.0 { -1.0 } else { 1.0 };
Affine::translate(p.to_vec2()) * Affine::new([sx, 0.0, -dir.y * sx, dir.x * sx, 0.0, 0.0]) * half
}
PathEffect::StairStep => {
let s_left = if closed { s.rem_euclid(ap.len) } else { s };
Affine::translate(ap.at(s_left).0.to_vec2())
}
// x axis along the tangent; vertical edges point at the path's centre (kept on the glyph's
// up side, and never closer than ~17° to the baseline so glyphs stay legible).
PathEffect::Gravity => {
let n = Vec2::new(dir.y, -dir.x);
let mut up = p - centre;
up = if up.hypot() < 1e-9 { n } else { up / up.hypot() };
if up.dot(n) < 0.0 {
up = -up;
}
if up.dot(n) < 0.3 {
let t = up - n * up.dot(n);
up = n * 0.3 + t / t.hypot().max(1e-9) * (1.0 - 0.09f64).sqrt();
}
Affine::translate(p.to_vec2()) * Affine::new([dir.x, dir.y, -up.x, -up.y, 0.0, 0.0]) * half
}
};
cx.emit(g, pre, p - dir * (g.adv * 0.5), angle, g.adv, 0);
x += g.adv;
}
let (ps, _) = ap.at(if closed { s0.rem_euclid(ap.len) } else { s0 });
let (pe, _) = ap.at(if closed { (s0 + x).rem_euclid(ap.len) } else { s0 + x });
cx.out.lines.push(LineInfo {
baseline: ps.y,
x0: ps.x,
x1: pe.x,
ascent: m.0,
descent: m.1,
start: 0,
end: text_len,
glyph_start: 0,
glyph_end: cx.out.glyphs.len(),
avail: (0.0, ap.len),
});
}