diff --git a/.gitattributes b/.gitattributes
index 279b318..f7321f3 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -30,5 +30,11 @@
*.sh text eol=lf
*.ps1 text eol=lf
+# Preserve the original bytes of encoding detection fixtures
+Assets/TestData/CSV4Unity/EncodingDetection/EncodingShiftJis.csv binary
+Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf16*.csv binary
+Assets/TestData/CSV4Unity/EncodingDetection/EncodingUtf32*.csv binary
+Assets/TestData/CSV4Unity/EncodingDetection/EncodingInvalid.csv binary
+
# Exclude TextMesh Pro files from language statistics
"Assets/TextMesh Pro/**" linguist-generated=true linguist-vendored=true
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index f1485ce..4740dd1 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -1,6 +1,12 @@
name: Docs
on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "Assets/Plugins/CSVLoader/Runtime/**/*.cs"
+ - "docs/**"
+ - ".github/workflows/docs.yml"
push:
branches: [main, develop]
paths:
@@ -11,8 +17,6 @@ on:
permissions:
contents: read
- pages: write
- id-token: write
concurrency:
group: docs-${{ github.ref }}
@@ -54,8 +58,11 @@ jobs:
deploy:
name: Deploy to GitHub Pages
needs: build
- if: github.ref == 'refs/heads/main'
+ if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
diff --git a/.github/workflows/unity-tests.yml b/.github/workflows/unity-tests.yml
index 067ca97..c704401 100644
--- a/.github/workflows/unity-tests.yml
+++ b/.github/workflows/unity-tests.yml
@@ -1,6 +1,13 @@
name: Unity Tests
on:
+ pull_request:
+ branches: [main]
+ paths:
+ - "Assets/**"
+ - "Packages/**"
+ - "ProjectSettings/**"
+ - ".github/workflows/unity-tests.yml"
push:
branches: [main]
paths:
@@ -33,11 +40,23 @@ jobs:
restore-keys: |
Library-${{ runner.os }}-
+ - name: Verify Unity license configuration
+ shell: bash
+ env:
+ UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
+ UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
+ run: |
+ if [[ -z "$UNITY_LICENSE" && -z "$UNITY_SERIAL" ]]; then
+ echo "::error::Configure either the UNITY_LICENSE or UNITY_SERIAL repository secret before running Unity tests."
+ exit 1
+ fi
+
- name: Run EditMode tests
id: tests
uses: game-ci/unity-test-runner@v4
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
+ UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }}
UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
with:
diff --git a/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs
new file mode 100644
index 0000000..a89f73b
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs
@@ -0,0 +1,3 @@
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("CSV4Unity.Tests.EditMode")]
diff --git a/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta
new file mode 100644
index 0000000..68d2ad7
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/AssemblyInfo.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 27e8d8a387a3487894f81f54dbc64647
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs
new file mode 100644
index 0000000..cd46a8f
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs
@@ -0,0 +1,19 @@
+#if UNITY_EDITOR
+using System;
+using System.IO;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// CSV用Editor拡張が対象にするアセットを判定します。
+ ///
+ internal static class CsvEditorAssetUtility
+ {
+ internal static bool IsCsvPath(string assetPath)
+ {
+ return !string.IsNullOrEmpty(assetPath) &&
+ string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta
new file mode 100644
index 0000000..02818b4
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEditorAssetUtility.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e0d42bc19a8148b2b6c574e2b7323ef1
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs
new file mode 100644
index 0000000..f725ad7
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs
@@ -0,0 +1,63 @@
+#if UNITY_EDITOR
+using System;
+using System.IO;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// 文字コード変換前のCSVをLibrary以下へ退避し、必要に応じて復元します。
+ ///
+ internal static class CsvEncodingBackupUtility
+ {
+ public static bool CreateIfMissing(string backupPath, byte[] source)
+ {
+ if (string.IsNullOrEmpty(backupPath)) throw new ArgumentException("Backup path is required.", nameof(backupPath));
+ if (source == null) throw new ArgumentNullException(nameof(source));
+ if (File.Exists(backupPath)) return false;
+
+ string directory = Path.GetDirectoryName(backupPath);
+ if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
+ WriteAtomically(backupPath, source);
+ return true;
+ }
+
+ public static void Restore(string backupPath, string targetPath)
+ {
+ if (string.IsNullOrEmpty(backupPath)) throw new ArgumentException("Backup path is required.", nameof(backupPath));
+ if (string.IsNullOrEmpty(targetPath)) throw new ArgumentException("Target path is required.", nameof(targetPath));
+ if (!File.Exists(backupPath)) throw new FileNotFoundException("CSV encoding backup was not found.", backupPath);
+
+ byte[] original = File.ReadAllBytes(backupPath);
+ WriteAtomically(targetPath, original);
+ File.Delete(backupPath);
+ }
+
+ public static void WriteAtomically(string targetPath, byte[] bytes)
+ {
+ if (string.IsNullOrEmpty(targetPath)) throw new ArgumentException("Target path is required.", nameof(targetPath));
+ if (bytes == null) throw new ArgumentNullException(nameof(bytes));
+
+ string directory = Path.GetDirectoryName(targetPath);
+ if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
+
+ string temporaryPath = targetPath + ".csv4unity-" + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllBytes(temporaryPath, bytes);
+ if (File.Exists(targetPath))
+ {
+ File.Replace(temporaryPath, targetPath, null);
+ }
+ else
+ {
+ File.Move(temporaryPath, targetPath);
+ }
+ }
+ finally
+ {
+ if (File.Exists(temporaryPath)) File.Delete(temporaryPath);
+ }
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta
new file mode 100644
index 0000000..15dad54
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingBackupUtility.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 58d6e38a87e14df9bf8dcebed8275fe1
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs
new file mode 100644
index 0000000..bc5b670
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs
@@ -0,0 +1,261 @@
+#if UNITY_EDITOR
+using System;
+using System.Text;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// CSVファイルの変換元文字コードを表します。
+ ///
+ internal enum CsvSourceEncoding
+ {
+ Auto,
+ Utf8,
+ ShiftJis,
+ Utf16LittleEndian,
+ Utf16BigEndian,
+ Utf32LittleEndian,
+ Utf32BigEndian
+ }
+
+ ///
+ /// CSVファイルの文字コード検査結果を保持します。
+ ///
+ internal readonly struct CsvEncodingInspection
+ {
+ public CsvEncodingInspection(
+ CsvSourceEncoding encoding,
+ bool hasBom,
+ bool isValid,
+ string text,
+ string errorMessage)
+ {
+ Encoding = encoding;
+ HasBom = hasBom;
+ IsValid = isValid;
+ Text = text;
+ ErrorMessage = errorMessage;
+ }
+
+ public CsvSourceEncoding Encoding { get; }
+ public bool HasBom { get; }
+ public bool IsValid { get; }
+ public string Text { get; }
+ public string ErrorMessage { get; }
+
+ public bool RequiresConversion => IsValid && Encoding != CsvSourceEncoding.Utf8;
+
+ public string DisplayName
+ {
+ get
+ {
+ switch (Encoding)
+ {
+ case CsvSourceEncoding.Utf8:
+ return HasBom ? "UTF-8 (BOM)" : "UTF-8";
+ case CsvSourceEncoding.ShiftJis:
+ return "Shift_JIS (CP932)";
+ case CsvSourceEncoding.Utf16LittleEndian:
+ return "UTF-16 LE";
+ case CsvSourceEncoding.Utf16BigEndian:
+ return "UTF-16 BE";
+ case CsvSourceEncoding.Utf32LittleEndian:
+ return "UTF-32 LE";
+ case CsvSourceEncoding.Utf32BigEndian:
+ return "UTF-32 BE";
+ default:
+ return "Unknown";
+ }
+ }
+ }
+ }
+
+ ///
+ /// CSVの元バイト列を検査し、UTF-8へ変換します。
+ ///
+ internal static class CsvEncodingUtility
+ {
+ private static readonly UTF8Encoding Utf8Strict = new UTF8Encoding(false, true);
+ private static readonly UnicodeEncoding Utf16LittleEndianStrict =
+ new UnicodeEncoding(false, true, true);
+ private static readonly UnicodeEncoding Utf16BigEndianStrict =
+ new UnicodeEncoding(true, true, true);
+ private static readonly UTF32Encoding Utf32LittleEndianStrict =
+ new UTF32Encoding(false, true, true);
+ private static readonly UTF32Encoding Utf32BigEndianStrict =
+ new UTF32Encoding(true, true, true);
+
+ public static CsvEncodingInspection Inspect(byte[] bytes)
+ {
+ if (bytes == null) throw new ArgumentNullException(nameof(bytes));
+
+ if (HasPrefix(bytes, 0x00, 0x00, 0xFE, 0xFF))
+ {
+ return Decode(bytes, CsvSourceEncoding.Utf32BigEndian, 4, true);
+ }
+
+ if (HasPrefix(bytes, 0xFF, 0xFE, 0x00, 0x00))
+ {
+ return Decode(bytes, CsvSourceEncoding.Utf32LittleEndian, 4, true);
+ }
+
+ if (HasPrefix(bytes, 0xEF, 0xBB, 0xBF))
+ {
+ return Decode(bytes, CsvSourceEncoding.Utf8, 3, true);
+ }
+
+ if (HasPrefix(bytes, 0xFE, 0xFF))
+ {
+ return Decode(bytes, CsvSourceEncoding.Utf16BigEndian, 2, true);
+ }
+
+ if (HasPrefix(bytes, 0xFF, 0xFE))
+ {
+ return Decode(bytes, CsvSourceEncoding.Utf16LittleEndian, 2, true);
+ }
+
+ CsvEncodingInspection utf8 = Decode(bytes, CsvSourceEncoding.Utf8, 0, false);
+ if (utf8.IsValid) return utf8;
+
+ CsvEncodingInspection shiftJis = Decode(bytes, CsvSourceEncoding.ShiftJis, 0, false);
+ if (shiftJis.IsValid) return shiftJis;
+
+ return Invalid(CsvSourceEncoding.Auto, "UTF-8またはShift_JISとして解釈できません。");
+ }
+
+ public static CsvEncodingInspection Decode(byte[] bytes, CsvSourceEncoding encoding)
+ {
+ if (bytes == null) throw new ArgumentNullException(nameof(bytes));
+ if (encoding == CsvSourceEncoding.Auto) return Inspect(bytes);
+
+ int preambleLength = GetMatchingPreambleLength(bytes, encoding);
+ return Decode(bytes, encoding, preambleLength, preambleLength > 0);
+ }
+
+ public static byte[] ConvertToUtf8(byte[] bytes, CsvSourceEncoding sourceEncoding)
+ {
+ CsvEncodingInspection inspection = Decode(bytes, sourceEncoding);
+ if (!inspection.IsValid)
+ {
+ throw new InvalidOperationException(inspection.ErrorMessage);
+ }
+
+ return new UTF8Encoding(false).GetBytes(inspection.Text);
+ }
+
+ private static CsvEncodingInspection Decode(
+ byte[] bytes,
+ CsvSourceEncoding encoding,
+ int offset,
+ bool hasBom)
+ {
+ // Unity/MonoのCP932デコーダーは、不完全な先行バイトを例外にしない場合がある。
+ if (encoding == CsvSourceEncoding.ShiftJis && !IsValidShiftJis(bytes, offset))
+ {
+ return Invalid(encoding, "Shift_JISのバイト列が不正です。");
+ }
+
+ try
+ {
+ Encoding decoder = GetEncoding(encoding);
+ string text = decoder.GetString(bytes, offset, bytes.Length - offset);
+ return new CsvEncodingInspection(encoding, hasBom, true, text, string.Empty);
+ }
+ catch (Exception exception) when (
+ exception is DecoderFallbackException ||
+ exception is ArgumentException ||
+ exception is NotSupportedException)
+ {
+ return Invalid(encoding, exception.Message);
+ }
+ }
+
+ private static bool IsValidShiftJis(byte[] bytes, int offset)
+ {
+ for (int i = offset; i < bytes.Length; i++)
+ {
+ byte current = bytes[i];
+ bool isSingleByte = current <= 0x80 ||
+ current == 0xA0 ||
+ current >= 0xA1 && current <= 0xDF ||
+ current >= 0xFD;
+ if (isSingleByte)
+ {
+ continue;
+ }
+
+ bool isLeadByte = current >= 0x81 && current <= 0x9F ||
+ current >= 0xE0 && current <= 0xFC;
+ if (!isLeadByte || i + 1 >= bytes.Length) return false;
+
+ byte trail = bytes[++i];
+ bool isTrailByte = trail >= 0x40 && trail <= 0x7E ||
+ trail >= 0x80 && trail <= 0xFC;
+ if (!isTrailByte) return false;
+ }
+
+ return true;
+ }
+
+ private static Encoding GetEncoding(CsvSourceEncoding encoding)
+ {
+ switch (encoding)
+ {
+ case CsvSourceEncoding.Utf8:
+ return Utf8Strict;
+ case CsvSourceEncoding.ShiftJis:
+ return Encoding.GetEncoding(
+ 932,
+ EncoderFallback.ExceptionFallback,
+ DecoderFallback.ExceptionFallback);
+ case CsvSourceEncoding.Utf16LittleEndian:
+ return Utf16LittleEndianStrict;
+ case CsvSourceEncoding.Utf16BigEndian:
+ return Utf16BigEndianStrict;
+ case CsvSourceEncoding.Utf32LittleEndian:
+ return Utf32LittleEndianStrict;
+ case CsvSourceEncoding.Utf32BigEndian:
+ return Utf32BigEndianStrict;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(encoding), encoding, null);
+ }
+ }
+
+ private static int GetMatchingPreambleLength(byte[] bytes, CsvSourceEncoding encoding)
+ {
+ switch (encoding)
+ {
+ case CsvSourceEncoding.Utf8:
+ return HasPrefix(bytes, 0xEF, 0xBB, 0xBF) ? 3 : 0;
+ case CsvSourceEncoding.Utf16LittleEndian:
+ return HasPrefix(bytes, 0xFF, 0xFE) ? 2 : 0;
+ case CsvSourceEncoding.Utf16BigEndian:
+ return HasPrefix(bytes, 0xFE, 0xFF) ? 2 : 0;
+ case CsvSourceEncoding.Utf32LittleEndian:
+ return HasPrefix(bytes, 0xFF, 0xFE, 0x00, 0x00) ? 4 : 0;
+ case CsvSourceEncoding.Utf32BigEndian:
+ return HasPrefix(bytes, 0x00, 0x00, 0xFE, 0xFF) ? 4 : 0;
+ default:
+ return 0;
+ }
+ }
+
+ private static bool HasPrefix(byte[] bytes, params byte[] prefix)
+ {
+ if (bytes.Length < prefix.Length) return false;
+
+ for (int i = 0; i < prefix.Length; i++)
+ {
+ if (bytes[i] != prefix[i]) return false;
+ }
+
+ return true;
+ }
+
+ private static CsvEncodingInspection Invalid(CsvSourceEncoding encoding, string message)
+ {
+ return new CsvEncodingInspection(encoding, false, false, null, message);
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta
new file mode 100644
index 0000000..25d09be
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvEncodingUtility.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 38d547a81d6d4c9385ec13f73d7d5d2f
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs
index 8a663c1..8e48c83 100644
--- a/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs
+++ b/Assets/Plugins/CSVLoader/Editor/CsvInspectorEditor.cs
@@ -1,6 +1,7 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -11,12 +12,32 @@
namespace CSV4Unity.Editor
{
///
- /// CSVのTextAssetに、Enumスキーマを使った検証UIを追加します。
+ /// CSVのTextAssetに、文字コード変換、Viewer、ValidationのUIを追加します。
///
[CustomEditor(typeof(TextAsset))]
public sealed class CsvInspectorEditor : UnityEditor.Editor
{
- private const string FieldsNamespace = "CSV4Unity.Fields";
+ private const string BuiltInTextAssetInspectorName = "UnityEditor.TextAssetInspector";
+ private static readonly CsvSourceEncoding[] SourceEncodingValues =
+ {
+ CsvSourceEncoding.Auto,
+ CsvSourceEncoding.Utf8,
+ CsvSourceEncoding.ShiftJis,
+ CsvSourceEncoding.Utf16LittleEndian,
+ CsvSourceEncoding.Utf16BigEndian,
+ CsvSourceEncoding.Utf32LittleEndian,
+ CsvSourceEncoding.Utf32BigEndian
+ };
+ private static readonly string[] SourceEncodingLabels =
+ {
+ "Auto Detect",
+ "UTF-8",
+ "Shift_JIS (CP932)",
+ "UTF-16 LE",
+ "UTF-16 BE",
+ "UTF-32 LE",
+ "UTF-32 BE"
+ };
private static readonly MethodInfo ValidateDocumentMethod = typeof(CsvInspectorEditor)
.GetMethod(nameof(ValidateDocument), BindingFlags.NonPublic | BindingFlags.Static);
@@ -27,22 +48,57 @@ public sealed class CsvInspectorEditor : UnityEditor.Editor
private CsvValidationResult _validationResult;
private Vector2 _scrollPosition;
private bool _showValidationResults;
+ private bool _showEncodingPreview;
+ private bool _overrideEncodingDetection;
private bool _isCsv;
+ private string _assetPath;
+ private CsvSourceEncoding _sourceEncoding;
+ private CsvEncodingInspection _automaticEncodingInspection;
+ private CsvEncodingInspection _encodingInspection;
+ private UnityEditor.Editor _builtInInspector;
private void OnEnable()
{
+ // CustomEditorは拡張子で対象を絞れないため、基礎表示はUnity標準Inspectorへ委譲する。
+ Type builtInInspectorType = FindBuiltInTextAssetInspectorType();
+ if (builtInInspectorType != null)
+ {
+ _builtInInspector = CreateEditor(targets, builtInInspectorType);
+ }
+
_csvFile = target as TextAsset;
- string assetPath = AssetDatabase.GetAssetPath(_csvFile);
- _isCsv = string.Equals(Path.GetExtension(assetPath), ".csv", StringComparison.OrdinalIgnoreCase);
+ _assetPath = AssetDatabase.GetAssetPath(_csvFile);
+ _isCsv = CsvEditorAssetUtility.IsCsvPath(_assetPath);
if (!_isCsv) return;
+ RefreshEncodingInspection();
RefreshEnums();
- RestoreSelection(assetPath);
+ RestoreSelection(_assetPath);
+ }
+
+ private void OnDisable()
+ {
+ if (_builtInInspector == null) return;
+ DestroyImmediate(_builtInInspector);
+ _builtInInspector = null;
}
public override void OnInspectorGUI()
{
- DrawDefaultInspector();
+ if (_isCsv)
+ {
+ // CSV本文は専用Viewerで表示するため、標準TextAssetの長いテキストプレビューは描画しない。
+ DrawDefaultInspector();
+ }
+ else if (_builtInInspector != null)
+ {
+ _builtInInspector.OnInspectorGUI();
+ }
+ else
+ {
+ DrawDefaultInspector();
+ }
+
if (!_isCsv || _csvFile == null) return;
// TextAssetの標準Inspectorは読み取り専用なので、追加UIだけ操作可能にする。
@@ -50,7 +106,7 @@ public override void OnInspectorGUI()
GUI.enabled = true;
try
{
- DrawCsvValidationControls();
+ DrawCsvControls();
}
finally
{
@@ -58,8 +114,42 @@ public override void OnInspectorGUI()
}
}
- private void DrawCsvValidationControls()
+ internal static Type FindBuiltInTextAssetInspectorType()
+ {
+ Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
+ for (int i = 0; i < assemblies.Length; i++)
+ {
+ Type type = assemblies[i].GetType(BuiltInTextAssetInspectorName, false);
+ if (type != null && type != typeof(CsvInspectorEditor)) return type;
+ }
+
+ return null;
+ }
+
+ private void DrawCsvControls()
{
+ EditorGUILayout.Space(10);
+ DrawSeparator();
+ EditorGUILayout.Space(5);
+
+ DrawEncodingControls();
+ if (!IsUtf8Ready())
+ {
+ EditorGUILayout.HelpBox(
+ "CSV ViewerとValidationを使用する前に、CSVをUTF-8へ変換してください。",
+ MessageType.Warning);
+ return;
+ }
+
+ EditorGUILayout.Space(10);
+ DrawSeparator();
+ EditorGUILayout.Space(5);
+ EditorGUILayout.LabelField("CSV Viewer", EditorStyles.boldLabel);
+ if (GUILayout.Button("Open CSV Viewer", GUILayout.Height(26)))
+ {
+ CsvViewerWindow.Open(_csvFile);
+ }
+
EditorGUILayout.Space(10);
DrawSeparator();
EditorGUILayout.Space(5);
@@ -68,7 +158,7 @@ private void DrawCsvValidationControls()
if (_availableEnums.Count == 0)
{
EditorGUILayout.HelpBox(
- $"{FieldsNamespace} 名前空間にEnumが見つかりません。",
+ "CSVスキーマが見つかりません。Enumに[CsvSchema]を付けてください。",
MessageType.Info);
if (GUILayout.Button("Refresh Enums")) RefreshEnums();
@@ -96,6 +186,263 @@ private void DrawCsvValidationControls()
}
}
+ private void DrawEncodingControls()
+ {
+ EditorGUILayout.LabelField("CSV Encoding", EditorStyles.boldLabel);
+
+ if (!_automaticEncodingInspection.IsValid)
+ {
+ EditorGUILayout.HelpBox(
+ $"Encoding could not be detected. {_automaticEncodingInspection.ErrorMessage}",
+ MessageType.Error);
+ }
+ else if (_automaticEncodingInspection.RequiresConversion)
+ {
+ EditorGUILayout.HelpBox(
+ $"Detected {_automaticEncodingInspection.DisplayName}. Convert this file to UTF-8 before using it.",
+ MessageType.Warning);
+ }
+ else
+ {
+ EditorGUILayout.HelpBox(
+ $"Encoding: {_automaticEncodingInspection.DisplayName}",
+ MessageType.Info);
+ }
+
+ bool overrideDetection = EditorGUILayout.ToggleLeft(
+ "Override automatic detection",
+ _overrideEncodingDetection);
+ if (overrideDetection != _overrideEncodingDetection)
+ {
+ _overrideEncodingDetection = overrideDetection;
+ if (_overrideEncodingDetection)
+ {
+ InspectUsingSelectedEncoding();
+ _showEncodingPreview = true;
+ }
+ else
+ {
+ _encodingInspection = _automaticEncodingInspection;
+ }
+ }
+
+ if (_overrideEncodingDetection)
+ {
+ int selectedIndex = Array.IndexOf(SourceEncodingValues, _sourceEncoding);
+ int nextIndex = EditorGUILayout.Popup(
+ "Source Encoding",
+ Math.Max(selectedIndex, 0),
+ SourceEncodingLabels);
+ CsvSourceEncoding selectedEncoding = SourceEncodingValues[nextIndex];
+ if (selectedEncoding != _sourceEncoding)
+ {
+ _sourceEncoding = selectedEncoding;
+ InspectUsingSelectedEncoding();
+ _showEncodingPreview = true;
+ }
+
+ if (IsOverrideDifferentFromDetection())
+ {
+ EditorGUILayout.HelpBox(
+ $"Selected {_encodingInspection.DisplayName}, but automatic detection found " +
+ $"{_automaticEncodingInspection.DisplayName}. Check the preview carefully before converting.",
+ MessageType.Error);
+ }
+
+ if (!_encodingInspection.IsValid)
+ {
+ EditorGUILayout.HelpBox(_encodingInspection.ErrorMessage, MessageType.Error);
+ }
+ }
+
+ if (_encodingInspection.IsValid)
+ {
+ _showEncodingPreview = EditorGUILayout.Foldout(
+ _showEncodingPreview,
+ "Decoded Preview",
+ true);
+ if (_showEncodingPreview)
+ {
+ EditorGUILayout.SelectableLabel(
+ CreatePreview(_encodingInspection.Text),
+ EditorStyles.textArea,
+ GUILayout.MinHeight(80),
+ GUILayout.MaxHeight(160));
+ }
+ }
+
+ using (new EditorGUI.DisabledScope(!_encodingInspection.RequiresConversion))
+ {
+ if (GUILayout.Button(
+ $"Convert {_encodingInspection.DisplayName} to UTF-8",
+ GUILayout.Height(26)))
+ {
+ ConvertAssetToUtf8();
+ }
+ }
+
+ DrawEncodingBackupControls();
+ }
+
+ private void RefreshEncodingInspection()
+ {
+ try
+ {
+ byte[] bytes = File.ReadAllBytes(GetAbsoluteAssetPath());
+ _automaticEncodingInspection = CsvEncodingUtility.Inspect(bytes);
+ _encodingInspection = _automaticEncodingInspection;
+ _sourceEncoding = _automaticEncodingInspection.IsValid
+ ? _automaticEncodingInspection.Encoding
+ : CsvSourceEncoding.Auto;
+ _overrideEncodingDetection = !_automaticEncodingInspection.IsValid;
+ _showEncodingPreview = _encodingInspection.RequiresConversion;
+ }
+ catch (Exception exception)
+ {
+ _automaticEncodingInspection = new CsvEncodingInspection(
+ CsvSourceEncoding.Auto,
+ false,
+ false,
+ null,
+ exception.Message);
+ _encodingInspection = _automaticEncodingInspection;
+ _sourceEncoding = CsvSourceEncoding.Auto;
+ _overrideEncodingDetection = true;
+ }
+ }
+
+ private void InspectUsingSelectedEncoding()
+ {
+ try
+ {
+ byte[] bytes = File.ReadAllBytes(GetAbsoluteAssetPath());
+ _encodingInspection = CsvEncodingUtility.Decode(bytes, _sourceEncoding);
+ }
+ catch (Exception exception)
+ {
+ _encodingInspection = new CsvEncodingInspection(
+ _sourceEncoding,
+ false,
+ false,
+ null,
+ exception.Message);
+ }
+ }
+
+ private void ConvertAssetToUtf8()
+ {
+ if (!_encodingInspection.RequiresConversion) return;
+
+ bool confirmed = EditorUtility.DisplayDialog(
+ "Convert CSV to UTF-8",
+ $"{_csvFile.name}.csv を {_encodingInspection.DisplayName} からUTF-8へ変換します。\n" +
+ "ファイル内容が更新され、Gitの変更対象になります。" +
+ (IsOverrideDifferentFromDetection()
+ ? $"\n\n警告: 自動判定は {_automaticEncodingInspection.DisplayName} です。"
+ : string.Empty),
+ "Convert",
+ "Cancel");
+ if (!confirmed) return;
+
+ try
+ {
+ string absolutePath = GetAbsoluteAssetPath();
+ byte[] source = File.ReadAllBytes(absolutePath);
+ CsvSourceEncoding sourceEncoding = _overrideEncodingDetection
+ ? _sourceEncoding
+ : CsvSourceEncoding.Auto;
+ byte[] utf8 = CsvEncodingUtility.ConvertToUtf8(source, sourceEncoding);
+ CsvEncodingBackupUtility.CreateIfMissing(GetBackupPath(), source);
+ CsvEncodingBackupUtility.WriteAtomically(absolutePath, utf8);
+ AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceUpdate);
+ _csvFile = AssetDatabase.LoadAssetAtPath(_assetPath);
+ RefreshEncodingInspection();
+ _validationResult = null;
+ _showValidationResults = false;
+ Debug.Log($"CSV4Unity: Converted '{_assetPath}' to UTF-8.", _csvFile);
+ }
+ catch (Exception exception)
+ {
+ Debug.LogError($"CSV4Unity: Failed to convert '{_assetPath}' to UTF-8. {exception}", _csvFile);
+ EditorUtility.DisplayDialog("CSV Conversion Failed", exception.Message, "OK");
+ RefreshEncodingInspection();
+ }
+ }
+
+ private void DrawEncodingBackupControls()
+ {
+ if (!File.Exists(GetBackupPath())) return;
+
+ EditorGUILayout.HelpBox(
+ "The original bytes from before the first conversion are available as a backup.",
+ MessageType.Info);
+ if (GUILayout.Button("Restore Pre-conversion File")) RestoreEncodingBackup();
+ }
+
+ private void RestoreEncodingBackup()
+ {
+ bool confirmed = EditorUtility.DisplayDialog(
+ "Restore CSV Before Conversion",
+ $"{_csvFile.name}.csv を最初の文字コード変換前の状態へ戻します。",
+ "Restore",
+ "Cancel");
+ if (!confirmed) return;
+
+ try
+ {
+ CsvEncodingBackupUtility.Restore(GetBackupPath(), GetAbsoluteAssetPath());
+ AssetDatabase.ImportAsset(_assetPath, ImportAssetOptions.ForceUpdate);
+ _csvFile = AssetDatabase.LoadAssetAtPath(_assetPath);
+ RefreshEncodingInspection();
+ _validationResult = null;
+ _showValidationResults = false;
+ Debug.Log($"CSV4Unity: Restored the pre-conversion file for '{_assetPath}'.", _csvFile);
+ }
+ catch (Exception exception)
+ {
+ Debug.LogError($"CSV4Unity: Failed to restore '{_assetPath}'. {exception}", _csvFile);
+ EditorUtility.DisplayDialog("CSV Restore Failed", exception.Message, "OK");
+ }
+ }
+
+ private bool IsOverrideDifferentFromDetection()
+ {
+ return _overrideEncodingDetection &&
+ _automaticEncodingInspection.IsValid &&
+ _sourceEncoding != CsvSourceEncoding.Auto &&
+ _sourceEncoding != _automaticEncodingInspection.Encoding;
+ }
+
+ private bool IsUtf8Ready()
+ {
+ return _encodingInspection.IsValid &&
+ _encodingInspection.Encoding == CsvSourceEncoding.Utf8;
+ }
+
+ private string GetAbsoluteAssetPath()
+ {
+ return Path.GetFullPath(_assetPath);
+ }
+
+ private string GetBackupPath()
+ {
+ string projectRoot = Directory.GetParent(Application.dataPath)?.FullName;
+ string assetGuid = AssetDatabase.AssetPathToGUID(_assetPath);
+ return Path.Combine(
+ projectRoot ?? Path.GetFullPath("."),
+ "Library",
+ "CSV4Unity",
+ "EncodingBackups",
+ assetGuid + ".bytes");
+ }
+
+ private static string CreatePreview(string text)
+ {
+ const int maxLength = 2000;
+ if (string.IsNullOrEmpty(text) || text.Length <= maxLength) return text ?? string.Empty;
+ return text.Substring(0, maxLength) + "\n...";
+ }
+
private void DrawSchemaSelector()
{
string[] options = new string[_availableEnums.Count + 1];
@@ -108,45 +455,171 @@ private void DrawSchemaSelector()
int popupIndex = EditorGUILayout.Popup("Validation Schema", _selectedEnumIndex + 1, options);
int enumIndex = popupIndex - 1;
- if (enumIndex == _selectedEnumIndex) return;
+ if (enumIndex != _selectedEnumIndex)
+ {
+ _selectedEnumIndex = enumIndex;
+ _selectedEnumType = enumIndex >= 0 ? _availableEnums[enumIndex] : null;
+ _validationResult = null;
+ _showValidationResults = false;
+ SaveSelection();
+ }
- _selectedEnumIndex = enumIndex;
- _selectedEnumType = enumIndex >= 0 ? _availableEnums[enumIndex] : null;
- _validationResult = null;
- _showValidationResults = false;
- SaveSelection();
+ if (_selectedEnumType != null && CsvSchemaTypeDiscovery.IsLegacySchema(_selectedEnumType))
+ {
+ EditorGUILayout.HelpBox(
+ "このスキーマは旧CSV4Unity.Fields名前空間規約で検出されています。" +
+ "Enumに[CsvSchema]を付けると、任意の名前空間へ配置できます。",
+ MessageType.Info);
+ }
}
private void DrawConstraints(Type enumType)
{
EditorGUILayout.Space(5);
EditorGUILayout.BeginVertical(EditorStyles.helpBox);
- EditorGUILayout.LabelField($"Constraints: {enumType.Name}", EditorStyles.boldLabel);
+ EditorGUILayout.LabelField($"Schema Preview: {enumType.Name}", EditorStyles.boldLabel);
+ EditorGUILayout.Space(3);
bool hasConstraints = false;
FieldInfo[] fields = enumType.GetFields(BindingFlags.Public | BindingFlags.Static);
for (int i = 0; i < fields.Length; i++)
{
object[] attributes = fields[i].GetCustomAttributes(false);
- List labels = attributes
- .OfType()
- .Select(GetAttributeDisplayText)
- .Where(label => label != null)
- .ToList();
+ ConditionAttribute[] conditions = attributes.OfType().ToArray();
+ CsvValidationAttribute[] validations = attributes.OfType().ToArray();
+
+ if (conditions.Length == 0 && validations.Length == 0) continue;
+ if (hasConstraints)
+ {
+ EditorGUILayout.Space(5);
+ DrawSeparator();
+ EditorGUILayout.Space(5);
+ }
- if (labels.Count == 0) continue;
hasConstraints = true;
- EditorGUILayout.LabelField(fields[i].Name, string.Join(", ", labels));
+ DrawFieldConstraints(fields[i].Name, conditions, validations);
}
if (!hasConstraints)
{
- EditorGUILayout.LabelField("制約属性は定義されていません。", EditorStyles.miniLabel);
+ EditorGUILayout.LabelField("No validation constraints are defined.", EditorStyles.miniLabel);
}
EditorGUILayout.EndVertical();
}
+ private static void DrawFieldConstraints(
+ string fieldName,
+ IReadOnlyList conditions,
+ IReadOnlyList validations)
+ {
+ EditorGUILayout.LabelField(fieldName, EditorStyles.boldLabel);
+
+ int[] groups = conditions
+ .Select(condition => condition.Group)
+ .Concat(validations.Select(validation => validation.ConditionGroup))
+ .Distinct()
+ .OrderBy(group => group)
+ .ToArray();
+
+ GUIStyle expressionStyle = new GUIStyle(EditorStyles.wordWrappedLabel)
+ {
+ padding = new RectOffset(8, 4, 1, 1)
+ };
+
+ for (int i = 0; i < groups.Length; i++)
+ {
+ int group = groups[i];
+ ConditionAttribute[] groupConditions = conditions
+ .Where(condition => condition.Group == group)
+ .ToArray();
+ CsvValidationAttribute[] groupValidations = validations
+ .Where(validation => validation.ConditionGroup == group)
+ .ToArray();
+
+ if (i > 0) EditorGUILayout.Space(4);
+
+ string conditionExpression = groupConditions.Length == 0
+ ? group == 0 ? "ALWAYS" : $"IF (GROUP {group} HAS NO CONDITION)"
+ : $"IF ({string.Join(" && ", groupConditions.Select(FormatCondition))})";
+ EditorGUILayout.LabelField(conditionExpression, expressionStyle);
+
+ string validationExpression = groupValidations.Length == 0
+ ? "NO CONSTRAINT"
+ : string.Join(" && ", groupValidations.Select(GetValidationDisplayText));
+ EditorGUILayout.LabelField(
+ $"=> {fieldName}: {validationExpression}",
+ expressionStyle);
+ }
+ }
+
+ private static string FormatCondition(ConditionAttribute condition)
+ {
+ string field = condition.Field?.ToString() ?? "null";
+ string suffix = condition.IgnoreCase ? " [IGNORE CASE]" : string.Empty;
+
+ switch (condition.Comparison)
+ {
+ case Compare.Equal:
+ return $"{field} == {FormatSingleValue(condition)}{suffix}";
+ case Compare.NotEqual:
+ return $"{field} != {FormatSingleValue(condition)}{suffix}";
+ case Compare.GreaterThan:
+ return $"{field} > {FormatSingleValue(condition)}";
+ case Compare.GreaterThanOrEqual:
+ return $"{field} >= {FormatSingleValue(condition)}";
+ case Compare.LessThan:
+ return $"{field} < {FormatSingleValue(condition)}";
+ case Compare.LessThanOrEqual:
+ return $"{field} <= {FormatSingleValue(condition)}";
+ case Compare.IsEmpty:
+ return $"{field} IS EMPTY";
+ case Compare.IsNotEmpty:
+ return $"{field} IS NOT EMPTY";
+ case Compare.In:
+ return $"{field} IN ({string.Join(", ", condition.Values.Select(FormatValue))}){suffix}";
+ case Compare.NotIn:
+ return $"{field} NOT IN ({string.Join(", ", condition.Values.Select(FormatValue))}){suffix}";
+ default:
+ return $"{field} {condition.Comparison}";
+ }
+ }
+
+ private static string FormatSingleValue(ConditionAttribute condition)
+ {
+ return condition.Values.Length == 0 ? ">" : FormatValue(condition.Values[0]);
+ }
+
+ private static string FormatValue(object value)
+ {
+ switch (value)
+ {
+ case null:
+ return "null";
+ case string text:
+ return $"\"{EscapeValue(text)}\"";
+ case char character:
+ return $"'{EscapeValue(character.ToString())}'";
+ case bool boolean:
+ return boolean ? "true" : "false";
+ case Enum enumValue:
+ return enumValue.ToString();
+ case IFormattable formattable:
+ return formattable.ToString(null, CultureInfo.InvariantCulture);
+ default:
+ return value.ToString();
+ }
+ }
+
+ private static string EscapeValue(string value)
+ {
+ return value
+ .Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+ }
+
private void ExecuteValidation()
{
if (_selectedEnumType == null || _csvFile == null) return;
@@ -232,38 +705,7 @@ private void CopyReportToClipboard()
private void RefreshEnums()
{
_availableEnums.Clear();
-
- Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
- for (int i = 0; i < assemblies.Length; i++)
- {
- foreach (Type type in GetLoadableTypes(assemblies[i]))
- {
- if (type.IsEnum && type.Namespace != null &&
- type.Namespace.StartsWith(FieldsNamespace, StringComparison.Ordinal))
- {
- _availableEnums.Add(type);
- }
- }
- }
-
- _availableEnums.Sort((left, right) =>
- string.Compare(left.FullName, right.FullName, StringComparison.Ordinal));
- }
-
- private static IEnumerable GetLoadableTypes(Assembly assembly)
- {
- try
- {
- return assembly.GetTypes();
- }
- catch (ReflectionTypeLoadException exception)
- {
- return exception.Types.Where(type => type != null);
- }
- catch
- {
- return Array.Empty();
- }
+ _availableEnums.AddRange(CsvSchemaTypeDiscovery.FindAll());
}
private void RestoreSelection(string assetPath)
@@ -292,32 +734,30 @@ private static string GetSelectionKey(string assetPath)
return $"CSV4Unity.SelectedEnum.{AssetDatabase.AssetPathToGUID(assetPath)}";
}
- private static string GetAttributeDisplayText(Attribute attribute)
+ private static string GetValidationDisplayText(CsvValidationAttribute attribute)
{
switch (attribute)
{
case PrimaryKeyAttribute:
- return "[PrimaryKey]";
+ return "PRIMARY KEY";
case NotNullAttribute:
- return "[NotNull]";
+ return "VALUE IS NOT EMPTY";
case UniqueAttribute:
- return "[Unique]";
+ return "UNIQUE";
case TypeConstraintAttribute typeConstraint:
- return $"[Type: {typeConstraint.ExpectedType.Name}]";
+ return $"TYPE = {typeConstraint.ExpectedType.Name}";
case Validation.RangeAttribute range:
- return $"[Range: {range.Min}-{range.Max}]";
+ return $"{FormatValue(range.Min)} <= VALUE <= {FormatValue(range.Max)}";
case RegexAttribute regex:
- return $"[Regex: {regex.Pattern}]";
+ return $"MATCHES {FormatValue(regex.Pattern)}";
case AllowedValuesAttribute allowed:
- return $"[Allowed: {string.Join("|", allowed.AllowedValues)}]";
+ return $"VALUE IN ({string.Join(", ", allowed.AllowedValues.Select(FormatValue))})";
case MinLengthAttribute minLength:
- return $"[MinLength: {minLength.MinLength}]";
+ return $"LENGTH >= {minLength.MinLength}";
case MaxLengthAttribute maxLength:
- return $"[MaxLength: {maxLength.MaxLength}]";
- case ForeignKeyAttribute foreignKey:
- return $"[ForeignKey: {foreignKey.ReferenceEnumType.Name}.{foreignKey.ReferenceField}]";
+ return $"LENGTH <= {maxLength.MaxLength}";
default:
- return null;
+ return attribute.GetType().Name;
}
}
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs
new file mode 100644
index 0000000..0a17b8e
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs
@@ -0,0 +1,68 @@
+#if UNITY_EDITOR
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using UnityEditor;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// Inspectorで選択可能なCSVスキーマを収集します。
+ ///
+ internal static class CsvSchemaTypeDiscovery
+ {
+ internal const string LegacyFieldsNamespace = "CSV4Unity.Fields";
+
+ internal static List FindAll()
+ {
+ var schemas = new HashSet();
+
+ foreach (Type type in TypeCache.GetTypesWithAttribute())
+ {
+ if (type.IsEnum) schemas.Add(type);
+ }
+
+ // v0.xとの互換性を保つため、旧名前空間規約も候補へ含める。
+ Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
+ for (int i = 0; i < assemblies.Length; i++)
+ {
+ foreach (Type type in GetLoadableTypes(assemblies[i]))
+ {
+ if (IsLegacySchema(type)) schemas.Add(type);
+ }
+ }
+
+ return schemas
+ .OrderBy(type => type.FullName ?? type.Name, StringComparer.Ordinal)
+ .ToList();
+ }
+
+ internal static bool IsLegacySchema(Type type)
+ {
+ return type != null &&
+ type.IsEnum &&
+ !type.IsDefined(typeof(CsvSchemaAttribute), false) &&
+ type.Namespace != null &&
+ (type.Namespace == LegacyFieldsNamespace ||
+ type.Namespace.StartsWith(LegacyFieldsNamespace + ".", StringComparison.Ordinal));
+ }
+
+ private static IEnumerable GetLoadableTypes(Assembly assembly)
+ {
+ try
+ {
+ return assembly.GetTypes();
+ }
+ catch (ReflectionTypeLoadException exception)
+ {
+ return exception.Types.Where(type => type != null);
+ }
+ catch
+ {
+ return Array.Empty();
+ }
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta
new file mode 100644
index 0000000..f22b6f1
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvSchemaTypeDiscovery.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 71dacd291c014d2e9788a4b45242bf1e
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs
new file mode 100644
index 0000000..5e5e0d6
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs
@@ -0,0 +1,400 @@
+#if UNITY_EDITOR
+using System;
+using System.Collections.Generic;
+using System.Text;
+using UnityEditor;
+using UnityEngine;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// CsvDocumentを仮想スクロールする読み取り専用テーブルです。
+ ///
+ internal sealed class CsvViewerTable
+ {
+ private const float BaseHeaderHeight = 24f;
+ private const float BaseRowHeight = 21f;
+ private const float BaseRowNumberWidth = 56f;
+ private const float MinimumColumnWidth = 64f;
+ private const float MaximumInitialColumnWidth = 280f;
+ private const float MinimumZoom = 0.75f;
+ private const float MaximumZoom = 2f;
+ private const int CellCacheRowLimit = 256;
+
+ private readonly CsvDocument _document;
+ private readonly float[] _columnWidths;
+ private readonly List _filteredRows = new List();
+ private readonly Dictionary _cellTextCache = new Dictionary();
+
+ private Vector2 _scrollPosition;
+ private string _searchText = string.Empty;
+ private int _selectedDisplayRow = -1;
+ private int _selectedColumn = -1;
+ private int _resizingColumn = -1;
+ private float _resizeStartMouseX;
+ private float _resizeStartWidth;
+ private float _zoom = 1f;
+ private GUIStyle _headerStyle;
+ private GUIStyle _cellStyle;
+ private GUIStyle _rowNumberStyle;
+
+ public CsvViewerTable(CsvDocument document)
+ {
+ _document = document ?? throw new ArgumentNullException(nameof(document));
+ _columnWidths = CreateInitialColumnWidths(document);
+ }
+
+ public int RowCount => _document.RowCount;
+
+ public int ColumnCount => _document.ColumnCount;
+
+ public int FilteredRowCount => string.IsNullOrEmpty(_searchText) ? RowCount : _filteredRows.Count;
+
+ internal float Zoom => _zoom;
+
+ public void SetZoom(float zoom)
+ {
+ float clamped = Mathf.Clamp(zoom, MinimumZoom, MaximumZoom);
+ if (Mathf.Approximately(_zoom, clamped)) return;
+
+ _zoom = clamped;
+ _headerStyle = null;
+ _cellStyle = null;
+ _rowNumberStyle = null;
+ }
+
+ public void SetSearch(string searchText)
+ {
+ string normalized = searchText ?? string.Empty;
+ if (string.Equals(_searchText, normalized, StringComparison.Ordinal)) return;
+
+ _searchText = normalized;
+ _filteredRows.Clear();
+ _selectedDisplayRow = -1;
+ _selectedColumn = -1;
+ _scrollPosition.y = 0f;
+
+ if (string.IsNullOrEmpty(_searchText)) return;
+
+ ReadOnlySpan query = _searchText.AsSpan();
+ for (int rowIndex = 0; rowIndex < RowCount; rowIndex++)
+ {
+ if (RowContains(rowIndex, query)) _filteredRows.Add(rowIndex);
+ }
+ }
+
+ public void OnGUI(Rect rect)
+ {
+ float headerHeight = BaseHeaderHeight * _zoom;
+ float rowHeight = BaseRowHeight * _zoom;
+ if (rect.width <= 0f || rect.height <= headerHeight) return;
+
+ Rect headerRect = new Rect(rect.x, rect.y, rect.width, headerHeight);
+ Rect bodyRect = new Rect(rect.x, rect.y + headerHeight, rect.width, rect.height - headerHeight);
+ float contentWidth = CalculateContentWidth();
+ float contentHeight = Mathf.Max(bodyRect.height, FilteredRowCount * rowHeight);
+
+ _scrollPosition = GUI.BeginScrollView(
+ bodyRect,
+ _scrollPosition,
+ new Rect(0f, 0f, contentWidth, contentHeight),
+ true,
+ true);
+ DrawVisibleRows(bodyRect.height, contentWidth);
+ HandleCopyShortcut();
+ GUI.EndScrollView();
+
+ DrawHeader(headerRect, contentWidth);
+ }
+
+ private void DrawHeader(Rect rect, float contentWidth)
+ {
+ EditorGUI.DrawRect(rect, new Color(0.16f, 0.16f, 0.16f, 1f));
+ GUI.BeginGroup(rect);
+
+ float headerHeight = BaseHeaderHeight * _zoom;
+ float rowNumberWidth = BaseRowNumberWidth * _zoom;
+ float x = -_scrollPosition.x;
+ DrawHeaderCell(new Rect(x, 0f, rowNumberWidth, headerHeight), "#");
+ x += rowNumberWidth;
+
+ for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++)
+ {
+ float width = _columnWidths[columnIndex] * _zoom;
+ string name = _document.HasHeader
+ ? _document.Headers[columnIndex]
+ : $"Column {columnIndex + 1}";
+ DrawHeaderCell(new Rect(x, 0f, width, headerHeight), name);
+ HandleColumnResize(columnIndex, new Rect(x + width - 3f, 0f, 6f, headerHeight));
+ x += width;
+ }
+
+ if (x < contentWidth - _scrollPosition.x)
+ {
+ EditorGUI.DrawRect(
+ new Rect(x, 0f, contentWidth - _scrollPosition.x - x, headerHeight),
+ new Color(0.16f, 0.16f, 0.16f, 1f));
+ }
+
+ GUI.EndGroup();
+ }
+
+ private void DrawHeaderCell(Rect rect, string text)
+ {
+ GUI.Box(rect, GUIContent.none, EditorStyles.toolbarButton);
+ GUI.Label(
+ new Rect(
+ rect.x + (6f * _zoom),
+ rect.y + (2f * _zoom),
+ Mathf.Max(0f, rect.width - (12f * _zoom)),
+ rect.height - (4f * _zoom)),
+ new GUIContent(text, text),
+ HeaderStyle);
+ }
+
+ private void DrawVisibleRows(float viewportHeight, float contentWidth)
+ {
+ float rowHeight = BaseRowHeight * _zoom;
+ float rowNumberWidth = BaseRowNumberWidth * _zoom;
+ int firstRow = Mathf.Max(0, Mathf.FloorToInt(_scrollPosition.y / rowHeight));
+ int visibleCount = Mathf.CeilToInt(viewportHeight / rowHeight) + 2;
+ int lastRow = Mathf.Min(FilteredRowCount, firstRow + visibleCount);
+
+ for (int displayRow = firstRow; displayRow < lastRow; displayRow++)
+ {
+ int sourceRow = GetSourceRow(displayRow);
+ float y = displayRow * rowHeight;
+ Rect rowRect = new Rect(0f, y, contentWidth, rowHeight);
+
+ if ((displayRow & 1) != 0)
+ {
+ EditorGUI.DrawRect(rowRect, new Color(1f, 1f, 1f, 0.025f));
+ }
+
+ DrawCellBackgrounds(displayRow, rowRect);
+
+ float x = 0f;
+ GUI.Label(
+ new Rect(
+ x + (5f * _zoom),
+ y + _zoom,
+ rowNumberWidth - (10f * _zoom),
+ rowHeight - (2f * _zoom)),
+ (sourceRow + 1).ToString(),
+ RowNumberStyle);
+ x += rowNumberWidth;
+
+ string[] cellTexts = GetRowTexts(sourceRow);
+ for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++)
+ {
+ float width = _columnWidths[columnIndex] * _zoom;
+ Rect cellRect = new Rect(x, y, width, rowHeight);
+ DrawCell(displayRow, columnIndex, cellRect, cellTexts[columnIndex]);
+ x += width;
+ }
+
+ EditorGUI.DrawRect(new Rect(0f, y + rowHeight - 1f, contentWidth, 1f), new Color(0f, 0f, 0f, 0.16f));
+ }
+ }
+
+ private void DrawCellBackgrounds(int displayRow, Rect rowRect)
+ {
+ if (displayRow != _selectedDisplayRow) return;
+ EditorGUI.DrawRect(rowRect, new Color(0.24f, 0.49f, 0.90f, 0.12f));
+ }
+
+ private void DrawCell(int displayRow, int columnIndex, Rect rect, string text)
+ {
+ if (displayRow == _selectedDisplayRow && columnIndex == _selectedColumn)
+ {
+ EditorGUI.DrawRect(rect, new Color(0.24f, 0.49f, 0.90f, 0.28f));
+ }
+
+ GUI.Label(
+ new Rect(
+ rect.x + (5f * _zoom),
+ rect.y + _zoom,
+ Mathf.Max(0f, rect.width - (10f * _zoom)),
+ rect.height - (2f * _zoom)),
+ new GUIContent(text, text),
+ CellStyle);
+ EditorGUI.DrawRect(new Rect(rect.x + rect.width - 1f, rect.y, 1f, rect.height), new Color(0f, 0f, 0f, 0.14f));
+
+ Event current = Event.current;
+ if (!rect.Contains(current.mousePosition)) return;
+
+ if (current.type == EventType.MouseDown && current.button == 0)
+ {
+ _selectedDisplayRow = displayRow;
+ _selectedColumn = columnIndex;
+ current.Use();
+ }
+ else if (current.type == EventType.ContextClick)
+ {
+ _selectedDisplayRow = displayRow;
+ _selectedColumn = columnIndex;
+ ShowContextMenu();
+ current.Use();
+ }
+ }
+
+ private void ShowContextMenu()
+ {
+ var menu = new GenericMenu();
+ menu.AddItem(new GUIContent("Copy Cell"), false, CopySelectedCell);
+ menu.AddItem(new GUIContent("Copy Row"), false, CopySelectedRow);
+ menu.ShowAsContext();
+ }
+
+ private void HandleCopyShortcut()
+ {
+ Event current = Event.current;
+ if (current.type != EventType.KeyDown || current.keyCode != KeyCode.C) return;
+ if (!current.control && !current.command) return;
+
+ CopySelectedCell();
+ current.Use();
+ }
+
+ private void CopySelectedCell()
+ {
+ if (!TryGetSelectedSourceRow(out int sourceRow) || _selectedColumn < 0) return;
+ GUIUtility.systemCopyBuffer = _document.Cell(sourceRow, _selectedColumn).GetString();
+ }
+
+ private void CopySelectedRow()
+ {
+ if (!TryGetSelectedSourceRow(out int sourceRow)) return;
+
+ string[] values = GetRowTexts(sourceRow);
+ var builder = new StringBuilder();
+ for (int columnIndex = 0; columnIndex < values.Length; columnIndex++)
+ {
+ if (columnIndex > 0) builder.Append('\t');
+ builder.Append(_document.Cell(sourceRow, columnIndex).GetString());
+ }
+
+ GUIUtility.systemCopyBuffer = builder.ToString();
+ }
+
+ private bool TryGetSelectedSourceRow(out int sourceRow)
+ {
+ if ((uint)_selectedDisplayRow >= (uint)FilteredRowCount)
+ {
+ sourceRow = -1;
+ return false;
+ }
+
+ sourceRow = GetSourceRow(_selectedDisplayRow);
+ return true;
+ }
+
+ private void HandleColumnResize(int columnIndex, Rect handleRect)
+ {
+ EditorGUIUtility.AddCursorRect(handleRect, MouseCursor.ResizeHorizontal);
+ Event current = Event.current;
+
+ if (current.type == EventType.MouseDown && current.button == 0 && handleRect.Contains(current.mousePosition))
+ {
+ _resizingColumn = columnIndex;
+ _resizeStartMouseX = current.mousePosition.x;
+ _resizeStartWidth = _columnWidths[columnIndex];
+ current.Use();
+ }
+ else if (current.type == EventType.MouseDrag && _resizingColumn == columnIndex)
+ {
+ float delta = (current.mousePosition.x - _resizeStartMouseX) / _zoom;
+ _columnWidths[columnIndex] = Mathf.Max(MinimumColumnWidth, _resizeStartWidth + delta);
+ current.Use();
+ }
+ else if (current.type == EventType.MouseUp && _resizingColumn == columnIndex)
+ {
+ _resizingColumn = -1;
+ current.Use();
+ }
+ }
+
+ private bool RowContains(int rowIndex, ReadOnlySpan query)
+ {
+ for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++)
+ {
+ CsvCell cell = _document.Cell(rowIndex, columnIndex);
+ if (cell.RawSpan.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0) return true;
+ }
+
+ return false;
+ }
+
+ private int GetSourceRow(int displayRow)
+ {
+ return string.IsNullOrEmpty(_searchText) ? displayRow : _filteredRows[displayRow];
+ }
+
+ private string[] GetRowTexts(int sourceRow)
+ {
+ if (_cellTextCache.TryGetValue(sourceRow, out string[] values)) return values;
+
+ if (_cellTextCache.Count >= CellCacheRowLimit) _cellTextCache.Clear();
+
+ values = new string[ColumnCount];
+ for (int columnIndex = 0; columnIndex < ColumnCount; columnIndex++)
+ {
+ values[columnIndex] = ToSingleLine(_document.Cell(sourceRow, columnIndex).GetString());
+ }
+
+ _cellTextCache.Add(sourceRow, values);
+ return values;
+ }
+
+ private static string ToSingleLine(string value)
+ {
+ return value
+ .Replace("\r\n", "\\n")
+ .Replace("\r", "\\r")
+ .Replace("\n", "\\n");
+ }
+
+ private float CalculateContentWidth()
+ {
+ float width = BaseRowNumberWidth;
+ for (int i = 0; i < _columnWidths.Length; i++) width += _columnWidths[i];
+ return width * _zoom;
+ }
+
+ private GUIStyle HeaderStyle => _headerStyle ??= CreateScaledStyle(EditorStyles.boldLabel, 12);
+
+ private GUIStyle CellStyle => _cellStyle ??= CreateScaledStyle(EditorStyles.label, 12);
+
+ private GUIStyle RowNumberStyle => _rowNumberStyle ??= CreateScaledStyle(EditorStyles.miniLabel, 10);
+
+ private GUIStyle CreateScaledStyle(GUIStyle source, int baseFontSize)
+ {
+ if (Mathf.Approximately(_zoom, 1f)) return source;
+
+ return new GUIStyle(source)
+ {
+ fontSize = Mathf.Max(1, Mathf.RoundToInt(baseFontSize * _zoom))
+ };
+ }
+
+ private static float[] CreateInitialColumnWidths(CsvDocument document)
+ {
+ var widths = new float[document.ColumnCount];
+ int sampleRows = Math.Min(document.RowCount, 100);
+
+ for (int columnIndex = 0; columnIndex < document.ColumnCount; columnIndex++)
+ {
+ int longest = document.HasHeader ? document.Headers[columnIndex].Length : 8;
+ for (int rowIndex = 0; rowIndex < sampleRows; rowIndex++)
+ {
+ longest = Math.Max(longest, Math.Min(document.Cell(rowIndex, columnIndex).RawSpan.Length, 40));
+ }
+
+ widths[columnIndex] = Mathf.Clamp((longest * 7f) + 24f, 110f, MaximumInitialColumnWidth);
+ }
+
+ return widths;
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta
new file mode 100644
index 0000000..87366fd
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerTable.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 03c8fe8a653f410bb74233580e3eca48
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs
new file mode 100644
index 0000000..2087a5a
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs
@@ -0,0 +1,263 @@
+#if UNITY_EDITOR
+using System;
+using UnityEditor;
+using UnityEngine;
+
+namespace CSV4Unity.Editor
+{
+ ///
+ /// CSVの内容を読み取り専用の表として表示します。
+ ///
+ public sealed class CsvViewerWindow : EditorWindow
+ {
+ private const float ToolbarHeight = 22f;
+ private const float StatusHeight = 20f;
+ private const float MinimumZoom = 0.75f;
+ private const float MaximumZoom = 2f;
+
+ [SerializeField] private TextAsset _csvAsset;
+ [SerializeField] private bool _hasHeader = true;
+ [SerializeField] private string _searchText = string.Empty;
+ [SerializeField] private float _zoom = 1f;
+
+ [NonSerialized] private CsvViewerTable _table;
+ [NonSerialized] private string _errorMessage;
+ [NonSerialized] private Hash128 _assetHash;
+
+ /// 指定したCSVをViewerで開きます。
+ /// 表示するCSVのTextAsset。
+ public static void Open(TextAsset csvAsset)
+ {
+ CsvViewerWindow window = GetWindow();
+ window.titleContent = new GUIContent("CSV Viewer");
+ window.minSize = new Vector2(640f, 260f);
+ window.SetAsset(csvAsset);
+ window.Show();
+ }
+
+ [MenuItem("Window/CSV4Unity/CSV Viewer")]
+ private static void OpenWindow()
+ {
+ CsvViewerWindow window = GetWindow();
+ window.titleContent = new GUIContent("CSV Viewer");
+ window.minSize = new Vector2(640f, 260f);
+ window.Show();
+ }
+
+ [MenuItem("Assets/Open in CSV Viewer", false, 2000)]
+ private static void OpenSelectedAsset()
+ {
+ Open(Selection.activeObject as TextAsset);
+ }
+
+ [MenuItem("Assets/Open in CSV Viewer", true)]
+ private static bool CanOpenSelectedAsset()
+ {
+ if (!(Selection.activeObject is TextAsset textAsset)) return false;
+ string path = AssetDatabase.GetAssetPath(textAsset);
+ return CsvEditorAssetUtility.IsCsvPath(path);
+ }
+
+ private void OnEnable()
+ {
+ titleContent = new GUIContent("CSV Viewer");
+ minSize = new Vector2(640f, 260f);
+ if (_zoom <= 0f) _zoom = 1f;
+ _zoom = Mathf.Clamp(_zoom, MinimumZoom, MaximumZoom);
+ EditorApplication.projectChanged += HandleProjectChanged;
+ Reload();
+ }
+
+ private void OnDisable()
+ {
+ EditorApplication.projectChanged -= HandleProjectChanged;
+ }
+
+ private void OnGUI()
+ {
+ DrawToolbar(new Rect(0f, 0f, position.width, ToolbarHeight));
+
+ Rect contentRect = new Rect(
+ 0f,
+ ToolbarHeight,
+ position.width,
+ Mathf.Max(0f, position.height - ToolbarHeight - StatusHeight));
+
+ if (!string.IsNullOrEmpty(_errorMessage))
+ {
+ EditorGUI.HelpBox(
+ new Rect(contentRect.x + 8f, contentRect.y + 8f, contentRect.width - 16f, 44f),
+ _errorMessage,
+ MessageType.Error);
+ }
+ else if (_table != null)
+ {
+ _table.OnGUI(contentRect);
+ }
+ else
+ {
+ EditorGUI.HelpBox(
+ new Rect(contentRect.x + 8f, contentRect.y + 8f, contentRect.width - 16f, 38f),
+ "Select a CSV TextAsset to preview.",
+ MessageType.Info);
+ }
+
+ DrawStatusBar(new Rect(0f, position.height - StatusHeight, position.width, StatusHeight));
+ }
+
+ private void DrawToolbar(Rect rect)
+ {
+ GUILayout.BeginArea(rect, EditorStyles.toolbar);
+ GUILayout.BeginHorizontal();
+
+ EditorGUI.BeginChangeCheck();
+ TextAsset selectedAsset = (TextAsset)EditorGUILayout.ObjectField(
+ _csvAsset,
+ typeof(TextAsset),
+ false,
+ GUILayout.MinWidth(140f));
+ if (EditorGUI.EndChangeCheck()) SetAsset(selectedAsset);
+
+ EditorGUI.BeginChangeCheck();
+ bool hasHeader = GUILayout.Toggle(_hasHeader, "Header", EditorStyles.toolbarButton, GUILayout.Width(58f));
+ if (EditorGUI.EndChangeCheck())
+ {
+ _hasHeader = hasHeader;
+ Reload();
+ }
+
+ if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(54f))) Reload();
+
+ GUILayout.Space(6f);
+ GUILayout.Label("Zoom", EditorStyles.miniLabel, GUILayout.Width(34f));
+ EditorGUI.BeginChangeCheck();
+ float zoom = GUILayout.HorizontalSlider(
+ _zoom,
+ MinimumZoom,
+ MaximumZoom,
+ GUILayout.Width(82f));
+ if (EditorGUI.EndChangeCheck())
+ {
+ _zoom = Mathf.Round(zoom * 20f) / 20f;
+ _table?.SetZoom(_zoom);
+ Repaint();
+ }
+
+ GUILayout.Label($"{_zoom * 100f:0}%", EditorStyles.miniLabel, GUILayout.Width(34f));
+
+ GUILayout.FlexibleSpace();
+ EditorGUI.BeginChangeCheck();
+ string search = EditorGUILayout.TextField(
+ _searchText ?? string.Empty,
+ EditorStyles.toolbarSearchField,
+ GUILayout.MinWidth(100f),
+ GUILayout.MaxWidth(240f));
+ if (EditorGUI.EndChangeCheck())
+ {
+ _searchText = search;
+ _table?.SetSearch(_searchText);
+ Repaint();
+ }
+
+ using (new EditorGUI.DisabledScope(string.IsNullOrEmpty(_searchText)))
+ {
+ if (GUILayout.Button(new GUIContent("x", "Clear search"), EditorStyles.toolbarButton, GUILayout.Width(20f)))
+ {
+ _searchText = string.Empty;
+ _table?.SetSearch(_searchText);
+ GUI.FocusControl(null);
+ Repaint();
+ }
+ }
+
+ GUILayout.EndHorizontal();
+ GUILayout.EndArea();
+ }
+
+ private void DrawStatusBar(Rect rect)
+ {
+ EditorGUI.DrawRect(rect, new Color(0f, 0f, 0f, 0.12f));
+
+ string status;
+ if (_table == null)
+ {
+ status = _csvAsset == null ? "No CSV selected" : "CSV unavailable";
+ }
+ else
+ {
+ status = _table.FilteredRowCount == _table.RowCount
+ ? $"{_table.RowCount:N0} rows, {_table.ColumnCount:N0} columns"
+ : $"{_table.FilteredRowCount:N0} of {_table.RowCount:N0} rows, {_table.ColumnCount:N0} columns";
+ }
+
+ GUI.Label(new Rect(rect.x + 6f, rect.y + 2f, rect.width - 12f, rect.height - 2f), status, EditorStyles.miniLabel);
+ }
+
+ private void SetAsset(TextAsset csvAsset)
+ {
+ if (_csvAsset == csvAsset) return;
+ _csvAsset = csvAsset;
+ _searchText = string.Empty;
+ Reload();
+ }
+
+ private void Reload()
+ {
+ _table = null;
+ _errorMessage = null;
+ _assetHash = default;
+
+ if (_csvAsset == null)
+ {
+ Repaint();
+ return;
+ }
+
+ string assetPath = AssetDatabase.GetAssetPath(_csvAsset);
+ if (!CsvEditorAssetUtility.IsCsvPath(assetPath))
+ {
+ _errorMessage = "The selected TextAsset is not a .csv file.";
+ Repaint();
+ return;
+ }
+
+ try
+ {
+ var options = new CsvParseOptions
+ {
+ HasHeader = _hasHeader,
+ IgnoreEmptyRecords = false,
+ TrimUnquotedFields = false
+ };
+
+ CsvDocument document = CSVLoader.LoadDocument(_csvAsset, options);
+ _table = new CsvViewerTable(document);
+ _table.SetZoom(_zoom);
+ _table.SetSearch(_searchText);
+ _assetHash = AssetDatabase.GetAssetDependencyHash(assetPath);
+ }
+ catch (Exception exception)
+ {
+ _errorMessage = exception.Message;
+ }
+
+ Repaint();
+ }
+
+ private void HandleProjectChanged()
+ {
+ if (_csvAsset == null) return;
+
+ string assetPath = AssetDatabase.GetAssetPath(_csvAsset);
+ if (string.IsNullOrEmpty(assetPath))
+ {
+ Reload();
+ return;
+ }
+
+ Hash128 currentHash = AssetDatabase.GetAssetDependencyHash(assetPath);
+ if (currentHash != _assetHash) Reload();
+ }
+ }
+}
+#endif
diff --git a/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta
new file mode 100644
index 0000000..684ae72
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Editor/CsvViewerWindow.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 3a990428c1ef408a92ef4769a33464d3
diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs
new file mode 100644
index 0000000..ea921eb
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs
@@ -0,0 +1,232 @@
+using System;
+
+namespace CSV4Unity.Validation
+{
+ ///
+ /// コンパイル済みの行条件を評価します。
+ ///
+ internal static class CsvConditionEvaluator
+ {
+ public static bool Matches(
+ CsvTable table,
+ int rowIndex,
+ CsvConditionRule[] conditions,
+ IFormatProvider formatProvider)
+ where TField : struct, Enum
+ {
+ for (int i = 0; i < conditions.Length; i++)
+ {
+ if (!Matches(table, rowIndex, conditions[i], formatProvider)) return false;
+ }
+
+ return true;
+ }
+
+ private static bool Matches(
+ CsvTable table,
+ int rowIndex,
+ CsvConditionRule condition,
+ IFormatProvider formatProvider)
+ where TField : struct, Enum
+ {
+ CsvCell cell = table.Cell(rowIndex, condition.Field);
+ switch (condition.Comparison)
+ {
+ case Compare.IsEmpty:
+ return cell.IsEmpty;
+ case Compare.IsNotEmpty:
+ return !cell.IsEmpty;
+ case Compare.In:
+ return MatchesAny(table, rowIndex, cell, condition, formatProvider);
+ case Compare.NotIn:
+ return !MatchesAny(table, rowIndex, cell, condition, formatProvider);
+ default:
+ return CompareCell(
+ table,
+ rowIndex,
+ cell,
+ condition.Values[0],
+ condition.Comparison,
+ condition.IgnoreCase,
+ formatProvider);
+ }
+ }
+
+ private static bool MatchesAny(
+ CsvTable table,
+ int rowIndex,
+ CsvCell cell,
+ CsvConditionRule condition,
+ IFormatProvider formatProvider)
+ where TField : struct, Enum
+ {
+ for (int i = 0; i < condition.Values.Length; i++)
+ {
+ if (CompareCell(
+ table,
+ rowIndex,
+ cell,
+ condition.Values[i],
+ Compare.Equal,
+ condition.IgnoreCase,
+ formatProvider))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool CompareCell(
+ CsvTable table,
+ int rowIndex,
+ CsvCell left,
+ object rightOperand,
+ Compare comparison,
+ bool ignoreCase,
+ IFormatProvider formatProvider)
+ where TField : struct, Enum
+ {
+ if (rightOperand is TField rightField)
+ {
+ return CompareCells(
+ left,
+ table.Cell(rowIndex, rightField),
+ comparison,
+ ignoreCase,
+ formatProvider);
+ }
+
+ if (rightOperand != null && IsNumericType(rightOperand.GetType()))
+ {
+ if (!left.TryGet(out double leftNumber, formatProvider))
+ {
+ return comparison == Compare.NotEqual;
+ }
+
+ double rightNumber = Convert.ToDouble(rightOperand, formatProvider);
+ return CompareNumbers(leftNumber, rightNumber, comparison);
+ }
+
+ if (rightOperand is bool rightBoolean)
+ {
+ if (!left.TryGet(out bool leftBoolean, formatProvider))
+ {
+ return comparison == Compare.NotEqual;
+ }
+
+ return CompareNumbers(leftBoolean ? 1 : 0, rightBoolean ? 1 : 0, comparison);
+ }
+
+ string rightText = Convert.ToString(rightOperand, formatProvider) ?? string.Empty;
+ return CompareCellText(left, rightText, comparison, ignoreCase);
+ }
+
+ private static bool CompareCells(
+ CsvCell left,
+ CsvCell right,
+ Compare comparison,
+ bool ignoreCase,
+ IFormatProvider formatProvider)
+ {
+ if (comparison == Compare.Equal || comparison == Compare.NotEqual)
+ {
+ return CompareCellTexts(left, right, comparison, ignoreCase);
+ }
+
+ if (!left.TryGet(out double leftNumber, formatProvider) ||
+ !right.TryGet(out double rightNumber, formatProvider))
+ {
+ return false;
+ }
+
+ return CompareNumbers(leftNumber, rightNumber, comparison);
+ }
+
+ private static bool CompareCellText(
+ CsvCell left,
+ string right,
+ Compare comparison,
+ bool ignoreCase)
+ {
+ StringComparison stringComparison = ignoreCase
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (!left.HasEscapedQuotes)
+ {
+ int spanResult = left.RawSpan.CompareTo(right.AsSpan(), stringComparison);
+ return CompareResult(spanResult, comparison);
+ }
+
+ int stringResult = string.Compare(left.GetString(), right, stringComparison);
+ return CompareResult(stringResult, comparison);
+ }
+
+ private static bool CompareCellTexts(
+ CsvCell left,
+ CsvCell right,
+ Compare comparison,
+ bool ignoreCase)
+ {
+ StringComparison stringComparison = ignoreCase
+ ? StringComparison.OrdinalIgnoreCase
+ : StringComparison.Ordinal;
+ if (!left.HasEscapedQuotes && !right.HasEscapedQuotes)
+ {
+ int spanResult = left.RawSpan.CompareTo(right.RawSpan, stringComparison);
+ return CompareResult(spanResult, comparison);
+ }
+
+ int stringResult = string.Compare(left.GetString(), right.GetString(), stringComparison);
+ return CompareResult(stringResult, comparison);
+ }
+
+ private static bool CompareNumbers(double left, double right, Compare comparison)
+ {
+ return CompareResult(left.CompareTo(right), comparison);
+ }
+
+ private static bool CompareResult(int result, Compare comparison)
+ {
+ switch (comparison)
+ {
+ case Compare.Equal:
+ return result == 0;
+ case Compare.NotEqual:
+ return result != 0;
+ case Compare.GreaterThan:
+ return result > 0;
+ case Compare.GreaterThanOrEqual:
+ return result >= 0;
+ case Compare.LessThan:
+ return result < 0;
+ case Compare.LessThanOrEqual:
+ return result <= 0;
+ default:
+ return false;
+ }
+ }
+
+ private static bool IsNumericType(Type type)
+ {
+ switch (Type.GetTypeCode(type))
+ {
+ case TypeCode.SByte:
+ case TypeCode.Byte:
+ case TypeCode.Int16:
+ case TypeCode.UInt16:
+ case TypeCode.Int32:
+ case TypeCode.UInt32:
+ case TypeCode.Int64:
+ case TypeCode.UInt64:
+ case TypeCode.Single:
+ case TypeCode.Double:
+ case TypeCode.Decimal:
+ return true;
+ default:
+ return false;
+ }
+ }
+ }
+}
diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta
new file mode 100644
index 0000000..b9327f6
--- /dev/null
+++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvConditionEvaluator.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e364276765b04cfca6bc95bca756cd58
diff --git a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs
index d028459..c79cbaf 100644
--- a/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs
+++ b/Assets/Plugins/CSVLoader/Runtime/CsvValidation/CsvValidationAttributes.cs
@@ -2,12 +2,109 @@
namespace CSV4Unity.Validation
{
+ ///
+ /// 条件付きValidationで使用する比較方法を表します。
+ ///
+ public enum Compare
+ {
+ /// 値が等しいことを判定します。
+ Equal,
+
+ /// 値が等しくないことを判定します。
+ NotEqual,
+
+ /// 値が比較対象より大きいことを判定します。
+ GreaterThan,
+
+ /// 値が比較対象以上であることを判定します。
+ GreaterThanOrEqual,
+
+ /// 値が比較対象より小さいことを判定します。
+ LessThan,
+
+ /// 値が比較対象以下であることを判定します。
+ LessThanOrEqual,
+
+ /// セルが空であることを判定します。
+ IsEmpty,
+
+ /// セルが空でないことを判定します。
+ IsNotEmpty,
+
+ /// 値が候補のいずれかと等しいことを判定します。
+ In,
+
+ /// 値がすべての候補と異なることを判定します。
+ NotIn
+ }
+
+ ///
+ /// Validation属性に適用する行条件を定義します。
+ ///
+ ///
+ /// 同じグループに属する条件はANDとして評価されます。条件値に同じEnum型の値を指定すると、
+ /// リテラルではなく同じ行の別列を参照します。数値として比較する場合は文字列ではなく数値リテラルを指定してください。
+ /// 条件不成立はエラーではなく、対応するValidation属性をその行で実行しないことを意味します。
+ ///
+ [AttributeUsage(AttributeTargets.Field, AllowMultiple = true)]
+ public sealed class ConditionAttribute : Attribute
+ {
+ /// 条件を適用するグループ番号を取得します。
+ public int Group { get; }
+
+ /// 条件判定に使用するEnumフィールドを取得します。
+ public object Field { get; }
+
+ /// 比較方法を取得します。
+ public Compare Comparison { get; }
+
+ /// 比較する値またはEnumフィールドを取得します。
+ public object[] Values { get; }
+
+ /// 文字列比較で大文字小文字を無視するかを取得または設定します。
+ public bool IgnoreCase { get; set; }
+
+ /// グループ0に行条件を定義します。
+ /// 条件判定に使用するEnumフィールド。
+ /// 比較方法。
+ /// 比較する値。IsEmptyとIsNotEmptyでは省略します。
+ public ConditionAttribute(object field, Compare comparison, params object[] values)
+ : this(0, field, comparison, values)
+ {
+ }
+
+ /// 指定グループに行条件を定義します。
+ /// 0以上のグループ番号。
+ /// 条件判定に使用するEnumフィールド。
+ /// 比較方法。
+ /// 比較する値。IsEmptyとIsNotEmptyでは省略します。
+ public ConditionAttribute(int group, object field, Compare comparison, params object[] values)
+ {
+ Group = group;
+ Field = field;
+ Comparison = comparison;
+ Values = values ?? Array.Empty