mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 10:30:20 -06:00
Merge #293: Text: a word longer than the line breaks at the column edge; a line that exactly fills the measure fits
Conflict: crates/compose/src/breaker.rs: Item constructors keep next's multi-line form with edge_elastic/start_elastic and add `emergency: false`; kp_pass keeps next's edge_elastic lines, then #293's FIT_EPS clamp and emergency rating; greedy keeps next's tab-aware `adv`, `end_shrink` and `available` with FIT_EPS in the kinsoku_priority == 2 test and the overflow test (`x + adv - hang_r > w + available.max(0.0) + FIT_EPS`); FIT_EPS also added to next's two last_natural checks Conflict: crates/compose/src/lib.rs: layout_line keeps next's segment-rebased distribute (#284); #293's overfull single-word branch works on line[seg..] (`line.len() > seg + 1 && ... && extra < 0.0`) and comes before next's single-word justification branch Conflict: crates/compose/src/tests.rs: kept both (#293's tracked_lines and its two tests appended)
This commit is contained in:
@@ -158,6 +158,8 @@ struct Item {
|
||||
start_elastic: [f64; 2],
|
||||
/// Auto hyphenation points: index of the break opportunity before the word (or `NONE`).
|
||||
zone_from: usize,
|
||||
/// An emergency break inside a word wider than the measure.
|
||||
emergency: bool,
|
||||
}
|
||||
|
||||
const NONE: usize = usize::MAX;
|
||||
@@ -176,6 +178,7 @@ impl Item {
|
||||
edge_elastic: [0.0; 2],
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
}
|
||||
}
|
||||
fn glue(w: f64, st: f64, sh: f64) -> Item {
|
||||
@@ -191,6 +194,7 @@ impl Item {
|
||||
edge_elastic: [0.0; 2],
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
}
|
||||
}
|
||||
fn penalty(w: f64, p: f64, flagged: bool) -> Item {
|
||||
@@ -206,12 +210,22 @@ impl Item {
|
||||
edge_elastic: [0.0; 2],
|
||||
start_elastic: [0.0; 2],
|
||||
zone_from: NONE,
|
||||
emergency: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const INF: f64 = 10000.0;
|
||||
|
||||
/// A line whose natural width exceeds its measure by no more than this (points) still fits: frame
|
||||
/// widths and glyph advances are rounded, so a line that exactly fills the measure can come out a
|
||||
/// hair too wide.
|
||||
const FIT_EPS: f64 = 0.01;
|
||||
|
||||
/// Penalty for an emergency break inside a word wider than the measure: high, so a paragraph
|
||||
/// takes no more of them than it must, but below [`INF`] so they are legal.
|
||||
const EMERGENCY_PENALTY: f64 = 5000.0;
|
||||
|
||||
/// Does the glyph force a line end after it?
|
||||
/// A line may break between `a` and `b` in CJK text (any break next to an ideograph, kana or
|
||||
/// hangul), except where kinsoku forbids it: no line starts with closing punctuation, small
|
||||
@@ -320,7 +334,8 @@ fn push_inword(it: &mut Vec<Item>, ig: &mut Vec<usize>, p: Item, g: usize, sp: &
|
||||
}
|
||||
|
||||
/// Build Knuth items. `item_glyph[k]` = glyph index of item k (penalties: the glyph *before* which the break happens).
|
||||
fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
|
||||
/// `emergency_after[i]` allows an emergency break after glyph `i` (see [`overlong_words`]).
|
||||
fn items(glyphs: &[Glyph], hyph_after: &[bool], emergency_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec<usize>) {
|
||||
let mut it: Vec<Item> = Vec::with_capacity(glyphs.len() * 2 + 2);
|
||||
let mut ig = Vec::with_capacity(glyphs.len() * 2 + 2);
|
||||
let n = glyphs.len();
|
||||
@@ -408,6 +423,10 @@ fn items(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing) -> (Vec<Item>, Vec
|
||||
p.hang = hw * hang('-').1;
|
||||
}
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
} else if emergency_after.get(i) == Some(&true) {
|
||||
let mut p = Item::penalty(0.0, EMERGENCY_PENALTY, false);
|
||||
p.emergency = true;
|
||||
push_inword(&mut it, &mut ig, p, i + 1, sp);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -443,7 +462,11 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
|
||||
if glyphs.is_empty() {
|
||||
return vec![Break { start: 0, end: 0, next: 0, hyphen: false, forced: true }];
|
||||
}
|
||||
let (items, ig) = items(glyphs, hyph_after, sp);
|
||||
let (mut items, mut ig) = items(glyphs, hyph_after, &[], sp);
|
||||
let narrowest = (0..=glyphs.len().min(64)).map(width).fold(f64::INFINITY, f64::min).max(1.0);
|
||||
if let Some(emergency_after) = overlong_words(&items, &ig, glyphs, narrowest) {
|
||||
(items, ig) = self::items(glyphs, hyph_after, &emergency_after, sp);
|
||||
}
|
||||
for tolerance in [2.5, 12.0, f64::INFINITY] {
|
||||
if let Some(b) = kp_pass(&items, &ig, glyphs, sp, width, tolerance, tolerance.is_infinite()) {
|
||||
return b;
|
||||
@@ -452,6 +475,46 @@ pub fn knuth_plass(glyphs: &[Glyph], hyph_after: &[bool], sp: &Spacing, width: &
|
||||
greedy(glyphs, hyph_after, sp, width, &|_, _, _| 0.0)
|
||||
}
|
||||
|
||||
/// Glyphs after which a word wider than `measure` may take an emergency break, or `None` when every
|
||||
/// word fits. A word is a run of boxes between two break opportunities (so a word with hyphenation
|
||||
/// points only gets emergency breaks where a part between them is still too wide), measured at
|
||||
/// its narrowest (fully shrunk) width. Emergency breaks respect no-break text and kinsoku.
|
||||
fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64) -> Option<Vec<bool>> {
|
||||
let mut out: Option<Vec<bool>> = None;
|
||||
let mut seg: Option<(usize, usize)> = None;
|
||||
let mut w = 0.0;
|
||||
for (k, it) in items.iter().enumerate() {
|
||||
match it.kind {
|
||||
Kind::Box => {
|
||||
let g = ig.get(k).copied().unwrap_or(0);
|
||||
seg = Some(seg.map_or((g, g), |(a, _)| (a, g)));
|
||||
w += it.w - it.sh.iter().sum::<f64>();
|
||||
}
|
||||
Kind::Penalty if it.p >= INF => {}
|
||||
Kind::Glue | Kind::Penalty => {
|
||||
if let Some((a, b)) = seg.take()
|
||||
&& w > measure + FIT_EPS
|
||||
{
|
||||
let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]);
|
||||
for i in a..b {
|
||||
let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue };
|
||||
let ok = !g.no_break
|
||||
&& g.break_after != Some(false)
|
||||
&& next.ch != SOFT_HYPHEN
|
||||
&& !is_forced(next.ch)
|
||||
&& kinsoku_allows(g.ch, next.ch);
|
||||
if let Some(m) = marks.get_mut(i) {
|
||||
*m = ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
w = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Upper bound on the hyphen-count states tracked per breakpoint.
|
||||
const MAX_HYPHEN_STATES: usize = 8;
|
||||
|
||||
@@ -527,7 +590,15 @@ fn kp_pass(
|
||||
}
|
||||
y[0] = (y[0] + it.edge_elastic[0]).max(0.0);
|
||||
z[0] = (z[0] + it.edge_elastic[1]).max(0.0);
|
||||
let (r, feasible) = tiered_ratio(target - l, y, z);
|
||||
// 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);
|
||||
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).
|
||||
r = d / target;
|
||||
}
|
||||
if feasible && !forced {
|
||||
keep.push(a);
|
||||
} else {
|
||||
@@ -749,7 +820,7 @@ pub fn greedy(
|
||||
shrink += sp.space_elastic(g).1;
|
||||
}
|
||||
last_ok = Some((i, false));
|
||||
if x <= w {
|
||||
if x <= w + FIT_EPS {
|
||||
last_natural = last_ok;
|
||||
}
|
||||
last_space = Some((i, x));
|
||||
@@ -762,13 +833,13 @@ pub fn greedy(
|
||||
let end_shrink = crate::mojikumi::end_elastic(g, sp.justify, sp.kinsoku_priority != 3)[1];
|
||||
let available = shrink + sp.box_elastic(g).1.iter().sum::<f64>() + end_shrink;
|
||||
if sp.kinsoku_priority == 2
|
||||
&& x + adv - hang_r > w
|
||||
&& x + adv - hang_r > w + FIT_EPS
|
||||
&& let Some((end, hyphen)) = last_natural
|
||||
{
|
||||
brk = Some((end, hyphen, false));
|
||||
break;
|
||||
}
|
||||
if x + adv - hang_r > w + available.max(0.0)
|
||||
if x + adv - hang_r > w + available.max(0.0) + FIT_EPS
|
||||
&& i > start
|
||||
&& (last_ok.is_some() || glyphs.get(i - 1).is_none_or(|p| p.break_after != Some(false) && !p.no_break))
|
||||
{
|
||||
@@ -790,12 +861,12 @@ pub fn greedy(
|
||||
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
|
||||
{
|
||||
last_ok = Some((i + 1, false));
|
||||
if x - hang_r <= w {
|
||||
if x - hang_r <= w + FIT_EPS {
|
||||
last_natural = last_ok;
|
||||
}
|
||||
} else if hyph_after[i] && may_hyphenate(hyphens) {
|
||||
let hy = hyphen_width(g, &mut hy_cache) * if sp.optical { 1.0 - hang('-').1 } else { 1.0 };
|
||||
if x + hy <= w + shrink {
|
||||
if x + hy <= w + shrink + FIT_EPS {
|
||||
last_ok = Some((i + 1, true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2348,6 +2348,11 @@ fn layout_line(
|
||||
let (seg_line, seg_add, seg_scale) =
|
||||
(line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut []));
|
||||
distribute(seg_line, &rebased, extra, sp, seg_add, seg_scale);
|
||||
} else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last && extra < 0.0 {
|
||||
// An overfull single word shrinks only as far as the minimum letter spacing and glyph scaling allow.
|
||||
let (seg_line, seg_add, seg_scale) =
|
||||
(line.get(seg..).unwrap_or(&[]), add.get_mut(seg..).unwrap_or(&mut []), scale.get_mut(seg..).unwrap_or(&mut []));
|
||||
distribute(seg_line, &[], extra, sp, seg_add, seg_scale);
|
||||
} else if justify_this && spaces.is_empty() && line.len() > seg + 1 && !last {
|
||||
// Single word: Single Word Justification.
|
||||
match pp.single_word_justify {
|
||||
@@ -2632,10 +2637,12 @@ 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 };
|
||||
for (k, &i) in spaces.iter().enumerate() {
|
||||
let share = if yw > 1e-9 { tw * word[k] / yw } else { 0.0 };
|
||||
// Left over beyond every limit: shared equally by the word spaces.
|
||||
add[i] += sign * (share + rem / spaces.len() as f64);
|
||||
add[i] += sign * share + over;
|
||||
}
|
||||
if yl > 1e-9 && tl > 0.0 {
|
||||
for (i, l) in letter.iter().enumerate() {
|
||||
|
||||
@@ -4112,3 +4112,56 @@ fn list_first_line_wraps_at_the_column_edge_after_its_label() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines of `text` set in a frame `width` wide, with `tracking` on every character.
|
||||
fn tracked_lines(text: &str, width: f64, para: ParaAttrs, tracking: f64) -> Vec<Line> {
|
||||
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, width, 4000.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.tracking = Some(tracking));
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
assert!(!cs.is_overset(), "overset at width {width}");
|
||||
all_lines(&cs).into_iter().cloned().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_word_longer_than_the_line_breaks_at_the_column_edge() {
|
||||
use designcraft_doc::Composer;
|
||||
let word = "n".repeat(60);
|
||||
let long = format!("Then {word} and on");
|
||||
for tracking in [0.0, 740.0] {
|
||||
// Natural advances, from one unbroken line.
|
||||
let natural: HashMap<usize, f64> =
|
||||
tracked_lines(&long, 1e5, ParaAttrs::default(), tracking)[0].glyphs.iter().map(|g| (g.byte, g.adv)).collect();
|
||||
for composer in [Composer::Paragraph, Composer::SingleLine] {
|
||||
for align in [Align::Left, Align::LeftJustified] {
|
||||
let para = ParaAttrs { composer: Some(composer), align: Some(align), hyphenate: Some(false), ..Default::default() };
|
||||
let lines = tracked_lines(&long, 72.0, para, tracking);
|
||||
let what = format!("{composer:?} {align:?} tracking {tracking}");
|
||||
assert!(lines.len() >= 4, "{what}: {} lines", lines.len());
|
||||
for l in &lines {
|
||||
let ink: Vec<_> = l.glyphs.iter().filter(|g| g.visible && g.adv > 0.0).collect();
|
||||
let right = ink.iter().map(|g| g.x + g.adv.min(natural[&g.byte])).fold(l.x0, f64::max);
|
||||
assert!(l.end_x <= l.x1 + 0.02 && right <= l.x1 + 0.02, "{what}: line {:?} ends at {} past {}", l.range, l.end_x, l.x1);
|
||||
for w in ink.windows(2) {
|
||||
assert!(w[1].x >= w[0].x + natural[&w[0].byte] - 1e-6, "{what}: glyphs overlap at {}", w[1].byte);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_exactly_fills_the_measure_fits() {
|
||||
use designcraft_doc::Composer;
|
||||
let text = "#knowyourplastic";
|
||||
let one = &tracked_lines(text, 1e5, ParaAttrs::default(), 0.0)[0];
|
||||
// The measure a rounding error short of the natural width.
|
||||
let width = one.end_x - one.x0 - 0.005;
|
||||
for composer in [Composer::Paragraph, Composer::SingleLine] {
|
||||
for align in [Align::Left, Align::LeftJustified] {
|
||||
let para = ParaAttrs { composer: Some(composer), align: Some(align), ..Default::default() };
|
||||
let lines = tracked_lines(text, width, para, 0.0);
|
||||
assert_eq!(lines.len(), 1, "{composer:?} {align:?}: {:?}", lines.iter().map(|l| l.range.clone()).collect::<Vec<_>>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user