Merge pull request #1617 from marcoarnulfo/fix/area-type-narrow-top-lines

Area type: a word too wide for the top lines of a shape moves down instead of breaking
This commit is contained in:
BFlat 2026-10-13 13:27:27 +07:00 committed by GitHub
commit f870f3ac09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 87 additions and 6 deletions

View File

@ -1013,6 +1013,8 @@ struct Pen<'r> {
/// Leading measured from em box top to em box top: where the next line's em box top goes.
model: LeadingModel,
next_top: Option<f64>,
/// The narrowest word no line from here down is wide enough for (see [`Pen::place`]).
no_room_for: f64,
}
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
@ -1020,10 +1022,27 @@ struct Pen<'r> {
const MAX_STEPS: usize = 100_000;
impl Pen<'_> {
/// Baseline and horizontal span for a line with estimated metrics `est` and indents; `None` =
/// the frame is full (overflow).
/// 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.
fn place(&mut self, est: Metrics, ind_l: f64, ind_r: f64) -> Option<(f64, f64, f64, bool)> {
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));
@ -1046,7 +1065,12 @@ impl Pen<'_> {
if baseline + est.desc > r.bottom() + 0.01 {
break;
}
let fits = |&(a, b): &(f64, f64)| b - a - ind_l - ind_r > est.asc.max(1.0);
// A rectangle's lines are all as wide: none below has more room for the word.
let min = if r.rect || r.polys.is_empty() { 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)),
@ -1114,6 +1138,25 @@ fn tab_advance(tabs: &[vectorcraft_doc::TabStop], origin: f64, x: f64, rest: &[S
(at - rel).max(0.0)
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it.
fn word_width(g: &[SGlyph], i: usize) -> f64 {
let mut w = 0.0;
for gl in g.get(i..).unwrap_or_default() {
if gl.is_space() {
break;
}
if gl.is_soft_hyphen() {
return w + hyphen_glyph(gl).adv;
}
w += gl.adv;
if gl.break_after() {
break;
}
}
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.
fn break_line(text: &str, g: &[SGlyph], i: usize, width: f64, hyphenate: bool, burasagari: Burasagari, kinsoku: Kinsoku) -> (usize, bool) {
@ -1450,7 +1493,10 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], para: &ParaStyle, pen: &Pen<'_>, rt
while widths.len() < sg.len() {
let first = widths.is_empty();
let ind_l = para.left_indent + if first { para.first_line_indent } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent) else { break };
// Only the first line's first word is known here. Later lines are placed as the composed
// lines need: each fits its width, so its first word does too.
let word = if first { word_width(sg, 0) } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break };
let w = x1 - x0 - ind_l - para.right_indent;
widths.push(w);
acc += w.max(1.0);
@ -1492,6 +1538,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
queued: vec![],
model: t.para_at(0).leading_model,
next_top: None,
no_room_for: f64::INFINITY,
};
'paras: for (pi, pr) in paras.iter().enumerate() {
let para = t.para_at(pi);
@ -1527,6 +1574,8 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
let est = if i < n { Metrics::of(&sg[i]) } else { pm };
let first_line = li_para == 0;
let ind_l = para.left_indent + if first_line { para.first_line_indent } else { 0.0 };
// Measured before Mojikumi narrows an opening bracket, as the composer measures it.
let word = word_width(&sg, i);
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
@ -1543,7 +1592,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio
// next row/column if it no longer fits).
let (baseline, x0, x1, end, hyph, m) = loop {
let first_in_region = pen.prev.is_none();
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent) else {
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, para.right_indent, word) else {
cx.out.overflow = cx.text.len() > if i < n { sg[i].byte } else { pr.start };
break 'paras;
};

View File

@ -212,6 +212,38 @@ fn area_flow_inside_circle_keeps_glyphs_inside() {
assert!(w(l.lines.len() / 2) > w(0));
}
/// In a frame that is narrow at the top, a word that doesn't fit the top lines moves down to the
/// first line wide enough for it (the lines above stay empty), with either composer. Only a word
/// wider than every line is broken.
#[test]
fn area_flow_moves_a_word_down_to_a_line_wide_enough_for_it() {
let triangle = |base: f64, height: f64| {
let mut p = BezPath::new();
p.move_to((base / 2.0, 0.0));
p.line_to((base, height));
p.line_to((0.0, height));
p.close_path();
p
};
let text = "Wonderful extraordinary typesetting";
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = area_path(text, style(12.0), &triangle(200.0, 200.0), Justify::Left);
t.para.composer = composer;
let l = layout(db(), &t);
assert!(!l.overflow, "{composer:?}");
let lines: Vec<&str> = l.lines.iter().map(|li| text[li.start..li.end].trim_end()).collect();
// The top lines are wide enough for "Wo" but not for "Wonderful": it starts lower.
assert_eq!(lines.first(), Some(&"Wonderful"), "{composer:?}: {lines:?}");
for li in &lines {
assert!(li.split(' ').all(|w| text.split(' ').any(|word| word == w)), "{composer:?}: {lines:?}");
}
}
// No line of this triangle is as wide as the word: it is still broken, and every letter is set.
let word = "Supercalifragilisticexpialidocious";
let l = layout(db(), &area_path(word, style(12.0), &triangle(60.0, 400.0), Justify::Left));
assert!(!l.overflow && l.lines.len() > 1, "{:?}", line_places(&l));
}
/// Lays `t` out as [`layout`] does, on a thread of its own, and fails the test when that panics or
/// takes more than 20 s.
fn layout_soon(t: &TextObject) -> TextLayout {