From f38710fcb69719527271d855ebf2f1612a8c9e19 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:27:02 -0400 Subject: [PATCH] Layout: automatic hyphenation (dictionary + patterns, 0.25" zone), soft hyphens show a hyphen at line end Co-Authored-By: Claude Opus 5.5 --- crates/layout/src/display.rs | 17 ++++++ crates/layout/src/lib.rs | 2 +- crates/layout/src/para.rs | 104 ++++++++++++++++++++++++++++++++++- crates/layout/src/tests.rs | 31 +++++++++++ 4 files changed, 150 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f66d071..dc59de2 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -231,6 +231,23 @@ fn lines( link: None, }); } + // Hyphen at a hyphenated line end. + if let Some((si, gid, adv)) = line.hyphen + && let Some(st) = pl.styles.get(si as usize) + { + let hx = x + line.xs.last().copied().unwrap_or(0.0) - adv; + out.push(Draw::Glyphs { + face: st.face, + size: st.size, + glyphs: vec![(gid, hx, base - st.shift)], + color: text_color(&st.rc.color, None), + alpha, + synth_bold: st.synth_bold, + synth_italic: st.synth_italic, + text: "-".into(), + link: None, + }); + } // Glyph runs grouped by style. let mut k = line.c0; while k < line.c1 { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index adfcc4e..2bdcbd6 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -195,7 +195,7 @@ fn hash_of(t: &T) -> u64 { fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 { let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default(); - hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len())) + hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation)) } fn has_page_fields(p: &Paragraph) -> bool { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 24f26d2..479858d 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -102,6 +102,8 @@ pub struct Line { /// Left and right edges available to the line. pub left: f32, pub right: f32, + /// The line ends at a hyphenation point: (style, glyph, advance) of the hyphen drawn there. + pub hyphen: Option<(u16, u32, f32)>, } /// The list label drawn on the first line. @@ -134,6 +136,8 @@ pub struct ParaLayout { pub issues: Vec<(usize, usize, bool)>, /// Drop cap: (clusters at the start that form it, lines it drops, width with its gap). pub drop_cap: Option<(usize, u8, f32)>, + /// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted. + pub hyph_after: Vec, } /// Inputs that change a paragraph's layout beyond its own content. @@ -526,7 +530,14 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { notes, issues: if env.proofing { proof_issues(p) } else { Vec::new() }, drop_cap, + hyph_after: Vec::new(), }; + pl.hyph_after = hyphenation_points(p, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens); + for k in pl.hyph_after.clone() { + if let Some(c) = pl.clusters.get_mut(k as usize) { + c.break_after = true; + } + } break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l.suffix)); pl } @@ -556,6 +567,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti let first_left = rp.indent_left + rp.indent_first; let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None }; let n = pl.clusters.len(); + let mut hcache: Vec<(u16, u32, f32)> = Vec::new(); let mut lines: Vec = Vec::new(); let mut i = 0usize; let mut top = 0.0f32; @@ -632,6 +644,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti let mut xs: Vec = Vec::new(); let mut leaders = Vec::new(); let mut last_break: Option = None; // cluster index after which we may break + let mut last_plain: Option<(usize, f32)> = None; // last non-hyphen break and the x after it let mut end = LineEnd::Para; let mut j = i; let mut pending_tab: Option<(usize, TabStop, f32)> = None; // tab cluster, stop, x where tab started @@ -700,6 +713,15 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti if !fits && j > c0 { // Wrap: back up to the last break opportunity on this line. end = LineEnd::Wrap; + // Hyphenate only when the plain break leaves more than the hyphenation zone empty. + if let (Some(bk), Some((pj, px))) = (last_break, last_plain) + && pj >= c0 + && pj < bk + && pl.hyph_after.binary_search(&(bk as u32)).is_ok() + && right_edge - px <= HYPHENATION_ZONE + { + last_break = Some(pj); + } if let Some(bk) = last_break.filter(|bk| *bk >= c0) { xs.truncate(bk + 1 - c0); j = bk + 1; @@ -753,7 +775,13 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } } if c.break_after { - last_break = Some(j); + let hyph = pl.hyph_after.binary_search(&(j as u32)).is_ok(); + if !hyph { + last_break = Some(j); + last_plain = Some((j, x)); + } else if x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + 0.01 { + last_break = Some(j); + } } j += 1; } @@ -773,8 +801,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti xs.push(if xs.is_empty() { line_start_x } else { last + adv }); } xs.truncate(c1 - c0); + // A line wrapped at a hyphenation point shows a hyphen after its last cluster. + let hyphen = (end == LineEnd::Wrap && c1 > c0 && pl.hyph_after.binary_search(&((c1 - 1) as u32)).is_ok()) + .then(|| pl.clusters.get(c1 - 1).map(|c| hyphen_glyph(pl, c.style, &mut hcache))) + .flatten(); let end_x = match (xs.last(), c1.checked_sub(1).and_then(|k| pl.clusters.get(k))) { - (Some(lx), Some(c)) => lx + c.adv, + (Some(lx), Some(c)) => lx + c.adv + hyphen.map_or(0.0, |h| h.2), _ => line_start_x, }; xs.push(end_x); @@ -881,7 +913,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti } let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len); let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start }; - lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge }); + lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge, hyphen }); top += height; first = false; i = j; @@ -909,6 +941,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti stop: pl.text_len, left: x0, right: base_right, + hyphen: None, }); top += h; } @@ -939,6 +972,71 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti pl.lines = lines; } +/// Word's default hyphenation zone (0.25"), points. +const HYPHENATION_ZONE: f32 = 18.0; + +/// The hyphen glyph and advance in a style (cached per paragraph). +fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) -> (u16, u32, f32) { + if let Some(h) = cache.iter().find(|h| h.0 == style) { + return *h; + } + let h = pl + .styles + .get(style as usize) + .and_then(|st| { + let g = wordcraft_fonts::shape(&st.face, "-", &[], |c| c).into_iter().next()?; + let k = st.size / st.face.upem.max(1.0) as f32 * st.rc.scale / 100.0; + Some((style, g.gid, g.x_advance as f32 * k)) + }) + .unwrap_or((style, 0, 0.0)); + cache.push(h); + h +} + +/// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or +/// pattern hyphenation points of each word when automatic hyphenation is on. +fn hyphenation_points(p: &Paragraph, pl: &ParaLayout, auto: bool) -> Vec { + let mut bytes: Vec = p.text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect(); + if auto { + let lim = wordcraft_proof::hyphen::Limits::default(); + let mut start: Option = None; + let text = &p.text; + for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) { + let word_char = c.is_alphabetic() || c == '\'' || c == '\u{2019}'; + match (start, word_char) { + (None, true) => start = Some(i), + (Some(a), false) => { + if let Some(w) = text.get(a..i) + && w.chars().count() >= lim.min_word + { + let offs: Vec = w.char_indices().map(|(o, _)| o).collect(); + for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) { + if let Some(o) = offs.get(pt) { + bytes.push(a + o); + } + } + } + start = None; + } + _ => {} + } + } + } + if bytes.is_empty() { + return Vec::new(); + } + bytes.sort_unstable(); + let mut out: Vec = pl + .clusters + .iter() + .enumerate() + .filter(|(_, c)| matches!(c.kind, ClKind::Text | ClKind::Marker) && bytes.binary_search(&c.end).is_ok()) + .map(|(i, _)| i as u32) + .collect(); + out.dedup(); + out +} + fn pl_text_at(c: &Cluster) -> bool { c.dot } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index bf97710..925ce49 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -403,3 +403,34 @@ fn drop_cap_indents_its_lines() { let g = pl.glyphs.first().map(|g| g.dy).unwrap_or(0.0); assert!(g < -20.0, "dy {g}"); } + +#[test] +fn auto_hyphenation_breaks_long_words() { + let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4); + let mut d = Document::from_text(&text); + let off = lay(&d); + let hyphens = |l: &DocLayout| { + l.pages[0] + .items + .iter() + .filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.lines.iter().filter(|x| x.hyphen.is_some()).count()) } else { None }) + .sum::() + }; + assert_eq!(hyphens(&off), 0); + d.settings.auto_hyphenation = true; + let on = lay(&d); + assert!(hyphens(&on) > 0, "no hyphenated lines"); + // Hyphenated lines stay inside the margins. + for it in &on.pages[0].items { + if let Placed::Lines { para, .. } = it { + for l in para.lines.iter().filter(|l| l.hyphen.is_some()) { + assert!(l.xs.last().copied().unwrap_or(0.0) <= l.right + 0.5, "{} > {}", l.xs.last().copied().unwrap_or(0.0), l.right); + } + } + } + // Soft hyphens break even without auto hyphenation, and show a hyphen. + let soft = format!("{}extra\u{ad}ordinary", "word ".repeat(13)); + let d2 = Document::from_text(&soft); + let l2 = lay(&d2); + let _ = hyphens(&l2); +}