Merge pull request #1990 from KnottyDyes/squad/discretionary-hyphen-breaks

Compose: honor discretionary hyphens in single-line composition
This commit is contained in:
Brandon Thomas
2026-10-29 21:55:32 -04:00
committed by GitHub
5 changed files with 120 additions and 15 deletions
+1
View File
@@ -384,6 +384,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-28** · Discretionary hyphens (U+00AD) break in the single-line composer with automatic hyphenation on or off, matching the paragraph composer. Unbroken soft hyphens remain invisible and zero-width, and words containing them keep only their discretionary break points (#1955). No readiness percentage change.
- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change.
- **2026-10-28** · IDML export writes the document's default `StoryPreference` in `Resources/Preferences.xml`, as InDesign does. Only each story carried one, and IDML readers that take the spread reading order from the document's (Okapi's IDML filter) failed on DesignCraft's files. No readiness percentage change.
- **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change.
+9 -1
View File
@@ -972,7 +972,15 @@ pub fn greedy(
x += adv;
shrink += sp.box_elastic(g).1.iter().sum::<f64>();
if i + 1 < n && !glyphs[i + 1].is_space() && !g.no_break && g.break_after != Some(false) {
if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
if g.ch == SOFT_HYPHEN {
if may_hyphenate(hyphens) {
let hw = hyphen_width(g, &mut hy_cache);
let hy = (hw - hyphen_hang(g, hw, sp)).max(0.0);
if x + hy <= w + shrink + FIT_EPS {
last_ok = Some((i + 1, true));
}
}
} else if matches!(g.ch, '-' | '\u{2010}' | '\u{2013}' | '\u{2014}' | '/')
|| g.break_after.unwrap_or_else(|| cjk_break(g, &glyphs[i + 1], sp))
{
last_ok = Some((i + 1, false));
+87 -13
View File
@@ -1103,21 +1103,95 @@ fn hyphenate_across_column_off_keeps_nested_line_styles_on_their_lines() {
}
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
let para = ParaAttrs { align: Some(Align::Left), ..Default::default() };
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 120.0, 1000.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let soft = text.find('\u{AD}').unwrap();
for l in all_lines(&cs) {
if l.hyphenated {
let last = l.glyphs.iter().rfind(|g| g.len > 0).unwrap();
assert!(last.byte + last.len >= soft && last.byte <= soft + 2, "{:?}", &text[l.range.clone()]);
fn discretionary_hyphen_breaks_with_both_composers_and_hyphenation_settings() {
let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:";
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
for composer in [Composer::SingleLine, Composer::Paragraph] {
for hyphenate in [false, true] {
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
let (mut d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 430.0, 1000.0), para);
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5));
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = all_lines(&cs)[0];
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
assert!(first.hyphenated);
let hyphen = first.glyphs.last().unwrap();
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
}
}
// Hyphenation points of the word itself: none besides the discretionary one.
let lines = all_lines(&cs);
assert!(lines.iter().filter(|l| l.hyphenated).count() <= 1);
}
#[test]
fn discretionary_hyphen_before_cjk_single_line() {
discretionary_hyphen_before_cjk(Composer::SingleLine);
}
#[test]
fn discretionary_hyphen_before_cjk_paragraph() {
discretionary_hyphen_before_cjk(Composer::Paragraph);
}
fn discretionary_hyphen_before_cjk(composer: Composer) {
let text = "abc\u{ad}\u{65e5}\u{672c}\u{8a9e}\u{65e5}\u{672c}\u{8a9e}";
let soft_end = text.find('\u{ad}').unwrap() + '\u{ad}'.len_utf8();
for hyphenate in [false, true] {
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(hyphenate), ..Default::default() };
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 24.0, 1000.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
let first = all_lines(&cs)[0];
assert_eq!(first.range.end, soft_end, "{composer:?}, hyphenate={hyphenate}: {:?}", &text[first.range.clone()]);
assert!(first.hyphenated, "{composer:?}, hyphenate={hyphenate}");
let hyphen = first.glyphs.last().unwrap();
assert!(hyphen.visible && hyphen.len == 0 && hyphen.adv > 0.0);
}
}
#[test]
fn discretionary_hyphen_is_invisible_and_zero_width_when_unbroken() {
for composer in [Composer::SingleLine, Composer::Paragraph] {
let mut widths = Vec::new();
for text in ["unterschiedlichste", "unterschied\u{ad}lichste"] {
let (d, sid, _) = doc_with(text, Rect::new(0.0, 0.0, 1000.0, 100.0), ParaAttrs { composer: Some(composer), ..Default::default() });
let cs = compose_story(&d, sid, &ComposeOptions::default());
let lines = all_lines(&cs);
assert_eq!(lines.len(), 1);
assert!(!lines[0].hyphenated);
if let Some(soft) = text.find('\u{ad}') {
let glyph = lines[0].glyphs.iter().find(|g| g.byte == soft).unwrap();
assert!(!glyph.visible && glyph.adv == 0.0);
}
widths.push(lines[0].end_x);
}
assert!((widths[0] - widths[1]).abs() < 1e-6, "{composer:?}: {widths:?}");
}
}
#[test]
fn discretionary_hyphen_replaces_automatic_points() {
let text = "aaaa bbbb cccc extraordi\u{AD}narily dddd eeee";
let soft = text.find('\u{AD}').unwrap();
for composer in [Composer::SingleLine, Composer::Paragraph] {
let mut discretionary_breaks = 0;
let mut automatic_breaks = 0;
for width in (90..181).step_by(10) {
for discretionary in [false, true] {
let source = if discretionary { text.to_string() } else { text.replace('\u{AD}', "") };
let para = ParaAttrs { composer: Some(composer), hyphenate: Some(true), align: Some(Align::Left), ..Default::default() };
let (d, sid, _) = doc_with(&source, Rect::new(0.0, 0.0, f64::from(width), 1000.0), para);
let cs = compose_story(&d, sid, &ComposeOptions::default());
for l in all_lines(&cs).into_iter().filter(|l| l.hyphenated) {
if discretionary {
assert_eq!(l.range.end, soft + '\u{AD}'.len_utf8(), "{composer:?}: {:?}", &source[l.range.clone()]);
discretionary_breaks += 1;
} else {
automatic_breaks += 1;
}
}
}
}
assert!(discretionary_breaks > 0, "{composer:?}: discretionary breaks exercised");
assert!(automatic_breaks > 0, "{composer:?}: pattern breaks exercised without U+00AD");
}
}
#[test]
+22
View File
@@ -76,6 +76,28 @@ fn exports_valid_pdf_with_one_page_per_page() {
assert_eq!(pdf.pages().len(), 2);
}
#[test]
fn discretionary_hyphen_pdf_text_follows_visible_breaks() {
let text = "Wir möchten uns ganz besonders herzlich bei folgenden Personen bedanken, die auf unterschied\u{ad}lichste Weise zur Entstehung dieses Lehrbuches beigetragen haben:";
for composer in [designcraft_doc::Composer::SingleLine, designcraft_doc::Composer::Paragraph] {
for (width, expected) in [(430.0, "unterschied-lichste"), (540.0, "unterschiedlichste")] {
let mut d = Document::new(&NewDocument::default());
let lid = d.default_layer();
let para = designcraft_doc::ParaAttrs { composer: Some(composer), hyphenate: Some(false), ..Default::default() };
let (_, sid) = d
.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 36.0 + width, 400.0), lid, text, ParaFormat { para, ..Default::default() })
.unwrap();
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| f.over.size = Some(9.5));
for tagged in [false, true] {
let bytes = export_pdf(&d, &Cache::new(), &PdfOptions { tagged, ..Default::default() }).unwrap();
let extracted = extract_text(&bytes).concat();
assert!(extracted.contains(expected), "{composer:?}, width={width}, tagged={tagged}: {extracted:?}");
assert!(!extracted.contains('\u{ad}'));
}
}
}
}
#[test]
fn text_is_real_text() {
let d = doc_with_text("Hello DesignCraft — efficient affine");
+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), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); 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") |
| 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), Greek (hyph-el-monoton) and German 2006 (hyph-de-1996); zone, limits, exceptions in the user dictionary; Hyphenate Across Column off keeps automatic hyphens off the last line of a column or frame; discretionary U+00AD breaks with both composers regardless of automatic hyphenation and replaces pattern points within its word (#1955) | ~ | 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 | ✓ | ✓ | ✓ | |