diff --git a/ROADMAP.md b/ROADMAP.md index 93c489299..8381d20a3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. 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 588059558..5d6989de5 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 6870fb7fd..8326ef574 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 742010813..65086cfdb 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), 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 | ✓ | ✓ | ✓ | |