mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-13 23:17:49 +00:00
Hyphenation: Bulgarian text follows the Bulgarian syllable rules (#826)
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 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
5 files changed
+196
-8
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
if word.len() < 2 || !word.iter().all(|&c| is_letter(c)) {
|
||||
return Vec::new();
|
||||
}
|
||||
let vowels: Vec<usize> = (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<char> = 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::<Vec<_>>()).is_empty());
|
||||
assert!(points(&"съобщение1".chars().collect::<Vec<_>>()).is_empty());
|
||||
assert!(points(&"объём".chars().collect::<Vec<_>>()).is_empty(), "Russian ё is not a Bulgarian letter");
|
||||
}
|
||||
}
|
||||
@@ -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<usize>, 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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<String> {
|
||||
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<Issue> {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user