TranslationValidator.cs 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5. using LocalizationTool.Models;
  6. namespace LocalizationTool.Services
  7. {
  8. /// <summary>一条译文质量问题。</summary>
  9. public class TranslationIssue
  10. {
  11. public RowVm Row { get; set; }
  12. public CellVm Cell { get; set; }
  13. public string LangCode { get; set; }
  14. /// <summary>true = 错误(如正文丢失/乱码);false = 警告(如首尾空格/疑似未翻译)。</summary>
  15. public bool IsError { get; set; }
  16. public string Message { get; set; }
  17. }
  18. /// <summary>
  19. /// 译文质量校验引擎:对照中文原文检查各语言译文。
  20. /// 错误:正文丢失(如“⏱ 延时等待”→“⏱”)、乱码字符、格式化占位符丢失。
  21. /// 警告:译文与原文完全相同(疑似未翻译)、首尾空白。
  22. /// </summary>
  23. public static class TranslationValidator
  24. {
  25. /// <summary>
  26. /// “疑似未翻译”豁免语言:这些语言的文字与中文写法相同属正常情况。
  27. /// 日语大量汉字词与中文写法一致(如“管理”“可能”“時間”“保存”),译文相同并非漏翻。
  28. /// </summary>
  29. public static readonly HashSet<string> NotTranslatedIgnoredLangs =
  30. new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "ja" };
  31. /// <summary>校验一组行 × 一组语言,返回问题列表(每个单元格最多一条主问题)。</summary>
  32. public static List<TranslationIssue> Validate(IEnumerable<RowVm> rows, IEnumerable<PackFile> langs)
  33. {
  34. var issues = new List<TranslationIssue>();
  35. foreach (var lang in langs)
  36. {
  37. var isZhTarget = lang.LanguageCode.StartsWith("zh", StringComparison.OrdinalIgnoreCase);
  38. foreach (var row in rows)
  39. {
  40. if (!row.Cells.TryGetValue(lang.LanguageCode, out var cell) || cell == null)
  41. continue;
  42. var v = cell.Value;
  43. if (string.IsNullOrWhiteSpace(v))
  44. continue; // 缺译由“缺译”列负责,这里只校验已填的译文
  45. var key = row.Key ?? "";
  46. // 乱码字符(错误)
  47. if (v.Contains('\uFFFD'))
  48. {
  49. Add(issues, row, cell, lang, true, "译文包含乱码字符(U+FFFD),编码可能已损坏");
  50. continue;
  51. }
  52. if (v.Any(ch => char.IsControl(ch) && ch != '\n' && ch != '\t'))
  53. {
  54. Add(issues, row, cell, lang, true, "译文包含不可见控制字符");
  55. continue;
  56. }
  57. // 正文丢失(错误):原文有实质文字,译文只剩 emoji/符号
  58. var keyCore = CoreText(key);
  59. var vCore = CoreText(v);
  60. if (keyCore.Length > 0 && vCore.Length == 0)
  61. {
  62. Add(issues, row, cell, lang, true,
  63. $"译文疑似丢失正文:原文实质内容为“{keyCore}”,译文只剩符号/表情");
  64. continue;
  65. }
  66. // 占位符丢失(错误):{0} {name} %s %d 等
  67. var keyPh = Placeholders(key);
  68. if (keyPh.Count > 0)
  69. {
  70. var missing = keyPh.Except(Placeholders(v)).ToList();
  71. if (missing.Count > 0)
  72. {
  73. Add(issues, row, cell, lang, true,
  74. $"译文丢失格式化占位符:{string.Join("、", missing)}");
  75. continue;
  76. }
  77. }
  78. // 疑似未翻译(警告):译文与原文完全一致。
  79. // 豁免语言(如日语)跳过:汉字词写法相同属正常,不标问题、不给 AI 重译
  80. var langMain = lang.LanguageCode.Split('-')[0].ToLowerInvariant();
  81. if (!isZhTarget && v == key && ContainsHan(key)
  82. && !NotTranslatedIgnoredLangs.Contains(langMain))
  83. {
  84. Add(issues, row, cell, lang, false, "译文与原文完全相同,疑似未翻译");
  85. continue;
  86. }
  87. // 首尾空白(警告)
  88. if (v != v.Trim())
  89. {
  90. Add(issues, row, cell, lang, false, "译文首尾存在空白字符");
  91. continue;
  92. }
  93. }
  94. }
  95. return issues;
  96. }
  97. /// <summary>
  98. /// 提取“实质文本”:仅保留字母与数字(汉字/假名/谚文/拉丁字母均算,emoji/符号/空白/标点全部忽略)。
  99. /// 用于判断译文是否只剩表情符号——“⏱ 延时等待”→“延时等待”,“⏱”→""。
  100. /// </summary>
  101. public static string CoreText(string s)
  102. {
  103. if (string.IsNullOrEmpty(s)) return "";
  104. var sb = new StringBuilder(s.Length);
  105. foreach (var ch in s)
  106. if (char.IsLetterOrDigit(ch)) sb.Append(ch);
  107. return sb.ToString();
  108. }
  109. /// <summary>提取格式化占位符:{0} {name} %s %d。</summary>
  110. public static HashSet<string> Placeholders(string s)
  111. {
  112. var set = new HashSet<string>();
  113. if (string.IsNullOrEmpty(s)) return set;
  114. foreach (System.Text.RegularExpressions.Match m in
  115. System.Text.RegularExpressions.Regex.Matches(s, @"\{\w+\}|%[sd]"))
  116. set.Add(m.Value);
  117. return set;
  118. }
  119. /// <summary>是否包含汉字(CJK 统一表意文字区)。</summary>
  120. private static bool ContainsHan(string s)
  121. {
  122. foreach (var ch in s)
  123. {
  124. var c = (int)ch;
  125. if ((c >= 0x4E00 && c <= 0x9FFF) || (c >= 0x3400 && c <= 0x4DBF)) return true;
  126. }
  127. return false;
  128. }
  129. private static void Add(List<TranslationIssue> issues, RowVm row, CellVm cell,
  130. PackFile lang, bool isError, string message)
  131. {
  132. issues.Add(new TranslationIssue
  133. {
  134. Row = row,
  135. Cell = cell,
  136. LangCode = lang.LanguageCode,
  137. IsError = isError,
  138. Message = message,
  139. });
  140. }
  141. }
  142. }