mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 05:36:46 -06:00
Merge pull request #1990 from KnottyDyes/squad/discretionary-hyphen-breaks
Compose: honor discretionary hyphens in single-line composition
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 | ✓ | ✓ | ✓ | |
|
||||
|
||||
Reference in New Issue
Block a user