From d7bc6633e763656ac53179f7ffb12b5c46eef6b4 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 28 Oct 2026 20:26:06 -0600 Subject: [PATCH] Compose: honor discretionary hyphens in single-line composition Recognize U+00AD independently of automatic hyphenation in the greedy breaker, reusing the existing visible hyphen output. Cover both composers, zero-width unbroken soft hyphens, pattern suppression, and tagged and untagged PDF text extraction. Fixes #1955 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/breaker.rs | 10 +++- crates/compose/src/tests.rs | 100 +++++++++++++++++++++++++++++----- crates/pdf/src/tests.rs | 22 ++++++++ docs/typography-parity.md | 2 +- 5 files changed, 120 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..6c49d77e7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,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-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. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs index 9b453ff0a..6f62542d7 100644 --- a/crates/compose/src/breaker.rs +++ b/crates/compose/src/breaker.rs @@ -972,7 +972,15 @@ pub fn greedy( x += adv; shrink += sp.box_elastic(g).1.iter().sum::(); 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)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..3128da3a7 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -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] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2accc79fa..f913854aa 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -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"); diff --git a/docs/typography-parity.md b/docs/typography-parity.md index 8c92e8dfa..9b4c2591a 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; 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) 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; 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 | ✓ | ✓ | ✓ | |