mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
Merge #310: Justification: tight word spacing doesn't isolate a word; fixed-width spaces don't stretch
Conflict: crates/compose/src/breaker.rs: next's multi-line Item::boxed/penalty with edge_elastic and start_elastic plus #310's over; glue in items() keeps next's edge_elastic and sets #310's over; kp_pass first node keeps next's first_box/first_hang/first_elastic and ty/tz plus #310's to
This commit is contained in:
@@ -97,19 +97,41 @@ impl Spacing {
|
||||
}
|
||||
/// Word-space stretch and shrink of space glyph `g` (only U+0020 is elastic).
|
||||
fn space_elastic(&self, g: &Glyph) -> (f64, f64) {
|
||||
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
|
||||
if !elastic_space(g) {
|
||||
return (0.0, 0.0);
|
||||
}
|
||||
(g.space * (self.word_max - self.word_desired).max(0.0), g.space * (self.word_desired - self.word_min).max(0.0))
|
||||
}
|
||||
/// The unit in which stretch past the maximum word spacing is rated (see [`tiered_ratio`]): the
|
||||
/// word space's own stretch, but at least [`OVERFLOW_STRETCH`] of its width, so a tight
|
||||
/// spacing range doesn't make a loose line with several word spaces look worse than a line
|
||||
/// letter-spaced across the measure.
|
||||
fn space_overflow(&self, g: &Glyph) -> f64 {
|
||||
if !elastic_space(g) {
|
||||
return 0.0;
|
||||
}
|
||||
g.space * (self.word_max - self.word_desired).max(OVERFLOW_STRETCH)
|
||||
}
|
||||
}
|
||||
|
||||
/// Does `g` stretch and shrink as a word space? Fixed-width spaces (em, en, thin…) don't.
|
||||
pub(crate) fn elastic_space(g: &Glyph) -> bool {
|
||||
g.ch == ' ' || (g.ch == '\u{3000}' && g.ideographic_space_elastic)
|
||||
}
|
||||
|
||||
/// Minimum stretch of a word space, as a fraction of its width, for rating lines that stretch
|
||||
/// past the maximum word spacing (the default range's 133% − 100%).
|
||||
const OVERFLOW_STRETCH: f64 = 0.33;
|
||||
|
||||
/// Relative cost of using each tier of elastic material (word, letter, glyph).
|
||||
pub const TIER_COST: [f64; 3] = [1.0, 0.75, 1.0];
|
||||
|
||||
/// Effective adjustment ratio for `d` points of stretch (`d > 0`) or shrink (`d < 0`), consuming
|
||||
/// the tiers in priority order. Returns `(r, feasible)`; shrinking beyond all tiers is infeasible.
|
||||
pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3]) -> (f64, bool) {
|
||||
/// Stretch past every tier goes into the word spaces and is rated in units of `over` (their
|
||||
/// overflow stretch, see [`Spacing::space_overflow`]); a line without word spaces rates it
|
||||
/// against its letter and glyph stretch.
|
||||
pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3], over: f64) -> (f64, bool) {
|
||||
const EPS: f64 = 1e-9;
|
||||
if d.abs() < EPS {
|
||||
return (0.0, true);
|
||||
@@ -128,7 +150,7 @@ pub fn tiered_ratio(d: f64, y: [f64; 3], z: [f64; 3]) -> (f64, bool) {
|
||||
}
|
||||
if rem > 1e-6 {
|
||||
// Beyond every maximum: word spaces grow without bound (an H&J violation).
|
||||
let unit = if cap[0] > EPS { cap[0] } else { cap.iter().sum::<f64>() };
|
||||
let unit = if over > EPS { over } else { cap.iter().sum::<f64>() };
|
||||
r = if unit > EPS { r + rem / unit } else { INF };
|
||||
}
|
||||
(r, true)
|
||||
@@ -160,6 +182,8 @@ struct Item {
|
||||
zone_from: usize,
|
||||
/// An emergency break inside a word wider than the measure.
|
||||
emergency: bool,
|
||||
/// Glue: stretch past the maximum word spacing ([`Spacing::space_overflow`]).
|
||||
over: f64,
|
||||
}
|
||||
|
||||
const NONE: usize = usize::MAX;
|
||||
@@ -179,6 +203,7 @@ impl Item {
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
over: 0.0,
|
||||
}
|
||||
}
|
||||
fn glue(w: f64, st: f64, sh: f64) -> Item {
|
||||
@@ -195,6 +220,7 @@ impl Item {
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
over: 0.0,
|
||||
}
|
||||
}
|
||||
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
|
||||
@@ -211,6 +237,7 @@ impl Item {
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
over: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -365,6 +392,7 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &S
|
||||
if let Some(prev) = i.checked_sub(1).and_then(|j| glyphs.get(j)) {
|
||||
gl.edge_elastic = crate::mojikumi::end_elastic(prev, sp.justify, sp.kinsoku_priority != 3);
|
||||
}
|
||||
gl.over = sp.space_overflow(g);
|
||||
last_space = it.len();
|
||||
it.push(gl);
|
||||
ig.push(i);
|
||||
@@ -451,6 +479,7 @@ struct Node {
|
||||
tw: f64,
|
||||
ty: [f64; 3],
|
||||
tz: [f64; 3],
|
||||
to: f64,
|
||||
demerits: f64,
|
||||
prev: Option<usize>,
|
||||
hyphens: u32,
|
||||
@@ -533,12 +562,14 @@ fn kp_pass(
|
||||
let mut sw = vec![0.0; m + 1];
|
||||
let mut sy = vec![[0.0; 3]; m + 1];
|
||||
let mut sz = vec![[0.0; 3]; m + 1];
|
||||
let mut so = vec![0.0; m + 1];
|
||||
for (i, it) in items.iter().enumerate() {
|
||||
let (w, y, z) = match it.kind {
|
||||
Kind::Box | Kind::Glue => (it.w, it.st, it.sh),
|
||||
Kind::Penalty => (0.0, [0.0; 3], [0.0; 3]),
|
||||
};
|
||||
sw[i + 1] = sw[i] + w;
|
||||
so[i + 1] = so[i] + it.over;
|
||||
for t in 0..3 {
|
||||
sy[i + 1][t] = sy[i][t] + y[t];
|
||||
sz[i + 1][t] = sz[i][t] + z[t];
|
||||
@@ -554,6 +585,7 @@ fn kp_pass(
|
||||
tw: first_hang,
|
||||
ty: [-first_elastic[0], 0.0, 0.0],
|
||||
tz: [-first_elastic[1], 0.0, 0.0],
|
||||
to: 0.0,
|
||||
demerits: 0.0,
|
||||
prev: None,
|
||||
hyphens: 0,
|
||||
@@ -593,7 +625,7 @@ fn kp_pass(
|
||||
// A line that overruns its measure by a rounding error fits exactly.
|
||||
let d = target - l;
|
||||
let d = if d < 0.0 && d > -FIT_EPS { 0.0 } else { d };
|
||||
let (mut r, feasible) = tiered_ratio(d, y, z);
|
||||
let (mut r, feasible) = tiered_ratio(d, y, z, so[b] - n.to);
|
||||
if it.emergency && sp.justify && r > 0.0 {
|
||||
// A piece of an overlong word usually has no elastic material: rate it by the
|
||||
// space it leaves, so the fullest piece wins (the break at the last glyph that fits).
|
||||
@@ -673,7 +705,7 @@ fn kp_pass(
|
||||
continue;
|
||||
}
|
||||
// Totals after the break: skip glue and penalties up to the next box.
|
||||
let (mut tw, mut ty, mut tz) = (sw[b], sy[b], sz[b]);
|
||||
let (mut tw, mut ty, mut tz, mut to) = (sw[b], sy[b], sz[b], so[b]);
|
||||
let mut i = b;
|
||||
while i < m {
|
||||
let x = items[i];
|
||||
@@ -686,6 +718,7 @@ fn kp_pass(
|
||||
}
|
||||
Kind::Glue => {
|
||||
tw += x.w;
|
||||
to += x.over;
|
||||
for t in 0..3 {
|
||||
ty[t] += x.st[t];
|
||||
tz[t] += x.sh[t];
|
||||
@@ -710,6 +743,7 @@ fn kp_pass(
|
||||
tw,
|
||||
ty,
|
||||
tz,
|
||||
to,
|
||||
demerits: d,
|
||||
prev: Some(a),
|
||||
hyphens: if flagged { prev.hyphens + 1 } else { 0 },
|
||||
@@ -942,16 +976,16 @@ mod tests {
|
||||
#[test]
|
||||
fn tiers_are_used_in_order() {
|
||||
// Word stretch alone.
|
||||
let (r, ok) = tiered_ratio(5.0, [10.0, 10.0, 10.0], [0.0; 3]);
|
||||
let (r, ok) = tiered_ratio(5.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
|
||||
assert!(ok && (r - 0.5).abs() < 1e-9);
|
||||
// Word saturated, half the letter tier.
|
||||
let (r, _) = tiered_ratio(15.0, [10.0, 10.0, 10.0], [0.0; 3]);
|
||||
let (r, _) = tiered_ratio(15.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
|
||||
assert!((r - (1.0 + 0.5 * TIER_COST[1])).abs() < 1e-9);
|
||||
// Beyond every tier: unbounded word spacing.
|
||||
let (r, _) = tiered_ratio(40.0, [10.0, 10.0, 10.0], [0.0; 3]);
|
||||
let (r, _) = tiered_ratio(40.0, [10.0, 10.0, 10.0], [0.0; 3], 10.0);
|
||||
assert!(r > 2.0);
|
||||
// Shrink beyond the tiers is infeasible.
|
||||
assert!(!tiered_ratio(-25.0, [0.0; 3], [10.0, 10.0, 0.0]).1);
|
||||
assert!(tiered_ratio(-15.0, [0.0; 3], [10.0, 10.0, 0.0]).1);
|
||||
assert!(!tiered_ratio(-25.0, [0.0; 3], [10.0, 10.0, 0.0], 0.0).1);
|
||||
assert!(tiered_ratio(-15.0, [0.0; 3], [10.0, 10.0, 0.0], 0.0).1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2877,7 +2877,7 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
.iter()
|
||||
.map(|&i| {
|
||||
let g = &line[i];
|
||||
if g.ch != ' ' && !(g.ch == '\u{3000}' && g.ideographic_space_elastic) {
|
||||
if !breaker::elastic_space(g) {
|
||||
0.0
|
||||
} else if stretch {
|
||||
g.space * (sp.word_max - sp.word_desired).max(0.0)
|
||||
@@ -2922,18 +2922,33 @@ fn distribute(line: &[Glyph], spaces: &[usize], extra: f64, sp: &Spacing, add: &
|
||||
let tg = rem.min(yg);
|
||||
rem -= tg;
|
||||
let sign = if stretch { 1.0 } else { -1.0 };
|
||||
// Stretch left over beyond every limit is shared equally by the word spaces; shrink never goes
|
||||
// past the minimums (that would overlap glyphs), so an overfull line stays overfull.
|
||||
let over = if stretch && !spaces.is_empty() { rem / spaces.len() as f64 } else { 0.0 };
|
||||
// Stretch left over beyond every limit is shared equally by the word spaces (fixed-width spaces
|
||||
// keep their width), else by the letter gaps; shrink never goes past the minimums (that would
|
||||
// overlap glyphs), so an overfull line stays overfull.
|
||||
let elastic = spaces.iter().filter(|&&i| breaker::elastic_space(&line[i])).count();
|
||||
let gaps = line.iter().enumerate().filter(|&(i, g)| is_box(i, g)).count();
|
||||
let (over, over_gap) = match (stretch && rem > 0.0, elastic, gaps) {
|
||||
(false, _, _) => (0.0, 0.0),
|
||||
(true, 0, 0) => (0.0, 0.0),
|
||||
(true, 0, n) => (0.0, rem / n as f64),
|
||||
(true, n, _) => (rem / n as f64, 0.0),
|
||||
};
|
||||
for (k, &i) in spaces.iter().enumerate() {
|
||||
let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 };
|
||||
add[i] += sign * share + over;
|
||||
add[i] += sign * share + if breaker::elastic_space(&line[i]) { over } else { 0.0 };
|
||||
}
|
||||
if yl > 1e-9 && tl > 0.0 {
|
||||
for (i, l) in letter.iter().enumerate() {
|
||||
add[i] += sign * tl * l / yl;
|
||||
}
|
||||
}
|
||||
if over_gap > 0.0 {
|
||||
for (i, g) in line.iter().enumerate() {
|
||||
if is_box(i, g) {
|
||||
add[i] += over_gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
if yg > 1e-9 && tg > 0.0 {
|
||||
for (i, gcap) in glyph.iter().enumerate() {
|
||||
if *gcap > 0.0 {
|
||||
|
||||
@@ -4598,3 +4598,73 @@ fn empty_paragraphs_get_no_bullet_or_number() {
|
||||
assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tight_word_spacing_does_not_isolate_a_word() {
|
||||
// A narrow word-spacing range (100–105%) with some letter spacing and glyph scaling, no
|
||||
// hyphenation: lines that need more stretch must open their word spaces, not letter-space a
|
||||
// single word across the measure.
|
||||
let para = ParaAttrs {
|
||||
align: Some(Align::LeftJustified),
|
||||
hyphenate: Some(false),
|
||||
first_line_indent: Some(9.0),
|
||||
word_space_min: Some(1.0),
|
||||
word_space_desired: Some(1.03),
|
||||
word_space_max: Some(1.05),
|
||||
letter_space_min: Some(-0.05),
|
||||
letter_space_max: Some(0.05),
|
||||
glyph_scale_min: Some(0.99),
|
||||
glyph_scale_max: Some(1.01),
|
||||
..Default::default()
|
||||
};
|
||||
// Short words: any two fit on a line together.
|
||||
let text = CORPUS[..2].join(" ");
|
||||
for w in (200..=320).step_by(5) {
|
||||
let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, w as f64, 20000.0), para.clone());
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = all_lines(&cs);
|
||||
for l in &lines[..lines.len() - 1] {
|
||||
let words = text[l.range.clone()].split_whitespace().count();
|
||||
assert!(words > 1, "width {w}: a single-word line {:?}", &text[l.range.clone()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_width_spaces_do_not_stretch_on_justified_lines() {
|
||||
// Long words in a narrow column: lines stretch past the maximum word spacing. Only the word
|
||||
// spaces take that stretch; em, en and thin spaces keep their width.
|
||||
let words = ["characteristically", "misunderstanding", "unquestionably", "responsibility", "administration", "photographer"];
|
||||
let seps = [" ", "\u{2003}", " ", "\u{2002}", " ", "\u{2009}"];
|
||||
let text: String = (0..60).map(|i| format!("{}{}", words[i % words.len()], seps[i % seps.len()])).collect();
|
||||
let text = text.trim_end().to_string();
|
||||
let fixed = |c: char| matches!(c, '\u{2003}' | '\u{2002}' | '\u{2009}');
|
||||
// (character, advance) of each fixed space, per line, and whether the line ends the paragraph.
|
||||
let spaces = |align: Align, w: f64| -> Vec<(Vec<(char, f64)>, bool)> {
|
||||
let para = ParaAttrs { align: Some(align), hyphenate: Some(false), ..Default::default() };
|
||||
let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, w, 20000.0), para);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
all_lines(&cs)
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let found = l.glyphs.iter().filter_map(|g| text[g.byte..].chars().next().filter(|&c| fixed(c)).map(|c| (c, g.adv))).collect();
|
||||
(found, l.last_in_para)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let mut checked = 0;
|
||||
for w in [190.0, 230.0, 270.0] {
|
||||
let natural: Vec<(char, f64)> = spaces(Align::Left, w).into_iter().flat_map(|l| l.0).collect();
|
||||
for (found, last) in spaces(Align::LeftJustified, w) {
|
||||
if last {
|
||||
continue;
|
||||
}
|
||||
for (c, a) in found {
|
||||
let Some(&(_, n)) = natural.iter().find(|x| x.0 == c) else { continue };
|
||||
assert!((a - n).abs() < 1e-6, "width {w}: U+{:04X} is {a:.2} wide, not {n:.2}", c as u32);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 5, "{checked} fixed spaces on justified lines");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user