Text: soft hyphens show a hyphen where a line breaks; no break after zero-width space or before em dash
Observed on PowerPoint's export of long joined words:
- a line broken at a soft hyphen (U+00AD) ends with a visible hyphen ("Softhyphenatedwordsina-");
DeckCraft broke there but drew nothing. The hyphen now takes its room on the line;
- PowerPoint does not break after a zero-width space (it splits the word where it overflows
instead), where UAX #14 allows a break;
- an em dash ends a line but never starts one ("Em—dash—joined—" / "words—that—are—long—"),
where UAX #14 allows a break before it too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f5d055b919
commit
6c4bcedbbc
|
|
@ -1112,7 +1112,12 @@ fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>)
|
|||
let chars: Vec<char> = text.chars().collect();
|
||||
for (i, w) in chars.windows(2).enumerate() {
|
||||
let (prev, next) = (w[0], w[1]);
|
||||
let keep = if is_hangul(prev) && is_hangul(next) { false } else { latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace() };
|
||||
// PowerPoint never breaks after a zero-width space, nor before an em dash (only after it).
|
||||
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' {
|
||||
false
|
||||
} else {
|
||||
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
|
||||
};
|
||||
if !keep
|
||||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||||
&& let Some(a) = allowed.get_mut(i + 1)
|
||||
|
|
@ -1461,8 +1466,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
// Logical advances; tabs measure from the start edge.
|
||||
let mut adv = Vec::with_capacity(n);
|
||||
let mut x = 0.0;
|
||||
// A line that breaks at a soft hyphen ends with a visible hyphen.
|
||||
let shy_end = li + 1 < raw.len() && line_cells.last().is_some_and(|c| c.ch == '\u{AD}');
|
||||
for (k, c) in line_cells.iter().enumerate() {
|
||||
let a = if c.ch == '\t' {
|
||||
let a = if shy_end && k + 1 == n {
|
||||
p.faces.get(c.face).zip(p.styles.get(c.style)).map(|(f, st)| measure_str(f, st.size, "-").0).unwrap_or(0.0)
|
||||
} else if c.ch == '\t' {
|
||||
tab_advance(x0 + x, &tabs, props.default_tab.unwrap_or(72.0), tab_segment(&p.cells, rl.start + k))
|
||||
} else {
|
||||
c.adv
|
||||
|
|
@ -1694,7 +1703,13 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
|||
}
|
||||
if let Some(r) = cur.as_mut() {
|
||||
r.chars = (r.chars.0.min(ci), r.chars.1.max(ci + 1));
|
||||
if cell.head == ci && !cell.glyphs.is_empty() {
|
||||
if cell.ch == '\u{AD}' && cx1 - cx > 0.01 {
|
||||
// The soft hyphen a line breaks at is drawn as a hyphen.
|
||||
if let Some(f) = p.faces.get(cell.face) {
|
||||
r.glyphs.push((f.glyph_for('-'), cx, base_y));
|
||||
r.cells.push((ci, cell.ch));
|
||||
}
|
||||
} else if cell.head == ci && !cell.glyphs.is_empty() {
|
||||
// The cluster's glyphs start at its leftmost cell; kashida (right-to-left
|
||||
// only) fills the gap on the cluster's left with tatweel glyphs.
|
||||
let cells_of =
|
||||
|
|
|
|||
|
|
@ -978,3 +978,38 @@ fn underlines_sit_at_the_font_underline_in_their_style() {
|
|||
let (_, words) = underline_decos("words");
|
||||
assert_eq!(words.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_break_after_a_zero_width_space_or_before_an_em_dash() {
|
||||
let (allowed, _) = break_opportunities("ab\u{200B}cd", false);
|
||||
assert!(!allowed[3], "PowerPoint does not break after a zero-width space");
|
||||
let (allowed, _) = break_opportunities("ab\u{2014}cd", false);
|
||||
assert!(!allowed[2] && allowed[3], "an em dash ends a line, never starts one");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_broken_at_a_soft_hyphen_shows_a_hyphen() {
|
||||
let (p, mut sh) = setup("Soft\u{AD}hyphenated\u{AD}words", 150.0, 300.0);
|
||||
let para = &mut sh.text.as_mut().unwrap().paragraphs[0];
|
||||
para.props.bullet = Some(Bullet::None);
|
||||
para.runs[0].props.size = Some(24.0);
|
||||
let l = lay(&p, &sh, 150.0, 300.0);
|
||||
assert!(l.lines.len() >= 2);
|
||||
let first = &l.lines[0];
|
||||
let shy = first.end - 1;
|
||||
assert_eq!(first.end, 5, "breaks after the soft hyphen: {:?}", l.lines.iter().map(|x| (x.start, x.end)).collect::<Vec<_>>());
|
||||
let (x0, x1) = first.edges[shy - first.start];
|
||||
assert!(x1 - x0 > 2.0, "the hyphen takes room");
|
||||
let r = l.runs.iter().find(|r| r.cells.iter().any(|c| c.0 == shy)).expect("a glyph for the soft hyphen");
|
||||
assert_eq!(r.glyphs[r.cells.iter().position(|c| c.0 == shy).unwrap()].0, r.face.glyph_for('-'));
|
||||
// Soft hyphens draw only where a line ends; in a wide box, nowhere.
|
||||
let ends: Vec<usize> = l.lines.iter().map(|x| x.end.saturating_sub(1)).collect();
|
||||
for r in &l.runs {
|
||||
for (g, c) in r.glyphs.iter().zip(&r.cells) {
|
||||
assert!(c.1 != '\u{AD}' || ends.contains(&c.0) || g.0 != r.face.glyph_for('-'), "mid-line soft hyphen drawn");
|
||||
}
|
||||
}
|
||||
let wide = lay(&p, &sh, 2000.0, 300.0);
|
||||
assert_eq!(wide.lines.len(), 1);
|
||||
assert!(wide.runs.iter().all(|r| r.cells.iter().all(|c| c.1 != '\u{AD}' || r.glyphs.iter().all(|g| g.0 != r.face.glyph_for('-')))));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue