From f7ce977e4a31d9bc47f3a28e2332dc37c2557c96 Mon Sep 17 00:00:00 2001 From: Dimitar Date: Mon, 12 Oct 2026 14:51:05 +0300 Subject: [PATCH] Hyphenation: Bulgarian text follows the Bulgarian syllable rules (#826) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs in Bulgarian (`w:lang` bg, bg-BG) are hyphenated by the Bulgarian syllable rules (crates/proof/src/bg.rs, clean-room from the spelling rules; no pattern file or word list): vowel-vowel splits, one consonant goes with the next syllable, a consonant cluster splits after its first consonant except that an obstruent before р/л starts the next syllable (до-бре, та-бли-ца), й stays with its vowel, дж/дз and ьо are never split. - ProofLang::Bg (bg, bul): hyphenation only. It isn't "proofed", so like Other there are no spelling or grammar marks until there is a Bulgarian dictionary. - Latin-script words in a Bulgarian run are hyphenated as English, as in any other language. - The layout already takes each word's language from its run (ProofRun), so nothing changes there. Tests: the rules (срич-ко-пре-на-ся-не, та-бли-ца, шо-фьор, хо-джа, строй-ка), tag resolution, hyphenate_word_in with the paragraph limits (Бъл-га-рия), and a laid-out bg-BG paragraph whose hyphenated lines all break at Bulgarian points. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/layout/src/tests.rs | 35 ++++++++++++ crates/proof/src/bg.rs | 112 +++++++++++++++++++++++++++++++++++++ crates/proof/src/hyphen.rs | 24 ++++++++ crates/proof/src/lang.rs | 19 ++++++- crates/proof/src/lib.rs | 14 +++-- 5 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 crates/proof/src/bg.rs diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 437758e..cd10dd9 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -4108,3 +4108,38 @@ fn footer_page_total_needs_one_body_pass() { p.insert_object(n, field(), &Default::default()).unwrap(); assert!(matches!(first(&d), Pass::Again(t) if t.pages == 3)); } + +/// Bulgarian text (`w:lang` `bg-BG`) is hyphenated by the Bulgarian syllable rules; the same +/// Cyrillic text in an English run is not hyphenated (the English data has nothing to say about it). +#[test] +fn bulgarian_text_is_hyphenated_by_its_own_rules() { + let text = "Сричкопренасянето в професионалните документи задължително следва правописните правила, разработени от специалистите. ".repeat(6); + let hyphenated = |d: &Document| -> Vec<(String, String)> { + let mut out = Vec::new(); + for it in &lay(d).pages[0].items { + if let Placed::Lines { para, .. } = it { + for l in para.lines.iter().filter(|x| x.hyphen.is_some()) { + let end = l.stop; + let before = text.get(..end).unwrap_or_default(); + let word_start = before.rfind(' ').map_or(0, |i| i + 1); + let after = text.get(end..).unwrap_or_default(); + let word_end = after.find(|c: char| !c.is_alphabetic()).unwrap_or(after.len()); + out.push((text[word_start..end].to_string(), after[..word_end].to_string())); + } + } + } + out + }; + let mut d = Document::from_text(&text); + d.settings.auto_hyphenation = true; + assert!(hyphenated(&d).is_empty(), "Cyrillic in an English run: {:?}", hyphenated(&d)); + d.format_range(&Pos::body(0, 0), &Pos::body(0, text.len()), &|c| c.lang = Some("bg-BG".into())).unwrap(); + let splits = hyphenated(&d); + assert!(!splits.is_empty(), "no hyphenated lines"); + let lim = wordcraft_proof::hyphen::Limits::default(); + for (head, tail) in &splits { + let word = format!("{head}{tail}"); + let at = head.chars().count(); + assert!(wordcraft_proof::hyphen::hyphen_points_in(&word, &lim, wordcraft_proof::ProofLang::Bg).contains(&at), "{head}-{tail}"); + } +} diff --git a/crates/proof/src/bg.rs b/crates/proof/src/bg.rs new file mode 100644 index 0000000..6b456d8 --- /dev/null +++ b/crates/proof/src/bg.rs @@ -0,0 +1,112 @@ +//! Bulgarian hyphenation by rule ([`ProofLang::Bg`](crate::ProofLang::Bg)). +//! +//! Bulgarian spelling splits words at syllables, and its rules are regular enough to apply +//! directly, so no pattern file or word list is needed (and none is borrowed): +//! - two vowels next to each other may be split (`на-у-ка`); +//! - one consonant between vowels goes with the next syllable (`ма-ма`, `ро-за`); +//! - of two or more consonants between vowels, the first stays with the syllable before and the +//! rest go with the next (`сес-тра`, `въз-дух`, `ком-пю-тър`) — the spelling rules allow any +//! split of a consonant group, and this is the usual one — except that a consonant followed by +//! `р` or `л` starts the next syllable together with it (`до-бре`, `ко-пре`, `та-бли-ца`); +//! - `й` before a consonant stays with the vowel before it (`строй-ка`, `май-ка`); +//! - `ьо` is never split, and the consonant before `ь` goes with it (`шо-фьор`); +//! - `дж` and `дз`, one sound each, are never split (`хо-джа`). +//! +//! A single letter left at the end or start of a line is ruled out by the paragraph's +//! [`Limits`](crate::hyphen::Limits) (`Бъл-га-рия`, not `Бъл-га-ри-я`). + +fn is_vowel(c: char) -> bool { + matches!(c, 'а' | 'е' | 'и' | 'о' | 'у' | 'ъ' | 'ю' | 'я' | 'ѝ') +} + +/// Consonants that may start a syllable before `р` or `л` (`бр`, `пл`, `тр`, `гл` …). +fn is_obstruent(c: char) -> bool { + matches!(c, 'б' | 'в' | 'г' | 'д' | 'з' | 'к' | 'п' | 'с' | 'т' | 'ф' | 'х') +} + +/// A lowercase Bulgarian letter (the words [`points`] splits). +pub(crate) fn is_letter(c: char) -> bool { + matches!(c, 'а'..='я' | 'ѝ') +} + +/// Break points of one lowercase Bulgarian word (char indices; a hyphen goes before that char). +/// Empty for anything that is not entirely Bulgarian letters. +pub fn points(word: &[char]) -> Vec { + if word.len() < 2 || !word.iter().all(|&c| is_letter(c)) { + return Vec::new(); + } + let vowels: Vec = (0..word.len()).filter(|&i| is_vowel(word[i])).collect(); + let mut out = Vec::new(); + for pair in vowels.windows(2) { + let (v1, v2) = (pair[0], pair[1]); + let mut pt = match v2 - v1 - 1 { + 0 | 1 => v1 + 1, + _ => v1 + 2, + }; + // A consonant with `р` or `л` after it starts a syllable together with it. + if v2 - v1 > 2 && matches!(word[v2 - 1], 'р' | 'л') && is_obstruent(word[v2 - 2]) { + pt = v2 - 2; + } + // `дж`, `дз`: one sound, so both go to the next syllable. + if pt > v1 + 1 && word[pt - 1] == 'д' && matches!(word[pt], 'ж' | 'з') { + pt -= 1; + } + // `ь` keeps the consonant before it (`фьо`). + if pt < v2 && word[pt] == 'ь' && pt > v1 + 1 { + pt -= 1; + } + if pt > v1 && pt <= v2 && word[pt - 1] != 'ь' { + out.push(pt); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn split(w: &str) -> String { + let chars: Vec = w.chars().collect(); + let pts = points(&chars); + let mut s = String::new(); + for (i, c) in chars.iter().enumerate() { + if pts.contains(&i) { + s.push('-'); + } + s.push(*c); + } + s + } + + #[test] + fn syllables_follow_the_bulgarian_rules() { + for (word, expected) in [ + ("мама", "ма-ма"), + ("наука", "на-у-ка"), + ("сестра", "сес-тра"), + ("въздух", "въз-дух"), + ("компютър", "ком-пю-тър"), + ("стройка", "строй-ка"), + ("майка", "май-ка"), + ("шофьор", "шо-фьор"), + ("ходжа", "хо-джа"), + ("здравей", "здра-вей"), + ("плевен", "пле-вен"), + ("българия", "бъл-га-ри-я"), + ("сричкопренасяне", "срич-ко-пре-на-ся-не"), + ("добре", "до-бре"), + ("таблица", "та-бли-ца"), + ("отговор", "от-го-вор"), + ] { + assert_eq!(split(word), expected, "{word}"); + } + } + + #[test] + fn non_bulgarian_letters_are_left_alone() { + assert!(points(&"word".chars().collect::>()).is_empty()); + assert!(points(&"съобщение1".chars().collect::>()).is_empty()); + assert!(points(&"объём".chars().collect::>()).is_empty(), "Russian ё is not a Bulgarian letter"); + } +} diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index 9919aa0..89ab240 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -13,6 +13,8 @@ //! Kołodziejska, Bogusław Jackowski and Marek Ryćko, MIT licence option), then a Polish syllable //! heuristic for words with letters outside the Polish alphabet (`é`, `ü`…). //! +//! **Bulgarian**: the Bulgarian syllable rules ([`crate::bg`]); no pattern file or word list. +//! //! Then the paragraph's limits ([`Limits`]) are applied. pub use crate::dict; @@ -89,6 +91,8 @@ pub enum Source { Dictionary, Patterns, Heuristic, + /// A language's syllable rules (Bulgarian, [`crate::bg`]). + Rules, None, } @@ -109,6 +113,11 @@ fn core_breaks(core: &[char], lang: ProofLang) -> (Vec, Source) { if lang == ProofLang::Pl { return core_breaks_pl(core); } + // Bulgarian words follow the Bulgarian syllable rules; Latin-script words in a Bulgarian run + // (`WordCraft`, `email`) are hyphenated as English, as in any other language. + if lang == ProofLang::Bg && core.iter().all(|&c| crate::bg::is_letter(c)) { + return (crate::bg::points(core), Source::Rules); + } if lang == ProofLang::Pt { return core_breaks_pt(core); } @@ -778,4 +787,19 @@ mod tests { assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Other), hyphen_points(w, &Limits::default()), "{w}"); } } + + #[test] + fn bulgarian_words_follow_the_bulgarian_rules() { + let bg = |w: &str| hyphenate_word_in(w, &Limits::default(), ProofLang::Bg); + assert_eq!(bg("сричкопренасяне"), "срич-ко-пре-на-ся-не"); + assert_eq!(bg("Таблица"), "Та-бли-ца"); + // A single letter is never left on a line (the paragraph limits): България, not Бъл-га-ри-я. + assert_eq!(bg("България"), "Бъл-га-рия"); + assert_eq!(bg("„шофьор“,"), "„шо-фьор“,"); + assert_eq!(word_breaks_in("таблица", ProofLang::Bg).1, Source::Rules); + // Latin words in a Bulgarian run are hyphenated as English. + for w in ["publication", "WordCraft"] { + assert_eq!(hyphen_points_in(w, &Limits::default(), ProofLang::Bg), hyphen_points(w, &Limits::default()), "{w}"); + } + } } diff --git a/crates/proof/src/lang.rs b/crates/proof/src/lang.rs index 344fc52..21d99b9 100644 --- a/crates/proof/src/lang.rs +++ b/crates/proof/src/lang.rs @@ -13,6 +13,10 @@ pub enum ProofLang { /// Polish (`pl`, `pl-PL`): the installed Hunspell dictionary (when there is one), the Polish grammar rules and the Polish /// hyphenation patterns. Pl, + /// Bulgarian (`bg`, `bg-BG`): hyphenation by the Bulgarian syllable rules ([`crate::bg`]). + /// There is no Bulgarian dictionary or grammar yet, so, as for [`ProofLang::Other`], no + /// spelling or grammar marks. + Bg, /// Portuguese (`pt`, `pt-BR`, `pt-PT`): the installed Hunspell dictionary (VERO and /// friends, when there is one), the Portuguese grammar rules and the Portuguese hyphenation /// patterns. @@ -37,6 +41,8 @@ impl ProofLang { ProofLang::En } else if primary.eq_ignore_ascii_case("pl") || primary.eq_ignore_ascii_case("pol") { ProofLang::Pl + } else if primary.eq_ignore_ascii_case("bg") || primary.eq_ignore_ascii_case("bul") { + ProofLang::Bg } else if primary.eq_ignore_ascii_case("pt") || primary.eq_ignore_ascii_case("por") { ProofLang::Pt } else { @@ -44,11 +50,12 @@ impl ProofLang { } } - /// The primary language subtag (`en`, `pl`); empty for [`ProofLang::Other`]. + /// The primary language subtag (`en`, `pl`, `bg`); empty for [`ProofLang::Other`]. pub fn code(self) -> &'static str { match self { ProofLang::En => "en", ProofLang::Pl => "pl", + ProofLang::Bg => "bg", ProofLang::Pt => "pt", ProofLang::Other => "", } @@ -56,7 +63,7 @@ impl ProofLang { /// Does WordCraft check spelling and grammar in this language? pub fn is_proofed(self) -> bool { - self != ProofLang::Other + Self::PROOFED.contains(&self) } } @@ -129,13 +136,19 @@ mod tests { for t in ["en", "en-US", "en-GB", "EN_au", "en-Latn-US"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::En, "{t}"); } - for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "e", "-"] { + for t in ["bg", "bg-BG", "BG_bg", "bg_BG.UTF-8", "bul"] { + assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Bg, "{t}"); + } + for t in ["fr-FR", "de", "x-none", "ja-JP", "plx", "bgx", "e", "-"] { assert_eq!(ProofLang::from_tag(Some(t)), ProofLang::Other, "{t}"); } assert_eq!(ProofLang::from_tag(None), ProofLang::En); assert_eq!(ProofLang::from_tag(Some("")), ProofLang::En); assert_eq!(ProofLang::from_tag(Some(" ")), ProofLang::En); assert!(ProofLang::Pl.is_proofed() && !ProofLang::Other.is_proofed()); + // Bulgarian has hyphenation only: no spelling or grammar marks. + assert!(!ProofLang::Bg.is_proofed()); + assert_eq!(ProofLang::Bg.code(), "bg"); assert!(ProofLang::Pt.is_proofed() && !ProofLang::Other.is_proofed()); assert_eq!(ProofLang::Pl.code(), "pl"); assert_eq!(ProofLang::Pt.code(), "pt"); diff --git a/crates/proof/src/lib.rs b/crates/proof/src/lib.rs index 736d216..075f3b4 100644 --- a/crates/proof/src/lib.rs +++ b/crates/proof/src/lib.rs @@ -12,18 +12,22 @@ //! spelling marks), read by the affix engine ([`affix`]), suggestions tuned for Polish //! (diacritics, `ż`/`rz`, `u`/`ó`, missing spaces), and Polish grammar rules with Polish //! messages. +//! - **Bulgarian** ([`ProofLang::Bg`], module [`bg`]): hyphenation by the Bulgarian syllable +//! rules; no spelling or grammar marks yet. //! - **Portuguese** ([`ProofLang::Pt`], module [`pt`]): spelling against the Hunspell `pt_BR` //! dictionary installed on the system (VERO and friends; not bundled), suggestions tuned for //! Portuguese (missing accents, `seje`/`seja` confusions, missing spaces), conservative //! grammar rules with Portuguese messages, and `hyph-utf8`'s Portuguese patterns. //! - **Other languages** ([`ProofLang::Other`]): no spelling or grammar marks, like Word without //! the language's proofing tools. -//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), heuristic. +//! - **Hyphenation** ([`hyphen`]): dictionary, Liang patterns (English, Polish, Portuguese), +//! Bulgarian syllable rules, heuristic. //! //! [`check_text`] checks a paragraph whose runs are in different languages. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod affix; +pub mod bg; pub mod dict; pub mod hunspell; pub mod hyphen; @@ -311,7 +315,7 @@ fn known_core(w: &str) -> bool { /// document default, which is en-US. Runs in a language without one are skipped (#100) instead /// of flagging every word against another language's dictionary; see [`ProofLang::from_tag`]. pub fn has_dictionary(lang: Option<&str>) -> bool { - ProofLang::from_tag(lang) != ProofLang::Other + ProofLang::from_tag(lang).is_proofed() } /// Is `word` spelled correctly in the run's proofing language? A language without a dictionary @@ -511,7 +515,7 @@ pub fn is_correct_in_with(word: &str, lang: ProofLang, opts: &Options) -> bool { ProofLang::En => is_correct_with(word, opts), ProofLang::Pl => accepted_by_options(word, opts) || pl::is_correct(word), ProofLang::Pt => accepted_by_options(word, opts) || pt::is_correct(word), - ProofLang::Other => true, + ProofLang::Bg | ProofLang::Other => true, } } @@ -521,7 +525,7 @@ pub fn suggest_in(word: &str, max: usize, lang: ProofLang) -> Vec { ProofLang::En => suggest(word, max), ProofLang::Pl => pl::suggest(word, max), ProofLang::Pt => pt::suggest(word, max), - ProofLang::Other => Vec::new(), + ProofLang::Bg | ProofLang::Other => Vec::new(), } } @@ -533,7 +537,7 @@ pub fn check_grammar_in(text: &str, lang: ProofLang) -> Vec { ProofLang::En => check_grammar(text), ProofLang::Pl => pl::grammar::check(text), ProofLang::Pt => pt::grammar::check(text), - ProofLang::Other => Vec::new(), + ProofLang::Bg | ProofLang::Other => Vec::new(), } }