feat(editor): 添加 Yarn 本地化校验工具

- 新增 Editor 窗口:AIBIS → Yarn 本地化校验
- 新增 BatchMode CLI:Tools/validate_yarn_l10n.py
- 新增文档:Docs/本地化校验工具.md
- 校验 Yarn 原文 string table 与各语言 CSV 的 line id 一致性
This commit is contained in:
2026-07-04 23:12:21 +08:00
parent 575ebebfaf
commit 7d7c8893fc
13 changed files with 1526 additions and 0 deletions
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3fe2278382cb87e4993d86ca5ed00e5f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public enum YarnL10nIssueType
{
MissingLocalization,
OrphanLocalization,
SourceTextChanged,
CompileError,
UntaggedLine,
CsvMissing,
InvalidProject,
NoLocalizationConfigured,
}
[Serializable]
public sealed class YarnL10nIssue
{
public YarnL10nIssueType Type;
public string Language;
public string LineId;
public string SourceText;
public string LocText;
public string YarnFile;
public string YarnLineNumber;
public string CsvFile;
public string ChapterFolder;
public string Message;
public string IssueTypeLabel => GetDisplayType();
public string GetDisplayType()
{
return Type switch
{
YarnL10nIssueType.MissingLocalization => $"缺少本地化-{Language}",
YarnL10nIssueType.OrphanLocalization => $"找不到原文-{Language}",
YarnL10nIssueType.SourceTextChanged => $"原文已变更-{Language}",
YarnL10nIssueType.CompileError => "编译错误",
YarnL10nIssueType.UntaggedLine => "未打line标签",
YarnL10nIssueType.CsvMissing => $"CSV缺失-{Language}",
YarnL10nIssueType.InvalidProject => "项目无效",
YarnL10nIssueType.NoLocalizationConfigured => "未配置本地化",
_ => Type.ToString(),
};
}
}
[Serializable]
public sealed class YarnL10nChapterSummary
{
public string ChapterFolder;
public string YarnProjectPath;
public int SourceLineCount;
public Dictionary<string, YarnL10nLocaleSummary> Locales = new();
public int IssueCount;
}
[Serializable]
public sealed class YarnL10nLocaleSummary
{
public string Language;
public int MissingCount;
public int OrphanCount;
public int SourceChangedCount;
}
[Serializable]
public sealed class YarnL10nValidationResult
{
public List<YarnL10nIssue> Issues = new();
public List<YarnL10nChapterSummary> Chapters = new();
public int TotalIssueCount;
public bool HasIssues => TotalIssueCount > 0;
public bool HasBlockingIssues
{
get
{
foreach (var issue in Issues)
{
switch (issue.Type)
{
case YarnL10nIssueType.MissingLocalization:
case YarnL10nIssueType.OrphanLocalization:
case YarnL10nIssueType.SourceTextChanged:
case YarnL10nIssueType.CompileError:
case YarnL10nIssueType.CsvMissing:
case YarnL10nIssueType.InvalidProject:
return true;
}
}
return false;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 340d8f4fbf1fb8a458b87a164afd827b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,57 @@
using System.Collections.Generic;
using System.IO;
using System.Text;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nReportExporter
{
public static void ExportToCsv(YarnL10nValidationResult result, string outputPath)
{
var directory = Path.GetDirectoryName(outputPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var builder = new StringBuilder();
builder.AppendLine("issueType,language,lineId,sourceText,locText,yarnFile,yarnLineNumber,csvFile,chapterFolder,message");
foreach (var issue in result.Issues)
{
builder.AppendLine(string.Join(",",
Escape(issue.GetDisplayType()),
Escape(issue.Language),
Escape(issue.LineId),
Escape(issue.SourceText),
Escape(issue.LocText),
Escape(issue.YarnFile),
Escape(issue.YarnLineNumber),
Escape(issue.CsvFile),
Escape(issue.ChapterFolder),
Escape(issue.Message)));
}
File.WriteAllText(outputPath, builder.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
}
public static string BuildDefaultExportFileName(string chapterFolder)
{
var chapterName = string.IsNullOrEmpty(chapterFolder)
? "yarn_l10n"
: Path.GetFileName(chapterFolder.Replace('/', Path.DirectorySeparatorChar));
return $"{chapterName}_l10n_report_{System.DateTime.Now:yyyyMMdd_HHmmss}.csv";
}
private static string Escape(string value)
{
value ??= string.Empty;
if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r'))
{
return $"\"{value.Replace("\"", "\"\"")}\"";
}
return value;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ab3c79927b366e459587175b8325dd6
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,471 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public class YarnL10nValidationWindow : EditorWindow
{
private const string MenuPath = "AIBIS/Yarn 本地化校验";
private const string WindowTitle = "Yarn Localization Validator";
private const string FolderPathPrefKey = "AibisDream.YarnL10nValidation.FolderPath";
private const string ScanSubdirsPrefKey = "AibisDream.YarnL10nValidation.ScanSubdirs";
private string _folderPath = "Assets/Yarn/FP/FP_Day1_mid";
private DefaultAsset _folderAsset;
private bool _scanSubdirectories;
private Vector2 _scrollPosition;
private YarnL10nValidationResult _lastResult;
private string _filterType = "全部";
private string _filterLanguage = "全部";
private string _searchText = string.Empty;
private string[] _typeOptions = { "全部" };
private string[] _languageOptions = { "全部" };
private int _filterTypeIndex;
private int _filterLanguageIndex;
[MenuItem(MenuPath)]
public static void Open()
{
var window = GetWindow<YarnL10nValidationWindow>(WindowTitle);
window.minSize = new Vector2(640, 480);
}
private void OnEnable()
{
_folderPath = EditorPrefs.GetString(FolderPathPrefKey, _folderPath);
_scanSubdirectories = EditorPrefs.GetBool(ScanSubdirsPrefKey, false);
SyncFolderAssetFromPath();
}
private void OnGUI()
{
EditorGUILayout.LabelField("Yarn 本地化校验", EditorStyles.boldLabel);
EditorGUILayout.Space(4);
DrawFolderSelector();
EditorGUILayout.Space(4);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("校验", GUILayout.Height(28)))
{
RunValidation();
}
using (new EditorGUI.DisabledScope(_lastResult == null || _lastResult.Issues.Count == 0))
{
if (GUILayout.Button("导出 CSV", GUILayout.Height(28)))
{
ExportReport();
}
}
if (GUILayout.Button("定位文件夹", GUILayout.Height(28)))
{
PingFolder();
}
EditorGUILayout.EndHorizontal();
_scanSubdirectories = EditorGUILayout.ToggleLeft("扫描子目录(批量)", _scanSubdirectories);
if (GUI.changed)
{
EditorPrefs.SetBool(ScanSubdirsPrefKey, _scanSubdirectories);
}
EditorGUILayout.Space(6);
DrawSummary();
DrawFilters();
DrawIssueList();
}
private void DrawFolderSelector()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("章节文件夹", GUILayout.Width(80));
var newAsset = (DefaultAsset)EditorGUILayout.ObjectField(_folderAsset, typeof(DefaultAsset), false);
if (newAsset != _folderAsset)
{
_folderAsset = newAsset;
if (_folderAsset != null)
{
_folderPath = AssetDatabase.GetAssetPath(_folderAsset);
SaveFolderPreferences();
}
}
var newPath = EditorGUILayout.TextField(_folderPath);
if (!string.Equals(newPath, _folderPath, StringComparison.Ordinal))
{
_folderPath = newPath;
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
if (GUILayout.Button("浏览", GUILayout.Width(60)))
{
var startDirectory = GetBrowseStartDirectory();
var selected = EditorUtility.OpenFolderPanel("选择 Yarn 章节文件夹", startDirectory, string.Empty);
if (!string.IsNullOrEmpty(selected))
{
_folderPath = YarnL10nValidator.NormalizeProjectRelativePath(selected);
SyncFolderAssetFromPath();
SaveFolderPreferences();
}
}
EditorGUILayout.EndHorizontal();
}
private void SaveFolderPreferences()
{
EditorPrefs.SetString(FolderPathPrefKey, _folderPath);
}
private string GetBrowseStartDirectory()
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(_folderPath);
if (!string.IsNullOrEmpty(absolutePath) && !IsFilesystemRoot(absolutePath))
{
var parent = Directory.GetParent(absolutePath);
if (parent != null)
{
return parent.FullName;
}
}
if (!string.IsNullOrEmpty(absolutePath) && Directory.Exists(absolutePath))
{
return absolutePath;
}
var assetsPath = Application.dataPath;
return Directory.Exists(assetsPath) ? assetsPath : Environment.CurrentDirectory;
}
private static bool IsFilesystemRoot(string absolutePath)
{
var normalized = Path.GetFullPath(absolutePath)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
var root = Path.GetPathRoot(normalized)?
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return !string.IsNullOrEmpty(root) &&
string.Equals(normalized, root, StringComparison.OrdinalIgnoreCase);
}
private void SyncFolderAssetFromPath()
{
_folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(_folderPath);
}
private void RunValidation()
{
SaveFolderPreferences();
_lastResult = _scanSubdirectories
? YarnL10nValidator.ValidateScanAll(_folderPath)
: YarnL10nValidator.ValidateFolder(_folderPath);
RefreshFilterOptions();
Repaint();
}
private void RefreshFilterOptions()
{
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
_typeOptions = new[] { "全部" };
_languageOptions = new[] { "全部" };
}
else
{
var types = _lastResult.Issues
.Select(issue => issue.IssueTypeLabel)
.Where(label => !string.IsNullOrEmpty(label))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(label => label, StringComparer.OrdinalIgnoreCase)
.ToList();
types.Insert(0, "全部");
_typeOptions = types.ToArray();
var languages = _lastResult.Issues
.Select(issue => issue.Language)
.Where(language => !string.IsNullOrEmpty(language))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(language => language, StringComparer.OrdinalIgnoreCase)
.ToList();
languages.Insert(0, "全部");
_languageOptions = languages.ToArray();
}
_filterTypeIndex = ClampFilterIndex(_filterType, _typeOptions, ref _filterType);
_filterLanguageIndex = ClampFilterIndex(_filterLanguage, _languageOptions, ref _filterLanguage);
}
private static int ClampFilterIndex(string currentValue, string[] options, ref string selectedValue)
{
if (options.Length == 0)
{
selectedValue = "全部";
return 0;
}
for (var i = 0; i < options.Length; i++)
{
if (string.Equals(options[i], currentValue, StringComparison.OrdinalIgnoreCase))
{
selectedValue = options[i];
return i;
}
}
selectedValue = options[0];
return 0;
}
private void ExportReport()
{
var defaultName = YarnL10nReportExporter.BuildDefaultExportFileName(_folderPath);
var savePath = EditorUtility.SaveFilePanel("导出校验报告", Application.dataPath, defaultName, "csv");
if (string.IsNullOrEmpty(savePath))
{
return;
}
YarnL10nReportExporter.ExportToCsv(_lastResult, savePath);
EditorUtility.RevealInFinder(savePath);
}
private void PingFolder()
{
SyncFolderAssetFromPath();
if (_folderAsset == null)
{
EditorUtility.DisplayDialog("Yarn 本地化校验", "无法定位文件夹,请检查路径。", "确定");
return;
}
EditorGUIUtility.PingObject(_folderAsset);
Selection.activeObject = _folderAsset;
}
private void DrawSummary()
{
EditorGUILayout.LabelField("摘要", EditorStyles.boldLabel);
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
if (_lastResult == null)
{
EditorGUILayout.LabelField("尚未执行校验。");
return;
}
EditorGUILayout.LabelField($"Issue 总数: {_lastResult.TotalIssueCount}");
EditorGUILayout.LabelField($"章节数: {_lastResult.Chapters.Count}");
foreach (var chapter in _lastResult.Chapters)
{
var localeParts = chapter.Locales.Values
.Select(locale => $"{locale.Language}: 缺 {locale.MissingCount} / 多余 {locale.OrphanCount} / 变更 {locale.SourceChangedCount}")
.ToArray();
var localeSummary = localeParts.Length > 0 ? string.Join(" | ", localeParts) : "无翻译语言";
EditorGUILayout.LabelField($"{chapter.ChapterFolder} — 原文 {chapter.SourceLineCount} 行 | {localeSummary}");
}
}
EditorGUILayout.Space(6);
}
private void DrawFilters()
{
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("类型", GUILayout.Width(40));
_filterTypeIndex = EditorGUILayout.Popup(_filterTypeIndex, _typeOptions, GUILayout.Width(180));
_filterType = _typeOptions[_filterTypeIndex];
EditorGUILayout.LabelField("语言", GUILayout.Width(40));
_filterLanguageIndex = EditorGUILayout.Popup(_filterLanguageIndex, _languageOptions, GUILayout.Width(100));
_filterLanguage = _languageOptions[_filterLanguageIndex];
EditorGUILayout.LabelField("搜索", GUILayout.Width(40));
_searchText = EditorGUILayout.TextField(_searchText);
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space(4);
}
private void DrawIssueList()
{
EditorGUILayout.LabelField("结果列表", EditorStyles.boldLabel);
_scrollPosition = EditorGUILayout.BeginScrollView(_scrollPosition, GUILayout.ExpandHeight(true));
if (_lastResult == null || _lastResult.Issues.Count == 0)
{
EditorGUILayout.LabelField(_lastResult == null ? "无结果。" : "未发现 issue。");
}
else
{
var index = 0;
foreach (var issue in FilterIssues(_lastResult.Issues))
{
DrawIssueRow(issue, index);
index++;
}
}
EditorGUILayout.EndScrollView();
}
private IEnumerable<YarnL10nIssue> FilterIssues(IEnumerable<YarnL10nIssue> issues)
{
foreach (var issue in issues)
{
if (!MatchesFilter(issue))
{
continue;
}
yield return issue;
}
}
private bool MatchesFilter(YarnL10nIssue issue)
{
if (!string.IsNullOrWhiteSpace(_filterType) &&
!string.Equals(_filterType, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.IssueTypeLabel, _filterType, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (!string.IsNullOrWhiteSpace(_filterLanguage) &&
!string.Equals(_filterLanguage, "全部", StringComparison.OrdinalIgnoreCase) &&
!string.Equals(issue.Language, _filterLanguage, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(_searchText))
{
return true;
}
var haystack = string.Join(" ",
issue.GetDisplayType(),
issue.Language,
issue.LineId,
issue.SourceText,
issue.LocText,
issue.YarnFile,
issue.ChapterFolder,
issue.Message);
return haystack.IndexOf(_searchText, StringComparison.OrdinalIgnoreCase) >= 0;
}
private void DrawIssueRow(YarnL10nIssue issue, int index)
{
using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox))
{
EditorGUILayout.LabelField($"{issue.GetDisplayType()} | {issue.Language} | {issue.LineId}", EditorStyles.boldLabel);
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
EditorGUILayout.LabelField("章节", issue.ChapterFolder);
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
EditorGUILayout.LabelField("源文件", $"{issue.YarnFile}:{issue.YarnLineNumber}");
}
if (!string.IsNullOrEmpty(issue.CsvFile))
{
EditorGUILayout.LabelField("CSV", issue.CsvFile);
}
if (!string.IsNullOrEmpty(issue.SourceText))
{
EditorGUILayout.LabelField("原文", issue.SourceText);
}
if (!string.IsNullOrEmpty(issue.LocText))
{
EditorGUILayout.LabelField("翻译", issue.LocText);
}
if (!string.IsNullOrEmpty(issue.Message))
{
EditorGUILayout.LabelField("说明", issue.Message);
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("定位", GUILayout.Width(80)))
{
PingIssue(issue);
}
EditorGUILayout.EndHorizontal();
}
}
private void PingIssue(YarnL10nIssue issue)
{
if (issue.Type == YarnL10nIssueType.OrphanLocalization && !string.IsNullOrEmpty(issue.CsvFile))
{
var csvAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(issue.CsvFile);
if (csvAsset != null)
{
EditorGUIUtility.PingObject(csvAsset);
Selection.activeObject = csvAsset;
return;
}
}
if (!string.IsNullOrEmpty(issue.YarnFile))
{
var yarnPath = ResolveYarnAssetPath(issue);
var yarnAsset = AssetDatabase.LoadAssetAtPath<UnityEngine.Object>(yarnPath);
if (yarnAsset != null)
{
EditorGUIUtility.PingObject(yarnAsset);
Selection.activeObject = yarnAsset;
if (int.TryParse(issue.YarnLineNumber, out var lineNumber) && lineNumber > 0)
{
var absolutePath = YarnL10nValidator.ToAbsolutePath(yarnPath);
InternalEditorUtility.OpenFileAtLineExternal(absolutePath, lineNumber);
}
return;
}
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
var folderAsset = AssetDatabase.LoadAssetAtPath<DefaultAsset>(issue.ChapterFolder);
if (folderAsset != null)
{
EditorGUIUtility.PingObject(folderAsset);
Selection.activeObject = folderAsset;
}
}
}
private static string ResolveYarnAssetPath(YarnL10nIssue issue)
{
if (issue.YarnFile.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
{
return issue.YarnFile;
}
if (!string.IsNullOrEmpty(issue.ChapterFolder))
{
return $"{issue.ChapterFolder}/{issue.YarnFile}";
}
return issue.YarnFile;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fe514a9bb460dd74e8b0ec7e1ae7cf98
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,412 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Yarn.Compiler;
using Yarn.Unity;
using Yarn.Unity.Editor;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nValidator
{
public static YarnL10nValidationResult ValidateFolder(string folderPath)
{
var result = new YarnL10nValidationResult();
if (string.IsNullOrWhiteSpace(folderPath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: folderPath,
message: "章节文件夹路径为空。"));
FinalizeResult(result);
return result;
}
var normalizedFolder = NormalizeProjectRelativePath(folderPath);
var absoluteFolder = ToAbsolutePath(normalizedFolder);
if (!Directory.Exists(absoluteFolder))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: $"文件夹不存在: {normalizedFolder}"));
FinalizeResult(result);
return result;
}
var yarnProjects = Directory.GetFiles(absoluteFolder, "*.yarnproject", SearchOption.TopDirectoryOnly);
if (yarnProjects.Length == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: "文件夹内未找到 .yarnproject 文件。"));
FinalizeResult(result);
return result;
}
if (yarnProjects.Length > 1)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedFolder,
message: $"文件夹内存在多个 .yarnproject{yarnProjects.Length} 个)。"));
FinalizeResult(result);
return result;
}
ValidateYarnProjectFile(yarnProjects[0], result);
FinalizeResult(result);
return result;
}
public static YarnL10nValidationResult ValidateScanAll(string rootPath)
{
var result = new YarnL10nValidationResult();
var normalizedRoot = NormalizeProjectRelativePath(rootPath);
var absoluteRoot = ToAbsolutePath(normalizedRoot);
if (!Directory.Exists(absoluteRoot))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedRoot,
message: $"扫描根目录不存在: {normalizedRoot}"));
FinalizeResult(result);
return result;
}
var yarnProjectFiles = Directory.GetFiles(absoluteRoot, "*.yarnproject", SearchOption.AllDirectories);
if (yarnProjectFiles.Length == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: normalizedRoot,
message: "扫描范围内未找到任何 .yarnproject。"));
FinalizeResult(result);
return result;
}
foreach (var yarnProjectFile in yarnProjectFiles.OrderBy(path => path, StringComparer.OrdinalIgnoreCase))
{
ValidateYarnProjectFile(yarnProjectFile, result);
}
FinalizeResult(result);
return result;
}
private static void ValidateYarnProjectFile(string yarnProjectAbsolutePath, YarnL10nValidationResult result)
{
var chapterFolder = NormalizeProjectRelativePath(Path.GetDirectoryName(yarnProjectAbsolutePath));
var yarnProjectRelativePath = NormalizeProjectRelativePath(yarnProjectAbsolutePath);
Project project;
try
{
project = Project.LoadFromFile(yarnProjectAbsolutePath);
}
catch (Exception ex)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
message: $"无法解析 .yarnproject: {ex.Message}"));
return;
}
var chapterSummary = new YarnL10nChapterSummary
{
ChapterFolder = chapterFolder,
YarnProjectPath = yarnProjectRelativePath,
};
var issueStartIndex = result.Issues.Count;
if (project.Localisation == null || project.Localisation.Count == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.NoLocalizationConfigured,
chapterFolder: chapterFolder,
message: "yarnproject 未配置 localisation。"));
result.Chapters.Add(chapterSummary);
return;
}
var sourceFiles = project.SourceFiles?.ToList() ?? new List<string>();
if (sourceFiles.Count == 0)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
message: "yarnproject 未包含任何 .yarn 源文件。"));
result.Chapters.Add(chapterSummary);
return;
}
var job = CompilationJob.CreateFromFiles(sourceFiles);
job.CompilationType = CompilationJob.Type.StringsOnly;
var compilationResult = Compiler.Compile(job);
foreach (var diagnostic in compilationResult.Diagnostics.Where(d => d.Severity == Diagnostic.DiagnosticSeverity.Error))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CompileError,
chapterFolder: chapterFolder,
message: diagnostic.Message));
}
if (compilationResult.ContainsErrors)
{
result.Chapters.Add(chapterSummary);
return;
}
if (compilationResult.ContainsImplicitStringTags)
{
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.isImplicitTag))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.UntaggedLine,
chapterFolder: chapterFolder,
lineId: pair.Key,
yarnFile: pair.Value.fileName,
yarnLineNumber: pair.Value.lineNumber.ToString(),
message: "存在未打 #line: 标签的可本地化行。"));
}
}
var baseEntries = BuildBaseEntries(project, compilationResult);
chapterSummary.SourceLineCount = baseEntries.Count;
foreach (var localePair in project.Localisation)
{
var language = localePair.Key;
if (string.Equals(language, project.BaseLanguage, StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (string.IsNullOrEmpty(localePair.Value.Strings))
{
continue;
}
if (localePair.Value.Strings.StartsWith("unity:", StringComparison.Ordinal))
{
continue;
}
var localeSummary = new YarnL10nLocaleSummary { Language = language };
chapterSummary.Locales[language] = localeSummary;
if (!project.TryGetStringsPath(language, out var csvRelativePath) ||
string.IsNullOrEmpty(csvRelativePath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CsvMissing,
chapterFolder: chapterFolder,
language: language,
message: $"未找到语言 {language} 的 CSV 路径。"));
continue;
}
var csvAbsolutePath = ToAbsolutePath(csvRelativePath);
if (!File.Exists(csvAbsolutePath))
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.CsvMissing,
chapterFolder: chapterFolder,
language: language,
csvFile: csvRelativePath,
message: $"本地化 CSV 不存在: {csvRelativePath}"));
continue;
}
IEnumerable<StringTableEntry> translatedEntries;
try
{
translatedEntries = StringTableEntry.ParseFromCSV(File.ReadAllText(csvAbsolutePath));
}
catch (Exception ex)
{
AddIssue(result, CreateIssue(
YarnL10nIssueType.InvalidProject,
chapterFolder: chapterFolder,
language: language,
csvFile: csvRelativePath,
message: $"无法解析 CSV: {ex.Message}"));
continue;
}
var translatedDictionary = translatedEntries
.Where(entry => !string.IsNullOrEmpty(entry.ID))
.GroupBy(entry => entry.ID)
.ToDictionary(group => group.Key, group => group.First());
var baseIds = baseEntries.Keys.ToHashSet();
var translatedIds = translatedDictionary.Keys.ToHashSet();
foreach (var missingId in baseIds.Except(translatedIds).OrderBy(id => id, StringComparer.Ordinal))
{
var baseEntry = baseEntries[missingId];
localeSummary.MissingCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.MissingLocalization,
chapterFolder: chapterFolder,
language: language,
lineId: missingId,
sourceText: baseEntry.Text,
yarnFile: baseEntry.File,
yarnLineNumber: baseEntry.LineNumber));
}
foreach (var orphanId in translatedIds.Except(baseIds).OrderBy(id => id, StringComparer.Ordinal))
{
var translatedEntry = translatedDictionary[orphanId];
localeSummary.OrphanCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.OrphanLocalization,
chapterFolder: chapterFolder,
language: language,
lineId: orphanId,
locText: translatedEntry.Text,
csvFile: csvRelativePath));
}
foreach (var sharedId in baseIds.Intersect(translatedIds))
{
var baseEntry = baseEntries[sharedId];
var translatedEntry = translatedDictionary[sharedId];
if (string.IsNullOrEmpty(baseEntry.Lock) || string.IsNullOrEmpty(translatedEntry.Lock))
{
continue;
}
if (baseEntry.Lock == translatedEntry.Lock)
{
continue;
}
localeSummary.SourceChangedCount++;
AddIssue(result, CreateIssue(
YarnL10nIssueType.SourceTextChanged,
chapterFolder: chapterFolder,
language: language,
lineId: sharedId,
sourceText: baseEntry.Text,
locText: translatedEntry.Text,
yarnFile: baseEntry.File,
yarnLineNumber: baseEntry.LineNumber,
csvFile: csvRelativePath,
message: "原文 Lock 与翻译 CSV 不一致,翻译可能已过期。"));
}
}
chapterSummary.IssueCount = result.Issues.Count - issueStartIndex;
result.Chapters.Add(chapterSummary);
}
private static Dictionary<string, StringTableEntry> BuildBaseEntries(Project project, CompilationResult compilationResult)
{
var entries = new Dictionary<string, StringTableEntry>(StringComparer.Ordinal);
foreach (var pair in compilationResult.StringTable.Where(entry => entry.Value.text != null))
{
var entry = new StringTableEntry
{
ID = pair.Key,
Language = project.BaseLanguage ?? "zh-Hans",
Text = pair.Value.text,
File = pair.Value.fileName,
Node = pair.Value.nodeName,
LineNumber = pair.Value.lineNumber.ToString(),
Lock = YarnImporter.GetHashString(pair.Value.text!, 8),
};
entries[pair.Key] = entry;
}
return entries;
}
private static YarnL10nIssue CreateIssue(
YarnL10nIssueType type,
string chapterFolder = "",
string language = "",
string lineId = "",
string sourceText = "",
string locText = "",
string yarnFile = "",
string yarnLineNumber = "",
string csvFile = "",
string message = "")
{
return new YarnL10nIssue
{
Type = type,
ChapterFolder = chapterFolder ?? string.Empty,
Language = language ?? string.Empty,
LineId = lineId ?? string.Empty,
SourceText = sourceText ?? string.Empty,
LocText = locText ?? string.Empty,
YarnFile = yarnFile ?? string.Empty,
YarnLineNumber = yarnLineNumber ?? string.Empty,
CsvFile = csvFile ?? string.Empty,
Message = message ?? string.Empty,
};
}
private static void AddIssue(YarnL10nValidationResult result, YarnL10nIssue issue)
{
result.Issues.Add(issue);
}
private static void FinalizeResult(YarnL10nValidationResult result)
{
result.TotalIssueCount = result.Issues.Count;
}
public static string NormalizeProjectRelativePath(string path)
{
if (string.IsNullOrWhiteSpace(path))
{
return string.Empty;
}
var normalized = path.Replace('\\', '/').Trim();
var projectRoot = GetProjectRootPath().Replace('\\', '/');
if (Path.IsPathRooted(normalized))
{
var absolute = Path.GetFullPath(normalized).Replace('\\', '/');
if (absolute.StartsWith(projectRoot, StringComparison.OrdinalIgnoreCase))
{
normalized = absolute.Substring(projectRoot.Length).TrimStart('/');
}
else
{
normalized = absolute;
}
}
return normalized.TrimEnd('/');
}
public static string ToAbsolutePath(string projectRelativeOrAbsolutePath)
{
if (string.IsNullOrWhiteSpace(projectRelativeOrAbsolutePath))
{
return GetProjectRootPath();
}
if (Path.IsPathRooted(projectRelativeOrAbsolutePath))
{
return Path.GetFullPath(projectRelativeOrAbsolutePath);
}
return Path.GetFullPath(Path.Combine(GetProjectRootPath(), projectRelativeOrAbsolutePath));
}
private static string GetProjectRootPath()
{
return Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath, ".."));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ea78060f3c3448d4aa9b19b8b55fb843
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,116 @@
using System;
using System.IO;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
namespace AibisDream.YarnLocalizationValidation.Editor
{
public static class YarnL10nValidatorCli
{
private const string JsonBeginMarker = "YARN_L10N_JSON_BEGIN";
private const string JsonEndMarker = "YARN_L10N_JSON_END";
public static void Run()
{
try
{
var args = Environment.GetCommandLineArgs();
var path = GetArgumentValue(args, "-yarnL10nPath");
var scanAll = HasFlag(args, "-yarnL10nScanAll");
var exportPath = GetArgumentValue(args, "-yarnL10nExport");
var outputPath = GetArgumentValue(args, "-yarnL10nOutput");
if (string.IsNullOrWhiteSpace(outputPath))
{
outputPath = "Build/yarn_l10n_result.json";
}
if (string.IsNullOrWhiteSpace(path))
{
WriteFailure("缺少参数 -yarnL10nPath。", 2);
return;
}
var validationResult = scanAll
? YarnL10nValidator.ValidateScanAll(path)
: YarnL10nValidator.ValidateFolder(path);
var json = JsonConvert.SerializeObject(validationResult, Formatting.Indented);
WriteJson(json, outputPath);
if (!string.IsNullOrWhiteSpace(exportPath))
{
var absoluteExportPath = YarnL10nValidator.ToAbsolutePath(exportPath);
YarnL10nReportExporter.ExportToCsv(validationResult, absoluteExportPath);
}
var exitCode = validationResult.HasBlockingIssues ? 1 : 0;
Debug.Log($"[YarnL10nValidatorCli] Completed with exit code {exitCode}, issues={validationResult.TotalIssueCount}");
EditorApplication.Exit(exitCode);
}
catch (Exception ex)
{
WriteFailure(ex.ToString(), 3);
}
}
private static void WriteJson(string json, string outputPath)
{
Console.WriteLine(JsonBeginMarker);
Console.WriteLine(json);
Console.WriteLine(JsonEndMarker);
if (!string.IsNullOrWhiteSpace(outputPath))
{
var absoluteOutputPath = YarnL10nValidator.ToAbsolutePath(outputPath);
var directory = Path.GetDirectoryName(absoluteOutputPath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(absoluteOutputPath, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
Debug.Log($"[YarnL10nValidatorCli] Wrote JSON to {absoluteOutputPath}");
}
}
private static void WriteFailure(string message, int exitCode)
{
Debug.LogError($"[YarnL10nValidatorCli] {message}");
Console.Error.WriteLine(message);
EditorApplication.Exit(exitCode);
}
private static bool HasFlag(string[] args, string flag)
{
foreach (var arg in args)
{
if (string.Equals(arg, flag, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string GetArgumentValue(string[] args, string key)
{
for (var i = 0; i < args.Length; i++)
{
var arg = args[i];
if (arg.StartsWith(key + "=", StringComparison.OrdinalIgnoreCase))
{
return arg.Substring(key.Length + 1).Trim('"');
}
if (string.Equals(arg, key, StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length)
{
return args[i + 1].Trim('"');
}
}
return string.Empty;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 23836ef03028055439615fda97642c4e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+94
View File
@@ -0,0 +1,94 @@
# Yarn 本地化校验工具
校验 Yarn 章节文件夹内 **原文(编译器 string table** 与各语言翻译 CSV 的 line id 是否一致。
## 背景
典型 Yarn 章节文件夹结构:
```
{章节名}/
├── {章节名}.yarnproject
├── {脚本}.yarn ← 中文原文 + #line:xxxxxxx
├── {章节名}-en.csv ← 英语
└── {章节名}-ja.csv ← 日语(注意:实际文件名为 -ja,不是 -jp)
```
编剧修改 `.yarn` 后,翻译 CSV 容易不同步。本工具报告:
- **缺少本地化-{语言}**:原文 id 存在,CSV 中没有
- **找不到原文-{语言}**:CSV 中有 id,原文已不存在
- **原文已变更-{语言}**(可选):Lock 不一致,翻译可能过期
原文 id 来自 **Yarn Spinner 编译器**(与 Inspector「Export Strings and Metadata as CSV」同源),不是手工正则扫 `.yarn`
参考样例:`Assets/Yarn/FP/FP_Day1_mid`
## Unity Editor 窗口
菜单:**AIBIS → Yarn 本地化校验**
1. 选择章节文件夹(如 `Assets/Yarn/FP/FP_Day1_mid`
2. 点击 **校验**
3. 可选 **导出 CSV** 报告
4. 勾选 **扫描子目录** 可批量校验 `Assets/Yarn/` 下所有 `.yarnproject`
## 命令行 / CI
Python 脚本 **不独立实现校验**,通过 Unity BatchMode 调用同一套 Editor 逻辑。
```bash
# 校验单个章节
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid
# 扫描 Assets/Yarn 下所有章节
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all
# 导出 CSV
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid --export report.csv
# CI:存在 blocking issue 时非零退出
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all --fail-on-issues
```
**环境要求**
- Unity 2022.3.7f1c1(或设置环境变量 `UNITY_EDITOR_PATH` 指向 `Unity.exe`
- 首次 BatchMode 可能较慢(导入/编译)
- JSON 结果写入 `Build/yarn_l10n_result.json`,日志在 `Build/yarn_l10n_validate.log`
**直接调用 Unity(可选)**
```bash
"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe" ^
-batchmode -nographics -quit ^
-projectPath "D:\UnityProject\aibis-dream" ^
-executeMethod AibisDream.YarnLocalizationValidation.Editor.YarnL10nValidatorCli.Run ^
-yarnL10nPath "Assets/Yarn/FP/FP_Day1_mid" ^
-yarnL10nOutput "Build/yarn_l10n_result.json" ^
-logFile "Build/yarn_l10n_validate.log"
```
扫描全部章节时追加 `-yarnL10nScanAll`
## 实现位置
```
Assets/Editor/YarnLocalizationValidation/
├── YarnL10nIssue.cs
├── YarnL10nValidator.cs
├── YarnL10nValidatorCli.cs
├── YarnL10nValidationWindow.cs
└── YarnL10nReportExporter.cs
Tools/validate_yarn_l10n.py
```
## 与 Yarn Inspector 内置功能的区别
| 能力 | Inspector「Update Existing Strings Files」 | 本工具 |
|------|---------------------------------------------|--------|
| 检测缺少/多余 id | 有 | 有 |
| 检测 Lock 变更 | 有(并写回 CSV) | 有(只读报告) |
| 写回 CSV | **会** | **不会** |
| 批量扫描 / 统一报告 | 无 | 有 |
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Yarn 本地化校验 CLI — 通过 Unity BatchMode 调用 YarnL10nValidatorCli。
用法:
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all
python Tools/validate_yarn_l10n.py Assets/Yarn/FP/FP_Day1_mid --export report.csv
python Tools/validate_yarn_l10n.py Assets/Yarn --scan-all --fail-on-issues
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
JSON_BEGIN = "YARN_L10N_JSON_BEGIN"
JSON_END = "YARN_L10N_JSON_END"
DEFAULT_UNITY_EDITOR = r"C:\Program Files\Unity\Hub\Editor\2022.3.7f1c1\Editor\Unity.exe"
def find_project_root() -> Path:
current = Path(__file__).resolve()
for parent in current.parents:
if (parent / "Assets").is_dir() and (parent / "ProjectSettings").is_dir():
return parent
raise RuntimeError("无法定位 Unity 项目根目录。")
def resolve_unity_editor() -> Path:
env_path = os.environ.get("UNITY_EDITOR_PATH")
if env_path:
candidate = Path(env_path)
if candidate.exists():
return candidate
if Path(DEFAULT_UNITY_EDITOR).exists():
return Path(DEFAULT_UNITY_EDITOR)
raise RuntimeError(
"未找到 Unity Editor。请设置环境变量 UNITY_EDITOR_PATH"
f"或安装默认路径下的 Unity: {DEFAULT_UNITY_EDITOR}"
)
def extract_json_from_output(text: str) -> dict:
if JSON_BEGIN in text and JSON_END in text:
payload = text.split(JSON_BEGIN, 1)[1].split(JSON_END, 1)[0].strip()
return json.loads(payload)
raise RuntimeError("Unity 输出中未找到 YARN_L10N_JSON 标记。")
def load_result_json(project_root: Path, log_file: Path) -> dict:
candidates = [
project_root / "Build" / "yarn_l10n_result.json",
project_root / "Temp" / "yarn_l10n_result.json",
]
for candidate in candidates:
if candidate.exists():
return json.loads(candidate.read_text(encoding="utf-8"))
if log_file.exists():
return extract_json_from_output(log_file.read_text(encoding="utf-8", errors="replace"))
raise RuntimeError("未找到校验结果 JSON,且无法从 Unity 日志解析。")
def print_summary(result: dict) -> None:
print(f"Issue 总数: {result.get('TotalIssueCount', 0)}")
chapters = result.get("Chapters") or []
print(f"章节数: {len(chapters)}")
for chapter in chapters:
folder = chapter.get("ChapterFolder", "")
source_count = chapter.get("SourceLineCount", 0)
locales = chapter.get("Locales") or {}
locale_parts = []
for language, summary in locales.items():
locale_parts.append(
f"{language}: 缺 {summary.get('MissingCount', 0)} / "
f"多余 {summary.get('OrphanCount', 0)} / "
f"变更 {summary.get('SourceChangedCount', 0)}"
)
locale_text = " | ".join(locale_parts) if locale_parts else "无翻译语言"
print(f"- {folder} — 原文 {source_count} 行 | {locale_text}")
issues = result.get("Issues") or []
if not issues:
print("未发现 issue。")
return
print("\nIssues:")
for issue in issues[:50]:
issue_type = issue.get("IssueTypeLabel") or issue.get("Type", "")
language = issue.get("Language", "")
line_id = issue.get("LineId", "")
chapter = issue.get("ChapterFolder", "")
print(f" [{issue_type}] {language} {line_id} @ {chapter}")
if len(issues) > 50:
print(f" ... 另有 {len(issues) - 50} 条 issue 未显示")
def build_unity_command(
project_root: Path,
unity_editor: Path,
yarn_path: str,
scan_all: bool,
export_path: str | None,
output_json_path: Path,
log_file: Path,
) -> list[str]:
command = [
str(unity_editor),
"-batchmode",
"-nographics",
"-projectPath",
str(project_root),
"-executeMethod",
"AibisDream.YarnLocalizationValidation.Editor.YarnL10nValidatorCli.Run",
"-yarnL10nPath",
yarn_path.replace("\\", "/"),
f"-yarnL10nOutput={output_json_path.as_posix()}",
"-logFile",
str(log_file),
]
if scan_all:
command.append("-yarnL10nScanAll")
if export_path:
command.append(f"-yarnL10nExport={export_path.replace(chr(92), '/')}")
return command
def main() -> int:
parser = argparse.ArgumentParser(description="Yarn 本地化校验(Unity BatchMode")
parser.add_argument("path", help="章节文件夹或扫描根目录(相对 Assets/ 或绝对路径)")
parser.add_argument("--scan-all", action="store_true", help="递归扫描子目录下所有 .yarnproject")
parser.add_argument("--export", dest="export_path", help="导出 CSV 报告路径")
parser.add_argument(
"--fail-on-issues",
action="store_true",
help="存在 blocking issue 时返回非零退出码",
)
parser.add_argument(
"--unity",
dest="unity_editor",
help="Unity Editor 可执行文件路径(默认读取 UNITY_EDITOR_PATH",
)
args = parser.parse_args()
project_root = find_project_root()
unity_editor = Path(args.unity_editor) if args.unity_editor else resolve_unity_editor()
temp_dir = project_root / "Build"
temp_dir.mkdir(parents=True, exist_ok=True)
output_json_path = temp_dir / "yarn_l10n_result.json"
log_file = project_root / "Build" / "yarn_l10n_validate.log"
log_file.parent.mkdir(parents=True, exist_ok=True)
if output_json_path.exists():
output_json_path.unlink()
command = build_unity_command(
project_root=project_root,
unity_editor=unity_editor,
yarn_path=args.path,
scan_all=args.scan_all,
export_path=args.export_path,
output_json_path=output_json_path,
log_file=log_file,
)
print("Running Unity BatchMode validation...")
completed = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
combined_output = "\n".join(part for part in [completed.stdout, completed.stderr] if part)
result = load_result_json(project_root, log_file)
print_summary(result)
if args.export_path and not Path(args.export_path).is_absolute():
export_absolute = project_root / args.export_path
if export_absolute.exists():
print(f"CSV 已导出: {export_absolute}")
exit_code = completed.returncode
if args.fail_on_issues and result.get("HasBlockingIssues"):
exit_code = max(exit_code, 1)
if completed.returncode != 0 and exit_code == 0:
exit_code = completed.returncode
if exit_code != 0:
print(f"Unity 退出码: {completed.returncode}")
print(f"日志: {log_file}")
return exit_code
if __name__ == "__main__":
sys.exit(main())