detect-language.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. /**
  2. * Detect the primary language of a text string based on Unicode script ranges.
  3. * Supports 20+ major languages. Returns an English language name.
  4. */
  5. export function detectLanguage(text: string): string {
  6. // Count characters in each script range
  7. const counts: Record<string, number> = {}
  8. for (const ch of text) {
  9. const cp = ch.codePointAt(0)
  10. if (!cp || cp < 0x80) continue // skip ASCII initially
  11. const script = getScript(cp)
  12. if (script) {
  13. counts[script] = (counts[script] ?? 0) + 1
  14. }
  15. }
  16. // Special case: Japanese uses BOTH Hiragana/Katakana and Kanji. Pure
  17. // Chinese uses ONLY Kanji. If we see any Japanese script characters at
  18. // all alongside Kanji, the language is Japanese, regardless of which
  19. // count dominates. (Kanji-heavy Japanese text would otherwise be
  20. // misclassified as Chinese.)
  21. if ((counts.Japanese ?? 0) > 0 && (counts.Chinese ?? 0) > 0) {
  22. return "Japanese"
  23. }
  24. // If non-Latin scripts detected, return the dominant one
  25. let maxScript = ""
  26. let maxCount = 0
  27. for (const [script, count] of Object.entries(counts)) {
  28. if (count > maxCount) {
  29. maxScript = script
  30. maxCount = count
  31. }
  32. }
  33. if (maxScript === "Arabic" && maxCount >= 2) {
  34. return detectArabicScriptLanguage(text)
  35. }
  36. if (maxScript && maxCount >= 2) {
  37. return maxScript
  38. }
  39. // For Latin-script languages, use diacritics and common word patterns
  40. const latinLang = detectLatinLanguage(text)
  41. if (latinLang) return latinLang
  42. return "English"
  43. }
  44. function detectArabicScriptLanguage(text: string): "Arabic" | "Persian" {
  45. let persianScore = 0
  46. let arabicScore = 0
  47. for (const ch of text) {
  48. switch (ch) {
  49. case "پ":
  50. case "چ":
  51. case "ژ":
  52. case "گ":
  53. persianScore += 3
  54. break
  55. case "ک":
  56. case "ی":
  57. persianScore += 1
  58. break
  59. case "ك":
  60. case "ي":
  61. case "ة":
  62. case "ى":
  63. case "إ":
  64. case "أ":
  65. case "ؤ":
  66. case "ئ":
  67. arabicScore += 1
  68. break
  69. }
  70. }
  71. const normalized = ` ${text.replace(/[^\p{L}\p{N}]+/gu, " ")} `
  72. const persianWords = [
  73. "این",
  74. "است",
  75. "که",
  76. "برای",
  77. "های",
  78. "را",
  79. "در",
  80. "به",
  81. "از",
  82. "می",
  83. "یک",
  84. ]
  85. const arabicWords = [
  86. "ال",
  87. "في",
  88. "من",
  89. "على",
  90. "هذا",
  91. "هذه",
  92. "إلى",
  93. "التي",
  94. "الذي",
  95. "كان",
  96. ]
  97. for (const word of persianWords) {
  98. if (normalized.includes(` ${word} `)) persianScore += 2
  99. }
  100. for (const word of arabicWords) {
  101. if (normalized.includes(` ${word} `)) arabicScore += 2
  102. }
  103. // Be conservative: Persian has reliable orthographic clues, but short
  104. // Arabic-script snippets can be ambiguous. Fall back to Arabic unless the
  105. // Persian signal is clearly stronger.
  106. return persianScore >= 3 && persianScore > arabicScore ? "Persian" : "Arabic"
  107. }
  108. function getScript(cp: number): string | null {
  109. // CJK Unified Ideographs (Chinese/Japanese Kanji)
  110. if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
  111. (cp >= 0x20000 && cp <= 0x2A6DF) || (cp >= 0xF900 && cp <= 0xFAFF)) {
  112. return "Chinese"
  113. }
  114. // Japanese Hiragana + Katakana
  115. if ((cp >= 0x3040 && cp <= 0x309F) || (cp >= 0x30A0 && cp <= 0x30FF) ||
  116. (cp >= 0x31F0 && cp <= 0x31FF) || (cp >= 0xFF65 && cp <= 0xFF9F)) {
  117. return "Japanese"
  118. }
  119. // Korean Hangul
  120. if ((cp >= 0xAC00 && cp <= 0xD7AF) || (cp >= 0x1100 && cp <= 0x11FF) ||
  121. (cp >= 0x3130 && cp <= 0x318F)) {
  122. return "Korean"
  123. }
  124. // Arabic
  125. if ((cp >= 0x0600 && cp <= 0x06FF) || (cp >= 0x0750 && cp <= 0x077F) ||
  126. (cp >= 0x08A0 && cp <= 0x08FF) || (cp >= 0xFB50 && cp <= 0xFDFF) ||
  127. (cp >= 0xFE70 && cp <= 0xFEFF)) {
  128. return "Arabic"
  129. }
  130. // Hebrew
  131. if ((cp >= 0x0590 && cp <= 0x05FF) || (cp >= 0xFB1D && cp <= 0xFB4F)) {
  132. return "Hebrew"
  133. }
  134. // Thai
  135. if (cp >= 0x0E00 && cp <= 0x0E7F) {
  136. return "Thai"
  137. }
  138. // Devanagari (Hindi, Sanskrit, Marathi, Nepali)
  139. if (cp >= 0x0900 && cp <= 0x097F) {
  140. return "Hindi"
  141. }
  142. // Bengali
  143. if (cp >= 0x0980 && cp <= 0x09FF) {
  144. return "Bengali"
  145. }
  146. // Tamil
  147. if (cp >= 0x0B80 && cp <= 0x0BFF) {
  148. return "Tamil"
  149. }
  150. // Telugu
  151. if (cp >= 0x0C00 && cp <= 0x0C7F) {
  152. return "Telugu"
  153. }
  154. // Kannada
  155. if (cp >= 0x0C80 && cp <= 0x0CFF) {
  156. return "Kannada"
  157. }
  158. // Malayalam
  159. if (cp >= 0x0D00 && cp <= 0x0D7F) {
  160. return "Malayalam"
  161. }
  162. // Gujarati
  163. if (cp >= 0x0A80 && cp <= 0x0AFF) {
  164. return "Gujarati"
  165. }
  166. // Gurmukhi (Punjabi)
  167. if (cp >= 0x0A00 && cp <= 0x0A7F) {
  168. return "Punjabi"
  169. }
  170. // Myanmar (Burmese)
  171. if (cp >= 0x1000 && cp <= 0x109F) {
  172. return "Burmese"
  173. }
  174. // Khmer (Cambodian)
  175. if (cp >= 0x1780 && cp <= 0x17FF) {
  176. return "Khmer"
  177. }
  178. // Lao
  179. if (cp >= 0x0E80 && cp <= 0x0EFF) {
  180. return "Lao"
  181. }
  182. // Georgian
  183. if ((cp >= 0x10A0 && cp <= 0x10FF) || (cp >= 0x2D00 && cp <= 0x2D2F)) {
  184. return "Georgian"
  185. }
  186. // Armenian
  187. if (cp >= 0x0530 && cp <= 0x058F) {
  188. return "Armenian"
  189. }
  190. // Ethiopic (Amharic)
  191. if (cp >= 0x1200 && cp <= 0x137F) {
  192. return "Amharic"
  193. }
  194. // Tibetan
  195. if (cp >= 0x0F00 && cp <= 0x0FFF) {
  196. return "Tibetan"
  197. }
  198. // Sinhala
  199. if (cp >= 0x0D80 && cp <= 0x0DFF) {
  200. return "Sinhala"
  201. }
  202. // Cyrillic (Russian, Ukrainian, Bulgarian, etc.)
  203. if ((cp >= 0x0400 && cp <= 0x04FF) || (cp >= 0x0500 && cp <= 0x052F)) {
  204. return "Russian" // default Cyrillic to Russian; refined below
  205. }
  206. // Greek
  207. if ((cp >= 0x0370 && cp <= 0x03FF) || (cp >= 0x1F00 && cp <= 0x1FFF)) {
  208. return "Greek"
  209. }
  210. return null
  211. }
  212. /**
  213. * Detect Latin-script languages via diacritics and common word patterns.
  214. */
  215. function detectLatinLanguage(text: string): string | null {
  216. const lower = text.toLowerCase()
  217. // Vietnamese — VN-EXCLUSIVE tone/hook marks only.
  218. // Earlier versions included shared Latin diacritics (à á â ã è é ê ì í ò ó ô õ
  219. // ù ú ă ý) which made any French / Portuguese / Spanish / Italian / Romanian
  220. // text with common diacritics false-positive as Vietnamese. The chars below
  221. // are Vietnamese-specific tone/hook/horn composites that don't appear in
  222. // other major languages detected here.
  223. if (/[ảạắằẳẵặấầẩẫậđẻẽẹếềểễệỉĩịỏọốồổỗộơớờởỡợủũụưứừửữựỷỹỵ]/.test(lower)) {
  224. return "Vietnamese"
  225. }
  226. // Turkish — require Turkish-unique chars (ğ, ı dotless, ş). Earlier versions
  227. // also matched ç/ö/ü, which are shared with French/German/Portuguese/Hungarian
  228. // and caused false positives on PT text like "coração".
  229. if (/[ğış]/.test(lower) && /\b(bir|ve|için|ile|bu|da|de|değil|ama)\b/.test(lower)) {
  230. return "Turkish"
  231. }
  232. // Polish — distinctive characters
  233. if (/[ąćęłńóśźż]/.test(lower)) {
  234. return "Polish"
  235. }
  236. // Czech/Slovak — háčky and čárky
  237. if (/[ěšžřďťňů]/.test(lower)) {
  238. return "Czech"
  239. }
  240. // Romanian — distinctive characters
  241. if (/[ăâîșț]/.test(lower) && /\b(și|este|sau|care|pentru)\b/.test(lower)) {
  242. return "Romanian"
  243. }
  244. // Hungarian — double acute accents
  245. if (/[őű]/.test(lower)) {
  246. return "Hungarian"
  247. }
  248. // German — common patterns
  249. if (/[äöüß]/.test(lower) || /\b(und|der|die|das|ist|nicht|ein|eine)\b/.test(lower)) {
  250. if (/\b(und|der|die|das|ist)\b/.test(lower)) return "German"
  251. }
  252. // French — common patterns
  253. if (/[àâçéèêëïîôùûüÿœæ]/.test(lower) || /\b(le|la|les|de|des|est|et|un|une|du|au)\b/.test(lower)) {
  254. if (/\b(le|la|les|est|une|des)\b/.test(lower)) return "French"
  255. }
  256. // Portuguese — must run BEFORE Spanish: PT has stricter char requirements
  257. // ([ãõç]) than ES, and their common-word sets overlap heavily (`que`, `de`,
  258. // `um`, etc.). Running ES first steals legitimate PT text.
  259. if (/[ãõç]/.test(lower) && /\b(o|a|os|as|de|do|da|é|em|um|uma|não|que)\b/.test(lower)) {
  260. return "Portuguese"
  261. }
  262. // Spanish — common patterns. The stage-2 word set is intentionally narrow
  263. // (words NOT shared with Portuguese): del/por/las/ñ-bearing/inverted-punct.
  264. if (/[áéíóúñ¿¡]/.test(lower) || /\b(el|la|los|las|de|del|es|en|por|que|un|una)\b/.test(lower)) {
  265. if (/\b(el|los|las|del|por)\b/.test(lower) || /[ñ¿¡]/.test(lower)) return "Spanish"
  266. }
  267. // Italian — common patterns
  268. if (/\b(il|lo|la|gli|le|di|del|della|è|e|un|una|che|non|per)\b/.test(lower)) {
  269. if (/\b(il|della|gli|che|è)\b/.test(lower)) return "Italian"
  270. }
  271. // Dutch — common patterns
  272. if (/\b(het|de|een|van|en|in|is|dat|op|te|met)\b/.test(lower)) {
  273. if (/\b(het|een|van|dat)\b/.test(lower)) return "Dutch"
  274. }
  275. // Swedish — common patterns
  276. if (/[åäö]/.test(lower) && /\b(och|att|det|en|ett|är|för|med)\b/.test(lower)) {
  277. return "Swedish"
  278. }
  279. // Norwegian — common patterns
  280. if (/[åæø]/.test(lower) && /\b(og|er|det|en|et|for|med|på)\b/.test(lower)) {
  281. return "Norwegian"
  282. }
  283. // Danish — similar to Norwegian
  284. if (/[åæø]/.test(lower) && /\b(og|er|det|en|et|til|med|af)\b/.test(lower)) {
  285. return "Danish"
  286. }
  287. // Finnish — common patterns
  288. if (/[äö]/.test(lower) && /\b(ja|on|ei|se|että|tai|kun|niin)\b/.test(lower)) {
  289. return "Finnish"
  290. }
  291. // Indonesian/Malay — common patterns
  292. if (/\b(dan|yang|di|dari|untuk|dengan|ini|itu|adalah|tidak|ada)\b/.test(lower)) {
  293. if (/\b(yang|dari|untuk|dengan|adalah)\b/.test(lower)) return "Indonesian"
  294. }
  295. // Swahili — common patterns
  296. if (/\b(na|ya|wa|ni|kwa|katika|hii|hiyo)\b/.test(lower)) {
  297. return "Swahili"
  298. }
  299. return null
  300. }