From 4ccb0f59e40323840bb9357d2a082236de412e42 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:32:14 +0300 Subject: [PATCH 1/8] feature: message writer --- UnityPackage/Authoring/MessageWriter.cs | 124 ++++++++++++++++ UnityPackage/Authoring/MessageWriter.cs.meta | 11 ++ UnityPackage/Core/Messages/MessageQuoting.cs | 26 +++- .../Tests/Authoring/MessageWriterTests.cs | 132 ++++++++++++++++++ .../Authoring/MessageWriterTests.cs.meta | 11 ++ UnityPackage/Tests/Core/MessageParserTests.cs | 26 ++++ 6 files changed, 324 insertions(+), 6 deletions(-) create mode 100644 UnityPackage/Authoring/MessageWriter.cs create mode 100644 UnityPackage/Authoring/MessageWriter.cs.meta create mode 100644 UnityPackage/Tests/Authoring/MessageWriterTests.cs create mode 100644 UnityPackage/Tests/Authoring/MessageWriterTests.cs.meta diff --git a/UnityPackage/Authoring/MessageWriter.cs b/UnityPackage/Authoring/MessageWriter.cs new file mode 100644 index 0000000..fa17038 --- /dev/null +++ b/UnityPackage/Authoring/MessageWriter.cs @@ -0,0 +1,124 @@ +using reromanlee.ReactiveLocalizer.Formatting; +using reromanlee.ReactiveLocalizer.Messages; +using System; +using System.Globalization; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// + /// Writes parsed messages back as ICU text that parses into the same parts, optionally rewriting their literal + /// text on the way, as pseudo-localization does. + /// + /// + /// Literal text is quoted only where ICU needs it, and the arguments, types, styles and keywords are written as the + /// message wrote them. Spacing between forms is normalized to one space. + /// + internal static class MessageWriter + { + /// Returns the text of , which must be free of errors. + public static string Write(ParsedMessage message) + { + StringBuilder output = new(); + Write(message.Body, output, null); + return output.ToString(); + } + + /// Appends the text of a message's to . + /// The whole message's body. + /// Where the text goes. + /// Rewrites each literal text before it is quoted, given the body it belongs to; null keeps every text. + public static void Write(MessageBody body, StringBuilder output, Func rewrite) + { + WriteBody(body, output, rewrite, false, false); + } + + /// Whether the body is a plural form, where # shows the number. + /// Whether a brace closes the body, which a final apostrophe would otherwise join. + private static void WriteBody(MessageBody body, StringBuilder output, Func rewrite, bool isPluralForm, bool isClosed) + { + for (int i = 0; i < body.Parts.Count; i++) + { + switch (body.Parts[i]) + { + case TextPart text: + string shown = rewrite != null ? rewrite(text.Text, body) : text.Text; + bool isFollowedBySyntax = i + 1 < body.Parts.Count || isClosed; + output.Append(MessageQuoting.Quote(shown, isPluralForm, isFollowedBySyntax)); + break; + case PoundPart: + output.Append('#'); + break; + case PlaceholderPart placeholder: + WritePlaceholder(placeholder, output); + break; + case PluralPart plural: + WritePlural(plural, output, rewrite); + break; + case SelectPart select: + WriteSelect(select, output, rewrite); + break; + } + } + } + + private static void WritePlaceholder(PlaceholderPart placeholder, StringBuilder output) + { + output.Append('{').Append(placeholder.Argument); + switch (placeholder.Format) + { + case PlaceholderFormat.Number: + output.Append(", number"); + if (placeholder.NumberStyle == NumberStyle.Integer) + { + output.Append(", integer"); + } + else if (placeholder.NumberStyle == NumberStyle.Percent) + { + output.Append(", percent"); + } + break; + case PlaceholderFormat.Custom: + output.Append(", ").Append(placeholder.FormatterType); + if (placeholder.FormatterStyle.Length > 0) + { + output.Append(", ").Append(placeholder.FormatterStyle); + } + break; + } + output.Append('}'); + } + + private static void WritePlural(PluralPart plural, StringBuilder output, Func rewrite) + { + output.Append('{').Append(plural.Argument).Append(plural.IsOrdinal ? ", selectordinal," : ", plural,"); + if (plural.Offset != 0d) + { + output.Append(" offset:").Append(FormatNumber(plural.Offset)); + } + for (int i = 0; i < plural.Cases.Count; i++) + { + PluralCase form = plural.Cases[i]; + output.Append(' ').Append(form.IsExplicit ? "=" + FormatNumber(form.ExplicitValue) : form.Keyword).Append(" {"); + WriteBody(form.Body, output, rewrite, true, true); + output.Append('}'); + } + output.Append('}'); + } + + private static void WriteSelect(SelectPart select, StringBuilder output, Func rewrite) + { + output.Append('{').Append(select.Argument).Append(", select,"); + for (int i = 0; i < select.Cases.Count; i++) + { + output.Append(' ').Append(select.Cases[i].Keyword).Append(" {"); + WriteBody(select.Cases[i].Body, output, rewrite, false, true); + output.Append('}'); + } + output.Append('}'); + } + + /// Writes an exact form's value or an offset the way the parser reads numbers: no exponent, no grouping. + private static string FormatNumber(double value) => value.ToString("0.###############", CultureInfo.InvariantCulture); + } +} diff --git a/UnityPackage/Authoring/MessageWriter.cs.meta b/UnityPackage/Authoring/MessageWriter.cs.meta new file mode 100644 index 0000000..8aed18b --- /dev/null +++ b/UnityPackage/Authoring/MessageWriter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: af19a5ca9a8c4cb581c2206b2ab7bc6d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Core/Messages/MessageQuoting.cs b/UnityPackage/Core/Messages/MessageQuoting.cs index 45e107d..92f1b4c 100644 --- a/UnityPackage/Core/Messages/MessageQuoting.cs +++ b/UnityPackage/Core/Messages/MessageQuoting.cs @@ -4,7 +4,7 @@ namespace reromanlee.ReactiveLocalizer.Messages { /// /// Writes plain text as a message that shows exactly that text, for entries created from text a project already - /// shows, such as the current text of a UI label. + /// shows, such as the current text of a UI label, and for messages written back from their parts. /// /// /// Braces go in quotes, together with any apostrophes and braces next to them, since an apostrophe right after @@ -13,10 +13,19 @@ namespace reromanlee.ReactiveLocalizer.Messages /// internal static class MessageQuoting { + private static readonly char[] Syntax = { '{', '}', '\'' }; + private static readonly char[] PluralFormSyntax = { '{', '}', '\'', '#' }; + /// Returns a message whose text is , character for character. - public static string Quote(string text) + public static string Quote(string text) => Quote(text, false, false); + + /// Returns message text that shows where a message writes it. + /// The text to show. + /// Whether the text goes directly inside a plural form, where # shows the number, so a # meant as text goes in quotes too. + /// Whether syntax such as a brace comes right after the text, which would join a final apostrophe. + public static string Quote(string text, bool isPluralForm, bool isFollowedBySyntax) { - if (string.IsNullOrEmpty(text) || text.IndexOfAny(new[] { '{', '}', '\'' }) < 0) + if (string.IsNullOrEmpty(text) || text.IndexOfAny(isPluralForm ? PluralFormSyntax : Syntax) < 0) { return text ?? string.Empty; } @@ -25,11 +34,11 @@ public static string Quote(string text) while (index < text.Length) { char character = text[index]; - if (character == '{' || character == '}') + if (IsQuoted(character, isPluralForm)) { // One quoted run holds the braces and the apostrophes between them; inside it, an apostrophe is doubled. builder.Append('\''); - while (index < text.Length && (text[index] == '{' || text[index] == '}' || text[index] == '\'')) + while (index < text.Length && (IsQuoted(text[index], isPluralForm) || text[index] == '\'')) { builder.Append(text[index]); if (text[index] == '\'') @@ -42,7 +51,10 @@ public static string Quote(string text) continue; } builder.Append(character); - if (character == '\'' && index + 1 < text.Length && (text[index + 1] == '\'' || text[index + 1] == '{' || text[index + 1] == '}')) + bool isBeforeSyntax = index + 1 < text.Length + ? text[index + 1] == '\'' || IsQuoted(text[index + 1], isPluralForm) + : isFollowedBySyntax; + if (character == '\'' && isBeforeSyntax) { builder.Append('\''); } @@ -50,5 +62,7 @@ public static string Quote(string text) } return builder.ToString(); } + + private static bool IsQuoted(char character, bool isPluralForm) => character == '{' || character == '}' || (isPluralForm && character == '#'); } } diff --git a/UnityPackage/Tests/Authoring/MessageWriterTests.cs b/UnityPackage/Tests/Authoring/MessageWriterTests.cs new file mode 100644 index 0000000..8cc43b8 --- /dev/null +++ b/UnityPackage/Tests/Authoring/MessageWriterTests.cs @@ -0,0 +1,132 @@ +using NUnit.Framework; +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Messages; +using System.Collections.Generic; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Tests +{ + public class MessageWriterTests + { + /// Describes a message's parts, quoting resolved, so two messages compare by what they show. + private static string Describe(MessageBody body) + { + StringBuilder output = new(); + Describe(body, output); + return output.ToString(); + } + + private static void Describe(MessageBody body, StringBuilder output) + { + foreach (MessagePart part in body.Parts) + { + switch (part) + { + case TextPart text: + output.Append("text(").Append(text.Text).Append(')'); + break; + case PoundPart: + output.Append("pound"); + break; + case PlaceholderPart placeholder: + output.Append("arg(").Append(placeholder.Argument).Append(',').Append(placeholder.Format).Append(',') + .Append(placeholder.NumberStyle).Append(',').Append(placeholder.FormatterType).Append(',').Append(placeholder.FormatterStyle).Append(')'); + break; + case PluralPart plural: + output.Append("plural(").Append(plural.Argument).Append(',').Append(plural.IsOrdinal).Append(',').Append(plural.Offset); + foreach (PluralCase form in plural.Cases) + { + output.Append(',').Append(form.IsExplicit ? "=" + form.ExplicitValue : form.Keyword).Append('['); + Describe(form.Body, output); + output.Append(']'); + } + output.Append(')'); + break; + case SelectPart select: + output.Append("select(").Append(select.Argument); + foreach (SelectCase branch in select.Cases) + { + output.Append(',').Append(branch.Keyword).Append('['); + Describe(branch.Body, output); + output.Append(']'); + } + output.Append(')'); + break; + } + } + } + + [TestCase("Buy", "Buy")] + [TestCase("Don't", "Don't")] + [TestCase("Hello, {name}!", "Hello, {name}!")] + [TestCase("{count, number}", "{count, number}")] + [TestCase("{share, number, percent} done", "{share, number, percent} done")] + [TestCase("Due {deadline, date, short}", "Due {deadline, date, short}")] + [TestCase("{coins,plural,one{# coin}other{# coins}}", "{coins, plural, one {# coin} other {# coins}}")] + [TestCase("{guests, plural, offset:1 =0 {Nobody} =1 {{host}} other {{host} and # others}}", "{guests, plural, offset:1 =0 {Nobody} =1 {{host}} other {{host} and # others}}")] + [TestCase("{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}", "{place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}")] + [TestCase("{gender, select, female {She} other {They}} won", "{gender, select, female {She} other {They}} won")] + [TestCase("Press '{'space'}'", "Press '{'space'}'")] + [TestCase("{n, plural, other {'#'1 and #}}", "{n, plural, other {'#'1 and #}}")] + [TestCase("{name}'s turn", "{name}'s turn")] + [TestCase("Players'' {name}", "Players' {name}")] + [TestCase("Players''{name}", "Players''{name}")] + [TestCase("{n, plural, other {Players''}}", "{n, plural, other {Players''}}")] + public void Write_WritesWhatParsesBackTheSame(string message, string expected) + { + ParsedMessage parsed = MessageParser.Parse(message); + Assert.That(parsed.HasErrors, Is.False); + + string written = MessageWriter.Write(parsed); + ParsedMessage reparsed = MessageParser.Parse(written); + + Assert.That(written, Is.EqualTo(expected)); + Assert.That(reparsed.HasErrors, Is.False); + Assert.That(Describe(reparsed.Body), Is.EqualTo(Describe(parsed.Body))); + } + + [Test] + public void Write_KeepsEveryValidMessageAsItWas() + { + System.Random random = new(11); + string[] pieces = { "a", " ", "'", "#", "{", "}", "{n}", "{n, plural, one {", "{g, select, x {", "} other {", "}}", "'{'", "''" }; + List failures = new(); + int checkedCount = 0; + for (int i = 0; i < 20000 && failures.Count < 10; i++) + { + StringBuilder message = new(); + int count = random.Next(1, 9); + for (int p = 0; p < count; p++) + { + message.Append(pieces[random.Next(pieces.Length)]); + } + ParsedMessage parsed = MessageParser.Parse(message.ToString()); + if (parsed.HasErrors) + { + continue; + } + checkedCount++; + string written = MessageWriter.Write(parsed); + ParsedMessage reparsed = MessageParser.Parse(written); + if (reparsed.HasErrors || Describe(reparsed.Body) != Describe(parsed.Body)) + { + failures.Add($"{message} -> {written}"); + } + } + + Assert.That(failures, Is.Empty); + Assert.That(checkedCount, Is.GreaterThan(500)); + } + + [Test] + public void Write_RewritesOnlyLiteralText() + { + ParsedMessage parsed = MessageParser.Parse("Hi {name}, {n, plural, one {# apple} other {# apples}}"); + StringBuilder output = new(); + + MessageWriter.Write(parsed.Body, output, (text, body) => text.ToUpperInvariant()); + + Assert.That(output.ToString(), Is.EqualTo("HI {name}, {n, plural, one {# APPLE} other {# APPLES}}")); + } + } +} diff --git a/UnityPackage/Tests/Authoring/MessageWriterTests.cs.meta b/UnityPackage/Tests/Authoring/MessageWriterTests.cs.meta new file mode 100644 index 0000000..64f777f --- /dev/null +++ b/UnityPackage/Tests/Authoring/MessageWriterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f0c4ebe53004a50823243c2f226f836 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Core/MessageParserTests.cs b/UnityPackage/Tests/Core/MessageParserTests.cs index 9fa84ed..c934e3e 100644 --- a/UnityPackage/Tests/Core/MessageParserTests.cs +++ b/UnityPackage/Tests/Core/MessageParserTests.cs @@ -236,6 +236,32 @@ public void Quote_RoundTripsAnyText() Assert.That(failures, Is.Empty); } + [Test] + public void Quote_RoundTripsAnyTextInsideAPluralForm() + { + System.Random random = new(9); + const string alphabet = "{}'# ab"; + List failures = new(); + for (int i = 0; i < 3000; i++) + { + char[] characters = new char[random.Next(1, 12)]; + for (int c = 0; c < characters.Length; c++) + { + characters[c] = alphabet[random.Next(alphabet.Length)]; + } + string text = new(characters); + string message = "{n, plural, other {" + MessageQuoting.Quote(text, true, true) + "}}"; + ParsedMessage parsed = MessageParser.Parse(message); + IReadOnlyList parts = parsed.Body.Parts.Count == 1 && parsed.Body.Parts[0] is PluralPart plural ? plural.Cases[0].Body.Parts : null; + if (parsed.Issues.Count > 0 || parts == null || parts.Count != 1 || !(parts[0] is TextPart shown) || shown.Text != text) + { + failures.Add($"{text} -> {message}"); + } + } + + Assert.That(failures, Is.Empty); + } + private static MessageIssue FirstError(ParsedMessage message) { foreach (MessageIssue issue in message.Issues) From a96806a3de820f2497e9354fe46ec454826878b0 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:35:31 +0300 Subject: [PATCH 2/8] feature: pseudo language --- UnityPackage/Authoring/PseudoLocalization.cs | 253 ++++++++++++++++++ .../Authoring/PseudoLocalization.cs.meta | 11 + UnityPackage/Authoring/RichTextTags.cs | 47 ++++ UnityPackage/Authoring/RichTextTags.cs.meta | 11 + .../Authoring/PseudoLocalizationTests.cs | 157 +++++++++++ .../Authoring/PseudoLocalizationTests.cs.meta | 11 + 6 files changed, 490 insertions(+) create mode 100644 UnityPackage/Authoring/PseudoLocalization.cs create mode 100644 UnityPackage/Authoring/PseudoLocalization.cs.meta create mode 100644 UnityPackage/Authoring/RichTextTags.cs create mode 100644 UnityPackage/Authoring/RichTextTags.cs.meta create mode 100644 UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs create mode 100644 UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs.meta diff --git a/UnityPackage/Authoring/PseudoLocalization.cs b/UnityPackage/Authoring/PseudoLocalization.cs new file mode 100644 index 0000000..5f6c3a4 --- /dev/null +++ b/UnityPackage/Authoring/PseudoLocalization.cs @@ -0,0 +1,253 @@ +using reromanlee.ReactiveLocalizer.Documents; +using reromanlee.ReactiveLocalizer.Messages; +using reromanlee.ReactiveLocalizer.Tables; +using System; +using System.Collections.Generic; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// + /// Pseudo-localization: a virtual language made from the source text, which shows hardcoded text, truncation and + /// overflow before any translation exists. Its text is accented, about a third longer and bracketed, so + /// Buy now shows as [Bûý ñöŵ one]. + /// + /// + /// Arguments, plural and select syntax, rich text tags and every character other than an ASCII letter stay as they + /// are. The accented letters all come from Latin-1 and Latin Extended-A, which nearly every font has. The language + /// is never stored in files; the editor offers it, and development builds pack it. + /// + public static class PseudoLocalization + { + /// The name of the pseudo language. + public const string LanguageName = "Pseudo"; + + /// How much longer pseudo text is than its source text, as translations into German or Finnish often are. + public const double Expansion = 0.35; + + // The accented form of each ASCII letter, A to Z, then a to z. Letters without one in Latin-1 or Latin Extended-A stay plain. + private const string Accents = "\u00C5B\u00C7\u00D0\u00C9F\u011C\u0124\u00CE\u0134\u0136\u013BM\u00D1\u00D6\u00DEQ\u0154\u0160\u0162\u00DBV\u0174X\u00DD\u017D" + + "\u00E5b\u00E7\u00F0\u00E9f\u011D\u0125\u00EE\u0135\u0137\u013Cm\u00F1\u00F6\u00FEq\u0155\u0161\u0163\u00FBv\u0175x\u00FD\u017E"; + + // Padding reads as words, so it wraps like text and is easy to tell apart from the accented text before it. + private static readonly string[] PaddingWords = { "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten" }; + + /// The key of the pseudo language. + public static LanguageKey Language { get; } = new(LanguageName); + + /// + /// Returns the pseudo text of : an ICU message with the same arguments, plural and + /// select forms and rich text tags, whose literal text is accented, padded and bracketed. An empty message stays + /// empty. A broken message shows as written, so its pseudo text is the written text, accented. + /// + public static string Transform(string message) + { + if (string.IsNullOrEmpty(message)) + { + return string.Empty; + } + ParsedMessage parsed = MessageParser.Parse(message); + StringBuilder output = new(message.Length * 3 / 2 + 16); + output.Append('['); + int shownLength; + if (parsed.HasErrors) + { + bool isInTag = false; + char[] accented = message.ToCharArray(); + shownLength = Accent(message, accented, ref isInTag); + output.Append(MessageQuoting.Quote(new string(accented))); + } + else + { + Accenter accenter = new(); + MessageWriter.Write(parsed.Body, output, accenter.Rewrite); + shownLength = Measure(parsed.Body); + } + int padding = (int)Math.Ceiling(shownLength * Expansion); + for (int added = 0, word = 0; added < padding; word++) + { + string next = PaddingWords[word % PaddingWords.Length]; + output.Append(' ').Append(next); + added += next.Length + 1; + } + output.Append(']'); + return output.ToString(); + } + + /// + /// Returns the pseudo language of . It writes and counts like the source language, + /// whose culture, number symbols and direction it takes, and falls back to it. + /// + /// is null. + public static LanguageInfo CreateLanguage(CatalogInfo catalog) + { + if (catalog == null) + { + throw new ArgumentNullException(nameof(catalog)); + } + LanguageInfo source = catalog.SourceLanguage; + return new LanguageInfo(Language, "Pseudo-localization", source.Culture, source.Key, source.Direction, false, + source.Digits, source.DecimalSeparator, source.GroupSeparator); + } + + /// + /// Whether , as its file defines it, can have the pseudo language: none of its own + /// languages has the name, since a language of its own with that name stays a language like any other. + /// + public static bool IsAvailable(CatalogInfo catalog) => catalog != null && !catalog.TryGetLanguage(Language, out _); + + /// Returns with the pseudo language after its languages, or the catalog itself when it isn't . + /// is null. + public static CatalogInfo AddLanguage(CatalogInfo catalog) + { + if (!IsAvailable(catalog)) + { + return catalog ?? throw new ArgumentNullException(nameof(catalog)); + } + return catalog.TryAddLanguage(CreateLanguage(catalog), out CatalogInfo extended, out _) ? extended : catalog; + } + + /// Returns a table document holding the pseudo text of every entry of , a source-language file. + /// is null. + public static TableDocument CreateDocument(TableDocument source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + TableDocumentEntry[] entries = new TableDocumentEntry[source.Entries.Count]; + for (int i = 0; i < entries.Length; i++) + { + TableDocumentEntry entry = source.Entries[i]; + entries[i] = new TableDocumentEntry(entry.Key, Transform(entry.Value), false, 0u, null, null, entry.Line); + } + return new TableDocument(Array.Empty(), Array.Empty(), entries, Array.Empty(), Array.Empty()); + } + + /// + /// Compiles the pseudo language's version of from , its + /// source-language file, checked like any translation, so the result has every key and alias of the source. + /// + /// The catalog as its file defines it, which must be . + /// The table being compiled. + /// The table's source-language file. + /// Where problems are appended, which pseudo text never has unless its source text does; null to ignore them. + /// or is null. + /// The catalog defines a language named itself, or is empty. + public static byte[] Compile(CatalogInfo catalog, TableKey table, TableDocument source, ICollection issues) + { + if (catalog == null) + { + throw new ArgumentNullException(nameof(catalog)); + } + if (!IsAvailable(catalog)) + { + throw new ArgumentException($"The catalog '{catalog.Key.Name}' defines a language named {LanguageName} itself.", nameof(catalog)); + } + return TableCompiler.Compile(AddLanguage(catalog), table, Language, CreateDocument(source), source, issues); + } + + /// + /// Writes the accented form of into , which starts as a copy of + /// it, leaving rich text tags as they are, and returns how many characters it shows outside tags. + /// + /// Whether the text starts inside a tag an earlier text left open; updated for the next text. + private static int Accent(string text, char[] output, ref bool isInTag) + { + int index = 0; + if (isInTag) + { + int close = text.IndexOf('>'); + if (close < 0) + { + return 0; + } + isInTag = false; + index = close + 1; + } + int shown = 0; + while (index < text.Length) + { + char character = text[index]; + if (character == '<') + { + int tag = RichTextTags.MeasureTag(text.AsSpan(), index, out bool isUnclosed); + if (tag > 0) + { + index += tag; + isInTag = isUnclosed; + continue; + } + } + if (output != null) + { + output[index] = Accent(character); + } + shown++; + index++; + } + return shown; + } + + private static char Accent(char character) + { + if ((uint)(character - 'A') <= 'Z' - 'A') + { + return Accents[character - 'A']; + } + return (uint)(character - 'a') <= 'z' - 'a' ? Accents[26 + character - 'a'] : character; + } + + /// Returns how many characters of literal text shows at most, taking its longest form each time. + private static int Measure(MessageBody body) + { + int shown = 0; + bool isInTag = false; + for (int i = 0; i < body.Parts.Count; i++) + { + switch (body.Parts[i]) + { + case TextPart text: + shown += Accent(text.Text, null, ref isInTag); + break; + case PluralPart plural: + int longestForm = 0; + for (int c = 0; c < plural.Cases.Count; c++) + { + longestForm = Math.Max(longestForm, Measure(plural.Cases[c].Body)); + } + shown += longestForm; + break; + case SelectPart select: + int longestBranch = 0; + for (int c = 0; c < select.Cases.Count; c++) + { + longestBranch = Math.Max(longestBranch, Measure(select.Cases[c].Body)); + } + shown += longestBranch; + break; + } + } + return shown; + } + + /// Accents the literal texts of one message in order, carrying a tag left open by one text into the next text of the same body. + private sealed class Accenter + { + private MessageBody _body; + private bool _isInTag; + + public string Rewrite(string text, MessageBody body) + { + if (!ReferenceEquals(body, _body)) + { + _body = body; + _isInTag = false; + } + char[] accented = text.ToCharArray(); + Accent(text, accented, ref _isInTag); + return new string(accented); + } + } + } +} diff --git a/UnityPackage/Authoring/PseudoLocalization.cs.meta b/UnityPackage/Authoring/PseudoLocalization.cs.meta new file mode 100644 index 0000000..a4fd6ea --- /dev/null +++ b/UnityPackage/Authoring/PseudoLocalization.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90390b0194ff43a08a1f87a9d2b73342 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Authoring/RichTextTags.cs b/UnityPackage/Authoring/RichTextTags.cs new file mode 100644 index 0000000..43ae070 --- /dev/null +++ b/UnityPackage/Authoring/RichTextTags.cs @@ -0,0 +1,47 @@ +using System; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// + /// Finds rich text tags such as <b>, </color>, <#FF0000> and + /// <sprite name="coin">, which TextMeshPro, UI Toolkit and uGUI draw as formatting, not as characters. + /// + internal static class RichTextTags + { + /// + /// Returns the length of the tag starting at : a < followed by a letter, + /// / or #, through the next >. Zero when no tag starts there, as when another + /// < or a line break comes first. + /// + /// The text to look in. + /// Where the tag would start. + /// + /// Set when the text ends before the tag closes, which happens when an argument goes inside a tag, as in + /// <color={color}>; the length then reaches the end of the text. + /// + public static int MeasureTag(ReadOnlySpan text, int index, out bool isUnclosed) + { + isUnclosed = false; + if (index + 1 >= text.Length || text[index] != '<' || !IsTagStart(text[index + 1])) + { + return 0; + } + for (int i = index + 2; i < text.Length; i++) + { + char character = text[i]; + if (character == '>') + { + return i + 1 - index; + } + if (character == '<' || character == '\n' || character == '\r') + { + return 0; + } + } + isUnclosed = true; + return text.Length - index; + } + + private static bool IsTagStart(char character) => (uint)((character | 0x20) - 'a') <= 'z' - 'a' || character == '/' || character == '#'; + } +} diff --git a/UnityPackage/Authoring/RichTextTags.cs.meta b/UnityPackage/Authoring/RichTextTags.cs.meta new file mode 100644 index 0000000..3783a6a --- /dev/null +++ b/UnityPackage/Authoring/RichTextTags.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c7c2f56c67ce4eac9d0bf55fc0e37eb5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs b/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs new file mode 100644 index 0000000..ed9627f --- /dev/null +++ b/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs @@ -0,0 +1,157 @@ +using NUnit.Framework; +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Documents; +using reromanlee.ReactiveLocalizer.Messages; +using reromanlee.ReactiveLocalizer.Tables; +using System; +using System.Collections.Generic; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Tests +{ + public class PseudoLocalizationTests + { + private static CatalogInfo CreateCatalog(string languages = "[English]\nCulture = en\n\n[Russian]\nCulture = ru\n") + { + return CatalogInfo.FromDocument(new CatalogKey("Game"), CatalogDocument.Parse("@source English\n\n" + languages), null, null); + } + + [TestCase("Buy now", "[B\u00FB\u00FD \u00F1\u00F6\u0175 one]")] + [TestCase("ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123", + "[\u00C5B\u00C7\u00D0\u00C9F\u011C\u0124\u00CE\u0134\u0136\u013BM\u00D1\u00D6\u00DEQ\u0154\u0160\u0162\u00DBV\u0174X\u00DD\u017D " + + "\u00E5b\u00E7\u00F0\u00E9f\u011D\u0125\u00EE\u0135\u0137\u013Cm\u00F1\u00F6\u00FEq\u0155\u0161\u0163\u00FBv\u0175x\u00FD\u017E 0123 one two three four five]")] + [TestCase("\u041A\u0443\u043F\u0438\u0442\u044C", "[\u041A\u0443\u043F\u0438\u0442\u044C one]")] + [TestCase("{name}", "[{name}]")] + [TestCase("", "")] + public void Transform_AccentsPadsAndBracketsText(string message, string expected) + { + Assert.That(PseudoLocalization.Transform(message), Is.EqualTo(expected)); + } + + [TestCase("You have {coins, plural, one {# coin} other {# coins}}.", + "[\u00DD\u00F6\u00FB \u0125\u00E5v\u00E9 {coins, plural, one {# \u00E7\u00F6\u00EE\u00F1} other {# \u00E7\u00F6\u00EE\u00F1\u0161}}. one two]")] + [TestCase("{gender, select, female {She} other {They}} {name}", + "[{gender, select, female {\u0160\u0125\u00E9} other {\u0162\u0125\u00E9\u00FD}} {name} one]")] + [TestCase("Due {deadline, date, short} at {share, number, percent}", + "[\u00D0\u00FB\u00E9 {deadline, date, short} \u00E5\u0163 {share, number, percent} one]")] + public void Transform_KeepsMessageSyntax(string message, string expected) + { + string pseudo = PseudoLocalization.Transform(message); + + Assert.That(pseudo, Is.EqualTo(expected)); + Assert.That(MessageParser.Parse(pseudo).HasErrors, Is.False); + } + + [TestCase("Buy now", "[B\u00FB\u00FD \u00F1\u00F6\u0175 one]")] + [TestCase("Hot", "[\u0124\u00F6\u0163 one]")] + [TestCase(" x <3", "[ x <3 one]")] + public void Transform_LeavesRichTextTags(string message, string expected) + { + Assert.That(PseudoLocalization.Transform(message), Is.EqualTo(expected)); + } + + [Test] + public void Transform_KeepsQuotedTextLiteral() + { + string pseudo = PseudoLocalization.Transform("Don't press '{'"); + + Assert.That(pseudo, Is.EqualTo("[\u00D0\u00F6\u00F1'\u0163 \u00FE\u0155\u00E9\u0161\u0161 '{' one two]")); + Assert.That(MessageParser.Parse(pseudo).LiteralText, Is.EqualTo("[\u00D0\u00F6\u00F1'\u0163 \u00FE\u0155\u00E9\u0161\u0161 { one two]")); + } + + [Test] + public void Transform_ShowsABrokenMessageAsWritten() + { + string pseudo = PseudoLocalization.Transform("Hello {name"); + + Assert.That(MessageParser.Parse(pseudo).LiteralText, Is.EqualTo("[\u0124\u00E9\u013C\u013C\u00F6 {\u00F1\u00E5m\u00E9 one]")); + } + + [Test] + public void Transform_KeepsTheArgumentsOfEveryValidMessage() + { + Random random = new(5); + string[] pieces = { "a", "Z ", "'", "#", "{", "}", "{n}", "{n, plural, one {", "{g, select, x {", "} other {", "}}", "'{'", "''", "", "", " failures = new(); + int checkedCount = 0; + for (int i = 0; i < 20000 && failures.Count < 10; i++) + { + StringBuilder message = new(); + int count = random.Next(1, 9); + for (int p = 0; p < count; p++) + { + message.Append(pieces[random.Next(pieces.Length)]); + } + ParsedMessage parsed = MessageParser.Parse(message.ToString()); + if (parsed.HasErrors) + { + continue; + } + checkedCount++; + string pseudo = PseudoLocalization.Transform(message.ToString()); + ParsedMessage pseudoParsed = MessageParser.Parse(pseudo); + bool isSame = !pseudoParsed.HasErrors && pseudoParsed.Arguments.Count == parsed.Arguments.Count; + for (int a = 0; isSame && a < parsed.Arguments.Count; a++) + { + isSame = pseudoParsed.Arguments[a].Name == parsed.Arguments[a].Name && pseudoParsed.Arguments[a].Uses == parsed.Arguments[a].Uses; + } + if (!isSame) + { + failures.Add($"{message} -> {pseudo}"); + } + } + + Assert.That(failures, Is.Empty); + Assert.That(checkedCount, Is.GreaterThan(500)); + } + + [Test] + public void AddLanguage_AddsALanguageThatWritesLikeTheSource() + { + CatalogInfo catalog = CreateCatalog(); + + CatalogInfo extended = PseudoLocalization.AddLanguage(catalog); + + Assert.That(PseudoLocalization.IsAvailable(catalog), Is.True); + Assert.That(extended.Languages.Count, Is.EqualTo(3)); + LanguageInfo pseudo = extended.Languages[2]; + Assert.That(pseudo.Key, Is.EqualTo(PseudoLocalization.Language)); + Assert.That(pseudo.Culture, Is.EqualTo("en")); + Assert.That(pseudo.Fallback.Name, Is.EqualTo("English")); + Assert.That(extended.GetFallbackChain(pseudo).Count, Is.EqualTo(2)); + Assert.That(PseudoLocalization.AddLanguage(extended), Is.SameAs(extended)); + } + + [Test] + public void AddLanguage_LeavesACatalogWithItsOwnPseudoLanguage() + { + CatalogInfo catalog = CreateCatalog("[English]\nCulture = en\n\n[Pseudo]\nDisplayName = Pirate\n"); + TableDocument source = TableDocument.Parse("Buy = Buy\n"); + + Assert.That(PseudoLocalization.IsAvailable(catalog), Is.False); + Assert.That(PseudoLocalization.AddLanguage(catalog), Is.SameAs(catalog)); + Assert.That(() => PseudoLocalization.Compile(catalog, new TableKey("Shop"), source, null), Throws.ArgumentException); + } + + [Test] + public void Compile_CompilesEveryEntryAndAliasOfTheSource() + { + CatalogInfo catalog = CreateCatalog(); + TableDocument source = TableDocument.Parse("Buy = Buy now\n\n@formerly CoinsLabel\nCoins = {n, plural, one {# coin} other {# coins}}\n\nEmpty =\n"); + List issues = new(); + + byte[] data = PseudoLocalization.Compile(catalog, new TableKey("Shop"), source, issues); + + Assert.That(issues, Is.Empty); + Assert.That(CompiledTable.TryRead(data, out CompiledTable table, out string error), Is.True, error); + Assert.That(table.LanguageHash, Is.EqualTo(PseudoLocalization.Language.Hash)); + Assert.That(table.IsComplete(TableCompiler.ComputeKeysHash(source)), Is.True); + Assert.That(table.TryFind(Hashing.ComputeNameHash("Buy"), out int buy), Is.True); + Assert.That(table.GetString(buy), Is.EqualTo("[B\u00FB\u00FD \u00F1\u00F6\u0175 one]")); + Assert.That(table.TryFind(Hashing.ComputeNameHash("CoinsLabel"), out int coins), Is.True); + Assert.That(table.TryGetMessage(coins, out _), Is.True); + Assert.That(table.TryFind(Hashing.ComputeNameHash("Empty"), out int empty), Is.True); + Assert.That(table.GetString(empty), Is.Empty); + } + } +} diff --git a/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs.meta b/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs.meta new file mode 100644 index 0000000..4344fe8 --- /dev/null +++ b/UnityPackage/Tests/Authoring/PseudoLocalizationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1582f68eb3d14331b3456f9492eb3723 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From beaed3be04be845c41fbb2a896a79874e169aa06 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:39:04 +0300 Subject: [PATCH 3/8] feature: pseudo preview --- UnityPackage/Editor/EditorTableSource.cs | 22 ++++++-- UnityPackage/Editor/EntryPreview.cs | 7 ++- UnityPackage/Editor/LocalizationSettings.cs | 28 ++++++++-- UnityPackage/Editor/PreviewLanguageOverlay.cs | 7 ++- UnityPackage/Editor/PseudoTables.cs | 54 +++++++++++++++++++ UnityPackage/Editor/PseudoTables.cs.meta | 11 ++++ .../Tests/Editor/EditorPipelineTests.cs | 20 +++++++ 7 files changed, 141 insertions(+), 8 deletions(-) create mode 100644 UnityPackage/Editor/PseudoTables.cs create mode 100644 UnityPackage/Editor/PseudoTables.cs.meta diff --git a/UnityPackage/Editor/EditorTableSource.cs b/UnityPackage/Editor/EditorTableSource.cs index c797518..adec748 100644 --- a/UnityPackage/Editor/EditorTableSource.cs +++ b/UnityPackage/Editor/EditorTableSource.cs @@ -7,7 +7,8 @@ namespace reromanlee.ReactiveLocalizer.Editor { /// /// Delivers catalogs and tables straight from the project's files in the editor, in Play Mode and Edit Mode - /// alike, so what the editor shows is always what the files say, with no build step in between. + /// alike, so what the editor shows is always what the files say, with no build step in between. Catalogs come + /// with the pseudo language when it is offered, and its tables are made from the source-language files. /// internal sealed class EditorTableSource : ITableSource { @@ -20,10 +21,25 @@ public bool TryLoad(in TableRequest request, TableReceiver receiver) } if (request.IsCatalog) { - receiver.Receive(CompiledCatalog.Write(catalog.Info)); + receiver.Receive(CompiledCatalog.Write(PseudoTables.GetCatalog(catalog, PseudoTables.IsOffered))); return true; } - if (!catalog.TryGetTable(request.Table, out IndexedTable table) || !table.TryGetFile(request.Language, out string path)) + if (!catalog.TryGetTable(request.Table, out IndexedTable table)) + { + return false; + } + if (PseudoTables.IsPseudo(catalog, request.Language)) + { + byte[] pseudo = PseudoTables.Compile(catalog, table, out string problem); + if (pseudo == null) + { + return false; + } + receiver.Warn(problem); + receiver.Receive(pseudo); + return true; + } + if (!table.TryGetFile(request.Language, out string path)) { return false; } diff --git a/UnityPackage/Editor/EntryPreview.cs b/UnityPackage/Editor/EntryPreview.cs index a54349c..0fa34ba 100644 --- a/UnityPackage/Editor/EntryPreview.cs +++ b/UnityPackage/Editor/EntryPreview.cs @@ -74,7 +74,12 @@ public static string GetText(IndexedCatalog catalog, string tableName, string en return null; } string text = source.Value; - if (NameRules.IsValid(language) && !string.Equals(language, catalog.Info?.SourceLanguage.Name, StringComparison.OrdinalIgnoreCase) && + if (NameRules.IsValid(language) && PseudoTables.IsPseudo(catalog, new LanguageKey(language))) + { + text = PseudoLocalization.Transform(text); + isTranslated = true; + } + else if (NameRules.IsValid(language) && !string.Equals(language, catalog.Info?.SourceLanguage.Name, StringComparison.OrdinalIgnoreCase) && table.TryGetFile(new LanguageKey(language), out string path) && CatalogIndex.ReadTableDocument(path).TryGetEntry(entryName, out TableDocumentEntry translation)) { diff --git a/UnityPackage/Editor/LocalizationSettings.cs b/UnityPackage/Editor/LocalizationSettings.cs index daf3337..9ad1eef 100644 --- a/UnityPackage/Editor/LocalizationSettings.cs +++ b/UnityPackage/Editor/LocalizationSettings.cs @@ -12,6 +12,7 @@ internal sealed class LocalizationSettings : ScriptableSingletonWhether validation errors fail player builds. On by default; turning it off ships whatever the errors break. public bool IsFailingBuildsOnErrors @@ -27,14 +28,32 @@ public bool IsCheckingPascalCase set => Change(ref _isCheckingPascalCase, value); } - private void Change(ref bool field, bool value) + /// + /// Whether Play Mode and development builds offer the pseudo language as one of every catalog's languages. On by + /// default; the editor's preview always offers it, and release builds never do. + /// + public bool IsOfferingPseudoLocalization + { + get => _isOfferingPseudoLocalization; + set + { + if (Change(ref _isOfferingPseudoLocalization, value)) + { + // Localizers in Play Mode take the catalog again, with or without the language. + Localizer.ReloadAll(); + } + } + } + + private bool Change(ref bool field, bool value) { if (field == value) { - return; + return false; } field = value; Save(true); + return true; } [SettingsProvider] @@ -43,7 +62,7 @@ private static SettingsProvider CreateProvider() return new SettingsProvider("Project/ReactiveLocalizer", SettingsScope.Project) { label = "ReactiveLocalizer", - keywords = new[] { "localization", "validation", "build", "naming" }, + keywords = new[] { "localization", "validation", "build", "naming", "pseudo" }, guiHandler = _ => { LocalizationSettings settings = instance; @@ -54,6 +73,9 @@ private static SettingsProvider CreateProvider() settings.IsCheckingPascalCase = EditorGUILayout.ToggleLeft( new GUIContent("Check names against PascalCase", "Validation warns about table and entry names such as main_menu or playButton."), settings.IsCheckingPascalCase); + settings.IsOfferingPseudoLocalization = EditorGUILayout.ToggleLeft( + new GUIContent("Pseudo-localization in Play Mode and development builds", "Every catalog gets the Pseudo language: its source text accented, a third longer and bracketed, so untranslated text, truncation and overflow show before any translation exists. Release builds never have it."), + settings.IsOfferingPseudoLocalization); EditorGUILayout.Space(); if (GUILayout.Button("Validate Now", GUILayout.Width(120f))) { diff --git a/UnityPackage/Editor/PreviewLanguageOverlay.cs b/UnityPackage/Editor/PreviewLanguageOverlay.cs index 2a42dca..b36a4aa 100644 --- a/UnityPackage/Editor/PreviewLanguageOverlay.cs +++ b/UnityPackage/Editor/PreviewLanguageOverlay.cs @@ -1,3 +1,4 @@ +using reromanlee.ReactiveLocalizer.Authoring; using System; using System.Collections.Generic; using UnityEditor; @@ -40,7 +41,7 @@ public override VisualElement CreatePanelContent() return root; } - /// Lists the source language and every language of the project's catalogs, selecting the preview language. + /// Lists the source language, every language of the project's catalogs and the pseudo language, selecting the preview language. private void Refresh() { List choices = new() { SourceChoice }; @@ -59,6 +60,10 @@ private void Refresh() } } } + if (choices.Count > 1 && seen.Add(PseudoLocalization.LanguageName)) + { + choices.Add(PseudoLocalization.LanguageName); + } string preview = LocalizationUserSettings.instance.PreviewLanguage; string selected = choices.Find(choice => string.Equals(choice, preview, StringComparison.OrdinalIgnoreCase)) ?? SourceChoice; _field.choices = choices; diff --git a/UnityPackage/Editor/PseudoTables.cs b/UnityPackage/Editor/PseudoTables.cs new file mode 100644 index 0000000..7e87f4c --- /dev/null +++ b/UnityPackage/Editor/PseudoTables.cs @@ -0,0 +1,54 @@ +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Documents; +using System; +using System.Collections.Generic; +using UnityEditor; + +namespace reromanlee.ReactiveLocalizer.Editor +{ + /// + /// The pseudo language of the project's catalogs: when localizers are offered it, and its tables, compiled from + /// the source-language files the first time they're asked for and again only after the file changes. + /// + internal static class PseudoTables + { + private static readonly Dictionary Compiled = new(StringComparer.Ordinal); + + /// Whether localizers in the editor are offered the pseudo language: always outside Play Mode, and in Play Mode as the project settings say. + public static bool IsOffered => !EditorApplication.isPlayingOrWillChangePlaymode || LocalizationSettings.instance.IsOfferingPseudoLocalization; + + /// Whether is the pseudo language of rather than a language its file defines. + public static bool IsPseudo(IndexedCatalog catalog, LanguageKey language) => + language == PseudoLocalization.Language && PseudoLocalization.IsAvailable(catalog?.Info); + + /// Returns the catalog as localizers receive it, with the pseudo language when . + public static CatalogInfo GetCatalog(IndexedCatalog catalog, bool isPseudoIncluded) => + isPseudoIncluded ? PseudoLocalization.AddLanguage(catalog.Info) : catalog.Info; + + /// + /// Returns the compiled pseudo version of , or null when it has no source-language + /// file. tells what pseudo text the table had to leave out, which only a bug can cause. + /// + public static byte[] Compile(IndexedCatalog catalog, IndexedTable table, out string problem) + { + problem = null; + if (!PseudoLocalization.IsAvailable(catalog.Info) || table.SourcePath == null || table.SourceDocument == null) + { + return null; + } + if (Compiled.TryGetValue(table.SourcePath, out (ulong Catalog, TableDocument Source, byte[] Data, string Problem) cached) && + cached.Catalog == catalog.Key.Hash && ReferenceEquals(cached.Source, table.SourceDocument)) + { + problem = cached.Problem; + return cached.Data; + } + List issues = new(); + byte[] data = PseudoLocalization.Compile(catalog.Info, table.Key, table.SourceDocument, issues); + // Warnings repeat what the source file's own import reports; an error means pseudo text broke a message. + int error = issues.FindIndex(issue => issue.Severity == IssueSeverity.Error); + problem = error >= 0 ? $"Pseudo-localization left out an entry of {table.SourcePath}{issues[error]}" : null; + Compiled[table.SourcePath] = (catalog.Key.Hash, table.SourceDocument, data, problem); + return data; + } + } +} diff --git a/UnityPackage/Editor/PseudoTables.cs.meta b/UnityPackage/Editor/PseudoTables.cs.meta new file mode 100644 index 0000000..d8a580f --- /dev/null +++ b/UnityPackage/Editor/PseudoTables.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4a14146b038799542a8cc8eb7e5a3672 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Editor/EditorPipelineTests.cs b/UnityPackage/Tests/Editor/EditorPipelineTests.cs index 0d67065..4398d76 100644 --- a/UnityPackage/Tests/Editor/EditorPipelineTests.cs +++ b/UnityPackage/Tests/Editor/EditorPipelineTests.cs @@ -194,6 +194,19 @@ public void Pack_PutsStreamingTablesInTheirOwnFolderWithAList() } } + [Test] + public void Localizer_InTheEditor_ShowsThePseudoLanguage() + { + using Localizer localizer = new(new CatalogKey("Game"), new UnityHost()); + localizer.InitializeAsync(); + + Assert.That(localizer.SetLanguageAsync(PseudoLocalization.Language).IsCompleted, Is.True); + Assert.That(localizer.CurrentLanguage.Key, Is.EqualTo(PseudoLocalization.Language)); + Assert.That(localizer.Get(Purchase), Is.EqualTo("[B\u00FB\u00FD one]")); + Assert.That(localizer.Get(new EntryKey("Shop", "BuyButton")), Is.EqualTo("[B\u00FB\u00FD one]")); + Assert.That(localizer.Get(BalanceOf(21)), Is.EqualTo("[\u00DD\u00F6\u00FB \u0125\u00E5v\u00E9 21 \u00E7\u00F6\u00EE\u00F1\u0161. one two]")); + } + [Test] public void Validate_FindsBrokenAndRenamedReferencesInAssets() { @@ -332,6 +345,11 @@ public void EntryPreview_ShowsTheTextInThePreviewLanguageOrWhatIsWrong() Assert.That(missing.Text, Does.Contain("Did you mean 'Shop.Purchase'?")); Assert.That(elsewhere.Kind, Is.EqualTo(EntryPreviewKind.Broken)); Assert.That(EntryPreview.Describe(default).Kind, Is.EqualTo(EntryPreviewKind.Unpicked)); + + settings.PreviewLanguage = PseudoLocalization.LanguageName; + EntryDescription pseudo = EntryPreview.Describe(new EntryReference("Game", "Shop", "Purchase")); + Assert.That(pseudo.Text, Is.EqualTo("[B\u00FB\u00FD one]")); + Assert.That(pseudo.Tooltip, Is.Null); } finally { @@ -370,6 +388,8 @@ public void EditModePreview_ShowsTextInThePreviewLanguage() Assert.That(preview.Get(Purchase), Is.EqualTo("Buy")); settings.PreviewLanguage = "Russian"; Assert.That(preview.Get(Purchase), Is.EqualTo("Kupit")); + settings.PreviewLanguage = PseudoLocalization.LanguageName; + Assert.That(preview.Get(Purchase), Is.EqualTo("[B\u00FB\u00FD one]")); settings.PreviewLanguage = "Klingon"; Assert.That(preview.Get(Purchase), Is.EqualTo("Buy")); Assert.That(EditModePreview.For(new CatalogKey("Nowhere")), Is.Null); From 3423f61386528fdaa51c6e613715ad5fb1495e57 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:39:04 +0300 Subject: [PATCH 4/8] feature: pseudo builds --- UnityPackage/Editor/TableBuildStep.cs | 48 ++++++++++++++----- .../Tests/Editor/EditorPipelineTests.cs | 23 ++++++++- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/UnityPackage/Editor/TableBuildStep.cs b/UnityPackage/Editor/TableBuildStep.cs index 603157f..8ba261e 100644 --- a/UnityPackage/Editor/TableBuildStep.cs +++ b/UnityPackage/Editor/TableBuildStep.cs @@ -12,7 +12,8 @@ namespace reromanlee.ReactiveLocalizer.Editor /// Packs every runtime catalog and its tables into the build: tables into the /// Resources reads synchronously on every platform, and /// tables into the StreamingAssets reads - /// on demand. The packed files exist only while a build runs. + /// on demand. Development builds also get the pseudo language, unless the project settings turn it off. The packed + /// files exist only while a build runs. /// /// /// Catalogs in Editor folders stay out. A failed build never reaches its postprocess step, so the first editor @@ -39,7 +40,8 @@ public void OnPreprocessBuild(BuildReport report) { SessionState.SetBool(CreatedStreamingAssetsKey, true); } - Pack($"{GeneratedFolder}/Resources/{EmbeddedTableSource.ResourcesRoot}", StreamingFolder); + bool isPseudoIncluded = (report.summary.options & BuildOptions.Development) != 0 && LocalizationSettings.instance.IsOfferingPseudoLocalization; + Pack($"{GeneratedFolder}/Resources/{EmbeddedTableSource.ResourcesRoot}", StreamingFolder, isPseudoIncluded); AssetDatabase.Refresh(); EditorApplication.update -= RemoveAfterBuild; EditorApplication.update += RemoveAfterBuild; @@ -54,7 +56,10 @@ public void OnPostprocessBuild(BuildReport report) /// Writes the compiled catalogs and Embedded tables under , and the Streaming /// tables under , with each catalog's list of them next to it in Resources. /// - public static void Pack(string resourcesRoot, string streamingRoot) + /// The Resources folder the catalogs and Embedded tables go to. + /// The folder the Streaming tables go to. + /// Whether catalogs get the pseudo language, with its tables made from the source-language files. + public static void Pack(string resourcesRoot, string streamingRoot, bool isPseudoIncluded) { foreach (IndexedCatalog catalog in CatalogIndex.Catalogs) { @@ -63,29 +68,26 @@ public static void Pack(string resourcesRoot, string streamingRoot) continue; } string catalogFolder = $"{resourcesRoot}/{catalog.Name}"; - Write($"{catalogFolder}/{EmbeddedTableSource.CatalogFileName}.bytes", CompiledCatalog.Write(catalog.Info)); + CatalogInfo packed = PseudoTables.GetCatalog(catalog, isPseudoIncluded); + Write($"{catalogFolder}/{EmbeddedTableSource.CatalogFileName}.bytes", CompiledCatalog.Write(packed)); List<(string Language, string Table)> streamed = new(); foreach (IndexedTable table in catalog.Tables) { - foreach (LanguageInfo language in catalog.Info.Languages) + foreach (LanguageInfo language in packed.Languages) { - if (!table.TryGetFile(language.Key, out string path)) - { - continue; - } - TableAsset asset = AssetDatabase.LoadAssetAtPath(path); - if (asset == null || asset.Data.IsEmpty) + byte[] data = ReadTable(catalog, table, language.Key); + if (data == null || data.Length == 0) { continue; } if (table.Settings.Delivery == TableDelivery.Streaming) { - Write($"{streamingRoot}/{StreamingTableSource.GetTablePath(catalog.Name, language.Name, table.Name)}", asset.Data.ToArray()); + Write($"{streamingRoot}/{StreamingTableSource.GetTablePath(catalog.Name, language.Name, table.Name)}", data); streamed.Add((language.Name, table.Name)); } else { - Write($"{catalogFolder}/{language.Name}/{table.Name}.bytes", asset.Data.ToArray()); + Write($"{catalogFolder}/{language.Name}/{table.Name}.bytes", data); } } } @@ -96,6 +98,26 @@ public static void Pack(string resourcesRoot, string streamingRoot) } } + /// Returns the compiled table of in , or null when there is none. + private static byte[] ReadTable(IndexedCatalog catalog, IndexedTable table, LanguageKey language) + { + if (PseudoTables.IsPseudo(catalog, language)) + { + byte[] pseudo = PseudoTables.Compile(catalog, table, out string problem); + if (problem != null) + { + UnityEngine.Debug.LogWarning(problem); + } + return pseudo; + } + if (!table.TryGetFile(language, out string path)) + { + return null; + } + TableAsset asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset.Data.ToArray() : null; + } + [InitializeOnLoadMethod] private static void RemoveLeftovers() { diff --git a/UnityPackage/Tests/Editor/EditorPipelineTests.cs b/UnityPackage/Tests/Editor/EditorPipelineTests.cs index 4398d76..7f8cb18 100644 --- a/UnityPackage/Tests/Editor/EditorPipelineTests.cs +++ b/UnityPackage/Tests/Editor/EditorPipelineTests.cs @@ -179,9 +179,10 @@ public void Pack_PutsStreamingTablesInTheirOwnFolderWithAList() string output = Path.Combine(Path.GetTempPath(), "ReactiveLocalizerPack", System.Guid.NewGuid().ToString("N")); try { - TableBuildStep.Pack($"{output}/Resources", $"{output}/Streaming"); + TableBuildStep.Pack($"{output}/Resources", $"{output}/Streaming", false); Assert.That(File.Exists($"{output}/Resources/Game/catalog.bytes"), Is.True); + Assert.That(Directory.Exists($"{output}/Resources/Game/Pseudo"), Is.False); Assert.That(File.Exists($"{output}/Resources/Game/Russian/Shop.bytes"), Is.True); Assert.That(File.Exists($"{output}/Resources/Game/English/Dialogue.bytes"), Is.False); Assert.That(File.Exists($"{output}/Streaming/Game/English/Dialogue.bytes"), Is.True); @@ -194,6 +195,26 @@ public void Pack_PutsStreamingTablesInTheirOwnFolderWithAList() } } + [Test] + public void Pack_ForADevelopmentBuild_AddsThePseudoLanguage() + { + string output = Path.Combine(Path.GetTempPath(), "ReactiveLocalizerPack", System.Guid.NewGuid().ToString("N")); + try + { + TableBuildStep.Pack($"{output}/Resources", $"{output}/Streaming", true); + + Assert.That(CompiledCatalog.TryRead(File.ReadAllBytes($"{output}/Resources/Game/catalog.bytes"), out CatalogInfo catalog, out string error), Is.True, error); + Assert.That(catalog.TryGetLanguage(PseudoLocalization.Language, out _), Is.True); + Assert.That(CompiledTable.TryRead(File.ReadAllBytes($"{output}/Resources/Game/Pseudo/Shop.bytes"), out CompiledTable shop, out error), Is.True, error); + Assert.That(shop.LanguageHash, Is.EqualTo(PseudoLocalization.Language.Hash)); + Assert.That(File.Exists($"{output}/Resources/Game/Pseudo/Inventory.bytes"), Is.True); + } + finally + { + Directory.Delete(output, true); + } + } + [Test] public void Localizer_InTheEditor_ShowsThePseudoLanguage() { From ef7cf6c0ad66aee09a8bfa355c789199fc422606 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:41:25 +0300 Subject: [PATCH 5/8] feature: character sets --- UnityPackage/Authoring/CharacterSet.cs | 188 ++++++++++++++++++ UnityPackage/Authoring/CharacterSet.cs.meta | 11 + UnityPackage/Core/Messages/MessageProgram.cs | 58 ++++++ UnityPackage/Core/Tables/CompiledTable.cs | 3 + .../Tests/Authoring/CharacterSetTests.cs | 91 +++++++++ .../Tests/Authoring/CharacterSetTests.cs.meta | 11 + 6 files changed, 362 insertions(+) create mode 100644 UnityPackage/Authoring/CharacterSet.cs create mode 100644 UnityPackage/Authoring/CharacterSet.cs.meta create mode 100644 UnityPackage/Tests/Authoring/CharacterSetTests.cs create mode 100644 UnityPackage/Tests/Authoring/CharacterSetTests.cs.meta diff --git a/UnityPackage/Authoring/CharacterSet.cs b/UnityPackage/Authoring/CharacterSet.cs new file mode 100644 index 0000000..0aaedcc --- /dev/null +++ b/UnityPackage/Authoring/CharacterSet.cs @@ -0,0 +1,188 @@ +using reromanlee.ReactiveLocalizer.Formatting; +using reromanlee.ReactiveLocalizer.Messages; +using reromanlee.ReactiveLocalizer.Tables; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace reromanlee.ReactiveLocalizer.Authoring +{ + /// + /// A set of characters, such as every character a language can show, for font assets that need to have each of + /// them. Written out in code point order, it is what TextMeshPro's Font Asset Creator takes as custom characters. + /// + /// + /// Rich text tags, control characters and invisible formatting characters are never added, since no font draws + /// them, and a surrogate pair counts as the one character it encodes. + /// + public sealed class CharacterSet + { + private readonly HashSet _codePoints = new(); + private readonly List<(int Start, int Length)> _texts = new(); + private readonly HashSet _counted = new(); + + /// How many characters the set holds. + public int Count => _codePoints.Count; + + /// Whether the set holds . + public bool Contains(int codePoint) => _codePoints.Contains(codePoint); + + /// Adds every character shows, leaving out its rich text tags. + public void AddText(ReadOnlySpan text) + { + int index = 0; + while (index < text.Length) + { + char character = text[index]; + if (character == '<') + { + // An unclosed tag, as when an argument sits inside it, counts as text: a few extra characters cost less than a missing one. + int tag = RichTextTags.MeasureTag(text, index, out bool isUnclosed); + if (tag > 0 && !isUnclosed) + { + index += tag; + continue; + } + } + if (char.IsHighSurrogate(character) && index + 1 < text.Length && char.IsLowSurrogate(text[index + 1])) + { + Add(char.ConvertToUtf32(character, text[index + 1])); + index += 2; + continue; + } + Add(character); + index++; + } + } + + /// Adds unless no font draws it: a control or formatting character, or half a surrogate pair. + public void Add(int codePoint) + { + if (codePoint < 0 || codePoint > 0x10FFFF) + { + return; + } + UnicodeCategory category = codePoint < 0x10000 + ? CharUnicodeInfo.GetUnicodeCategory((char)codePoint) + : CharUnicodeInfo.GetUnicodeCategory(char.ConvertFromUtf32(codePoint), 0); + if (category == UnicodeCategory.Control || category == UnicodeCategory.Format || category == UnicodeCategory.Surrogate || + category == UnicodeCategory.LineSeparator || category == UnicodeCategory.ParagraphSeparator) + { + return; + } + _codePoints.Add(codePoint); + } + + /// Adds every character of . + public void UnionWith(CharacterSet other) + { + if (other != null) + { + _codePoints.UnionWith(other._codePoints); + } + } + + /// + /// Adds every character one table shows in a language: its literal text in every plural and select form, but + /// not argument names or select keywords. An entry counts from the first table that has it, as lookups find it. + /// + /// The table's compiled data along the language's fallback chain, from the language itself to the source language; empty data stands for a missing file. + /// Why a table couldn't be read; the tables before it still count. + /// False when a table couldn't be read. + public bool AddTable(IReadOnlyList> compiledTables, out string problem) + { + problem = null; + _counted.Clear(); + for (int t = 0; t < compiledTables.Count; t++) + { + if (compiledTables[t].IsEmpty) + { + continue; + } + if (!CompiledTable.TryRead(compiledTables[t].Span, out CompiledTable table, out problem)) + { + return false; + } + for (int i = 0; i < table.EntryCount; i++) + { + if (!_counted.Add(table.GetHash(i))) + { + continue; + } + if (!table.TryGetMessage(i, out int start)) + { + AddText(table.GetMemory(i).Span); + continue; + } + _texts.Clear(); + MessageProgram.CollectTexts(table.Program, start, _texts); + for (int s = 0; s < _texts.Count; s++) + { + AddText(new ReadOnlySpan(table.Characters, _texts[s].Start, _texts[s].Length)); + } + } + } + return true; + } + + /// + /// Adds the symbols of writes numbers with: its digits, + /// its separators, and the signs around negative numbers and percentages. + /// + /// or is null. + public void AddNumberSymbols(CatalogInfo catalog, LanguageInfo language) + { + if (catalog == null) + { + throw new ArgumentNullException(nameof(catalog)); + } + if (language == null) + { + throw new ArgumentNullException(nameof(language)); + } + NumberSymbols numbers = catalog.GetFormat(language).Numbers; + AddText(numbers.Digits.AsSpan()); + AddText(numbers.DecimalSeparator.AsSpan()); + AddText(numbers.GroupSeparator.AsSpan()); + AddPattern(numbers.Decimal); + AddPattern(numbers.Percent); + } + + /// Returns the characters in code point order. + public int[] ToCodePoints() + { + int[] codePoints = new int[_codePoints.Count]; + _codePoints.CopyTo(codePoints); + Array.Sort(codePoints); + return codePoints; + } + + /// Returns every character in code point order, as one text. + public override string ToString() + { + int[] codePoints = ToCodePoints(); + StringBuilder text = new(codePoints.Length + 16); + for (int i = 0; i < codePoints.Length; i++) + { + if (codePoints[i] < 0x10000) + { + text.Append((char)codePoints[i]); + } + else + { + text.Append(char.ConvertFromUtf32(codePoints[i])); + } + } + return text.ToString(); + } + + private void AddPattern(NumberPattern pattern) + { + AddText(pattern.PositivePrefix.AsSpan()); + AddText(pattern.PositiveSuffix.AsSpan()); + AddText(pattern.NegativePrefix.AsSpan()); + AddText(pattern.NegativeSuffix.AsSpan()); + } + } +} diff --git a/UnityPackage/Authoring/CharacterSet.cs.meta b/UnityPackage/Authoring/CharacterSet.cs.meta new file mode 100644 index 0000000..f717aac --- /dev/null +++ b/UnityPackage/Authoring/CharacterSet.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e22c7d43f6d142d1a3a5f2a533f1f6e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Core/Messages/MessageProgram.cs b/UnityPackage/Core/Messages/MessageProgram.cs index 5c0c42b..04ed6c9 100644 --- a/UnityPackage/Core/Messages/MessageProgram.cs +++ b/UnityPackage/Core/Messages/MessageProgram.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; namespace reromanlee.ReactiveLocalizer.Messages { @@ -25,6 +26,63 @@ internal static class MessageProgram public static double ReadDouble(int[] program, int index) => BitConverter.Int64BitsToDouble(unchecked((long)ReadUInt64(program, index))); + /// + /// Adds the start and length in the character buffer of every literal text of the verified message at + /// to , every plural and select form included, in order. + /// + public static void CollectTexts(int[] program, int start, List<(int Start, int Length)> texts) + { + int bodyStart = GetBodyStart(program, start); + CollectTexts(program, bodyStart, program[bodyStart - 1], texts); + } + + private static void CollectTexts(int[] program, int position, int end, List<(int Start, int Length)> texts) + { + while (position < end) + { + switch (program[position]) + { + case MessageOperation.Text: + texts.Add((program[position + 1], program[position + 2])); + position += 3; + break; + case MessageOperation.Plain: + position += 2; + break; + case MessageOperation.Number: + position += 3; + break; + case MessageOperation.Custom: + position += 8; + break; + case MessageOperation.Pound: + position++; + break; + case MessageOperation.Plural: + int form = position + 7; + for (int i = program[position + 6]; i > 0; i--) + { + CollectTexts(program, form + 4, program[form + 3], texts); + form = program[form + 3]; + } + position = program[position + 5]; + break; + case MessageOperation.Select: + int branch = position + 4; + for (int i = program[position + 3]; i > 0; i--) + { + CollectTexts(program, branch + 3, program[branch + 2], texts); + branch = program[branch + 2]; + } + position = program[position + 2]; + break; + default: + // Verification rejects unknown operations, so this only guards against an unverified program. + return; + } + } + } + /// Checks the message starting at , failing with a reason instead of throwing. public static bool TryVerify(int[] program, int start, int characterCount, out string error) { diff --git a/UnityPackage/Core/Tables/CompiledTable.cs b/UnityPackage/Core/Tables/CompiledTable.cs index 2312f54..c1bf9c4 100644 --- a/UnityPackage/Core/Tables/CompiledTable.cs +++ b/UnityPackage/Core/Tables/CompiledTable.cs @@ -74,6 +74,9 @@ private CompiledTable(ulong catalogHash, ulong tableHash, ulong languageHash, ul /// Every text of the table, which entries and compiled messages point into. public char[] Characters => _characters; + /// Returns the key hash of the entry at . + public ulong GetHash(int index) => _hashes[index]; + /// /// Finds the entry with , or the entry an alias with that hash points to. /// diff --git a/UnityPackage/Tests/Authoring/CharacterSetTests.cs b/UnityPackage/Tests/Authoring/CharacterSetTests.cs new file mode 100644 index 0000000..cd0774a --- /dev/null +++ b/UnityPackage/Tests/Authoring/CharacterSetTests.cs @@ -0,0 +1,91 @@ +using NUnit.Framework; +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Documents; +using reromanlee.ReactiveLocalizer.Tables; +using System; +using System.Collections.Generic; + +namespace reromanlee.ReactiveLocalizer.Tests +{ + public class CharacterSetTests + { + private static readonly CatalogInfo Catalog = CatalogInfo.FromDocument(new CatalogKey("Game"), + CatalogDocument.Parse("@source English\n\n[English]\nCulture = en\n\n[Russian]\nCulture = ru\n"), null, null); + + private static byte[] Compile(string language, string text, string sourceText) + { + List issues = new(); + byte[] data = TableCompiler.Compile(Catalog, new TableKey("Shop"), new LanguageKey(language), TableDocument.Parse(text), + sourceText != null ? TableDocument.Parse(sourceText) : null, issues); + Assert.That(issues.FindAll(issue => issue.Severity == IssueSeverity.Error), Is.Empty); + return data; + } + + [Test] + public void AddText_LeavesOutTagsAndControlCharacters() + { + CharacterSet set = new(); + + set.AddText("Hi there\n\t\u200B".AsSpan()); + + Assert.That(set.ToString(), Is.EqualTo(" Hehirt")); + } + + [Test] + public void AddText_CountsASurrogatePairAsOneCharacter() + { + CharacterSet set = new(); + + set.AddText("\uD83D\uDE00a\u00A0\uD800".AsSpan()); + + Assert.That(set.Count, Is.EqualTo(3)); + Assert.That(set.Contains(0x1F600), Is.True); + Assert.That(set.ToCodePoints(), Is.EqualTo(new[] { 'a', 0xA0, 0x1F600 })); + Assert.That(set.ToString(), Is.EqualTo("a\u00A0\uD83D\uDE00")); + } + + [Test] + public void AddText_CountsAnUnclosedTagAsText() + { + CharacterSet set = new(); + + set.AddText("[] { russian, ReadOnlyMemory.Empty, source }, out string problem); + + Assert.That(isRead, Is.True, problem); + Assert.That(set.ToString(), Is.EqualTo(" ABCDHIQXYZ\u041A\u0438\u043F\u0442\u0443\u044C")); + } + + [Test] + public void AddTable_ReportsDamagedData() + { + CharacterSet set = new(); + + Assert.That(set.AddTable(new ReadOnlyMemory[] { new byte[] { 1, 2, 3 } }, out string problem), Is.False); + Assert.That(problem, Is.Not.Null); + } + + [Test] + public void AddNumberSymbols_AddsDigitsSeparatorsAndSigns() + { + CharacterSet set = new(); + + Catalog.TryGetLanguage(new LanguageKey("Russian"), out LanguageInfo russian); + set.AddNumberSymbols(Catalog, russian); + + Assert.That(set.ToString(), Is.EqualTo("%,-0123456789\u00A0")); + } + } +} diff --git a/UnityPackage/Tests/Authoring/CharacterSetTests.cs.meta b/UnityPackage/Tests/Authoring/CharacterSetTests.cs.meta new file mode 100644 index 0000000..b3809f0 --- /dev/null +++ b/UnityPackage/Tests/Authoring/CharacterSetTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9c1f8ffaa1d406d859dccda0afe591a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 9de1244b6af742fba6e89a99eb459c3bcad2f2f0 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:43:30 +0300 Subject: [PATCH 6/8] feature: character window --- UnityPackage/Editor/CharacterSetWindow.cs | 273 ++++++++++++++++++ .../Editor/CharacterSetWindow.cs.meta | 11 + UnityPackage/Editor/CharacterSets.cs | 88 ++++++ UnityPackage/Editor/CharacterSets.cs.meta | 11 + .../Tests/Editor/EditorPipelineTests.cs | 18 ++ 5 files changed, 401 insertions(+) create mode 100644 UnityPackage/Editor/CharacterSetWindow.cs create mode 100644 UnityPackage/Editor/CharacterSetWindow.cs.meta create mode 100644 UnityPackage/Editor/CharacterSets.cs create mode 100644 UnityPackage/Editor/CharacterSets.cs.meta diff --git a/UnityPackage/Editor/CharacterSetWindow.cs b/UnityPackage/Editor/CharacterSetWindow.cs new file mode 100644 index 0000000..040be63 --- /dev/null +++ b/UnityPackage/Editor/CharacterSetWindow.cs @@ -0,0 +1,273 @@ +using reromanlee.ReactiveLocalizer.Authoring; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using UnityEditor; +using UnityEngine; +using UnityEngine.UIElements; + +namespace reromanlee.ReactiveLocalizer.Editor +{ + /// + /// Tools/ReactiveLocalizer/Character Sets: collects every character the picked languages of a catalog can + /// show, for TextMeshPro's Font Asset Creator or any other font tool, and copies it or saves it as a text file. + /// Picking several languages collects the characters a font they share needs. + /// + internal sealed class CharacterSetWindow : EditorWindow + { + private const string PrintableAscii = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + // A preview of tens of thousands of characters would slow the window down without showing anything more. + private const int PreviewLimit = 4000; + + [SerializeField] private string _catalogName; + [SerializeField] private List _excludedLanguages = new() { PseudoLocalization.LanguageName }; + [SerializeField] private bool _isAddingAscii; + [SerializeField] private string _extraCharacters = string.Empty; + + private Label _summary; + private TextField _preview; + private Button _copy; + private Button _save; + private IVisualElementScheduledItem _pendingUpdate; + private string _characters = string.Empty; + private readonly List _pickedLanguages = new(); + + [MenuItem("Tools/ReactiveLocalizer/Character Sets...", priority = 40)] + public static void Open() + { + CharacterSetWindow window = GetWindow(true, "Character Sets"); + window.minSize = new Vector2(440f, 460f); + window.Rebuild(); + } + + private void CreateGUI() => Rebuild(); + + private IndexedCatalog Catalog + { + get + { + IndexedCatalog catalog = string.IsNullOrEmpty(_catalogName) ? null : EntryPreview.FindCatalog(_catalogName); + if (catalog?.Info != null) + { + return catalog; + } + foreach (IndexedCatalog candidate in CatalogIndex.Catalogs) + { + if (candidate.Info != null) + { + _catalogName = candidate.Name; + return candidate; + } + } + return null; + } + } + + private void Rebuild() + { + VisualElement root = rootVisualElement; + root.Clear(); + root.style.paddingLeft = 10f; + root.style.paddingRight = 10f; + root.style.paddingTop = 8f; + root.style.paddingBottom = 8f; + IndexedCatalog catalog = Catalog; + if (catalog == null) + { + root.Add(Wrapped(new Label("The project has no usable catalog. Create one with Assets > Create > ReactiveLocalizer > Catalog."))); + return; + } + + List catalogNames = new(); + foreach (IndexedCatalog candidate in CatalogIndex.Catalogs) + { + if (candidate.Info != null) + { + catalogNames.Add(candidate.Name); + } + } + PopupField catalogField = new("Catalog", catalogNames, catalog.Name); + catalogField.RegisterValueChangedCallback(change => + { + _catalogName = change.newValue; + Rebuild(); + }); + root.Add(catalogField); + + IReadOnlyList languages = CharacterSets.GetLanguages(catalog.Key); + List languageNames = new(languages.Count); + for (int i = 0; i < languages.Count; i++) + { + languageNames.Add(languages[i].Name); + } + AddChecklist(root, "Languages, which share one font when several are picked", languageNames); + + Toggle ascii = new("Add printable ASCII") { value = _isAddingAscii, tooltip = "For text players type or code brings at runtime, such as names, which isn't in the files." }; + ascii.style.marginTop = 8f; + ascii.RegisterValueChangedCallback(change => + { + _isAddingAscii = change.newValue; + ScheduleUpdate(); + }); + root.Add(ascii); + TextField extra = new("Extra characters") { value = _extraCharacters, tooltip = "Characters arguments and formatters bring at runtime, such as a currency sign or the digits of dates." }; + extra.RegisterValueChangedCallback(change => + { + _extraCharacters = change.newValue ?? string.Empty; + ScheduleUpdate(); + }); + root.Add(extra); + + _summary = Wrapped(new Label()); + _summary.style.marginTop = 6f; + root.Add(_summary); + _preview = new TextField { multiline = true, isReadOnly = true }; + _preview.style.whiteSpace = WhiteSpace.Normal; + _preview.style.flexGrow = 1f; + _preview.style.minHeight = 80f; + root.Add(_preview); + + VisualElement buttons = new(); + buttons.style.flexDirection = FlexDirection.Row; + buttons.style.justifyContent = Justify.FlexEnd; + buttons.style.marginTop = 4f; + buttons.Add(new Button(Close) { text = "Close" }); + _copy = new Button(Copy) { text = "Copy to Clipboard" }; + buttons.Add(_copy); + _save = new Button(Save) { text = "Save As..." }; + buttons.Add(_save); + root.Add(buttons); + UpdateCharacters(); + } + + private void AddChecklist(VisualElement root, string title, List names) + { + VisualElement header = new(); + header.style.flexDirection = FlexDirection.Row; + header.style.marginTop = 6f; + Label label = new(title); + label.style.flexGrow = 1f; + label.style.unityFontStyleAndWeight = FontStyle.Bold; + header.Add(label); + ScrollView list = new(); + list.style.maxHeight = 140f; + list.style.minHeight = 40f; + header.Add(new Button(() => SetAll(list, names, true)) { text = "All" }); + header.Add(new Button(() => SetAll(list, names, false)) { text = "None" }); + root.Add(header); + for (int i = 0; i < names.Count; i++) + { + string name = names[i]; + Toggle toggle = new(name) { value = !_excludedLanguages.Contains(name) }; + if (name == PseudoLocalization.LanguageName) + { + toggle.tooltip = "The accented letters of pseudo text, for a font that development builds show it with."; + } + toggle.RegisterValueChangedCallback(change => + { + _excludedLanguages.Remove(name); + if (!change.newValue) + { + _excludedLanguages.Add(name); + } + ScheduleUpdate(); + }); + list.Add(toggle); + } + root.Add(list); + } + + private void SetAll(ScrollView list, List names, bool isPicked) + { + _excludedLanguages.Clear(); + if (!isPicked) + { + _excludedLanguages.AddRange(names); + } + foreach (VisualElement child in list.Children()) + { + (child as Toggle)?.SetValueWithoutNotify(isPicked); + } + ScheduleUpdate(); + } + + private void ScheduleUpdate() + { + // Toggling several boxes or typing several characters in a row collects once. + _pendingUpdate?.Pause(); + _pendingUpdate = rootVisualElement.schedule.Execute(UpdateCharacters).StartingIn(200); + } + + private void UpdateCharacters() + { + IndexedCatalog catalog = Catalog; + if (catalog == null || _summary == null) + { + return; + } + CharacterSet characters = new(); + _pickedLanguages.Clear(); + IReadOnlyList languages = CharacterSets.GetLanguages(catalog.Key); + for (int i = 0; i < languages.Count; i++) + { + if (!_excludedLanguages.Contains(languages[i].Name)) + { + _pickedLanguages.Add(languages[i].Name); + characters.UnionWith(CharacterSets.Collect(catalog.Key, languages[i].Key)); + } + } + if (_isAddingAscii) + { + characters.AddText(PrintableAscii.AsSpan()); + } + characters.AddText(_extraCharacters.AsSpan()); + _characters = characters.ToString(); + + string problem = _pickedLanguages.Count == 0 ? "Pick at least one language." : characters.Count == 0 ? "The languages picked show no text yet." : null; + _summary.text = problem ?? $"{characters.Count} characters, in code point order. Text arguments bring at runtime, such as player names, may need more."; + _summary.style.color = problem != null ? LocalizationColors.Warning : LocalizationColors.Muted; + _preview.SetValueWithoutNotify(_characters.Length > PreviewLimit ? _characters.Substring(0, PreviewLimit) + "\u2026" : _characters); + _copy.SetEnabled(problem == null); + _save.SetEnabled(problem == null); + } + + private void Copy() + { + EditorGUIUtility.systemCopyBuffer = _characters; + _summary.text = "Copied. In the Font Asset Creator, pick Character Set > Custom Characters and paste them."; + _summary.style.color = LocalizationColors.Added; + } + + private void Save() + { + string name = _pickedLanguages.Count == 1 ? $"{_catalogName}.{_pickedLanguages[0]}.characters" : $"{_catalogName}.characters"; + string path = EditorUtility.SaveFilePanelInProject("Save Character Set", name, "txt", + "Choose where to save the characters. The Font Asset Creator reads them with Character Set > Characters from File."); + if (string.IsNullOrEmpty(path)) + { + return; + } + try + { + File.WriteAllText(path, _characters, new UTF8Encoding(false)); + } + catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException) + { + EditorUtility.DisplayDialog("Save Character Set", $"The file couldn't be written: {exception.Message}", "OK"); + return; + } + AssetDatabase.ImportAsset(path); + EditorGUIUtility.PingObject(AssetDatabase.LoadAssetAtPath(path)); + _summary.text = $"Saved {path}. In the Font Asset Creator, pick Character Set > Characters from File and this file."; + _summary.style.color = LocalizationColors.Added; + } + + private static Label Wrapped(Label label) + { + label.style.whiteSpace = WhiteSpace.Normal; + return label; + } + } +} diff --git a/UnityPackage/Editor/CharacterSetWindow.cs.meta b/UnityPackage/Editor/CharacterSetWindow.cs.meta new file mode 100644 index 0000000..e013337 --- /dev/null +++ b/UnityPackage/Editor/CharacterSetWindow.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6634e791470a420e8e25010a93340b7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Editor/CharacterSets.cs b/UnityPackage/Editor/CharacterSets.cs new file mode 100644 index 0000000..58fddc5 --- /dev/null +++ b/UnityPackage/Editor/CharacterSets.cs @@ -0,0 +1,88 @@ +using reromanlee.ReactiveLocalizer.Authoring; +using reromanlee.ReactiveLocalizer.Unity; +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace reromanlee.ReactiveLocalizer.Editor +{ + /// + /// Collects the characters the project's languages can show, for font assets that cover them: every text of + /// every table in the language, the fallback text its missing entries show, every plural and select form, and the + /// symbols its numbers are written with. + /// + /// + /// Text that arguments and formatters bring at runtime, such as player names and dates, isn't in the files, so a + /// font may need more characters than these. + /// + public static class CharacterSets + { + /// + /// Returns the languages of a set can be collected for: its own, then the pseudo + /// language unless it defines one itself. Empty when the project has no usable catalog of that name. + /// + /// The catalog; empty for the default catalog. + public static IReadOnlyList GetLanguages(CatalogKey catalog) + { + IndexedCatalog indexed = Find(catalog); + return indexed != null ? PseudoTables.GetCatalog(indexed, true).Languages : Array.Empty(); + } + + /// + /// Returns every character of can show. Null when the + /// project has no usable catalog of that name, or the catalog no such language. + /// + /// The catalog; empty for the default catalog. + /// One of the catalog's languages, or the pseudo language. + public static CharacterSet Collect(CatalogKey catalog, LanguageKey language) + { + IndexedCatalog indexed = Find(catalog); + if (indexed == null) + { + return null; + } + CatalogInfo info = PseudoTables.GetCatalog(indexed, true); + if (!info.TryGetLanguage(language, out LanguageInfo found)) + { + return null; + } + IReadOnlyList chain = info.GetFallbackChain(found); + ReadOnlyMemory[] layers = new ReadOnlyMemory[chain.Count]; + CharacterSet set = new(); + foreach (IndexedTable table in indexed.Tables) + { + for (int i = 0; i < chain.Count; i++) + { + layers[i] = Read(indexed, table, chain[i].Key); + } + if (!set.AddTable(layers, out string problem)) + { + Debug.LogWarning($"[ReactiveLocalizer] The {found.Name} character set leaves out part of the table '{table.Name}': {problem}"); + } + } + set.AddNumberSymbols(info, found); + return set; + } + + private static ReadOnlyMemory Read(IndexedCatalog catalog, IndexedTable table, LanguageKey language) + { + if (PseudoTables.IsPseudo(catalog, language)) + { + return PseudoTables.Compile(catalog, table, out _); + } + if (!table.TryGetFile(language, out string path)) + { + return ReadOnlyMemory.Empty; + } + TableAsset asset = AssetDatabase.LoadAssetAtPath(path); + return asset != null ? asset.Data : ReadOnlyMemory.Empty; + } + + private static IndexedCatalog Find(CatalogKey catalog) + { + IndexedCatalog indexed = catalog.IsEmpty ? CatalogIndex.DefaultCatalog : CatalogIndex.Find(catalog); + return indexed?.Info != null ? indexed : null; + } + } +} diff --git a/UnityPackage/Editor/CharacterSets.cs.meta b/UnityPackage/Editor/CharacterSets.cs.meta new file mode 100644 index 0000000..002cd99 --- /dev/null +++ b/UnityPackage/Editor/CharacterSets.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e375d03852534933aa6f6cf5aa884cbe +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Editor/EditorPipelineTests.cs b/UnityPackage/Tests/Editor/EditorPipelineTests.cs index 7f8cb18..65c5639 100644 --- a/UnityPackage/Tests/Editor/EditorPipelineTests.cs +++ b/UnityPackage/Tests/Editor/EditorPipelineTests.cs @@ -228,6 +228,24 @@ public void Localizer_InTheEditor_ShowsThePseudoLanguage() Assert.That(localizer.Get(BalanceOf(21)), Is.EqualTo("[\u00DD\u00F6\u00FB \u0125\u00E5v\u00E9 21 \u00E7\u00F6\u00EE\u00F1\u0161. one two]")); } + [Test] + public void CharacterSets_CollectWhatEachLanguageShows() + { + CatalogKey game = new("Game"); + + CharacterSet russian = CharacterSets.Collect(game, new LanguageKey("Russian")); + CharacterSet pseudo = CharacterSets.Collect(game, PseudoLocalization.Language); + + Assert.That(CharacterSets.GetLanguages(game).Count, Is.EqualTo(3)); + Assert.That(russian.Contains('K'), Is.True, "Its own text"); + Assert.That(russian.Contains(0x0423), Is.True, "Its plural forms"); + Assert.That(russian.Contains('S') && russian.Contains('w'), Is.True, "The English text of what it lacks"); + Assert.That(russian.Contains('B'), Is.False, "English text it translates"); + Assert.That(russian.Contains(0x00A0), Is.True, "Its group separator"); + Assert.That(pseudo.Contains(0x00FB), Is.True); + Assert.That(CharacterSets.Collect(game, new LanguageKey("Klingon")), Is.Null); + } + [Test] public void Validate_FindsBrokenAndRenamedReferencesInAssets() { From 550d0fd9e7b5de83d33d826c2e9098d7e0951fbe Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:47:34 +0300 Subject: [PATCH 7/8] document: spec tools --- ...eactiveLocalizer (design specification).md | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/Concept/ReactiveLocalizer (design specification).md b/Concept/ReactiveLocalizer (design specification).md index ee559de..ac83a3a 100644 --- a/Concept/ReactiveLocalizer (design specification).md +++ b/Concept/ReactiveLocalizer (design specification).md @@ -401,7 +401,7 @@ Purchase [3fa2c1] = Купить ### 12.5 Scene view overlay -The overlay switches the preview language, and every bound text in the open scenes updates live in Edit Mode. Entry field previews and the table window's preview use the same language. +The overlay switches the preview language, the pseudo language included (section 14), and every bound text in the open scenes updates live in Edit Mode. Entry field previews and the table window's preview use the same language. 1. **Components opt in through `EditModePreview`.** Outside Play Mode it hands out one localizer per catalog in the preview language. It falls back to the catalog's source language when the catalog lacks that one. Those localizers are disposed before Play Mode and before scripts reload. 2. **Preview text is never saved.** A component showing it registers with `EditModePreview.Track`. Before its scene or prefab saves, the component puts its authored text back, and it shows the preview again right after. Text typed over the preview counts as the new authored text. @@ -410,7 +410,7 @@ The overlay switches the preview language, and every bound text in the open scen ### 12.6 Menus 1. `Window/ReactiveLocalizer/Tables`, whose *Exchange* menu exports the open table or the whole catalog and imports files and replies. -2. `Tools/ReactiveLocalizer/…`: *Validate*, *Export…*, *Import File…*, *Import Folder…*, *Import LLM Reply*, and in later steps character sets and the agent guide. +2. `Tools/ReactiveLocalizer/…`: *Validate*, *Export…*, *Import File…*, *Import Folder…*, *Import LLM Reply*, *Character Sets…*, and in a later step the agent guide. 3. `Assets/Create/ReactiveLocalizer/…` (catalog, table). Creating a catalog asks for its name, source language, culture and display name, and checks the culture against CLDR. Creating the first table of a project without a catalog asks the same first. ## 13. Exchange formats @@ -447,11 +447,18 @@ The overlay switches the preview language, and every bound text in the open scen ## 14. Developer tools -1. **Pseudo-localization:** a virtual language generated on the fly from the source text and never stored in files. - 1. It's accented so hardcoded strings stand out, padded by about 35% so overflow shows up, and bracketed so truncation and glued-together strings are obvious. - 2. ICU syntax, rich text tags and escapes stay intact. - 3. It's available in the editor preview and in development builds, and excluded from release builds. -2. **Character sets:** for each language, the exact set of characters any table can display (literal text, plural forms, number symbols, digits, without rich text tags). They're exported to a file or the clipboard for TextMeshPro's Font Asset Creator. +1. **Pseudo-localization:** a virtual language named `Pseudo`, made from the source text and never stored in files. + 1. It's accented so hardcoded strings stand out, padded by about 35% with words so overflow shows up, and bracketed so truncation and glued-together strings are obvious: `Buy now` shows as `[Bûý ñöŵ one]`. + 2. ICU syntax, rich text tags and escapes stay intact. The accented letters all come from Latin-1 and Latin Extended-A, which nearly every font has, so letters without such a form stay plain, and so do other scripts, which still get the padding and brackets. + 3. It writes and counts like the source language, whose culture and number symbols it takes, and falls back to it. + 4. Each table's pseudo text is compiled as a translation of its source-language file, so the same checks prove it keeps every argument, and it has every key and alias. + 5. The editor's preview always offers it. Play Mode and development builds have it as one more language of every catalog, so a language picker lists it, unless the project settings turn that off. Release builds never have it. + 6. A catalog that defines a language named `Pseudo` itself keeps that language and gets no pseudo language. + 7. Tables delivered by a custom source, such as Addressables, have no pseudo version, so they show their source text. +2. **Character sets** (`Tools/ReactiveLocalizer/Character Sets…`): for each language, the exact set of characters any table can display. + 1. That's the language's own text, the fallback text its missing entries show, every plural and select form, its digits and number symbols, without rich text tags or control and formatting characters. Argument names and select keywords never show, so they don't count. + 2. Picking several languages gives the set a font they share needs. Printable ASCII and extra characters can be added for text that arguments bring at runtime, such as player names. + 3. The set is copied to the clipboard or saved as a text file in code point order, for the *Custom Characters* or *Characters from File* options of TextMeshPro's Font Asset Creator. `CharacterSets.Collect` is the editor API behind it. 3. **Font coverage check** (TextMeshPro sample): lists every character a language's assigned font would render as an empty box. ## 15. Samples @@ -493,7 +500,8 @@ Each sample is a thin adapter with its own folder and asmdef. Code that integrat | Runtime languages | `RegisterLanguage` | | Table sources | `EmbeddedTableSource`, `StreamingTableSource`, `FolderTableSource`, and in samples `AddressablesTableSource` | | Inspector fields | `EntryReference`, `CatalogReference`, `[EntryCatalog(typeof(...))]` | -| Editor authoring API | `EntryAuthoring` | +| Editor authoring API | `EntryAuthoring`, `CharacterSets` | +| Developer tools (authoring) | `PseudoLocalization`, `CharacterSet` | | Exchange (authoring) | `ExportBook`, `CsvExchange`, `XlsxExchange`, `XliffExchange`, `LlmHandoff`, `ImportedFile`, `ImportPlanner`, `ImportPlan` | | Generated code | `LocalizationKeys`, `LocalizationLanguages` | | Sample access point | `GlobalLocalizer.Instance` | From 49c600d375be69d085bcd1ba7576a99eca0f65d2 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Sun, 11 Oct 2026 07:47:34 +0300 Subject: [PATCH 8/8] document: changelog tools --- CHANGELOG.md | 42 +++++++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d544f39..232b57c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,28 +12,32 @@ 8. Exchange with translators: an XLSX workbook, CSV files or XLIFF 1.2 and 2.0 files, exported with a choice of tables, languages, and all entries or only missing or outdated ones. Keys are written `Shop.Purchase`, so renaming a file or sheet changes nothing. 9. Imports that change translations only, never keys: a preview lists every new, updated, outdated, confirmed and rejected text before anything is written, each compiled like the files, and the whole import is one step Undo can take back. The source text in the file becomes the fingerprint, so a translation of an older text arrives outdated. 10. An LLM handoff: missing or outdated entries copied as a text bundle with rules for the model, its plural forms included, and the model's reply imported from the clipboard, with no API keys. -11. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change. -12. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode. -13. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with. -14. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code. -15. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do. -16. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL. -17. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry. -18. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override. -19. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory. -20. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's. -21. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant. -22. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed. -23. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`. -24. Creating a catalog asks for its source language and culture, and checks the culture against CLDR. -25. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load. -26. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code. -27. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields. -28. Quick Start and TextMeshPro samples, the TextMeshPro component previewing its text in Edit Mode. +11. Pseudo-localization: a `Pseudo` language made from the source text, accented, a third longer and bracketed, so hardcoded text, truncation and overflow show before any translation exists. The editor previews it, Play Mode and development builds list it, and release builds never ship it. +12. Character sets, `Tools/ReactiveLocalizer/Character Sets`: every character a language can show, the fallback text of missing entries and number symbols included, copied or saved for TextMeshPro's Font Asset Creator. +13. Bindings that update text on every language switch without allocating, and messages that are formatted again only when their arguments change. +14. Live reload: saving a table file updates bound text at once, in Play Mode and Edit Mode. +15. A Scene view overlay that picks the preview language. Components preview their text live in Edit Mode through `EditModePreview`, and scenes and prefabs always save the text they were authored with. +16. Rename, move and delete entries across every language at once. Moved entries keep working through `@formerly Table.Entry`, at runtime and in generated code. +17. Tables loaded on demand: `@loading OnDemand` tables stay in memory only while bindings or `HoldTable` handles use them, and follow every language switch while they do. +18. Streaming delivery: `@delivery Streaming` tables ship in StreamingAssets and load without blocking the main thread, through UnityWebRequest on Android and WebGL. +19. Fan translations and mods: `FolderTableSource` compiles `.lang` files from a folder at runtime, `RegisterLanguage` adds new languages, and sources asked first patch shipped languages entry by entry. +20. Plural rules and number formats for every CLDR 48 locale, built in without `CultureInfo`, with digits and separators each language can override. +21. Fallback languages loaded only for tables a translation leaves gaps in, so a complete translation costs no extra memory. +22. `ReactiveText` for view models and data binding, and `BindCharacters` for text that updates without allocating, such as TextMeshPro's. +23. Import checks that report problems at their line and column, check every translation against its source text and the plural forms of its language, and suggest the key an orphan most likely meant. +24. *Show source change* finds the source text an outdated translation was made from in git, and marks the words that changed. +25. One canonical layout for table files, written by every tool, with keys in natural order, such as `Line9` before `Line10`. +26. Creating a catalog asks for its source language and culture, and checks the culture against CLDR. +27. Lock-free lookups from any thread, with language switches applied all at once on the main thread, and rapid switches sharing what they load. +28. Missing keys shown as `[Table.Key]` in every build and reported once, and failures that never throw into game code. +29. Unity integration: `UnityHost`, tables packed into builds, the editor reading table files directly, and `EntryReference` and `CatalogReference` Inspector fields. +30. Quick Start and TextMeshPro samples, the TextMeshPro component previewing its text in Edit Mode. ### Known issues -1. Pseudo-localization, character sets and the remaining samples, Google Sheets sync among them, aren't available yet. +1. The remaining samples, the TextMeshPro font coverage check and Google Sheets sync among them, aren't available yet. 2. Undo keeps whole copies of the table files each edit changes, so many edits of one very large table take a lot of memory. 3. Spreadsheet applications take CSV texts starting with = for formulas, so exchange XLSX with spreadsheets; formula errors in an imported CSV are rejected. 4. XLIFF 1.2 can't hold control characters, so entries with one are left out of it and reported; XLIFF 2.0 and XLSX keep them. +5. Tables delivered by custom sources, such as Addressables, have no pseudo version, so pseudo-localization shows their source text. +6. Character sets can't know text that arguments bring at runtime, such as player names; the window adds printable ASCII or extra characters for it.