sdsforge_core/language.rs
1/// Document language for SDS extraction and output generation.
2#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3pub enum Language {
4 /// Japanese (日本語) — default
5 #[default]
6 Japanese,
7 /// English
8 English,
9 /// Simplified Chinese (简体中文)
10 ChineseSimplified,
11 /// Traditional Chinese (繁體中文)
12 ChineseTraditional,
13}
14
15impl Language {
16 /// BCP-47 language tag.
17 pub fn bcp47(&self) -> &'static str {
18 match self {
19 Self::Japanese => "ja",
20 Self::English => "en",
21 Self::ChineseSimplified => "zh-CN",
22 Self::ChineseTraditional => "zh-TW",
23 }
24 }
25
26 /// Human-readable name of the language in English.
27 pub fn name_en(&self) -> &'static str {
28 match self {
29 Self::Japanese => "Japanese",
30 Self::English => "English",
31 Self::ChineseSimplified => "Simplified Chinese",
32 Self::ChineseTraditional => "Traditional Chinese",
33 }
34 }
35}
36
37/// Heuristically detect the language of an SDS document from its extracted text.
38///
39/// Detection order:
40/// 0. Fewer than 30 non-whitespace characters → [`Language::Japanese`] (default, not enough text)
41/// 1. Hiragana or katakana present → [`Language::Japanese`]
42/// 2. No CJK ideographs AND text is substantially Latin (≥ 30 % of meaningful chars are
43/// ASCII printable a-z/A-Z/0-9) → [`Language::English`]
44/// This avoids misclassifying Japanese PDFs whose pdftotext output contains only garbage
45/// ASCII (e.g. garbled CID/Shift-JIS font metrics) as English.
46/// 3. Fewer than 20 CJK characters → [`Language::Japanese`] (not enough CJK to distinguish)
47/// 4. Traditional-Chinese-only characters outnumber simplified-only → [`Language::ChineseTraditional`]
48/// 5. Otherwise → [`Language::ChineseSimplified`]
49///
50/// Works on as little as ~200 characters of text. No LLM or network call required.
51pub fn detect_language(text: &str) -> Language {
52 // Not enough text to analyse reliably (e.g. image-only or encrypted PDF returned empty).
53 // Fall back to the default language (Japanese) rather than incorrectly guessing English.
54 let meaningful_chars = text.chars().filter(|c| !c.is_whitespace()).count();
55 if meaningful_chars < 30 {
56 return Language::default();
57 }
58
59 // Hiragana (あ…ん) and katakana (ア…ン) are unique to Japanese.
60 let japanese_kana = text
61 .chars()
62 .filter(|&c| matches!(c, '\u{3040}'..='\u{309F}' | '\u{30A0}'..='\u{30FF}'))
63 .count();
64 if japanese_kana > 5 {
65 return Language::Japanese;
66 }
67
68 // Count CJK unified ideographs.
69 let cjk_total = text
70 .chars()
71 .filter(|&c| matches!(c, '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}'))
72 .count();
73
74 // If there are no CJK ideographs, check whether the text is substantially Latin
75 // before concluding it is English.
76 //
77 // Rationale: pdftotext applied to CID/Shift-JIS Japanese PDFs can produce garbled ASCII
78 // (font metric codes, fallback glyphs, etc.) that contains no kana and no CJK characters.
79 // Without this guard, `detect_language` would incorrectly return English for those PDFs.
80 //
81 // We require ≥ 30 % of meaningful chars to be basic Latin letters/digits to call English.
82 // For real English SDS documents this threshold is easily exceeded (typically > 80 %).
83 // For garbled CID font output the "text" is mostly punctuation/specials, not letters/digits.
84 if cjk_total < 20 {
85 let latin_alphanum = text
86 .chars()
87 .filter(|c| c.is_ascii_alphabetic() || c.is_ascii_digit())
88 .count();
89 // Need ≥ 30 % of meaningful chars to be ASCII alphanumeric to call English.
90 if latin_alphanum * 100 >= meaningful_chars * 30 {
91 return Language::English;
92 } else {
93 // Not enough Latin signal — garbled CID font output or near-empty text.
94 return Language::default(); // Japanese
95 }
96 }
97
98 // Distinguish Simplified vs Traditional Chinese by counting characters that diverge
99 // between the two writing systems. Each entry is a Simplified char whose Traditional
100 // counterpart is the corresponding entry in TRADITIONAL_MARKERS (same index).
101 const SIMPLIFIED_MARKERS: &[char] = &[
102 '国', '语', '时', '书', '来', '这', '过', '东', '样', '从',
103 '实', '动', '产', '问', '给', '长', '发', '规', '药', '标',
104 '剂', '险', '质', '现', '处', '须', '经', '联', '则', '级',
105 '为', '与', '对', '气', '无', '变', '数', '间', '应', '关',
106 ];
107 const TRADITIONAL_MARKERS: &[char] = &[
108 '國', '語', '時', '書', '來', '這', '過', '東', '樣', '從',
109 '實', '動', '產', '問', '給', '長', '發', '規', '藥', '標',
110 '劑', '險', '質', '現', '處', '須', '經', '聯', '則', '級',
111 '為', '與', '對', '氣', '無', '變', '數', '間', '應', '關',
112 ];
113
114 let simplified_score = text.chars().filter(|c| SIMPLIFIED_MARKERS.contains(c)).count();
115 let traditional_score = text.chars().filter(|c| TRADITIONAL_MARKERS.contains(c)).count();
116
117 if traditional_score > simplified_score {
118 Language::ChineseTraditional
119 } else {
120 Language::ChineseSimplified
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn japanese_kana_detected() {
130 let text = "安全データシート\n製品名:テストシリカ\nSection 1 化学品及び会社情報";
131 assert_eq!(detect_language(text), Language::Japanese);
132 }
133
134 #[test]
135 fn english_sds_detected() {
136 let text = "Safety Data Sheet\nSection 1: Identification\nProduct name: Test Chemical\n\
137 Supplier: ABC Corp, 123 Industrial Blvd, City, Country\n\
138 Emergency telephone: +1-800-555-1234";
139 assert_eq!(detect_language(text), Language::English);
140 }
141
142 #[test]
143 fn simplified_chinese_detected() {
144 let text = "安全技术说明书\n产品名称:测试化学品\n公司名称:某某化工有限公司\n\
145 危险性概述:本产品为易燃液体,可能导致皮肤刺激。";
146 assert_eq!(detect_language(text), Language::ChineseSimplified);
147 }
148
149 #[test]
150 fn traditional_chinese_detected() {
151 let text = "安全資料表\n產品名稱:測試化學品\n公司名稱:某某化工有限公司\n\
152 危害辨識資料:本產品為易燃液體,可能導致皮膚刺激。";
153 assert_eq!(detect_language(text), Language::ChineseTraditional);
154 }
155
156 /// Garbled CID/Shift-JIS font output from pdftotext should NOT be classified as English.
157 /// Such output typically contains lots of punctuation / special chars but few a-z letters.
158 #[test]
159 fn garbled_cid_font_output_defaults_to_japanese() {
160 // Simulate pdftotext output for a Shift-JIS PDF where only font metrics survive:
161 // lots of punctuation/brackets but no meaningful Latin words or CJK characters.
162 let garbled = "(.)(.)(.)(.)(.)(.)(.)[][][][][]{}{}{}^^^***///\\\\\\###@@@";
163 // 30+ non-whitespace chars, no kana, no CJK, but also < 30% alphabetic
164 assert_eq!(
165 detect_language(garbled),
166 Language::Japanese,
167 "garbled CID font output should default to Japanese"
168 );
169 }
170
171 #[test]
172 fn empty_text_defaults_to_japanese() {
173 assert_eq!(detect_language(""), Language::Japanese);
174 assert_eq!(detect_language(" "), Language::Japanese);
175 }
176}