vectorcraft/crates/text/src/layout.rs

1121 lines
46 KiB
Rust
Raw 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, Mojikumi, ParaStyle, PathEffect, TextKind, TextObject};
use crate::composer::{Breakpoint, 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};
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);
// A glyph whose leading space was taken off (mojikumi) is drawn that much earlier: an
// upright one in vertical type by turning about a point that much higher.
let upright = self.vertical && !self.on_path && g.tcy.is_none() && stands_upright(g);
let lead = if upright { 0.0 } else { g.lead };
let local =
Affine::rotate(-g.rotation.to_radians()) * Affine::translate((g.dx - lead, 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 - g.lead, 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;
}
}
/// Half an em of `g`'s size when it is full-width Japanese punctuation of kind `kind` (its
/// advance an em, give or take a tenth), the space that mojikumi can take off.
fn punct_half(g: &SGlyph, kind: Punct) -> Option<f64> {
if punct(g.ch)? != kind || g.tcy.is_some() {
return None;
}
let em = g.face.units_per_em();
((g.face.advance(g.gid) - em).abs() <= em * 0.1).then_some(em * 0.5 * g.sx)
}
/// Mojikumi (JLREQ 3.1.4): consecutive punctuation shares one half-em space. A closing bracket,
/// comma or full stop followed by punctuation loses the space after it; an opening bracket after
/// another loses the space before it. Returns which glyphs lost the space after them (a line
/// ending in one takes nothing more off).
fn compress_punctuation(sg: &mut [SGlyph]) -> Vec<bool> {
let mut lost_after = vec![false; sg.len()];
for j in 1..sg.len() {
let (before, after) = sg.split_at_mut(j);
let (Some(a), Some(b)) = (before.last_mut(), after.first_mut()) else { continue };
if punct(b.ch).is_none() {
continue;
}
if let Some(h) = punct_half(a, Punct::Closing) {
a.adv -= h;
if let Some(l) = lost_after.get_mut(j - 1) {
*l = true;
}
} else if punct(a.ch) == Some(Punct::Opening)
&& let Some(h) = punct_half(b, Punct::Opening)
{
b.adv -= h;
b.lead += h;
}
}
lost_after
}
/// 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 mut sg = cx.shape_para(pr.clone());
// Japanese composition: consecutive punctuation shares one half-em space.
let compressed = if para.mojikumi == Mojikumi::LineEndHalf { compress_punctuation(&mut sg) } else { vec![] };
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]));
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width.
let end_trim = match trimmed.checked_sub(1).filter(|&k| k >= i && para.mojikumi == Mojikumi::LineEndHalf) {
Some(k) if !compressed.get(k).copied().unwrap_or(false) => sg.get(k).and_then(|g| punct_half(g, Punct::Closing)).unwrap_or(0.0),
_ => 0.0,
};
let w: f64 = sg[i..trimmed].iter().map(|g| g.adv).sum::<f64>() + hyphen.as_ref().map_or(0.0, |h| h.adv) - end_trim;
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, mut per_cjk) = (0.0, 0.0, 0.0);
let spaces = sg[i..trimmed].iter().filter(|g| g.is_space()).count();
// Japanese (and Chinese) lines are justified between their characters (JLREQ 3.8): the
// gaps next to a CJK character, not inside a Latin word or a tate-chu-yoko block.
let cjk_gap = |j: usize| {
j + 1 < trimmed
&& sg
.get(j)
.zip(sg.get(j + 1))
.is_some_and(|(a, b)| !a.is_space() && !b.is_space() && !b.continues_tcy() && a.ch != '\t' && (is_cjk(a.ch) || is_cjk(b.ch)))
};
let cjk_gaps = (i..trimmed).filter(|&j| cjk_gap(j)).count();
if justify && (width - w).abs() > EPS {
if cjk_gaps > 0 && width > w {
// Spread over the CJK gaps and the word spaces alike.
let per = (width - w) / (cjk_gaps + spaces) as f64;
(per_space, per_cjk) = (per, per);
} else 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 j + 1 == trimmed {
adv -= end_trim;
}
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 per_cjk != 0.0 {
if cjk_gap(j) {
adv += per_cjk;
}
} 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),
});
}