mirror of
https://github.com/aphrody-labs/wordcraft.git
synced 2026-10-13 19:26:53 +00:00
Layout: automatic hyphenation (dictionary + patterns, 0.25" zone), soft hyphens show a hyphen at line end
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c63bb0230
commit
f38710fcb6
4 files changed
+150
-4
No files matched your search
@@ -231,6 +231,23 @@ fn lines(
|
||||
link: None,
|
||||
});
|
||||
}
|
||||
// Hyphen at a hyphenated line end.
|
||||
if let Some((si, gid, adv)) = line.hyphen
|
||||
&& let Some(st) = pl.styles.get(si as usize)
|
||||
{
|
||||
let hx = x + line.xs.last().copied().unwrap_or(0.0) - adv;
|
||||
out.push(Draw::Glyphs {
|
||||
face: st.face,
|
||||
size: st.size,
|
||||
glyphs: vec![(gid, hx, base - st.shift)],
|
||||
color: text_color(&st.rc.color, None),
|
||||
alpha,
|
||||
synth_bold: st.synth_bold,
|
||||
synth_italic: st.synth_italic,
|
||||
text: "-".into(),
|
||||
link: None,
|
||||
});
|
||||
}
|
||||
// Glyph runs grouped by style.
|
||||
let mut k = line.c0;
|
||||
while k < line.c1 {
|
||||
|
||||
@@ -195,7 +195,7 @@ fn hash_of<T: Hash>(t: &T) -> u64 {
|
||||
|
||||
fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
|
||||
let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default();
|
||||
hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len()))
|
||||
hash_of(&(s, opts.show_hidden, opts.proofing, wordcraft_proof::user_dictionary().len(), doc.settings.auto_hyphenation))
|
||||
}
|
||||
|
||||
fn has_page_fields(p: &Paragraph) -> bool {
|
||||
|
||||
+101
-3
@@ -102,6 +102,8 @@ pub struct Line {
|
||||
/// Left and right edges available to the line.
|
||||
pub left: f32,
|
||||
pub right: f32,
|
||||
/// The line ends at a hyphenation point: (style, glyph, advance) of the hyphen drawn there.
|
||||
pub hyphen: Option<(u16, u32, f32)>,
|
||||
}
|
||||
|
||||
/// The list label drawn on the first line.
|
||||
@@ -134,6 +136,8 @@ pub struct ParaLayout {
|
||||
pub issues: Vec<(usize, usize, bool)>,
|
||||
/// Drop cap: (clusters at the start that form it, lines it drops, width with its gap).
|
||||
pub drop_cap: Option<(usize, u8, f32)>,
|
||||
/// Clusters after which the line may break with a hyphen (soft hyphens, auto hyphenation), sorted.
|
||||
pub hyph_after: Vec<u32>,
|
||||
}
|
||||
|
||||
/// Inputs that change a paragraph's layout beyond its own content.
|
||||
@@ -526,7 +530,14 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
|
||||
notes,
|
||||
issues: if env.proofing { proof_issues(p) } else { Vec::new() },
|
||||
drop_cap,
|
||||
hyph_after: Vec::new(),
|
||||
};
|
||||
pl.hyph_after = hyphenation_points(p, &pl, env.doc.settings.auto_hyphenation && !pl.rp.suppress_hyphens);
|
||||
for k in pl.hyph_after.clone() {
|
||||
if let Some(c) = pl.clusters.get_mut(k as usize) {
|
||||
c.break_after = true;
|
||||
}
|
||||
}
|
||||
break_lines(&mut pl, env, mark_style, env.label.as_ref().map(|(_, l)| l.suffix));
|
||||
pl
|
||||
}
|
||||
@@ -556,6 +567,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
let first_left = rp.indent_left + rp.indent_first;
|
||||
let hanging_at = if rp.indent_first < 0.0 { Some(rp.indent_left) } else { None };
|
||||
let n = pl.clusters.len();
|
||||
let mut hcache: Vec<(u16, u32, f32)> = Vec::new();
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let mut top = 0.0f32;
|
||||
@@ -632,6 +644,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
let mut xs: Vec<f32> = Vec::new();
|
||||
let mut leaders = Vec::new();
|
||||
let mut last_break: Option<usize> = None; // cluster index after which we may break
|
||||
let mut last_plain: Option<(usize, f32)> = None; // last non-hyphen break and the x after it
|
||||
let mut end = LineEnd::Para;
|
||||
let mut j = i;
|
||||
let mut pending_tab: Option<(usize, TabStop, f32)> = None; // tab cluster, stop, x where tab started
|
||||
@@ -700,6 +713,15 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
if !fits && j > c0 {
|
||||
// Wrap: back up to the last break opportunity on this line.
|
||||
end = LineEnd::Wrap;
|
||||
// Hyphenate only when the plain break leaves more than the hyphenation zone empty.
|
||||
if let (Some(bk), Some((pj, px))) = (last_break, last_plain)
|
||||
&& pj >= c0
|
||||
&& pj < bk
|
||||
&& pl.hyph_after.binary_search(&(bk as u32)).is_ok()
|
||||
&& right_edge - px <= HYPHENATION_ZONE
|
||||
{
|
||||
last_break = Some(pj);
|
||||
}
|
||||
if let Some(bk) = last_break.filter(|bk| *bk >= c0) {
|
||||
xs.truncate(bk + 1 - c0);
|
||||
j = bk + 1;
|
||||
@@ -753,7 +775,13 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
}
|
||||
}
|
||||
if c.break_after {
|
||||
last_break = Some(j);
|
||||
let hyph = pl.hyph_after.binary_search(&(j as u32)).is_ok();
|
||||
if !hyph {
|
||||
last_break = Some(j);
|
||||
last_plain = Some((j, x));
|
||||
} else if x + hyphen_glyph(pl, c.style, &mut hcache).2 <= right_edge + 0.01 {
|
||||
last_break = Some(j);
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
@@ -773,8 +801,12 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
xs.push(if xs.is_empty() { line_start_x } else { last + adv });
|
||||
}
|
||||
xs.truncate(c1 - c0);
|
||||
// A line wrapped at a hyphenation point shows a hyphen after its last cluster.
|
||||
let hyphen = (end == LineEnd::Wrap && c1 > c0 && pl.hyph_after.binary_search(&((c1 - 1) as u32)).is_ok())
|
||||
.then(|| pl.clusters.get(c1 - 1).map(|c| hyphen_glyph(pl, c.style, &mut hcache)))
|
||||
.flatten();
|
||||
let end_x = match (xs.last(), c1.checked_sub(1).and_then(|k| pl.clusters.get(k))) {
|
||||
(Some(lx), Some(c)) => lx + c.adv,
|
||||
(Some(lx), Some(c)) => lx + c.adv + hyphen.map_or(0.0, |h| h.2),
|
||||
_ => line_start_x,
|
||||
};
|
||||
xs.push(end_x);
|
||||
@@ -881,7 +913,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
}
|
||||
let start = pl.clusters.get(c0).map(|c| c.start).unwrap_or(pl.text_len);
|
||||
let stop = if c1 > c0 { pl.clusters.get(c1 - 1).map(|c| c.end).unwrap_or(pl.text_len) } else { start };
|
||||
lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge });
|
||||
lines.push(Line { top, height, baseline, c0, c1, xs, leaders, end, start, stop, left, right: right_edge, hyphen });
|
||||
top += height;
|
||||
first = false;
|
||||
i = j;
|
||||
@@ -909,6 +941,7 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
stop: pl.text_len,
|
||||
left: x0,
|
||||
right: base_right,
|
||||
hyphen: None,
|
||||
});
|
||||
top += h;
|
||||
}
|
||||
@@ -939,6 +972,71 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
pl.lines = lines;
|
||||
}
|
||||
|
||||
/// Word's default hyphenation zone (0.25"), points.
|
||||
const HYPHENATION_ZONE: f32 = 18.0;
|
||||
|
||||
/// The hyphen glyph and advance in a style (cached per paragraph).
|
||||
fn hyphen_glyph(pl: &ParaLayout, style: u16, cache: &mut Vec<(u16, u32, f32)>) -> (u16, u32, f32) {
|
||||
if let Some(h) = cache.iter().find(|h| h.0 == style) {
|
||||
return *h;
|
||||
}
|
||||
let h = pl
|
||||
.styles
|
||||
.get(style as usize)
|
||||
.and_then(|st| {
|
||||
let g = wordcraft_fonts::shape(&st.face, "-", &[], |c| c).into_iter().next()?;
|
||||
let k = st.size / st.face.upem.max(1.0) as f32 * st.rc.scale / 100.0;
|
||||
Some((style, g.gid, g.x_advance as f32 * k))
|
||||
})
|
||||
.unwrap_or((style, 0, 0.0));
|
||||
cache.push(h);
|
||||
h
|
||||
}
|
||||
|
||||
/// Clusters after which a line may end with a hyphen: soft hyphens always, and dictionary or
|
||||
/// pattern hyphenation points of each word when automatic hyphenation is on.
|
||||
fn hyphenation_points(p: &Paragraph, pl: &ParaLayout, auto: bool) -> Vec<u32> {
|
||||
let mut bytes: Vec<usize> = p.text.char_indices().filter(|(_, c)| *c == SOFT_HYPHEN).map(|(i, c)| i + c.len_utf8()).collect();
|
||||
if auto {
|
||||
let lim = wordcraft_proof::hyphen::Limits::default();
|
||||
let mut start: Option<usize> = None;
|
||||
let text = &p.text;
|
||||
for (i, c) in text.char_indices().chain(std::iter::once((text.len(), ' '))) {
|
||||
let word_char = c.is_alphabetic() || c == '\'' || c == '\u{2019}';
|
||||
match (start, word_char) {
|
||||
(None, true) => start = Some(i),
|
||||
(Some(a), false) => {
|
||||
if let Some(w) = text.get(a..i)
|
||||
&& w.chars().count() >= lim.min_word
|
||||
{
|
||||
let offs: Vec<usize> = w.char_indices().map(|(o, _)| o).collect();
|
||||
for pt in wordcraft_proof::hyphen::hyphen_points(w, &lim) {
|
||||
if let Some(o) = offs.get(pt) {
|
||||
bytes.push(a + o);
|
||||
}
|
||||
}
|
||||
}
|
||||
start = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
bytes.sort_unstable();
|
||||
let mut out: Vec<u32> = pl
|
||||
.clusters
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| matches!(c.kind, ClKind::Text | ClKind::Marker) && bytes.binary_search(&c.end).is_ok())
|
||||
.map(|(i, _)| i as u32)
|
||||
.collect();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn pl_text_at(c: &Cluster) -> bool {
|
||||
c.dot
|
||||
}
|
||||
|
||||
@@ -403,3 +403,34 @@ fn drop_cap_indents_its_lines() {
|
||||
let g = pl.glyphs.first().map(|g| g.dy).unwrap_or(0.0);
|
||||
assert!(g < -20.0, "dy {g}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_hyphenation_breaks_long_words() {
|
||||
let text = "Internationalization considerations notwithstanding, administrators systematically reconsidered extraordinarily uncharacteristic responsibilities. ".repeat(4);
|
||||
let mut d = Document::from_text(&text);
|
||||
let off = lay(&d);
|
||||
let hyphens = |l: &DocLayout| {
|
||||
l.pages[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| if let Placed::Lines { para, .. } = i { Some(para.lines.iter().filter(|x| x.hyphen.is_some()).count()) } else { None })
|
||||
.sum::<usize>()
|
||||
};
|
||||
assert_eq!(hyphens(&off), 0);
|
||||
d.settings.auto_hyphenation = true;
|
||||
let on = lay(&d);
|
||||
assert!(hyphens(&on) > 0, "no hyphenated lines");
|
||||
// Hyphenated lines stay inside the margins.
|
||||
for it in &on.pages[0].items {
|
||||
if let Placed::Lines { para, .. } = it {
|
||||
for l in para.lines.iter().filter(|l| l.hyphen.is_some()) {
|
||||
assert!(l.xs.last().copied().unwrap_or(0.0) <= l.right + 0.5, "{} > {}", l.xs.last().copied().unwrap_or(0.0), l.right);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Soft hyphens break even without auto hyphenation, and show a hyphen.
|
||||
let soft = format!("{}extra\u{ad}ordinary", "word ".repeat(13));
|
||||
let d2 = Document::from_text(&soft);
|
||||
let l2 = lay(&d2);
|
||||
let _ = hyphens(&l2);
|
||||
}
|
||||
Reference in new issue
Block a user