mod tables;
pub use tables::UCD_VERSION;
const COMBINING_RUN_CAPACITY: usize = 32;
const DECOMP_BUF_CAPACITY: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NfcError {
InvalidUtf8 {
at: usize,
},
OutputOverflow,
CombiningRunOverflow,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NfcQc {
Yes,
No,
Maybe,
}
#[must_use]
pub fn quick_check(input: &[u8]) -> NfcQc {
let s = match core::str::from_utf8(input) {
Ok(s) => s,
Err(_) => return NfcQc::No,
};
let mut last_cc: u8 = 0;
let mut result = NfcQc::Yes;
for c in s.chars() {
let cp = c as u32;
let cc = combining_class(cp);
if cc != 0 && cc < last_cc {
return NfcQc::No;
}
match nfc_qc_lookup(cp) {
NfcQc::No => return NfcQc::No,
NfcQc::Maybe => result = NfcQc::Maybe,
NfcQc::Yes => {}
}
last_cc = if cc == 0 { 0 } else { cc };
}
result
}
pub fn normalize_into(input: &[u8], out: &mut [u8]) -> Result<usize, NfcError> {
let s = core::str::from_utf8(input).map_err(|e| NfcError::InvalidUtf8 {
at: e.valid_up_to(),
})?;
let mut writer = Writer::new(out);
let mut state = NfcState::new();
let mut decomp_buf = [0u32; DECOMP_BUF_CAPACITY];
for c in s.chars() {
let cp = c as u32;
let len = decompose_recursive(cp, &mut decomp_buf);
for &dp in &decomp_buf[..len] {
state.feed(dp, &mut writer)?;
}
}
state.flush(&mut writer)?;
Ok(writer.pos)
}
struct NfcState {
starter: Option<u32>,
pending: [u32; COMBINING_RUN_CAPACITY],
pending_len: u8,
}
impl NfcState {
fn new() -> Self {
Self {
starter: None,
pending: [0; COMBINING_RUN_CAPACITY],
pending_len: 0,
}
}
fn feed(&mut self, cp: u32, writer: &mut Writer<'_>) -> Result<(), NfcError> {
let cc = combining_class(cp);
if cc == 0 {
if self.pending_len == 0 {
if let Some(prev) = self.starter {
if let Some(composed) = compose_pair(prev, cp) {
self.starter = Some(composed);
return Ok(());
}
}
}
self.resolve_run(writer)?;
self.starter = Some(cp);
return Ok(());
}
if (self.pending_len as usize) >= COMBINING_RUN_CAPACITY {
return Err(NfcError::CombiningRunOverflow);
}
let mut idx = self.pending_len as usize;
while idx > 0 && combining_class(self.pending[idx - 1]) > cc {
self.pending[idx] = self.pending[idx - 1];
idx -= 1;
}
self.pending[idx] = cp;
self.pending_len += 1;
Ok(())
}
fn flush(&mut self, writer: &mut Writer<'_>) -> Result<(), NfcError> {
self.resolve_run(writer)?;
self.starter = None;
Ok(())
}
fn resolve_run(&mut self, writer: &mut Writer<'_>) -> Result<(), NfcError> {
if let Some(mut l) = self.starter.take() {
let mut max_cc_seen: u8 = 0;
let mut i: usize = 0;
let mut len = self.pending_len as usize;
while i < len {
let m = self.pending[i];
let cc_m = combining_class(m);
if cc_m > max_cc_seen {
if let Some(composed) = compose_pair(l, m) {
l = composed;
let mut j = i;
while j + 1 < len {
self.pending[j] = self.pending[j + 1];
j += 1;
}
len -= 1;
continue;
}
}
max_cc_seen = cc_m;
i += 1;
}
self.pending_len = len as u8;
writer.write_code_point(l)?;
}
for k in 0..(self.pending_len as usize) {
writer.write_code_point(self.pending[k])?;
}
self.pending_len = 0;
Ok(())
}
}
struct Writer<'a> {
out: &'a mut [u8],
pos: usize,
}
impl<'a> Writer<'a> {
fn new(out: &'a mut [u8]) -> Self {
Self { out, pos: 0 }
}
fn write_code_point(&mut self, cp: u32) -> Result<(), NfcError> {
let c = char::from_u32(cp).ok_or(NfcError::OutputOverflow)?;
let mut buf = [0u8; 4];
let s = c.encode_utf8(&mut buf);
let bytes = s.as_bytes();
if self.pos + bytes.len() > self.out.len() {
return Err(NfcError::OutputOverflow);
}
self.out[self.pos..self.pos + bytes.len()].copy_from_slice(bytes);
self.pos += bytes.len();
Ok(())
}
}
fn decompose_recursive(cp: u32, out: &mut [u32; DECOMP_BUF_CAPACITY]) -> usize {
if (tables::HANGUL_S_BASE..=tables::HANGUL_S_LAST).contains(&cp) {
let s_index = cp - tables::HANGUL_S_BASE;
let l_index = s_index / tables::HANGUL_N_COUNT;
let v_index = (s_index % tables::HANGUL_N_COUNT) / tables::HANGUL_T_COUNT;
let t_index = s_index % tables::HANGUL_T_COUNT;
out[0] = tables::HANGUL_L_BASE + l_index;
out[1] = tables::HANGUL_V_BASE + v_index;
if t_index == 0 {
return 2;
}
out[2] = tables::HANGUL_T_BASE + t_index;
return 3;
}
match tables::DECOMP_TABLE.binary_search_by_key(&cp, |&(c, _, _)| c) {
Ok(idx) => {
let (_, off, len) = tables::DECOMP_TABLE[idx];
let off = off as usize;
let len = len as usize;
out[..len].copy_from_slice(&tables::DECOMP_DATA[off..off + len]);
len
}
Err(_) => {
out[0] = cp;
1
}
}
}
fn compose_pair(starter: u32, mark: u32) -> Option<u32> {
if (tables::HANGUL_L_BASE..tables::HANGUL_L_BASE + tables::HANGUL_L_COUNT).contains(&starter)
&& (tables::HANGUL_V_BASE..tables::HANGUL_V_BASE + tables::HANGUL_V_COUNT).contains(&mark)
{
let l_index = starter - tables::HANGUL_L_BASE;
let v_index = mark - tables::HANGUL_V_BASE;
let s_index = l_index * tables::HANGUL_N_COUNT + v_index * tables::HANGUL_T_COUNT;
return Some(tables::HANGUL_S_BASE + s_index);
}
if (tables::HANGUL_S_BASE..tables::HANGUL_S_BASE + tables::HANGUL_S_COUNT).contains(&starter)
&& (starter - tables::HANGUL_S_BASE) % tables::HANGUL_T_COUNT == 0
&& mark > tables::HANGUL_T_BASE
&& mark < tables::HANGUL_T_BASE + tables::HANGUL_T_COUNT
{
let t_index = mark - tables::HANGUL_T_BASE;
return Some(starter + t_index);
}
let key = (starter, mark);
tables::COMP_TABLE
.binary_search_by_key(&key, |&(s, m, _)| (s, m))
.ok()
.map(|idx| tables::COMP_TABLE[idx].2)
}
fn combining_class(cp: u32) -> u8 {
match tables::CCC_TABLE.binary_search_by_key(&cp, |&(c, _)| c) {
Ok(idx) => tables::CCC_TABLE[idx].1,
Err(_) => 0,
}
}
fn nfc_qc_lookup(cp: u32) -> NfcQc {
if tables::NFC_QC_NO.binary_search(&cp).is_ok() {
NfcQc::No
} else if tables::NFC_QC_MAYBE.binary_search(&cp).is_ok() {
NfcQc::Maybe
} else {
NfcQc::Yes
}
}
#[cfg(test)]
mod algorithm_tests {
extern crate alloc;
use super::*;
use alloc::string::String;
fn nfc(input: &str) -> String {
let mut out = [0u8; 1024];
let n = normalize_into(input.as_bytes(), &mut out).expect("ok");
String::from_utf8(out[..n].to_vec()).expect("utf8")
}
#[test]
fn ascii_passes_through_unchanged() {
assert_eq!(nfc("hello"), "hello");
assert_eq!(nfc(""), "");
assert_eq!(nfc("foo bar baz"), "foo bar baz");
}
#[test]
fn nfd_to_nfc_recomposes_combining_marks() {
assert_eq!(nfc("cafe\u{0301}"), "caf\u{00E9}");
}
#[test]
fn nfc_input_is_idempotent() {
assert_eq!(nfc("caf\u{00E9}"), "caf\u{00E9}");
assert_eq!(nfc("\u{00C5}ngstr\u{00F6}m"), "\u{00C5}ngstr\u{00F6}m");
}
#[test]
fn double_normalisation_yields_same_output() {
let inputs = ["cafe\u{0301}", "A\u{030A}", "\u{1E0B}\u{0323}", "한국어"];
for input in inputs.iter() {
let once = nfc(input);
let twice = nfc(&once);
assert_eq!(once, twice, "idempotence broken for {input:?}");
}
}
#[test]
fn hangul_lv_composes_algorithmically() {
assert_eq!(nfc("\u{1100}\u{1161}"), "\u{AC00}");
}
#[test]
fn hangul_lvt_composes_algorithmically() {
assert_eq!(nfc("\u{1100}\u{1161}\u{11A8}"), "\u{AC01}");
}
#[test]
fn hangul_syllable_already_composed_is_idempotent() {
assert_eq!(nfc("\u{AC00}"), "\u{AC00}");
assert_eq!(nfc("한국어"), "한국어");
}
#[test]
fn canonical_reorder_then_compose_picks_lower_ccc_first() {
assert_eq!(nfc("d\u{0307}\u{0323}"), "\u{1E0D}\u{0307}");
assert_eq!(nfc("d\u{0323}\u{0307}"), "\u{1E0D}\u{0307}");
}
#[test]
fn composition_exclusion_pairs_do_not_recompose() {
assert_eq!(nfc("\u{212B}"), "\u{00C5}");
}
#[test]
fn quick_check_recognises_ascii_as_nfc_yes() {
assert_eq!(quick_check(b"hello"), NfcQc::Yes);
assert_eq!(quick_check(b""), NfcQc::Yes);
}
#[test]
fn quick_check_recognises_decomposed_input_as_non_nfc() {
let qc = quick_check("cafe\u{0301}".as_bytes());
assert!(matches!(qc, NfcQc::Maybe | NfcQc::No));
}
#[test]
fn rejects_output_buffer_too_small() {
let mut tiny = [0u8; 1];
let err = normalize_into("café".as_bytes(), &mut tiny).expect_err("must error");
assert_eq!(err, NfcError::OutputOverflow);
}
}