use std::collections::VecDeque;
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
include!(concat!(env!("OUT_DIR"), "/excluded_compositions_phf.rs"));
pub(crate) fn composed(text: &str) -> Composed<'_> {
Composed {
text,
iter: text.char_indices().peekable(),
pending: VecDeque::new(),
scratch: String::new(),
}
}
pub(crate) fn compose_str(text: &str) -> std::borrow::Cow<'_, str> {
if needs_composition(text) {
std::borrow::Cow::Owned(composed(text).map(|(c, _)| c).collect())
} else {
std::borrow::Cow::Borrowed(text)
}
}
pub(crate) fn needs_composition(text: &str) -> bool {
text.chars().any(could_compose)
}
#[inline]
pub(crate) fn could_compose(c: char) -> bool {
let u = c as u32;
if u < 0x0590 {
return (0x0300..=0x036F).contains(&u) || (0x0483..=0x0489).contains(&u);
}
is_combining_mark(c) || (HANGUL_L_BASE..=HANGUL_L_LAST).contains(&u)
}
pub(crate) struct Composed<'a> {
text: &'a str,
iter: std::iter::Peekable<std::str::CharIndices<'a>>,
pending: VecDeque<(char, usize)>,
scratch: String,
}
const HANGUL_S_BASE: u32 = 0xAC00;
const HANGUL_L_BASE: u32 = 0x1100;
const HANGUL_L_LAST: u32 = 0x1112; const HANGUL_V_BASE: u32 = 0x1161;
const HANGUL_V_LAST: u32 = 0x1175; const HANGUL_T_BASE: u32 = 0x11A7; const HANGUL_T_FIRST: u32 = 0x11A8;
const HANGUL_T_LAST: u32 = 0x11C2; const HANGUL_T_COUNT: u32 = 28;
const HANGUL_N_COUNT: u32 = 21 * HANGUL_T_COUNT;
impl Composed<'_> {
fn try_compose_hangul(&mut self, l: char) -> Option<char> {
let lc = l as u32;
if !(HANGUL_L_BASE..=HANGUL_L_LAST).contains(&lc) {
return None;
}
let vc = self.iter.peek()?.1 as u32;
if !(HANGUL_V_BASE..=HANGUL_V_LAST).contains(&vc) {
return None;
}
self.iter.next(); let lv = HANGUL_S_BASE
+ (lc - HANGUL_L_BASE) * HANGUL_N_COUNT
+ (vc - HANGUL_V_BASE) * HANGUL_T_COUNT;
if let Some(&(_, t)) = self.iter.peek() {
let tc = t as u32;
if (HANGUL_T_FIRST..=HANGUL_T_LAST).contains(&tc) {
self.iter.next(); return char::from_u32(lv + (tc - HANGUL_T_BASE));
}
}
char::from_u32(lv)
}
}
impl Iterator for Composed<'_> {
type Item = (char, usize);
fn next(&mut self) -> Option<Self::Item> {
if let Some(item) = self.pending.pop_front() {
return Some(item);
}
let (start, ch) = self.iter.next()?;
if let Some(syllable) = self.try_compose_hangul(ch) {
return Some((syllable, start));
}
let next_is_mark = self.iter.peek().is_some_and(|&(_, c)| is_combining_mark(c));
if !next_is_mark {
return Some((ch, start));
}
let mut end = start + ch.len_utf8();
while let Some(&(j, c)) = self.iter.peek() {
if !is_combining_mark(c) {
break;
}
end = j + c.len_utf8();
self.iter.next();
}
let mut scratch = std::mem::take(&mut self.scratch);
scratch.clear();
scratch.extend(self.text[start..end].nfc());
self.recompose_excluded(&scratch, start);
self.scratch = scratch;
self.pending.pop_front()
}
}
impl Composed<'_> {
fn recompose_excluded(&mut self, nfc: &str, start: usize) {
if let Some(&precomposed) = EXCLUDED_COMPOSITIONS.get(nfc) {
self.pending.push_back((precomposed, start));
return;
}
let mut rest = nfc;
while !rest.is_empty() {
if let Some((precomposed, len)) = excluded_prefix(rest) {
self.pending.push_back((precomposed, start));
rest = &rest[len..];
} else {
let ch = rest.chars().next().unwrap_or('\u{FFFD}');
self.pending.push_back((ch, start));
rest = &rest[ch.len_utf8()..];
}
}
}
}
fn excluded_prefix(s: &str) -> Option<(char, usize)> {
let mut cuts = [0usize; EXCLUDED_COMPOSITIONS_MAX_KEY_CHARS];
let mut count = 0;
for (i, (off, ch)) in s.char_indices().enumerate() {
if i >= EXCLUDED_COMPOSITIONS_MAX_KEY_CHARS {
break;
}
cuts[i] = off + ch.len_utf8();
count = i + 1;
}
(2..=count).rev().find_map(|n| {
let end = cuts[n - 1];
EXCLUDED_COMPOSITIONS.get(&s[..end]).map(|&pc| (pc, end))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn chars(text: &str) -> Vec<char> {
composed(text).map(|(c, _)| c).collect()
}
#[test]
fn ascii_and_mark_free_pass_through_verbatim() {
assert_eq!(chars("abc 123"), "abc 123".chars().collect::<Vec<_>>());
assert_eq!(chars("日本語"), vec!['日', '本', '語']);
assert_eq!(chars("\u{0457}"), vec!['\u{0457}']); }
#[test]
fn decomposed_homoglyph_composes_to_precomposed() {
assert_eq!(chars("\u{0456}\u{0308}"), vec!['\u{0457}']);
let got: Vec<_> = composed("a\u{0456}\u{0308}b").collect();
assert_eq!(got, vec![('a', 0), ('\u{0457}', 1), ('b', 5)]);
}
#[test]
fn multi_mark_clusters_compose_fully() {
assert_eq!(chars("e\u{0323}\u{0302}"), vec!['\u{1EC7}']);
assert_eq!(chars("e\u{0302}\u{0323}"), vec!['\u{1EC7}']);
assert_eq!(chars("\u{03B1}\u{0342}\u{0345}"), vec!['\u{1FB7}']);
}
#[test]
fn brahmic_two_part_vowels_compose_via_spacing_marks() {
assert_eq!(chars("\u{09C7}\u{09BE}"), vec!['\u{09CB}']);
assert_eq!(chars("\u{0B92}\u{0BD7}"), vec!['\u{0B94}']);
assert_eq!(chars("\u{0CC6}\u{0CC2}\u{0CD5}"), vec!['\u{0CCB}']);
}
#[test]
fn composition_excluded_pairs_compose_via_widening_map() {
assert_eq!(chars("\u{05E9}\u{05C2}"), vec!['\u{FB2B}']);
assert_eq!(chars("\u{0915}\u{093C}"), vec!['\u{0958}']);
assert_eq!(chars("\u{FB2B}"), vec!['\u{FB2B}']);
assert_eq!(chars("\u{0F71}\u{0F72}"), vec!['\u{0F73}']);
}
#[test]
fn compose_str_borrows_mark_free_and_owns_composed() {
use std::borrow::Cow;
assert!(matches!(compose_str("hello"), Cow::Borrowed("hello")));
assert!(matches!(compose_str("\u{FB2B}"), Cow::Borrowed(_))); assert_eq!(compose_str("\u{0456}\u{0308}"), "\u{0457}"); assert_eq!(compose_str("e\u{0323}\u{0302}"), "\u{1EC7}"); assert_eq!(compose_str("\u{05E9}\u{05C2}"), "\u{FB2B}");
}
#[test]
fn legitimate_accents_recompose_unchanged() {
assert_eq!(chars("cafe\u{0301}"), vec!['c', 'a', 'f', 'é']);
}
#[test]
fn conjoining_hangul_jamo_compose_to_syllables() {
assert_eq!(chars("\u{1100}\u{1161}"), vec!['\u{AC00}']);
assert_eq!(chars("\u{1112}\u{1161}\u{11AB}"), vec!['\u{D55C}']);
assert_eq!(
chars("\u{110E}\u{1165}\u{1105}\u{1175}"),
vec!['\u{CC98}', '\u{B9AC}']
);
}
#[test]
fn partial_hangul_jamo_left_alone() {
assert_eq!(chars("\u{1100}"), vec!['\u{1100}']); assert_eq!(chars("\u{1161}"), vec!['\u{1161}']); assert_eq!(chars("\u{1100}\u{11AB}"), vec!['\u{1100}', '\u{11AB}']); assert_eq!(chars("\u{1100}a"), vec!['\u{1100}', 'a']);
}
#[test]
fn excluded_singleton_with_trailing_unrelated_mark_recomposes() {
assert_eq!(chars("\u{09DC}\u{0983}"), vec!['\u{09DC}', '\u{0983}']);
assert_eq!(chars("\u{FB31}\u{0B03}"), vec!['\u{FB31}', '\u{0B03}']);
assert_eq!(
chars("\u{09A1}\u{09BC}\u{0983}"),
vec!['\u{09DC}', '\u{0983}']
);
}
#[test]
fn compose_str_composes_conjoining_jamo() {
use std::borrow::Cow;
assert_eq!(compose_str("\u{1100}\u{1161}"), "\u{AC00}"); assert!(matches!(compose_str("hello"), Cow::Borrowed("hello")));
}
}