From bc50eb5564af5d557b04fc914791995288eb8d56 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:09:12 +0200 Subject: [PATCH 1/2] Hyphenate Across Column: no column or frame ends in a hyphen when it's off With Hyphenate Across Column off, a paragraph whose line ending at an automatic hyphenation point would end a column or frame is re-laid without that point, so the word moves on whole, as InDesign does. The points left out are kept per paragraph across re-lays and cleared when an earlier paragraph is re-laid. On by default, as in InDesign. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 100 ++++++++++++++++++++++++++++++++---- crates/compose/src/tests.rs | 39 ++++++++++++++ docs/typography-parity.md | 2 +- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a2352e86..57b32ca0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · Hyphenate Across Column (Hyphenation Settings; IDML HyphenateAcrossColumns) is applied: with it off, a paragraph whose line ending in an automatic hyphen would end a column or frame is re-composed without that hyphenation point, so the word moves on whole, as in InDesign. On by default, as in InDesign, so existing layouts don't change. No readiness percentage change. - **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 89ea89bd3..658fc116f 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -845,6 +845,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut info: Vec = Vec::with_capacity(np); let mut force_col = vec![false; np]; let mut line_cap: Vec> = vec![None; np]; + // Hyphenate Across Column off: the hyphenation points (glyph indices) a paragraph is re-laid + // without, because a line ending at one of them ended a column or frame. + let mut no_hyph: Vec> = vec![Vec::new(); np]; let mut restores = 0usize; let mut pi = 0; let var_story = vars::has_vars(story); @@ -884,7 +887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -894,7 +897,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -948,7 +951,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1218,7 +1221,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C bidi::resolve_mirroring(&mut glyphs, &bidi_info); apply_desired_spacing(&mut glyphs, &pp); mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); - let hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hyph_after = + hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + for &k in no_hyph.get(pi).into_iter().flatten() { + if let Some(h) = hyph_after.get_mut(k) { + *h = false; + } + } let base_size = base_chars.size; let spacing = spacing_for(&pp, base_size); // An empty paragraph takes its height from its paragraph mark's character format. @@ -1325,7 +1334,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { trial = Some(t); balance_runs += 1; - rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.1, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.1; continue 'paras; } @@ -1361,6 +1381,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut line_no = 0usize; // Paragraph line number of the first line in the current column. let mut col_first_line = 0usize; + // The glyph after which the last line set ended at an automatic hyphenation point. + let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span let rtl = pp.direction == designcraft_doc::TextDirection::RightToLeft; @@ -1378,7 +1400,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); pi = j; continue 'paras; } @@ -1605,6 +1627,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 0.0 }; let fits = baseline + fit_descent <= col.y1 - reserve + 0.01 && !capped; + if !fits + && !pp.hyph_across_column + && line_no > col_first_line + && let Some(at) = hyphen_at + && restores < (4 * np + 64) * (balance_runs + 1) + { + restores += 1; + no_hyph[pi].push(at); + rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); + continue 'paras; + } if !fits { if cur.can_next_sub() { g0 = s; @@ -1626,7 +1659,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; if let Some(j) = j { split_runs += 1; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1659,7 +1703,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1765,7 +1820,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) { - rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + j, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = j; continue 'paras; } @@ -1797,6 +1863,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.last_descent = desc; cur.pending = 0.0; cur.grid_next = grid_end.map(|end| (baseline, end)); + hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)); line_no += 1; // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the @@ -1924,7 +1991,18 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !t.done { split_trial = Some(t); split_runs += 1; - rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + rewind( + key.2, + &snaps, + ¬e_snaps, + &mut notes, + &mut out, + &mut cur, + &mut list_counter, + &mut force_col, + &mut line_cap, + &mut no_hyph, + ); pi = key.2; continue 'paras; } @@ -2282,9 +2360,11 @@ fn rewind( list_counter: &mut ListCounter, force_col: &mut [bool], line_cap: &mut [Option], + no_hyph: &mut [Vec], ) { force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear); if let Some(s) = snaps.get(j) { s.restore(out, cur, list_counter); } diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 15c412ada..6045d667e 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1013,6 +1013,45 @@ fn hyphenation_zone_limits_ragged_hyphens() { assert!(free > 0 && zoned == free && huge == free, "{c:?}: {free} {zoned} {huge}"); } +#[test] +fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() { + let text = CORPUS.join(" "); + // Column-end lines that are hyphenated, over frame heights that end the first column at each + // line in turn. + let ends = |across: bool, composer: designcraft_doc::Composer| { + let mut ends = 0; + let mut lines = 0; + for h in (30..400).step_by(3) { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + composer: Some(composer), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 240.0, h as f64), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let col0: Vec<&Line> = all_lines(&cs).into_iter().filter(|l| l.column == 0).collect(); + ends += usize::from(col0.last().is_some_and(|l| l.hyphenated)); + lines += col0.len(); + // The frame's last line too, when the text goes on (overset). + let last = all_lines(&cs).into_iter().last(); + ends += usize::from(cs.is_overset() && last.is_some_and(|l| l.hyphenated && !l.last_in_para)); + } + (ends, lines) + }; + for c in [designcraft_doc::Composer::Paragraph, designcraft_doc::Composer::SingleLine] { + let (on, lines_on) = ends(true, c); + let (off, lines_off) = ends(false, c); + // With it on, columns do end in hyphens here; with it off, none does, and the columns + // still fill. + assert!(on > 3, "{c:?}: {on}"); + assert_eq!(off, 0, "{c:?}"); + assert!(lines_off * 10 >= lines_on * 9, "{c:?}: {lines_off} {lines_on}"); + } +} + #[test] fn discretionary_hyphen_replaces_automatic_points() { let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee"; diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 68d7a5d9b..7408e3d4a 100644 --- a/docs/typography-parity.md +++ b/docs/typography-parity.md @@ -36,7 +36,7 @@ Fidelity: **unmeasured** unless stated. | Single-line Composer | greedy | greedy with the same ranges | ✓ | | | World-Ready composers | Paragraph / Single-line World-Ready | RTL, Arabic and bidi in the main composer | ~ | Indic scripts (Devanagari etc.) shape through harfrust but are untested | | Justification | word/letter spacing, glyph scaling min/desired/max, single-word, last line | all of these | ✓ | a justified line with a tab justifies after the tab (#284); set per paragraph in Type › Justification… and the Paragraph panel menu (#531) | -| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary | ~ | other languages don't hyphenate (#87 reported "doesn't work") | +| Hyphenation | per-language dictionaries (~40), zone, limits, capitalized, last word, across columns | Liang patterns: English (trained from the public-domain Moby list), Spanish (Bezos), Portuguese (hyph-pt), Norwegian (hyph-no) and Greek (hyph-el-monoton); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame | ~ | other languages don't hyphenate (#87 reported "doesn't work") | | Spelling | per-language dictionaries | English (Moby) + document/user dictionary | ~ | | | Kerning | Metrics, Optical, manual, none | Metrics, Optical (outline-based: pairs pulled towards the font's stem gap, `crates/compose/src/optical.rs`), manual, none | ~ | Optical is an approximation of Adobe's algorithm (#181), not a match: imported display text can still break differently | | Tracking, scale, skew, baseline shift | ✓ | ✓ | ✓ | | From 2088cee52d13ff9186db44aa8b6e7bca50fe776e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:55:59 +0200 Subject: [PATCH 2/2] Hyphenate Across Column: nested line styles and its own re-lay bound The points left out are recorded as story bytes, so the nested line styles prepass (which indexes glyphs differently) leaves them out too, and a style still covers exactly the lines it is set on. Their re-lays have a bound of their own: each leaves out one more point of the paragraph, and the keep options' budget no longer stops them. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 37 +++++++++++++++++++---------- crates/compose/src/tests.rs | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 658fc116f..acff8cc97 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -845,9 +845,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut info: Vec = Vec::with_capacity(np); let mut force_col = vec![false; np]; let mut line_cap: Vec> = vec![None; np]; - // Hyphenate Across Column off: the hyphenation points (glyph indices) a paragraph is re-laid - // without, because a line ending at one of them ended a column or frame. + // Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid + // without, because a line ending at one of them ended a column or frame. Each re-lay leaves + // out one more point of the paragraph, so they are bounded by its points; the cap only guards + // against keep options clearing them over and over. let mut no_hyph: Vec> = vec![Vec::new(); np]; + let mut hyph_relays = 0usize; let mut restores = 0usize; let mut pi = 0; let var_story = vars::has_vars(story); @@ -1086,7 +1089,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; apply_desired_spacing(&mut gl, &pp); mojikumi::apply(&mut gl, &doc.styles, &pp.mojikumi); - let hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + let mut hy = hyphenation_points(&story.text, &gl, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); + suppress_hyphens(&mut hy, &gl, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let breaks = if pp.composer.single_line() || gl.iter().any(|g| g.ch == '\t') || gl.len() > 4000 { let tab = |j: usize, x: f64, i: usize| { let ind = pp.left_indent + if j == 0 { pp.first_line_indent } else { 0.0 }; @@ -1223,11 +1227,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C mojikumi::apply(&mut glyphs, &doc.styles, &pp.mojikumi); let mut hyph_after = hyphenation_points(&story.text, &glyphs, &pp, &hyph_exceptions, &foreign_ranges(doc, story, prange.clone(), &base_chars)); - for &k in no_hyph.get(pi).into_iter().flatten() { - if let Some(h) = hyph_after.get_mut(k) { - *h = false; - } - } + suppress_hyphens(&mut hyph_after, &glyphs, no_hyph.get(pi).map_or(&[], Vec::as_slice)); let base_size = base_chars.size; let spacing = spacing_for(&pp, base_size); // An empty paragraph takes its height from its paragraph mark's character format. @@ -1381,7 +1381,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut line_no = 0usize; // Paragraph line number of the first line in the current column. let mut col_first_line = 0usize; - // The glyph after which the last line set ended at an automatic hyphenation point. + // The story byte at which the last line set ended at an automatic hyphenation point. let mut hyphen_at: Option = None; let has_tabs = glyphs.iter().any(|g| g.ch == '\t'); let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span @@ -1631,9 +1631,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && !pp.hyph_across_column && line_no > col_first_line && let Some(at) = hyphen_at - && restores < (4 * np + 64) * (balance_runs + 1) + && hyph_relays < (story.text.len() + 64) * (restores + 1) { - restores += 1; + hyph_relays += 1; no_hyph[pi].push(at); rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph); continue 'paras; @@ -1863,7 +1863,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C cur.last_descent = desc; cur.pending = 0.0; cur.grid_next = grid_end.map(|end| (baseline, end)); - hyphen_at = e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)); + hyphen_at = + e.checked_sub(1).filter(|&i| b.hyphen && hyph_after.get(i) == Some(&true)).and_then(|i| glyphs.get(i)).map(|g| g.byte + g.len); line_no += 1; // A column / frame / page break inside the paragraph: the rest of it continues in the // new column/frame/page, re-broken there. (A break that ends the paragraph moves the @@ -4064,6 +4065,18 @@ fn foreign_ranges( .collect() } +/// Leave out the hyphenation points after the glyphs that end at story bytes `at`. +fn suppress_hyphens(hy: &mut [bool], glyphs: &[Glyph], at: &[usize]) { + if at.is_empty() { + return; + } + for (h, g) in hy.iter_mut().zip(glyphs) { + if *h && at.contains(&(g.byte + g.len)) { + *h = false; + } + } +} + fn hyphenation_points( text: &str, glyphs: &[Glyph], diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6045d667e..f07fdc927 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1052,6 +1052,53 @@ fn hyphenate_across_column_off_ends_no_column_with_a_hyphen() { } } +#[test] +fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() { + // The first line in a character style of another colour (the same widths): when its hyphen is + // left out at a column end, the style still covers exactly the first line as set. + let text = CORPUS.join(" "); + let compose = |w: f64, h: f64, across: bool| { + let para = ParaAttrs { + align: Some(Align::Left), + hyph_weight: Some(0.0), + hyph_across_column: Some(across), + nested_line_styles: Some(vec![designcraft_doc::NestedLineStyle { style: "Lead".into(), lines: 1 }]), + ..Default::default() + }; + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, w, h), para); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let chars = designcraft_doc::CharAttrs { fill: Some("[Registration]".into()), ..Default::default() }; + std::sync::Arc::make_mut(&mut d.styles).character.push(designcraft_doc::CharacterStyle { + name: "Lead".into(), + based_on: None, + chars, + shortcut: String::new(), + }); + compose_story(&d, sid, &ComposeOptions::default()) + }; + let mut checked = 0; + for w in (160..320).step_by(10) { + for h in (12..40).step_by(2) { + // Where, with the setting on, a hyphenated first line ends column 1. + let on = compose(w as f64, h as f64, true); + let lines = all_lines(&on); + if !(lines.iter().filter(|l| l.column == 0).count() == 1 && lines[0].hyphenated) { + continue; + } + let cs = compose(w as f64, h as f64, false); + let lines = all_lines(&cs); + assert!(!lines[0].hyphenated, "{w}×{h}"); + let lead = |l: &Line| { + l.glyphs.iter().filter(|g| g.len > 0 && g.visible).map(|g| cs.styles[g.style as usize].fill == "[Registration]").collect::>() + }; + assert!(lead(lines[0]).iter().all(|&r| r), "{w}×{h}: first line"); + assert!(lead(lines[1]).iter().all(|&r| !r), "{w}×{h}: second line"); + checked += 1; + } + } + assert!(checked > 3, "{checked}"); +} + #[test] fn discretionary_hyphen_replaces_automatic_points() { let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";