Files
vectorcraft/crates/text/src/layout/breaking.rs
T
dotCooCoo ba2a6fc1f3 Type: a further span beside a wrap object is measured across its line's height
Text wrap can split a line into spans that share a baseline. The spans
after the first were measured across the height of the line set in the
first one, so a taller line set in a later span could run into a wrap
object the shorter line stayed clear of. Each further span is now
measured again across the height of the line that goes in it. The line
gets the part that is free, and a span with no free part, or with a part
narrowed to less than the line's first word, is left empty. The
Every-line Composer and the line loop both place lines through
`Pen::place`, so they agree.

The Every-line Composer also keeps a line start whose line found no room
while a longer line from it would be taller. A taller line steps down
the frame by its own leading and can find room the shorter line's steps
missed.

Tests: a_further_span_is_measured_across_the_height_of_its_line,
composition_tries_a_longer_line_after_one_that_cannot_be_set.
2026-11-13 04:48:43 -08:00

871 lines
41 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.
use super::region::Region;
use super::vertical::stands_upright;
use super::{
Affine, BezPath, Breakpoint, Burasagari, EPS, FirstBaseline, FontDb, HyphenationOptions, Kinsoku, KinsokuShoriType, LeadingModel, SGlyph,
hyphen_glyph, is_cjk, no_line_end, no_line_start, points_for, soft_line_start,
};
use crate::justify::is_format_control;
/// Vertical metrics of a line: (ascent, descent, leading, cap height, x height).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Metrics {
pub(super) asc: f64,
pub(super) desc: f64,
pub(super) lead: f64,
pub(super) cap: f64,
pub(super) xh: f64,
/// Height of the top of the ideographic em box above the baseline.
pub(super) top: f64,
}
impl Metrics {
pub(super) fn of(g: &SGlyph) -> Self {
let em = g.face.units_per_em() * g.sy;
Self { asc: g.ascent, desc: g.descent, lead: g.leading, cap: g.cap, xh: g.xh, top: (g.face.ideographic_centre() + 0.5) * em }
}
pub(super) fn max(g: &[SGlyph]) -> Option<Self> {
let mut it = g.iter();
let first = Self::of(it.next()?);
Some(it.fold(first, |m, g| m.union(Self::of(g))))
}
/// The larger of each of the metrics of `self` and `o`: those of a line holding both.
pub(super) fn union(self, o: Self) -> Self {
Self {
asc: self.asc.max(o.asc),
desc: self.desc.max(o.desc),
lead: self.lead.max(o.lead),
cap: self.cap.max(o.cap),
xh: self.xh.max(o.xh),
top: self.top.max(o.top),
}
}
/// Distance from the frame top to the first baseline.
pub(super) 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)]
pub(super) struct Pen<'r> {
pub(super) regions: Option<&'r [Region]>,
pub(super) ri: usize,
pub(super) prev: Option<f64>,
pub(super) pending: f64,
pub(super) fb: FirstBaseline,
pub(super) fb_min: f64,
/// Further spans at the current baseline (text wrapping on both sides of an object).
pub(super) queued: Vec<(f64, f64, f64)>,
/// Leading measured from em box top to em box top: where the next line's em box top goes.
pub(super) model: LeadingModel,
pub(super) next_top: Option<f64>,
/// The narrowest word no line from here down is wide enough for (see [`Pen::place`]).
pub(super) no_room_for: f64,
/// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's
/// copies): see [`MAX_TOTAL_STEPS`].
pub(super) steps_left: std::rc::Rc<std::cell::Cell<usize>>,
/// What is left of the every-line composer's search over the frame's paragraphs (shared by
/// this pen's copies): see [`super::shaped::Budget`].
pub(super) search: std::rc::Rc<super::shaped::Budget>,
}
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
/// in a cell taller than this many leads, a step is the cell's height divided by this.
const MAX_STEPS: usize = 100_000;
/// The most baseline steps one frame's layout takes in all, over every cell: a cell may take
/// [`MAX_STEPS`], but rows and columns of tall, narrow-shaped cells would multiply that.
pub(super) const MAX_TOTAL_STEPS: usize = 10_000_000;
impl Pen<'_> {
/// Baseline and horizontal span for a line with estimated metrics `est` and indents that starts
/// with a word `word` wide; `None` = the frame is full (overflow).
/// In a frame that isn't a rectangle, lines too short for the word are left empty and it moves
/// down to the first one wide enough. Only when no line is does it go on the first line with
/// room for any text, and get broken there.
/// The `bool` is true for a further span at the previous line's baseline.
pub(super) fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64, word: f64) -> Option<(f64, f64, f64, bool)> {
if word > 0.0 && word < self.no_room_for {
let mut pen = self.clone();
if let Some(placed) = pen.place_at_least(est, ind_l, ind_r, word) {
*self = pen;
return Some(placed);
}
// Lines further down are no wider for this word: don't search for it, or a wider one, again.
self.no_room_for = word;
}
self.place_at_least(est, ind_l, ind_r, 0.0)
}
/// [`Pen::place`], skipping lines of a non-rectangular frame narrower than `min`.
fn place_at_least(&mut self, est: Metrics, ind_l: f64, ind_r: f64, min: f64) -> Option<(f64, f64, f64, bool)> {
let Some(regions) = self.regions else {
let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est));
return Some((b, f64::NEG_INFINITY, f64::INFINITY, false));
};
// A further span was measured across the height of the line set before it on its baseline.
// This line gets the part of it that is free across its own height: a taller line can
// reach a wrap object the line before it stayed clear of. A span with no such part is
// left empty, and so is a part the line's height made narrower than `min`.
while !self.queued.is_empty() {
let (b, x0, x1) = self.queued.remove(0);
let Some(r) = regions.get(self.ri) else { return Some((b, x0, x1, true)) };
let mut free = r.spans(b - est.asc, b + est.desc).into_iter().filter_map(|(s, e)| {
let (s, e) = (s.max(x0), e.min(x1));
let room = e - s - ind_l - ind_r;
let narrowed = s > x0 + EPS || e < x1 - EPS;
(room > est.asc.max(1.0) && (!narrowed || room + EPS >= min)).then_some((s, e))
});
let Some((s, e)) = free.next() else { continue };
// An object inside the span splits it: the parts after the first stay queued.
let rest: Vec<_> = free.map(|(s, e)| (b, s, e)).collect();
self.queued.splice(0..0, rest);
return Some((b, s, e, true));
}
loop {
let r = regions.get(self.ri)?;
let mut baseline = match self.prev {
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,
};
// The baseline moves down the cell a lead at a time, or a `MAX_STEPS`th of the cell's
// height when that is more, in at most `MAX_STEPS` steps.
let step = est.lead.max(1.0).max(r.cell.height() / MAX_STEPS as f64);
for _ in 0..=MAX_STEPS {
let left = self.steps_left.get();
if left == 0 {
// Out of steps: the frame is full as far as this text goes.
return None;
}
self.steps_left.set(left - 1);
// A baseline a step no longer moves (past a gutter of 1e300, say) won't reach a line.
if baseline + est.desc > r.bottom() + 0.01 || baseline + step <= baseline || baseline.is_nan() {
break;
}
// A rectangle's lines are all as wide: none below has more room for the word
// (until wrap shapes can narrow the first baseline: they stay bounded).
let constant = (r.rect || r.polys.is_empty()) && r.wraps.is_empty();
let min = if constant { 0.0 } else { min };
let fits = |&(a, b): &(f64, f64)| {
let room = b - a - ind_l - ind_r;
room > est.asc.max(1.0) && room + EPS >= min
};
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)),
// Too narrow here is too narrow all the way down the cell: next cell.
Some(_) if constant => break,
_ => baseline += step,
}
} 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 += step;
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();
}
}
pub(super) fn bottom(&self) -> f64 {
self.regions.and_then(|r| r.get(self.ri)).map_or(f64::INFINITY, |r| r.bottom())
}
/// The baseline of the line after the one at `prev`, for a line of metrics `est`.
fn next_baseline(&self, prev: f64, est: Metrics) -> f64 {
match (self.model, self.next_top) {
(LeadingModel::EmBoxTop, Some(top)) => top + est.top + self.pending,
_ => prev + est.lead + self.pending,
}
}
/// A line was set at `baseline` with metrics `m`: where the next one goes from.
pub(super) fn settled(&mut self, baseline: f64, m: Metrics) {
self.prev = Some(baseline);
self.next_top = Some(baseline - m.top + m.lead);
}
}
/// Advance of a tab at pen position `x`: up to the next stop after it (explicit stops first, then
/// every ½ inch), and the leader of that stop (default stops have none). Right, centre and decimal
/// stops align the text that follows (`rest`, up to the next tab) on the stop.
pub(super) fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[SGlyph]) -> (f64, &'t str) {
use vectorcraft_doc::TabAlign;
let rel = x - origin;
let seg: Vec<&SGlyph> = rest.iter().take_while(|g| g.ch != '\t' && g.ch != '\u{2028}').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, t.leader.as_str()))
});
let (at, leader) = 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), leader)
}
/// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the
/// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their
/// glyph. The tab of a list marker (length 0) keeps its own width.
pub(super) fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
let mut x = x_start;
for (j, g) in sg.iter().enumerate().take(to).skip(from) {
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, sg.get(j + 1..to).unwrap_or_default()).0 } else { g.adv };
}
x - x_start
}
/// Most copies of a leader drawn in one tab: a tiny type size and a far stop must not make
/// millions of outlines.
const MAX_LEADER_COPIES: f64 = 4096.0;
/// The outline of a tab leader: `leader`'s characters, in the font and size of tab `g`, repeated
/// across the tab's gap `from..to` on baseline `y`. Copies sit on a grid of the leader's width
/// from `origin` (where tab stops are measured from), so the leaders of lines above one another
/// line up; only whole copies are drawn. Characters the font lacks draw nothing.
pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64, from: f64, to: f64, y: f64) -> BezPath {
let mut out = BezPath::new();
let glyphs: Vec<(u32, f64)> = leader
.chars()
.filter(|c| !c.is_control())
.map(|c| {
let gid = g.face.glyph_for(c);
(gid, g.face.advance(gid) * g.sx)
})
.collect();
let width: f64 = glyphs.iter().map(|(_, a)| a).sum();
if !(width > EPS && width.is_finite() && from.is_finite() && to.is_finite()) {
return out;
}
let first = ((from - origin) / width - EPS).ceil();
let copies = (((to - origin) / width + EPS).floor() - first).clamp(0.0, MAX_LEADER_COPIES) as usize;
let local = Affine::translate((0.0, -g.bshift)) * Affine::rotate(-g.rotation.to_radians()) * Affine::scale_non_uniform(g.sx, g.sy);
for k in 0..copies {
let mut pen = origin + (first + k as f64) * width;
for &(gid, adv) in &glyphs {
if gid != 0 {
let m = Affine::translate((pen, y)) * local;
out.extend(db.outline(&g.face, gid).elements().iter().map(|el| m * *el));
}
pen += adv;
}
}
out
}
/// Width of the word starting at glyph `i`: its advances up to the first place a line may break
/// (spaces left out, the hyphen of a soft hyphen in), the least room a line needs not to break
/// inside it. As in [`break_line`], kinsoku keeps a character that may not end a line with the one
/// after it, a No Break run (its spaces inside it included) is one word up to a forced line break,
/// and a cluster is never split. A soft hyphen just before a forced line break adds no hyphen. A list marker at `i` (glyphs of length 0) counts whole, with the word after it. A word
/// that ends where a line may end is measured as the line is set: without the spaces at its end,
/// and with its last glyph as narrow as `line_end` sets it; one that ends at a soft hyphen or past
/// the measured glyphs is not.
pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kinsoku) -> f64 {
/// Glyphs measured at most: a word wrapped over many lines is measured again from each line's
/// start, and one this long is broken inside whatever cell it meets.
const MAX_GLYPHS: usize = 1024;
let rest = g.get(i..).unwrap_or_default();
let marker = rest.iter().take_while(|gl| gl.len == 0).count();
let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum();
let start = i + marker;
let mut end = start;
// The spaces at the end of what is measured so far (inside a No Break run), and the end of its
// last glyph that is not a space.
let (mut trailing, mut ink) = (0.0, start);
let set = |w: f64, trailing: f64, ink: usize| w - trailing - line_end_credit(g, line_end, ink);
while let Some(gl) = g.get(end).filter(|_| end - start < MAX_GLYPHS) {
// A forced line break ends the line, inside a No Break run too, as in [`break_line`].
if gl.ch == '\u{2028}' {
return set(w, trailing, ink);
}
if gl.is_space() && (end == start || !splits_no_break(g, end)) {
return set(w, trailing, ink);
}
let forced_next = g.get(end + 1).is_some_and(|n| n.ch == '\u{2028}');
if gl.is_soft_hyphen() && !forced_next && kinsoku_allows(g, end, kinsoku) && !splits_no_break(g, end + 1) {
return w + hyphen_glyph(gl).adv;
}
w += gl.adv;
end += 1;
if gl.is_space() {
trailing += gl.adv;
} else {
(trailing, ink) = (0.0, end);
}
let cluster_ends = g.get(end).is_none_or(|next| next.byte != gl.byte);
if gl.break_after() && cluster_ends && kinsoku_allows(g, end - 1, kinsoku) && !splits_no_break(g, end) {
return set(w, trailing, ink);
}
}
if end >= g.len() { set(w, trailing, ink) } else { w }
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
/// With burasagari, a comma or full stop that doesn't fit ends the line, hanging outside it.
/// `hyphenate` holds the paragraph's Hyphenation options and the Hyphenation Zone in force when
/// the line may hyphenate a word; a line whose text before the overflowing word already ends
/// within the zone breaks before that word. A tab is as wide as it is set: up to its next stop
/// (`tabs`), with the line's content starting `x_start` from the tab origin. Where kinsoku alone
/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides
/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical
/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type.
/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster
/// that fits only that narrower stays on the line when the line may end after it (empty: none).
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
g: &[SGlyph],
i: usize,
width: f64,
tabs: &[vectorcraft_doc::TabStop],
x_start: f64,
hyphenate: Option<(&HyphenationOptions, f64)>,
burasagari: Burasagari,
kinsoku: Kinsoku,
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> (usize, bool) {
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
return (i + pos + 1, false);
}
return (g.len(), false);
}
let mut x = 0.0;
let mut last_break: Option<(usize, bool)> = None;
// A list marker (length 0) is a cluster of its own; the line holds it and at least the first
// cluster after it.
let first = i + g.get(i..).unwrap_or_default().iter().take_while(|h| h.len == 0).count();
let mut j = i;
while j < g.len() {
let gl = &g[j];
let mut cluster_end = j + 1;
while g.get(cluster_end).is_some_and(|next| next.byte == gl.byte && (next.len == 0) == (gl.len == 0)) {
cluster_end += 1;
}
// No Break: the marked characters stay on one line, including across a space inside the run;
// Bunri-Kinshi keeps an inseparable pair, or a chain of them, on one line too.
if gl.ch != '\u{2028}' {
while let Some(n) = g.get(cluster_end) {
if !splits_no_break(g, cluster_end) || n.ch == '\u{2028}' {
break;
}
let b = n.byte;
cluster_end += 1;
while g.get(cluster_end).is_some_and(|next| next.byte == b) {
cluster_end += 1;
}
}
}
// A list marker's glyphs, its tab included, keep their own widths.
let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 {
tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0
} else {
g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()
};
if gl.ch == '\u{2028}' {
return (cluster_end, false);
}
// Measured as the line is set when it ends with this cluster: only the cluster's last glyph
// is narrowed, and only where the line may end.
let end_credit =
Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0);
if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
let mut end = cluster_end;
while g.get(end).is_some_and(SGlyph::is_space) {
end += 1;
}
return (end, false);
}
let pushed_out = last_break.map(|b| b.0);
if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, pushed_out, kinsoku, kinsoku_type, hanging, vertical, line_end)
{
return (end, false);
}
break;
}
x += cluster_width;
let last = &g[cluster_end - 1];
if last.break_after() && last.len > 0 && kinsoku_allows(g, cluster_end - 1, kinsoku) && !splits_no_break(g, cluster_end) {
if last.is_soft_hyphen() {
if x + hyphen_glyph(last).adv <= width + EPS {
last_break = Some((cluster_end, true));
}
} else {
last_break = Some((cluster_end, false));
}
}
j = cluster_end;
}
if j >= g.len() {
return (g.len(), false);
}
// Hyphenate the word that overflows. A list marker starting the line is not part of a word.
let word_start = last_break.map_or(first, |b| b.0);
let in_zone = |zone: f64| {
let mut te = word_start;
while te > first && g[te - 1].is_space() {
te -= 1;
}
te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS
};
if let Some((opts, zone)) = hyphenate
&& g[j].is_letter()
&& !in_zone(zone)
{
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, opts)
.into_iter()
.filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
.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);
}
}
}
}
// Break candidates and greedy overflow are cluster boundaries; hyphenation also returns only
// cluster starts, so the resulting end always keeps each source cluster together.
let (end, hy) = match last_break {
Some(b) if b.0 > i => b,
_ => (j, false),
};
(end, hy)
}
/// Can glyph `g` hang outside the line (burasagari)? An East Asian comma or full stop, full width
/// (、。,.) or half width (、。); not a closing bracket, nor Latin punctuation (Latin text keeps
/// its line breaks and composer). Never an inline graphic (U+FFFC).
pub(super) fn hangs(g: &SGlyph) -> bool {
g.inline.is_none() && matches!(g.ch, '、' | '。' | ',' | '.' | '、' | '。') && g.tcy.is_none()
}
/// Optical Margin Alignment: the share of glyph `g`'s advance that hangs outside the margin when
/// `g` starts or ends a line. Quotation marks, hyphens, full stops and commas hang whole, and
/// asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half (the amounts
/// published for Roman Hanging Punctuation). A, V, W, Y, v, w and y hang by a tenth of their
/// advance, VectorCraft's own amount for their slanted sides. Other characters, East Asian
/// punctuation (which burasagari hangs) and inline graphics don't hang.
pub(super) fn optical_share(g: &SGlyph) -> f64 {
if g.inline.is_some() || g.tcy.is_some() {
return 0.0;
}
match g.ch {
'-' | '\u{2010}' | '\u{2011}' | '.' | ',' => 1.0,
c if is_quote(c) => 1.0,
'*' | '~' | '…' | '–' | '—' | ':' | ';' => 0.5,
'A' | 'V' | 'W' | 'Y' | 'v' | 'w' | 'y' => 0.1,
_ => 0.0,
}
}
/// Roman Hanging Punctuation: the share of glyph `g`'s advance that hangs outside the margin when
/// `g` starts or ends a line: [`optical_share`]'s punctuation, and no letters.
pub(super) fn roman_share(g: &SGlyph) -> f64 {
if g.ch.is_alphabetic() { 0.0 } else { optical_share(g) }
}
/// A punctuation mark that hangs ([`optical_share`]), not a letter.
pub(super) fn optical_punct(g: &SGlyph) -> bool {
!g.ch.is_alphabetic() && optical_share(g) > 0.0
}
/// A single or double quotation mark: straight, curly, low-9 or reversed-9.
fn is_quote(c: char) -> bool {
matches!(c, '\'' | '"' | '‘' | '’' | '‚' | '‛' | '“' | '”' | '„' | '‟')
}
/// Optical Margin Alignment and Roman Hanging Punctuation: how far a line hangs outside its left
/// and right margins, from the glyph at each end of `line` (its glyphs in visual order, each with
/// the advance it is set with) and its `share` ([`optical_share`] or [`roman_share`]), and how many glyphs hang at each end → (left, right, left glyphs,
/// right glyphs). A punctuation mark followed by a quotation mark hangs together with it: `."`
/// ending a line and `'"` starting one hang both their glyphs. A line of one glyph hangs at
/// neither end.
pub(super) fn optical_hangs(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) {
let n = line.len();
if n < 2 {
return (0.0, 0.0, 0, 0);
}
let hang = |k: usize| line.get(k).map_or(0.0, |&(g, adv)| share(g) * adv.max(0.0));
let punct = |k: usize| line.get(k).is_some_and(|&(g, _)| optical_punct(g));
let quote = |k: usize| line.get(k).is_some_and(|&(g, _)| is_quote(g.ch) && share(g) > 0.0);
let mut left = hang(0);
// The glyphs that hang at the left; the right end takes none of them.
let mut taken = usize::from(left > 0.0);
if left > 0.0 && n > 2 && punct(0) && quote(1) {
left += hang(1);
taken = 2;
}
let mut right = hang(n - 1);
let mut right_glyphs = usize::from(right > 0.0);
if right > 0.0 && n > 2 && n - 2 >= taken && punct(n - 2) && quote(n - 1) {
right += hang(n - 2);
right_glyphs = 2;
}
(left, right, taken, right_glyphs)
}
/// Works as [`optical_hangs`] does for a right-to-left line, whose `line` is also in visual order.
/// The line is read from right to left: `."` ending it hangs both its glyphs at the left, `'"` starting it hangs both at
/// the right, and the right end takes its glyphs first.
pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f64) -> (f64, f64, usize, usize) {
let reversed: Vec<(&SGlyph, f64)> = line.iter().rev().copied().collect();
let (right, left, right_glyphs, left_glyphs) = optical_hangs(&reversed, share);
(left, right, left_glyphs, right_glyphs)
}
/// How much narrower a line whose last character is glyph `end - 1` is set: `line_end` of that
/// glyph ([`break_line`]), at most its advance, and 0 when it is not finite.
fn line_end_credit(g: &[SGlyph], line_end: &[f64], end: usize) -> f64 {
let Some(k) = end.checked_sub(1) else { return 0.0 };
match (line_end.get(k), g.get(k)) {
(Some(&credit), Some(gl)) if credit.is_finite() && credit > 0.0 => credit.min(gl.adv.max(0.0)),
_ => 0.0,
}
}
/// May [`break_line`] end a line after glyph `end - 1` (and the spaces after it)? At the paragraph's
/// end or a forced line break, and where it records a break without a hyphen.
fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool {
let mut e = end;
while g.get(e).is_some_and(SGlyph::is_space) {
e += 1;
}
if e >= g.len() || g[e].ch == '\u{2028}' {
return true;
}
let Some(last) = e.checked_sub(1).and_then(|k| g.get(k)) else { return false };
last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e)
}
/// Would a line ending at glyph index `end` split a No Break run, or an inseparable pair that
/// Bunri-Kinshi keeps together ([`SGlyph::keeps_next`])?
fn splits_no_break(g: &[SGlyph], end: usize) -> bool {
end > 0 && g.get(end - 1).is_some_and(|a| (a.no_break && g.get(end).is_some_and(|b| b.no_break)) || (a.keeps_next && g.get(end).is_some()))
}
/// 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, kinsoku: Kinsoku) -> bool {
g.get(j).is_some_and(|gl| kinsoku_between(gl.ch, g.get(j + 1).map(|n| n.ch), kinsoku))
}
/// Kinsoku for a break between `before` and `after` (none: the end of the paragraph): Hard keeps
/// every character of the set off the line start and end, Soft lets 々, ー and small kana start a
/// line, None allows the break.
fn kinsoku_between(before: char, after: Option<char>, kinsoku: Kinsoku) -> bool {
match kinsoku {
Kinsoku::None => true,
Kinsoku::Hard => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c)),
Kinsoku::Soft => !no_line_end(before) && after.is_none_or(|c| !no_line_start(c) || soft_line_start(c)),
}
}
/// The share of an em by which a push-in ([`push_in`]) may close each gap next to a CJK character
/// on the line, measured on the character before the gap.
pub(super) const PUSH_IN_SQUEEZE: f64 = 0.1;
/// The most glyphs after the overflowing cluster a push-in ([`push_in`]) takes onto the line; a
/// longer run of characters kinsoku keeps together pushes out.
const PUSH_IN_RUN: usize = 16;
/// Kinsoku Shori Type: the end of the line from `i` that overflows `width` at the cluster
/// `j..cluster_end` (the line is `x` wide through it) when the line pushes in, or None when it
/// pushes out. A line pushes in only where kinsoku rules out the break before `j`: a character
/// that may not end a line comes before it (an opening bracket or quotation mark, Latin ones
/// included), or one that may not start a line comes at `j`. It then takes the characters up to
/// the next break kinsoku allows (not at a soft hyphen), and the spaces after it, when closing
/// its gaps next to CJK characters, each by up to [`PUSH_IN_SQUEEZE`] of an em of the character
/// before it, makes room for them. Push In First tries this before it pushes out. Push Out First
/// tries it only when pushing out, to the line's last allowed break `pushed_out`, would leave the
/// line short by more than an em of the character before `j`. Push Out Only never pushes in, and
/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in
/// reach a space after a character that may not end a line. With `hanging` (Optical Margin
/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). Both lines are
/// measured with their last character set as narrow as `line_end` sets it ([`break_line`]).
#[allow(clippy::too_many_arguments)]
fn push_in(
g: &[SGlyph],
i: usize,
j: usize,
cluster_end: usize,
x: f64,
width: f64,
pushed_out: Option<usize>,
kinsoku: Kinsoku,
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> Option<usize> {
if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly {
return None;
}
let before = g.get(j.checked_sub(1)?)?;
if before.len == 0 || g.get(j)?.no_break || splits_no_break(g, j) || kinsoku_allows(g, j - 1, kinsoku) {
return None;
}
let allowed = |k: usize| {
k >= g.len()
|| (g[k].byte != g[k - 1].byte
&& kinsoku_allows(g, k - 1, kinsoku)
&& !splits_no_break(g, k)
&& (g[k].is_space() || (g[k - 1].break_after() && !g[k - 1].is_soft_hyphen())))
};
let (mut end, mut x) = (cluster_end, x);
while !allowed(end) {
let gl = &g[end];
// A space here follows a character kinsoku keeps off a line's end: the line pushes out.
if gl.is_space() || gl.ch == '\t' || gl.ch == '\u{2028}' || gl.no_break || end - cluster_end >= PUSH_IN_RUN {
return None;
}
x += gl.adv;
end += 1;
}
// A tab's width depends on where it falls; a line with one (other than a list marker's)
// pushes out.
if g[i..end].iter().any(|h| h.ch == '\t' && h.len > 0) {
return None;
}
// With `hanging`, punctuation keeps the gap after a mark that may hang at the line's start,
// and the gap before one that may hang at its end ([`optical_hangs`]): those gaps are not
// counted.
let mut last = end;
while last > i && g[last - 1].is_space() {
last -= 1;
}
// A line holding right-to-left text is reordered before it is drawn, and justification closes
// the gaps between its glyphs in that order: it pushes out.
if g[i..last].iter().any(|h| h.len > 0 && h.level.is_rtl()) {
return None;
}
let punct = |k: usize| g.get(k).is_some_and(optical_punct);
let quote = |k: usize| g.get(k).is_some_and(|h| is_quote(h.ch));
let may_hang = |a: usize| {
hanging
&& ((a == i && punct(i))
|| (a == i + 1 && punct(i) && quote(i + 1))
|| (a + 2 == last && punct(last - 1))
|| (a + 3 == last && punct(last - 2) && quote(last - 1)))
};
let room: f64 = (i..end - 1).filter(|&a| closes(g, a, end) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
if x - line_end_credit(g, line_end, last) - width > room + EPS {
return None;
}
if kinsoku_type == KinsokuShoriType::PushOutFirst {
let mut kept = pushed_out.filter(|&b| b > i).unwrap_or(i);
while kept > i && g[kept - 1].is_space() {
kept -= 1;
}
let set = if kept > i { line_end_credit(g, line_end, kept) } else { 0.0 };
let short = width - (g[i..kept].iter().map(|h| h.adv).sum::<f64>() - set);
if short <= squeeze_em(before, vertical) + EPS {
return None;
}
}
while g.get(end).is_some_and(SGlyph::is_space) {
end += 1;
}
if g.get(end).is_some_and(|h| h.ch == '\u{2028}') {
end += 1;
}
Some(end)
}
/// May a push-in close the gap after glyph `a`? A gap between two characters, at least one of them
/// CJK, outside a tate-chu-yoko block, a list marker and a shaped cluster, and not next to a space,
/// a tab or an inline graphic before it, nor inside an inseparable pair that Bunri-Kinshi keeps
/// together. Zero-width format controls take no gap: the gap is the one between the visible
/// characters on either side, before `end`, as the layout justifies it.
fn closes(g: &[SGlyph], a: usize, end: usize) -> bool {
let Some(p) = g.get(a).filter(|p| !is_format_control(p.ch)) else { return false };
let Some(n) = g.get(a + 1..end).and_then(|rest| rest.iter().find(|n| !is_format_control(n.ch))) else { return false };
p.len > 0
&& !p.keeps_next
&& p.byte != n.byte
&& p.inline.is_none()
&& !p.is_space()
&& !n.is_space()
&& p.ch != '\t'
&& !n.continues_tcy()
&& (is_cjk(p.ch) || is_cjk(n.ch))
}
/// The em a push-in measures in, along the line: `g`'s em at its Horizontal Scale, as Mojikumi's
/// spacing is, or at its Vertical Scale when it stands upright in vertical type and the font's
/// vertical metrics set its length down the column ([`super::vertical::upright_cell`]).
pub(super) fn squeeze_em(g: &SGlyph, vertical: bool) -> f64 {
let down_the_column = vertical && stands_upright(g) && g.face.vertical_glyph(g.gid).is_some();
g.face.units_per_em() * if down_the_column { g.sy } else { g.sx }
}
#[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), Kinsoku::Hard), "{a}{b}");
}
for (a, b) in [('。', '雅'), ('」', 'は'), ('字', '「'), ('の', '演')] {
assert!(kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}");
}
assert!(kinsoku_between('。', None, Kinsoku::Hard));
}
/// Soft kinsoku lets 々, ー and small kana (JLREQ cl-09's 々, cl-10, cl-11) start a line and keeps
/// the rest of the set (half-width forms aren't in those classes); None allows any break.
#[cfg(test)]
#[test]
fn soft_kinsoku_lets_small_kana_and_the_prolonged_sound_mark_start_a_line() {
for (a, b) in [('ャ', 'ー'), ('カ', 'ッ'), ('時', '々'), ('ト', 'ㇰ'), ('か', 'ゃ')] {
assert!(!kinsoku_between(a, Some(b), Kinsoku::Hard), "{a}{b}");
assert!(kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}");
}
for (a, b) in [('字', '。'), ('弧', '」'), ('「', 'か'), ('カ', 'ッ'), ('カ', 'ー'), ('字', 'ゝ')] {
assert!(!kinsoku_between(a, Some(b), Kinsoku::Soft), "{a}{b}");
assert!(kinsoku_between(a, Some(b), Kinsoku::None), "{a}{b}");
}
}
/// Glyph indices inside `g[ws..we]` (a word) where a hyphenated break may go under `opts`, in the
/// language of the word's first letter; never next to a Hangul letter, which takes no hyphen, and
/// nowhere in a Korean word that Korean Line Break Anywhere off keeps whole.
fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> Vec<usize> {
if g.get(ws..we).is_some_and(|word| word.iter().any(|h| h.keeps_word)) {
return Vec::new();
}
let mut points = word_hyphen_breaks(text, g, ws, we, opts);
let hangul = |i: usize| g.get(i).is_some_and(|h| h.inline.is_none() && crate::shape::is_hangul(h.ch));
points.retain(|&k| !hangul(k.wrapping_sub(1)) && !hangul(k));
points
}
/// [`hyphen_breaks`] before the points next to Hangul are left out.
fn word_hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &HyphenationOptions) -> 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();
// The first glyph of each cluster start (not inside a ligature), by its byte: one pass, not a
// search for each point.
let mut starts = std::collections::HashMap::new();
for k in a + 1..b {
if g[k].byte != g[k - 1].byte {
starts.entry(g[k].byte).or_insert(k);
}
}
points_for(word, opts, g[a].hyphen_language).into_iter().filter_map(|ci| starts.get(offs.get(ci)?).copied()).collect()
}
/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the
/// paragraph's Hyphenation options) when it is given. No break comes before a space: a line that
/// may end after a dash, a Thai word or a space ends after the spaces that follow it, as in
/// [`break_line`].
pub(super) fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec<Breakpoint> {
let mut v = vec![];
let mut ws = 0;
for (j, gl) in g.iter().enumerate() {
// A list marker (the glyphs of length 0 here) ends before the first word.
let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0);
let end_of_word = gl.is_space() || gl.break_after() || marker_end;
if end_of_word {
if let Some(opts) = hyphenate
&& j > ws
{
for k in
hyphen_breaks(text, g, ws, j, opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
{
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 && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) && !splits_no_break(g, j + 1) {
v.push(Breakpoint { end: j + 1, hyphen: hy });
}
ws = j + 1;
}
}
if let Some(opts) = hyphenate
&& g.len() > ws
{
for k in
hyphen_breaks(text, g, ws, g.len(), opts).into_iter().filter(|&k| k > 0 && kinsoku_allows(g, k - 1, kinsoku) && !splits_no_break(g, k))
{
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
}
#[cfg(test)]
#[test]
fn a_pen_out_of_baseline_steps_finds_no_room() {
let frame = kurbo::Shape::to_path(&kurbo::Rect::new(0.0, 0.0, 400.0, 400.0), 0.1);
let regions = Region::cells(&frame, &super::LayoutOptions::default(), &[]);
let est = Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 };
let pen = |steps| Pen {
regions: Some(&regions),
ri: 0,
prev: None,
pending: 0.0,
fb: FirstBaseline::Ascent,
fb_min: 0.0,
queued: vec![],
model: LeadingModel::default(),
next_top: None,
no_room_for: f64::INFINITY,
steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)),
search: Default::default(),
};
assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some());
assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none());
}