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 <noreply@anthropic.com>
This commit is contained in:
moppeeer
2026-10-22 14:09:12 +02:00
co-authored by Claude Opus 5.5
parent 5ce92b6d96
commit bc50eb5564
4 changed files with 131 additions and 11 deletions
+1
View File
@@ -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.
+90 -10
View File
@@ -845,6 +845,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let mut info: Vec<ParaInfo> = Vec::with_capacity(np);
let mut force_col = vec![false; np];
let mut line_cap: Vec<Option<usize>> = 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<usize>> = 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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.1,
&snaps,
&note_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<usize> = 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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(j, &snaps, &note_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, &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
j,
&snaps,
&note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap);
rewind(
key.2,
&snaps,
&note_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<usize>],
no_hyph: &mut [Vec<usize>],
) {
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);
}
+39
View File
@@ -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";
+1 -1
View File
@@ -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 | ✓ | ✓ | ✓ | |