From bc50eb5564af5d557b04fc914791995288eb8d56 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 14:09:12 +0200 Subject: [PATCH] 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 | ✓ | ✓ | ✓ | |