diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 14c04166..be0edba3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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, + /// 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], 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], 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], 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; }; diff --git a/crates/text/src/tests_typo.rs b/crates/text/src/tests_typo.rs index 40180c06..051e471c 100644 --- a/crates/text/src/tests_typo.rs +++ b/crates/text/src/tests_typo.rs @@ -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 {