Files
zeling_v2/Assets/_Game/Scripts/Editor/Localization/LocalizationCsv.cs
2026-06-06 09:00:11 +08:00

206 lines
8.7 KiB
C#
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEngine;
using BaseGames.Localization;
namespace BaseGames.Editor.Localization
{
/// <summary>
/// 本地化 CSVExcel 往返)读写共享工具。
///
/// CSV 约定:
/// - 列顺序:<c>key, ChineseSimplified, English, Japanese, Korean</c>(表头用 <see cref="Language"/> 枚举名)。
/// - 以 <b>UTF-8 BOM</b> 写出,使 Excel 双击打开时中日韩文不乱码。
/// - 解析遵循 RFC 4180带引号字段可含逗号 / 换行 / 双引号转义),并自动剥离前导 BOM。
/// - 导入语义:合并(覆盖已有 key、追加新 key、不删除多余 key
/// </summary>
public static class LocalizationCsv
{
/// <summary>导出列顺序(与导入表头解析共用)。</summary>
public static readonly Language[] Columns = LocalizationFileIO.AllLanguages;
// 带 BOM 的 UTF-8Excel 友好)
private static readonly Encoding Utf8Bom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: true);
// ── 高层:表 ⇄ CSV 文件 ─────────────────────────────────────────────────
/// <summary>
/// 将给定的"语言→字典"导出为该表的 CSV 文件(含 BOM。返回写出的资产路径。
/// </summary>
public static string ExportToFile(string table,
IReadOnlyDictionary<Language, Dictionary<string, string>> langDicts)
{
// 汇总所有语言出现过的 key排序保证稳定 diff
var allKeys = new SortedSet<string>(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;
}
/// <summary>
/// 读取该表的 CSV 文件并解析为"语言→字典"(仅含表头出现的语言列)。
/// CSV 文件不存在时返回 null。
/// </summary>
public static Dictionary<Language, Dictionary<string, string>> 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));
}
/// <summary>
/// 将该表的 CSV 合并写回磁盘 JSON供批处理工具用
/// 对 CSV 表头出现的每个语言,读现有 JSON → 合并 CSV → <see cref="LocalizationFileIO.Write"/>。
/// 返回受影响的语言数CSV 不存在时返回 0。
/// </summary>
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;
}
// ── 低层:文本 ⇄ 行 ─────────────────────────────────────────────────────
/// <summary>构造 CSV 文本(表头 + 数据行,按 allKeys 顺序)。</summary>
public static string BuildCsvText(IEnumerable<string> orderedKeys,
IReadOnlyDictionary<Language, Dictionary<string, string>> 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();
}
/// <summary>解析 CSV 文本为"语言→字典"(仅表头识别出的 Language 列)。</summary>
public static Dictionary<Language, Dictionary<string, string>> ParseText(string text)
{
var rows = ParseRows(text);
var result = new Dictionary<Language, Dictionary<string, string>>();
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<Language>(header[col].Trim(), out var lang))
{
langCols.Add((col, lang));
result[lang] = new Dictionary<string, string>(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;
}
/// <summary>按 RFC 4180 规范转义单元格(含逗号 / 引号 / 换行时用双引号包裹)。</summary>
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("\"", "\"\"") + "\"";
}
/// <summary>RFC 4180 CSV 解析器,支持带引号字段(含换行),并剥离前导 UTF-8 BOM。</summary>
public static List<List<string>> ParseRows(string text)
{
var rows = new List<List<string>>();
if (string.IsNullOrEmpty(text)) return rows;
// 剥离前导 BOMFile.ReadAllText 以 UTF8 读时若文件带 BOM 会保留为 U+FEFF
if (text[0] == '') text = text.Substring(1);
var row = new List<string>();
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<string>();
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<string>(); 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));
}
}
}