Layout: automatic hyphenation (dictionary + patterns, 0.25" zone), soft hyphens show a hyphen at line end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-06 01:27:02 -04:00
1 parent 4c63bb0230
commit f38710fcb6
4 files changed
+150 -4

No files matched your search

+17
View File
@@ -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 {
+1 -1
View File
@@ -195,7 +195,7 @@ fn hash_of<T: Hash>(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 {
+101 -3
View File
@@ -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<u32>,
}
/// 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<Line> = 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<f32> = Vec::new();
let mut leaders = Vec::new();
let mut last_break: Option<usize> = 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<u32> {
let mut bytes: Vec<usize> = 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<usize> = 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<usize> = 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<u32> = 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
}
+31
View File
@@ -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::<usize>()
};
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);
}