Skip to main content

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}