using System; using System.Collections.Generic; using System.IO; using System.Text; using UnityEditor; using UnityEngine; using BaseGames.Localization; namespace BaseGames.Editor.Localization { /// /// 本地化 CSV(Excel 往返)读写共享工具。 /// /// CSV 约定: /// - 列顺序:key, ChineseSimplified, English, Japanese, Korean(表头用 枚举名)。 /// - 以 UTF-8 BOM 写出,使 Excel 双击打开时中日韩文不乱码。 /// - 解析遵循 RFC 4180(带引号字段可含逗号 / 换行 / 双引号转义),并自动剥离前导 BOM。 /// - 导入语义:合并(覆盖已有 key、追加新 key、不删除多余 key)。 /// public static class LocalizationCsv { /// 导出列顺序(与导入表头解析共用)。 public static readonly Language[] Columns = LocalizationFileIO.AllLanguages; // 带 BOM 的 UTF-8(Excel 友好) private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true); // ── 高层:表 ⇄ CSV 文件 ───────────────────────────────────────────────── /// /// 将给定的"语言→字典"导出为该表的 CSV 文件(含 BOM)。返回写出的资产路径。 /// public static string ExportToFile(string table, IReadOnlyDictionary> langDicts) { // 汇总所有语言出现过的 key(排序,保证稳定 diff) var allKeys = new SortedSet(StringComparer.Ordinal); foreach (var lang in Columns) if (langDicts.TryGetValue(lang, out var d) && d != null) foreach (var k in d.Keys) allKeys.Add(k); string csvText = BuildCsvText(allKeys, langDicts); EditorScaffoldUtils.EnsureFolder(LocalizationPaths.ExportRoot); string assetPath = LocalizationPaths.CsvPath(table); File.WriteAllText(ToFullPath(assetPath), csvText, Utf8Bom); AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceUpdate); return assetPath; } /// /// 读取该表的 CSV 文件并解析为"语言→字典"(仅含表头出现的语言列)。 /// CSV 文件不存在时返回 null。 /// public static Dictionary> ParseFile(string table) { string assetPath = LocalizationPaths.CsvPath(table); string fullPath = ToFullPath(assetPath); if (!File.Exists(fullPath)) return null; return ParseText(File.ReadAllText(fullPath, Encoding.UTF8)); } /// /// 将该表的 CSV 合并写回磁盘 JSON(供批处理工具用): /// 对 CSV 表头出现的每个语言,读现有 JSON → 合并 CSV → 。 /// 返回受影响的语言数;CSV 不存在时返回 0。 /// public static int ImportFileToJson(string table) { var parsed = ParseFile(table); if (parsed == null) return 0; foreach (var (lang, csvDict) in parsed) { var merged = LocalizationFileIO.Read(lang, table); // 现有 JSON 副本 foreach (var kv in csvDict) merged[kv.Key] = kv.Value; LocalizationFileIO.Write(lang, table, merged); } return parsed.Count; } // ── 低层:文本 ⇄ 行 ───────────────────────────────────────────────────── /// 构造 CSV 文本(表头 + 数据行,按 allKeys 顺序)。 public static string BuildCsvText(IEnumerable orderedKeys, IReadOnlyDictionary> langDicts) { var sb = new StringBuilder(); sb.Append("key"); foreach (var lang in Columns) sb.Append(',').Append(lang); sb.Append('\n'); foreach (var key in orderedKeys) { sb.Append(Escape(key)); foreach (var lang in Columns) { string val = ""; if (langDicts.TryGetValue(lang, out var d) && d != null) d.TryGetValue(key, out val); sb.Append(',').Append(Escape(val ?? "")); } sb.Append('\n'); } return sb.ToString(); } /// 解析 CSV 文本为"语言→字典"(仅表头识别出的 Language 列)。 public static Dictionary> ParseText(string text) { var rows = ParseRows(text); var result = new Dictionary>(); if (rows.Count < 2) return result; // 表头:列号 → Language var header = rows[0]; var langCols = new List<(int col, Language lang)>(); for (int col = 1; col < header.Count; col++) if (Enum.TryParse(header[col].Trim(), out var lang)) { langCols.Add((col, lang)); result[lang] = new Dictionary(StringComparer.Ordinal); } for (int row = 1; row < rows.Count; row++) { var cells = rows[row]; if (cells.Count == 0 || string.IsNullOrWhiteSpace(cells[0])) continue; string key = cells[0]; foreach (var (col, lang) in langCols) result[lang][key] = col < cells.Count ? cells[col] : ""; } return result; } /// 按 RFC 4180 规范转义单元格(含逗号 / 引号 / 换行时用双引号包裹)。 public static string Escape(string value) { if (value == null) return ""; bool needsQuote = value.IndexOf(',') >= 0 || value.IndexOf('"') >= 0 || value.IndexOf('\n') >= 0 || value.IndexOf('\r') >= 0; if (!needsQuote) return value; return "\"" + value.Replace("\"", "\"\"") + "\""; } /// RFC 4180 CSV 解析器,支持带引号字段(含换行),并剥离前导 UTF-8 BOM。 public static List> ParseRows(string text) { var rows = new List>(); if (string.IsNullOrEmpty(text)) return rows; // 剥离前导 BOM(File.ReadAllText 以 UTF8 读时若文件带 BOM 会保留为 U+FEFF) if (text[0] == '') text = text.Substring(1); var row = new List(); var cell = new StringBuilder(); bool inQuote = false; int i = 0; while (i < text.Length) { char c = text[i]; if (inQuote) { if (c == '"') { if (i + 1 < text.Length && text[i + 1] == '"') { cell.Append('"'); i += 2; } else { inQuote = false; i++; } } else { cell.Append(c); i++; } } else { if (c == '"') { inQuote = true; i++; } else if (c == ',') { row.Add(cell.ToString()); cell.Clear(); i++; } else if (c == '\r') { row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List(); if (i + 1 < text.Length && text[i + 1] == '\n') i++; i++; } else if (c == '\n') { row.Add(cell.ToString()); cell.Clear(); rows.Add(row); row = new List(); i++; } else { cell.Append(c); i++; } } } if (cell.Length > 0 || row.Count > 0) { row.Add(cell.ToString()); rows.Add(row); } return rows; } private static string ToFullPath(string assetPath) { string projectRoot = Path.GetDirectoryName(Application.dataPath)!; return Path.Combine(projectRoot, assetPath.Replace('/', Path.DirectorySeparatorChar)); } } }