Files
vectorcraft/crates/text/src/composer.rs
haven d2aad22dac M7: Every-line Composer for ragged text
Illustrator's Every-line Composer balances the breaks of the whole
paragraph for left/centre/right aligned text too, not only justified
text. VectorCraft only composed justified area text; ragged text was
always broken greedily.

- Model: ParaStyle.composer (vectorcraft_doc::Composer, re-exported by
  vectorcraft-text), Every-line by default like Illustrator, saved only
  when Single-line. layout() reads it from the object;
  LayoutOptions.composer is now an Option override for tests.
- Composer: the justified gate is gone. Ragged lines never shrink (a
  line that doesn't fit ends the node), badness is 100*r^3 with r = the
  line's leftover space over a rag zone of 1/6 of the width (finite for
  lines without spaces); the last line is free unless shorter than 1/3
  of the width (measured in zones of 1/3), so a lone short word on the
  last line pulls a word down. Hyphens cost 25 in ragged text (50 in
  justified). Knuth-Plass fitness classes with adjacency demerits
  (justified 10000, ragged 1000). Lines past the last width change
  share one node key, active nodes are capped (4096) and paragraphs
  over 200k glyphs break single-line, so work stays bounded; ragged
  tries a tolerance of one rag zone, then any fitting breaks, then
  falls back to greedy.
- text.setFormat {composer: "singleLine"|"everyLine"}; the Paragraph
  panel menu's composer items work with a check mark (Reset Panel goes
  back to Every-line).
- Tests: ragged Every-line moves "or" down in "Destroy all creatures
  with toughness X or less." where greedy leaves "less." alone; the rag
  of long paragraphs is no worse than greedy (sum of squared leftover
  and of neighbour differences); Single-line is greedy; the 10k-char
  layout budget covers ragged text; command test with undo; native
  format round trip (rich_session's text is now Single-line).
  No existing test needed changing: the justified tests and
  hyphenated_layout_adds_hyphens (ragged, now Every-line) still pass.

Auto alignment (start of the paragraph direction) is ragged; a wrapped
line's Mojikumi flush opening bracket gives its half em back to the
composer, as in the line loop; tests cover Auto in both directions.

Layout of 10k chars, release: justified Every-line 5.6 ms, ragged
Every-line 5.8 ms, ragged Single-line 4.8 ms.
2026-10-08 14:03:33 -07:00

215 lines
9.0 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.
//! Every-line composer: a Knuth–Plass style total-fit line breaker.
//!
//! Boxes are the paragraph's glyphs, glue is its spaces, and penalties are break opportunities
//! after hyphens/dashes/CJK and at hyphenation points. The breaks minimise the sum of squared
//! (badness + penalty) demerits over the whole paragraph, plus a demerit when neighbouring lines
//! fall in fitness classes more than one apart (a tight line next to a loose one).
//!
//! - **Justified** lines: word spacing may stretch to 150% and shrink to 80%; the badness comes
//! from the stretch/shrink ratio, so loose lines are traded for evenly spaced ones.
//! - **Ragged** lines (left/centre/right aligned): spaces keep their width, so a line never
//! shrinks (it must fit). The badness comes from the line's leftover space measured against a
//! rag zone of a sixth of the line width, so the rag is even rather than full lines followed by
//! a short one, and hyphenating costs less. The last line costs nothing unless it is shorter
//! than a third of the width (a word or two left dangling).
use crate::shape::SGlyph;
const STRETCH: f64 = 0.5;
const SHRINK: f64 = 0.2;
const HYPHEN_PENALTY: f64 = 50.0;
const DOUBLE_HYPHEN_DEMERITS: f64 = 3000.0;
const LINE_PENALTY: f64 = 10.0;
/// Demerits for adjacent lines whose fitness classes differ by more than one.
const ADJ_DEMERITS_JUSTIFIED: f64 = 10_000.0;
const ADJ_DEMERITS_RAGGED: f64 = 1_000.0;
/// Ragged lines only gain from hyphenation by filling the rag, so it costs less than in
/// justified text (where it also evens the word spacing).
const HYPHEN_PENALTY_RAGGED: f64 = 25.0;
/// Ragged text: leftover space is measured in units of this fraction of the line width.
const RAG_ZONE: f64 = 1.0 / 6.0;
/// Ragged text: a last line shorter than this fraction of the width is penalised, by how much
/// shorter it is in units of `SHORT_LAST_ZONE` of the width.
const SHORT_LAST: f64 = 1.0 / 3.0;
const SHORT_LAST_ZONE: f64 = 1.0 / 3.0;
/// Give up (fall back to the single-line composer) beyond this many active breakpoints.
const MAX_ACTIVE: usize = 4096;
const EPS: f64 = 1e-6;
/// A place a line may end: glyph index `end` (exclusive), plus the width of the hyphen that is
/// added when breaking there (0 = no hyphen).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Breakpoint {
pub end: usize,
pub hyphen: f64,
}
/// How a paragraph is composed.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Params {
/// The last line is justified too (Justify All).
pub justify_last: bool,
/// Left/centre/right aligned text: spaces keep their width and lines are scored by their rag.
pub ragged: bool,
/// Maximum stretch ratio (justified) or leftover space in rag zones (ragged) of a line.
pub tolerance: f64,
/// Lines from this index on all have the same width, so their line numbers are interchangeable
/// (keeps the number of active breakpoints small).
pub uniform_from: usize,
}
struct Node {
pos: usize,
/// Line number of the next line, clamped to `uniform_from`.
line: usize,
fitness: u8,
demerits: f64,
hyphenated: bool,
prev: Option<usize>,
}
/// Fitness class 0 (tight) … 3 (very loose) of a line with adjustment ratio `r`.
fn fitness(r: f64, ragged: bool) -> u8 {
let bounds = if ragged { [0.15, 0.4, 0.75] } else { [-0.5, 0.5, 1.0] };
if r < bounds[0] {
0
} else if r <= bounds[1] {
1
} else if r <= bounds[2] {
2
} else {
3
}
}
/// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`;
/// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets
/// an opening bracket there flush with the line's start); `cands` are the break opportunities
/// in increasing order (the paragraph end is added).
/// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance
/// (the caller then falls back to single-line breaking).
pub(crate) fn compose(
g: &[SGlyph],
width: &dyn Fn(usize) -> f64,
start_credit: &dyn Fn(usize) -> f64,
cands: &[Breakpoint],
p: &Params,
) -> Option<Vec<(usize, bool)>> {
let n = g.len();
if n == 0 {
return Some(vec![]);
}
// Prefix sums of advances and of space advances.
let mut w = vec![0.0; n + 1];
let mut sp = vec![0.0; n + 1];
for (i, gl) in g.iter().enumerate() {
w[i + 1] = w[i] + gl.adv;
sp[i + 1] = sp[i] + if gl.is_space() { gl.adv } else { 0.0 };
}
if !w[n].is_finite() {
return None;
}
// Trailing spaces are dropped at a break.
let trim = |mut b: usize, a: usize| {
while b > a && g.get(b - 1).is_some_and(SGlyph::is_space) {
b -= 1;
}
b
};
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
all.push(Breakpoint { end: n, hyphen: 0.0 });
let (adj_demerits, hyphen_penalty) =
if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) };
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphenated: false, prev: None }];
let mut active: Vec<usize> = vec![0];
for bp in &all {
let last = bp.end == n;
let mut best: Vec<(usize, u8, f64, usize)> = vec![]; // (line, fitness, demerits, from)
let mut keep = Vec::with_capacity(active.len());
for &ai in &active {
let Some(a) = nodes.get(ai) else { continue };
let t = trim(bp.end, a.pos);
let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 };
let natural = w[t] - w[a.pos] - credit + bp.hyphen;
let lw = width(a.line);
// Adjustment ratio, badness and whether the line ends the active node's reach.
let ratio = if p.ragged {
if natural > lw + EPS {
// Ragged lines never shrink; longer lines from this node only get worse.
continue;
}
if last && !p.justify_last {
((lw * SHORT_LAST - natural) / (lw * SHORT_LAST_ZONE).max(1.0)).max(0.0)
} else {
(lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0)
}
} else {
let spaces = sp[t] - sp[a.pos];
if natural < lw {
if last && !p.justify_last {
0.0
} else if spaces > 0.0 {
(lw - natural) / (spaces * STRETCH)
} else {
f64::INFINITY
}
} else if natural > lw {
if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY }
} else {
0.0
}
};
if ratio < -1.0 {
// Too long already; longer lines from this node only get worse.
continue;
}
keep.push(ai);
let free_last = last && (p.ragged || !p.justify_last);
if ratio > p.tolerance && !(last && (p.ragged || (ratio.is_infinite() && p.justify_last))) {
continue;
}
let badness = if ratio.is_finite() { (100.0 * ratio.abs().powi(3)).min(10_000.0) } else { 10_000.0 };
let penalty = if bp.hyphen > 0.0 { hyphen_penalty } else { 0.0 };
let mut d = (LINE_PENALTY + badness).powi(2) + penalty * penalty;
if bp.hyphen > 0.0 && a.hyphenated {
d += DOUBLE_HYPHEN_DEMERITS;
}
// A ragged last line's length says nothing about the rag: it keeps its neighbour's class.
let fit = if p.ragged && free_last { a.fitness } else { fitness(ratio, p.ragged) };
if a.prev.is_some() && fit.abs_diff(a.fitness) > 1 {
d += adj_demerits;
}
let total = a.demerits + d;
let line = (a.line + 1).min(p.uniform_from);
match best.iter_mut().find(|b| b.0 == line && b.1 == fit) {
Some(b) if b.2 <= total => {}
Some(b) => *b = (line, fit, total, ai),
None => best.push((line, fit, total, ai)),
}
}
active = keep;
for (line, fitness, demerits, from) in best {
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) });
active.push(nodes.len() - 1);
}
if active.is_empty() || active.len() > MAX_ACTIVE {
return None;
}
}
let end = nodes.iter().enumerate().filter(|(_, nd)| nd.pos == n && nd.prev.is_some()).min_by(|a, b| a.1.demerits.total_cmp(&b.1.demerits))?.0;
let mut out = vec![];
let mut cur = Some(end);
while let Some(i) = cur {
let nd = nodes.get(i)?;
if nd.prev.is_some() {
out.push((nd.pos, nd.hyphenated));
}
cur = nd.prev;
if out.len() > n {
return None;
}
}
out.reverse();
Some(out)
}