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.
This commit is contained in:
haven
2026-10-08 14:03:33 -07:00
parent 53297259d2
commit d2aad22dac
13 changed files with 389 additions and 87 deletions
+122 -38
View File
@@ -1,9 +1,17 @@
//! Every-line composer: a Knuth–Plass style total-fit line breaker for justified paragraphs.
//! Every-line composer: a Knuth–Plass style total-fit line breaker.
//!
//! Boxes are the paragraph's glyphs, glue is its spaces (word spacing may stretch to 150% and
//! shrink to 80%), 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, so loose lines are traded for evenly spaced ones.
//! 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;
@@ -12,6 +20,21 @@ 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).
@@ -21,24 +44,56 @@ pub(crate) struct Breakpoint {
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`;
/// `cands` are the break opportunities in increasing order (the paragraph end is added).
/// `justify_last`: the last line is justified too (Justify All). Returns `(end, hyphen)` per line,
/// or `None` if no set of breaks fits within `tolerance` (maximum stretch ratio).
/// `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],
justify_last: bool,
tolerance: f64,
p: &Params,
) -> Option<Vec<(usize, bool)>> {
let n = g.len();
if n == 0 {
@@ -51,68 +106,93 @@ pub(crate) fn compose(
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[b - 1].is_space() {
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 mut nodes = vec![Node { pos: 0, line: 0, demerits: 0.0, hyphenated: false, prev: None }];
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, f64, usize)> = vec![]; // (line, demerits, from)
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 a = &nodes[ai];
let Some(a) = nodes.get(ai) else { continue };
let t = trim(bp.end, a.pos);
let natural = w[t] - w[a.pos] + bp.hyphen;
let spaces = sp[t] - sp[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);
let ratio = if natural < lw {
if last && !justify_last {
0.0
} else if spaces > 0.0 {
(lw - natural) / (spaces * STRETCH)
// 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 {
f64::INFINITY
(lw - natural).max(0.0) / (lw * RAG_ZONE).max(1.0)
}
} else if natural > lw {
if spaces > 0.0 { (lw - natural) / (spaces * SHRINK) } else { f64::NEG_INFINITY }
} else {
0.0
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);
if ratio > tolerance && !(last && ratio.is_infinite() && justify_last) {
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 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;
match best.iter_mut().find(|b| b.0 == line) {
Some(b) if b.1 <= total => {}
Some(b) => *b = (line, total, ai),
None => best.push((line, total, ai)),
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, demerits, from) in best {
nodes.push(Node { pos: bp.end, line, demerits, hyphenated: bp.hyphen > 0.0, prev: Some(from) });
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() {
if active.is_empty() || active.len() > MAX_ACTIVE {
return None;
}
}
@@ -120,10 +200,14 @@ pub(crate) fn compose(
let mut out = vec![];
let mut cur = Some(end);
while let Some(i) = cur {
if nodes[i].prev.is_some() {
out.push((nodes[i].pos, nodes[i].hyphenated));
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;
}
cur = nodes[i].prev;
}
out.reverse();
Some(out)
+24 -8
View File
@@ -9,7 +9,7 @@ use vectorcraft_doc::{
};
use vectorcraft_geom::{ArcPath, PathData};
use crate::composer::{Breakpoint, compose};
use crate::composer::{Breakpoint, Params, compose};
use crate::fontdb::FontDb;
use crate::hyphen::hyphen_points;
use crate::shape::{Punct, SGlyph, Tcy, cap_x_heights, hyphen_glyph, is_cjk, no_line_end, no_line_start, punct, shape_range, style_metrics};
@@ -914,14 +914,18 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: bool) -> Vec<Breakpoint> {
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::Auto | Justify::Left | Justify::Center | Justify::Right);
/// Paragraphs longer than this (glyphs) are always broken line by line.
const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// Every-line composition of paragraph glyphs `sg` if applicable (area text with uniform line
/// metrics, justified or ragged); `None` falls back to the greedy single-line composer.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option<Vec<(usize, bool)>> {
let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let composer = cx.opts.composer.unwrap_or(para.composer);
// A paragraph whose commas or full stops may hang is composed line by line (as the Japanese
// single-line composer does).
let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs);
if cx.opts.composer != Composer::EveryLine || !justified || pen.regions.is_none() || sg.len() < 2 || may_hang {
if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang {
return None;
}
let m = Metrics::of(&sg[0]);
@@ -948,9 +952,21 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>) ->
}
let last = *widths.last()?;
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
// Lines from `uniform_from` on are interchangeable (same width).
let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1);
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))
let params = |tolerance| Params { justify_last, ragged, tolerance, uniform_from };
// Ragged: first look for breaks that leave at most one rag zone (a sixth of the width) on
// each line, then accept any lines that fit.
let (strict, loose) = if ragged { (1.0, f64::INFINITY) } else { (1.0, 4.0) };
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
compose(sg, &width, &start_credit, &cands, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, &params(loose)))
}
fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Option<&[Region]>) {
@@ -985,7 +1001,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], para: &ParaStyle, regions: Opt
pen.pending += para.space_before;
}
let n = sg.len();
let composed = compose_para(cx, &sg, para, &pen);
let composed = compose_para(cx, &sg, para, &pen, rtl);
let mut li_para = 0;
let mut i = 0;
loop {
+5 -11
View File
@@ -31,15 +31,8 @@ pub use vectorcraft_doc::TextObject;
pub use vectorcraft_doc::FirstBaseline;
/// Paragraph composer (Paragraph panel menu).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Composer {
/// Break each line as soon as it is full.
SingleLine,
/// Knuth–Plass total fit over the paragraph (justified area text only).
#[default]
EveryLine,
}
/// Paragraph composer; stored per text object in [`vectorcraft_doc::ParaStyle::composer`].
pub use vectorcraft_doc::Composer;
/// Layout parameters that the document model doesn't store per object (Area Type Options,
/// composer, OpenType features). [`layout`] takes rows/columns/inset/first baseline from the object.
@@ -55,7 +48,8 @@ pub struct LayoutOptions {
pub first_baseline: FirstBaseline,
/// Minimum first-baseline offset in points.
pub first_baseline_min: f64,
pub composer: Composer,
/// Overrides the object's paragraph composer (`None` = use `ParaStyle::composer`).
pub composer: Option<Composer>,
pub features: OtFeatures,
}
@@ -68,7 +62,7 @@ impl Default for LayoutOptions {
inset: 0.0,
first_baseline: FirstBaseline::Ascent,
first_baseline_min: 0.0,
composer: Composer::EveryLine,
composer: None,
features: OtFeatures::default(),
}
}
+116 -15
View File
@@ -227,7 +227,7 @@ fn composer_lines_never_exceed_frame() {
for (hy, composer) in [(false, Composer::EveryLine), (true, Composer::EveryLine), (false, Composer::SingleLine), (true, Composer::SingleLine)] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 180.0, 2000.0), Justify::JustifyLeft);
t.para.hyphenate = hy;
let l = layout_with(db(), &t, &LayoutOptions { composer, ..Default::default() });
let l = layout_with(db(), &t, &LayoutOptions { composer: Some(composer), ..Default::default() });
assert!(l.lines.len() > 10);
for (i, li) in l.lines.iter().enumerate() {
assert!(li.x1 - li.x0 <= 180.0 + 1e-6, "{composer:?} hy={hy} line {i} too wide: {li:?}");
@@ -246,7 +246,7 @@ fn composer_lines_never_exceed_frame() {
fn every_line_composer_is_more_even() {
let t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 190.0, 2000.0), Justify::JustifyLeft);
let spread = |c: Composer| {
let l = layout_with(db(), &t, &LayoutOptions { composer: c, ..Default::default() });
let l = layout_with(db(), &t, &LayoutOptions { composer: Some(c), ..Default::default() });
// Worst word-space stretch (space glyph advance) over all justified lines.
let mut worst: f64 = 0.0;
for li in &l.lines[..l.lines.len() - 1] {
@@ -269,7 +269,7 @@ fn burasagari_keeps_the_every_line_composer_for_latin_text() {
// A measure where the two composers break the copy differently.
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, 300.0, 4000.0), Justify::JustifyLeft);
let ends = |t: &TextObject, c: Composer| {
let l = layout_with(db(), t, &LayoutOptions { composer: c, ..Default::default() });
let l = layout_with(db(), t, &LayoutOptions { composer: Some(c), ..Default::default() });
l.lines.iter().map(|li| li.glyph_end).collect::<Vec<_>>()
};
let every_line = ends(&t, Composer::EveryLine);
@@ -278,6 +278,102 @@ fn burasagari_keeps_the_every_line_composer_for_latin_text() {
assert_eq!(ends(&t, Composer::EveryLine), every_line);
}
/// Width of `s` set on one line.
fn natural_width(s: &str, size: f64) -> f64 {
let l = layout(db(), &TextObject::point(Point::ZERO, s, style(size)));
l.lines[0].x1 - l.lines[0].x0
}
/// The text of each line.
fn line_texts(t: &TextObject, l: &TextLayout) -> Vec<String> {
let text = t.plain_text();
l.lines.iter().map(|li| text[li.start..li.end].trim_end().to_string()).collect()
}
/// Ragged text: Every-line breaks before a word that would fit, to avoid leaving a lone short
/// word on the last line (single-line keeps filling the first line). Auto alignment is ragged
/// too, in either paragraph direction: set right to left, the lines break the same and stay
/// flush right.
#[test]
fn every_line_composer_balances_ragged_lines() {
use vectorcraft_doc::ParaDirection;
const S: &str = "Destroy all creatures with toughness X or less.";
let w = natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0;
assert!(w < natural_width(S, 12.0), "the sentence needs two lines");
let cases = [
(Justify::Left, None),
(Justify::Center, None),
(Justify::Right, None),
(Justify::Auto, None),
(Justify::Auto, Some(ParaDirection::RightToLeft)),
(Justify::Left, Some(ParaDirection::RightToLeft)),
];
for (justify, direction) in cases {
let mut t = area(S, style(12.0), Rect::new(0.0, 0.0, w, 400.0), justify);
t.para.direction = direction;
t.para.composer = Composer::SingleLine;
let greedy = line_texts(&t, &layout(db(), &t));
assert_eq!(greedy, ["Destroy all creatures with toughness X or", "less."], "{justify:?} {direction:?}");
t.para.composer = Composer::EveryLine;
let every = line_texts(&t, &layout(db(), &t));
assert_eq!(every, ["Destroy all creatures with toughness X", "or less."], "{justify:?} {direction:?}");
// Burasagari (new type's Standard) leaves Latin text to the every-line composer.
t.para.burasagari = vectorcraft_doc::Burasagari::Standard;
assert_eq!(line_texts(&t, &layout(db(), &t)), every, "{justify:?} {direction:?} with burasagari");
if (justify, direction) == (Justify::Auto, Some(ParaDirection::RightToLeft)) {
let l = layout(db(), &t);
assert!(l.lines.iter().all(|li| li.rtl && (li.x1 - w).abs() < 1e-6), "right to left Auto is flush right: {:?}", l.lines);
}
// The options override wins over the object's setting.
let forced = layout_with(db(), &t, &LayoutOptions { composer: Some(Composer::SingleLine), ..Default::default() });
assert_eq!(line_texts(&t, &forced), greedy);
}
}
/// Every-line evens the rag of a long ragged paragraph: the spread of the line ends is no worse
/// than greedy breaking, every line fits, and nothing is lost.
#[test]
fn every_line_composer_evens_the_rag() {
for (width, hy) in [(150.0, false), (190.0, false), (240.0, true), (310.0, false)] {
let mut t = area(&COPY.repeat(2), style(12.0), Rect::new(0.0, 0.0, width, 4000.0), Justify::Left);
t.para.hyphenate = hy;
let rag = |c: Composer| {
let mut t = t.clone();
t.para.composer = c;
let l = layout(db(), &t);
assert!(!l.overflow);
for li in &l.lines {
assert!(li.x1 - li.x0 <= width + 1e-6, "{c:?} {width}: line too wide {li:?}");
}
let clusters: std::collections::BTreeSet<(usize, usize)> = l.glyphs.iter().filter(|g| g.len > 0).map(|g| (g.byte, g.len)).collect();
assert_eq!(clusters.iter().map(|c| c.1).sum::<usize>(), COPY.len() * 2, "every character placed");
// Leftover space of all lines but the last: its sum of squares (how ragged) and the
// sum of squared differences between neighbours (how jagged).
let slack: Vec<f64> = l.lines[..l.lines.len() - 1].iter().map(|li| width - (li.x1 - li.x0)).collect();
let jag: f64 = slack.windows(2).map(|p| (p[0] - p[1]).powi(2)).sum();
(slack.iter().map(|s| s * s).sum::<f64>(), jag, l.lines.len())
};
let ((kp, kp_jag, kp_lines), (greedy, greedy_jag, greedy_lines)) = (rag(Composer::EveryLine), rag(Composer::SingleLine));
assert!(kp <= greedy + 1e-6, "{width}: every-line rag {kp} vs greedy {greedy}");
assert!(kp_jag <= greedy_jag + 1e-6, "{width}: every-line jag {kp_jag} vs greedy {greedy_jag}");
assert!(kp_lines <= greedy_lines + 1, "{width}: {kp_lines} lines vs {greedy_lines}");
}
}
/// Single-line breaking is the greedy breaker: each line takes as many words as fit.
#[test]
fn single_line_composer_is_greedy() {
let width = 190.0;
let mut t = area(COPY, style(12.0), Rect::new(0.0, 0.0, width, 2000.0), Justify::Left);
t.para.composer = Composer::SingleLine;
let l = layout(db(), &t);
let lines = line_texts(&t, &l);
for pair in lines.windows(2) {
let next_word = pair[1].split(' ').next().unwrap();
assert!(natural_width(&format!("{} {next_word}", pair[0]), 12.0) > width - 1e-6, "{pair:?}");
}
}
#[test]
fn hyphenation_rules() {
assert_eq!(hyphen::hyphenate_word("typography"), "typo-gra-phy");
@@ -404,17 +500,22 @@ fn uncovered_characters_do_not_break_layout() {
#[test]
fn layout_10k_area_text_is_fast() {
let text: String = COPY.chars().cycle().take(10_000).collect();
let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), Justify::JustifyLeft);
t.para.hyphenate = true;
let _ = layout(db(), &t);
let n = 5;
let start = std::time::Instant::now();
for _ in 0..n {
let l = layout(db(), &t);
assert!(!l.overflow);
for (justify, composer) in
[(Justify::JustifyLeft, Composer::EveryLine), (Justify::Left, Composer::EveryLine), (Justify::Left, Composer::SingleLine)]
{
let mut t = area(&text, style(10.0), Rect::new(0.0, 0.0, 400.0, 20_000.0), justify);
t.para.hyphenate = true;
t.para.composer = composer;
let _ = layout(db(), &t);
let n = 5;
let start = std::time::Instant::now();
for _ in 0..n {
let l = layout(db(), &t);
assert!(!l.overflow);
}
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
eprintln!("layout of 10k chars ({justify:?}, {composer:?}, hyphenated): {per:.3} ms");
let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 };
assert!(per < budget, "{justify:?} {composer:?}: {per} ms");
}
let per = start.elapsed().as_secs_f64() * 1000.0 / n as f64;
eprintln!("layout of 10k chars (justified, hyphenated): {per:.3} ms");
let budget = if cfg!(debug_assertions) { 400.0 } else { 10.0 };
assert!(per < budget, "{per} ms");
}