collective

Browse / github.com/storytold/designcraft / patch

Hyphenation: a Korean word or an address is not broken by English rules (#1688)

Korean body text: English hyphenation splits Hangul-attached Latin words ("New-/s와") and URLs Ordinary Korean documents (notices, newsletters) leave the paragraph language at the default English, and DesignCraft then hyphenates Latin words attached to Hangul and addresses. **Setup.** Windows 11 (Korean locale), DesignCraft `main` at `e74208dd`, `designcraft-cli` export to PDF. Measurements are from the PDF's glyph positions. Test documents: synthetic Korean paragraphs in Malgun Gothic, HCR Batang and Noto Sans KR, 9.5 pt, justified, columns 60 mm, 80 mm and full width, default spacing 80/100/133 %. On the current `main` (`348a8a4d`) the functions involved are unchanged (`compose/src

Status
open
Score
0.00
Adopters
0 other operators
Author
Hamza0101 (imported from pull request #1754, open)
Base
50650f74d7328745cfcdd16098313a9fc31b23d5
Size
+157 −0 in 3 files
Review
pending
Shared
2026-10-28
Id
a9d7dd3cca9ae968acd711a6

How it went

No other operator has reported using it yet.

Diff

raw · crates/compose/src/hyphen.rs crates/compose/src/lib.rs crates/compose/src/tests.rs

diff --git a/crates/compose/src/hyphen.rs b/crates/compose/src/hyphen.rs--- a/crates/compose/src/hyphen.rs+++ b/crates/compose/src/hyphen.rs@@ -198,6 +198,82 @@ pub fn word_breaks_in(word: &str, lang: Lang) -> (Vec<usize>, Source) {  /// Allowed hyphenation points in `word` as char indices (a hyphen goes before that char), with /// the paragraph limits applied to each letter run.+/// Whether a whitespace-delimited token is one no language's hyphenation should break.+///+/// Korean attaches particles straight onto Latin words ("News와", "PDF로"), and notices are full+/// of addresses. With the paragraph language left at the default English -- which is what an+/// ordinary Korean document has -- a word run inside such a token was hyphenated as English,+/// giving `New-` / `s와` and `library.exam-` / `ple.kr` (#1688).+///+/// A token is kept whole when it carries Hangul, kana or Han, or when it reads as an address: a+/// URL with a scheme, an e-mail address, a dotted domain, a `/`-rooted path or a Windows drive+/// path. Breaking at a hyphen the author typed is still allowed; this only stops hyphens being+/// invented inside the token.+pub fn keep_whole(token: &str) -> bool {+    let cjk = |c: char| {+        matches!(c as u32,+            0xAC00..=0xD7A3     // Hangul syllables+            | 0x1100..=0x11FF   // Hangul Jamo+            | 0x3130..=0x318F   // Hangul compatibility Jamo+            | 0x3040..=0x30FF   // kana+            | 0x4E00..=0x9FFF   // CJK unified ideographs+            | 0x3400..=0x4DBF+            | 0xF900..=0xFAFF)+    };+    if token.chars().any(cjk) {+        return true;+    }+    let lower = token.to_ascii_lowercase();+    if lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("ftp://") || lower.starts_with("www.") {+        return true;+    }+    // An e-mail address: one `@` with something either side, no space (tokens have none).+    if let Some((user, host)) = token.split_once('@') {+        return !user.is_empty() && host.contains('.');+    }+    // A path, rooted or with a drive letter.+    if token.starts_with('/') || token.starts_with('\\') {+        return true;+    }+    let mut cs = token.chars();+    if let (Some(a), Some(b), Some(c)) = (cs.next(), cs.next(), cs.next())+        && a.is_ascii_alphabetic()+        && b == ':'+        && (c == '/' || c == '\\')+    {+        return true;+    }+    // A domain: dot-separated labels up to the first `/`, whose last label is a short run of+    // letters -- "example.kr", "library.example.kr". Ordinary prose ("end. Next") never reaches+    // here, because a token holds no space.+    let host = lower.split('/').next().unwrap_or("");+    let labels: Vec<&str> = host.trim_end_matches('.').split('.').collect();+    if labels.len() >= 2+        && labels.iter().all(|l| !l.is_empty() && l.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'))+        && labels.last().is_some_and(|tld| tld.len() <= 6 && tld.chars().all(|c| c.is_ascii_lowercase()))+    {+        return true;+    }+    false+}++/// Whether a paragraph is set in Korean, so automatic hyphenation does not apply to it.+///+/// Korean is set word by word and is not hyphenated (KLREQ). A paragraph with at least as many+/// Hangul syllables as Latin letters is treated as Korean however its language is set, since an+/// ordinary Korean document leaves the paragraph language at the default English (#1688).+pub fn mostly_hangul(text: &str) -> bool {+    let (mut hangul, mut latin) = (0usize, 0usize);+    for c in text.chars() {+        match c as u32 {+            0xAC00..=0xD7A3 | 0x1100..=0x11FF | 0x3130..=0x318F => hangul += 1,+            _ if c.is_ascii_alphabetic() => latin += 1,+            _ => {}+        }+    }+    hangul > 0 && hangul >= latin+}+ pub fn hyphen_points(word: &str, lim: &Limits) -> Vec<usize> {     hyphen_points_in(word, lim, Lang::English) }@@ -308,6 +384,40 @@ fn heuristic_points(c: &[char]) -> Vec<usize> {  #[cfg(test)] mod tests {+    /// A token no language's hyphenation should break: Korean particles on Latin words, and the+    /// addresses a notice is full of (#1688).+    #[test]+    fn a_token_with_hangul_or_an_address_is_kept_whole() {+        for t in [+            "News\u{C640}",             // News와+            "PDF\u{B85C}",              // PDF로+            "\u{C77C}\u{BCF8}\u{C5B4}", // 일본어+            "https://example.kr/page",+            "www.example.kr",+            "library.example.kr",+            "mail@example.kr",+            "/usr/share/fonts",+            "C:\\Users\\Hong",+            "example.kr",+        ] {+            assert!(keep_whole(t), "{t} should be kept whole");+        }+        // Ordinary words, including ones with a hyphen or a full stop, are still hyphenated.+        for t in ["hyphenation", "well-known", "sentence.", "Mr.", "re-examine", "3.5"] {+            assert!(!keep_whole(t), "{t} should not be protected");+        }+    }++    /// A Korean paragraph gets no automatic hyphens whatever its language is set to (#1688).+    #[test]+    fn a_paragraph_that_is_mostly_hangul_is_not_hyphenated() {+        assert!(mostly_hangul("\u{C774}\u{BC88} \u{D1B5}\u{C9C0}\u{BB38}\u{C740} News\u{C640} \u{AD00}\u{B828}"));+        assert!(!mostly_hangul("An English paragraph about hyphenation and composition"));+        // A Latin paragraph with a Korean word in it is still hyphenated as Latin.+        assert!(!mostly_hangul("The Korean word \u{D55C}\u{AD6D} appears once in this English sentence"));+        assert!(!mostly_hangul(""));+    }+     use super::*;      fn h(w: &str) -> String {diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs--- a/crates/compose/src/lib.rs+++ b/crates/compose/src/lib.rs@@ -4192,6 +4192,12 @@ fn hyphenation_points(     if !pp.hyphenate {         return out;     }+    // Korean is set word by word and is not hyphenated (KLREQ). An ordinary Korean document+    // leaves the paragraph language at the default English, so the paragraph's own text decides+    // rather than its language setting (#1688).+    if hyphen::mostly_hangul(text) {+        return out;+    }     let lim = hyphen::Limits {         min_word: pp.hyph_min_word as usize,         after_first: pp.hyph_after_first as usize,@@ -4236,6 +4242,14 @@ fn hyphenation_points(         let (a, b) = (glyphs[i].byte, glyphs[j - 1].byte + glyphs[j - 1].len);         if !discretionary && b > a && b <= text.len() && text.is_char_boundary(a) && text.is_char_boundary(b) {             let word = &text[a..b];+            // The whitespace-delimited token the word sits in: a Latin run with a Korean particle+            // on it ("News와") or inside an address is not a word to hyphenate (#1688).+            let token_start = text[..a].rfind(char::is_whitespace).map_or(0, |i| i + text[i..].chars().next().map_or(1, char::len_utf8));+            let token_end = text[b..].find(char::is_whitespace).map_or(text.len(), |i| b + i);+            if hyphen::keep_whole(&text[token_start..token_end]) {+                i = j.max(i + 1);+                continue;+            }             // Do not hyphenate the paragraph's last word unless allowed.             let is_last_word = !pp.hyph_last_word && glyphs[j..].iter().all(|g| !g.is_letter());             let lang = foreign.iter().find(|(r, _)| r.contains(&a)).map_or(Some(hyphen::Lang::English), |(_, l)| *l);diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -994,6 +994,39 @@ fn hyphen_limit_is_a_hard_constraint() {     } } +/// A Korean notice leaves its paragraphs at the default English language, so English+/// hyphenation broke the Latin words Korean particles attach to and the addresses such a document+/// is full of: `New-` / `s와`, `library.exam-` / `ple.kr` (#1688). The lines may now be looser --+/// that is the cost of not hyphenating, and what #1691 is about -- but no hyphen is invented+/// inside those tokens.+#[test]+fn korean_text_and_addresses_are_not_hyphenated() {+    // Narrow enough that every line has to break somewhere.+    let rect = Rect::new(0.0, 0.0, 90.0, 100_000.0);+    let para = ParaAttrs { align: Some(Align::LeftJustified), hyphenate: Some(true), ..Default::default() };+    let hyphens_in = |text: &str| {+        let (d, sid, _) = doc_with(text, rect, para.clone());+        let cs = compose_story(&d, sid, &ComposeOptions::default());+        all_lines(&cs).iter().filter(|l| l.hyphenated).count()+    };++    // 이번 통지문은 News와 관련된 library.example.kr 주소를 포함합니다+    let korean = "\u{C774}\u{BC88} \u{D1B5}\u{C9C0}\u{BB38}\u{C740} News\u{C640} \u{AD00}\u{B828}\u{B41C} library.example.kr \u{C8FC}\u{C18C}\u{B97C} \u{D3EC}\u{D568}\u{D569}\u{B2C8}\u{B2E4}";+    assert_eq!(hyphens_in(korean), 0, "a Korean paragraph invents no hyphens");++    // An English paragraph holding the same address: the prose still hyphenates, the address does+    // not, so the protection is about the token and not about turning hyphenation off.+    let english = "This notice about hyphenation and composition mentions library.example.kr somewhere inside a long sentence";+    assert!(hyphens_in(english) > 0, "English prose is still hyphenated");+    let (d, sid, _) = doc_with(english, rect, para.clone());+    let cs = compose_story(&d, sid, &ComposeOptions::default());+    for l in all_lines(&cs).iter().filter(|l| l.hyphenated) {+        let upto = &english[..l.range.end.min(english.len())];+        let token = upto.rsplit(char::is_whitespace).next().unwrap_or("");+        assert!(!"library.example.kr".starts_with(token) || token.is_empty(), "a hyphen was invented inside the address: {token:?}");+    }+}+ #[test] fn hyphenation_zone_limits_ragged_hyphens() {     let text = CORPUS.join(" ");