From 2088cee52d13ff9186db44aa8b6e7bca50fe776e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:55:59 +0200 Subject: [PATCH] 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";