Merge #1572: Hyphenate Across Column: no column or frame ends in a hyphen when it's off

This commit is contained in:
aiongg
2026-10-24 05:52:41 +00:00
4 changed files with 192 additions and 12 deletions
+1
View File
@@ -358,6 +358,7 @@ Newest first.
- **2026-10-16** · Edit › Keyboard Shortcuts imports a set saved by InDesign's Show Set (.txt, Windows or Mac key names, UTF-8 or UTF-16): matched commands take its Default shortcuts or lose theirs, other commands give up keys it takes, and a report lists what it changed and the items it could not match (`window.importShortcuts`, #1180). Tool keys stay fixed. No readiness percentage change.
- **2026-10-23** · Translations: a missing string shows in English and fails no test; `cargo xtask i18n-report`, run by `cargo xtask ci`, prints the missing strings per language. Tests keep duplicate-key and placeholder checks and the language mechanics (lookup, context captions, plural captions, right-to-left). No readiness percentage change.
- **2026-10-22** · Hyphenation Settings, as in InDesign: the Paragraph panel menu's Hyphenation… (`app.hyphenationDialog`) sets the selected paragraphs' Hyphenate, Words with at Least, After First, Before Last, Hyphen Limit, Hyphenation Zone, the Better Spacing ↔ Fewer Hyphens slider (InDesign's 0–10 steps), Hyphenate Capitalized Words, Last Word and Across Column, with Preview, as one undo step; Paragraph Style Options › Hyphenation has the same form. The panel menu lists its items in InDesign's order. Replicated from a hyphenation tutorial: the same text in Minion Pro breaks into the same lines as InDesign's with hyphenation on, off, and at Fewer Hyphens. Hyphenate Across Column is kept and round-trips through IDML, but composition doesn't apply it yet. No readiness percentage change.
- **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.
+104 -11
View File
@@ -863,6 +863,12 @@ 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 (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<usize>> = vec![Vec::new(); np];
let mut hyph_relays = 0usize;
let mut restores = 0usize;
let mut pi = 0;
let var_story = vars::has_vars(story);
@@ -902,7 +908,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;
}
@@ -912,7 +918,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;
}
@@ -966,7 +972,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;
}
@@ -1101,7 +1107,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 };
@@ -1236,7 +1243,9 @@ 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));
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.
@@ -1343,7 +1352,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;
}
@@ -1379,6 +1399,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 story byte at 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;
@@ -1396,7 +1418,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;
}
@@ -1623,6 +1645,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
&& hyph_relays < (story.text.len() + 64) * (restores + 1)
{
hyph_relays += 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;
@@ -1644,7 +1677,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;
}
@@ -1677,7 +1721,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;
}
@@ -1789,7 +1844,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;
}
@@ -1821,6 +1887,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)).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
@@ -1948,7 +2016,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;
}
@@ -2313,9 +2392,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);
}
@@ -4015,6 +4096,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],
+86
View File
@@ -1013,6 +1013,92 @@ 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 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::<Vec<_>>()
};
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";
+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 | ✓ | ✓ | ✓ | |