From b97b45368d6d2e3091b8d7ac544e052b6db757cf Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Sat, 22 Aug 2026 00:51:19 -0700 Subject: [PATCH 1/3] Add build-time pseudo-localization - Generate accented and mirrored pseudo resources before PRI indexing - Advertise pseudo languages in generated AppX manifests - Package the MSBuild task and document configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Directory.Packages.props | 8 +- README.md | 4 + ReswPlus.slnx | 1 + docs/Pseudo-localization.md | 99 ++++++++ ...eswPlus.SourceGenerator.NugetPackage.props | 4 + nuget/ReswPlus.targets | 55 +++++ .../AddPseudoLanguagesToAppxManifest.cs | 88 +++++++ .../GeneratePseudoResources.cs | 227 ++++++++++++++++++ .../PseudoLocalizationMode.cs | 7 + src/ReswPlus.BuildTasks/PseudoLocalizer.cs | 159 ++++++++++++ .../ReswPlus.BuildTasks.csproj | 15 ++ .../ReswPlus.SourceGenerator.csproj | 3 + tests/ReswPlusUnitTests/PseudoLocalization.cs | 161 +++++++++++++ .../ReswPlusUnitTests.csproj | 2 + 14 files changed, 829 insertions(+), 4 deletions(-) create mode 100644 docs/Pseudo-localization.md create mode 100644 src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs create mode 100644 src/ReswPlus.BuildTasks/GeneratePseudoResources.cs create mode 100644 src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs create mode 100644 src/ReswPlus.BuildTasks/PseudoLocalizer.cs create mode 100644 src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj create mode 100644 tests/ReswPlusUnitTests/PseudoLocalization.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ae4c326..4eacb2f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,18 +2,18 @@ true - + + - + - + \ No newline at end of file diff --git a/README.md b/README.md index fc22aef..0145e3d 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,10 @@ Without it, such a project is reported as `RESWP0005` and no code is generated f 🗨 [How to inject generated resources](docs/Injectable-resource-providers.md) +### Pseudo-localization + +🗨 [How to test localization before translations are available](docs/Pseudo-localization.md) + ### Generator performance diagnostics To include compiler-measured source-generator timings in detailed build output, enable: diff --git a/ReswPlus.slnx b/ReswPlus.slnx index 0de3f81..3354543 100644 --- a/ReswPlus.slnx +++ b/ReswPlus.slnx @@ -7,6 +7,7 @@ + diff --git a/docs/Pseudo-localization.md b/docs/Pseudo-localization.md new file mode 100644 index 0000000..4acda10 --- /dev/null +++ b/docs/Pseudo-localization.md @@ -0,0 +1,99 @@ +# Pseudo-localization + +Pseudo-localization generates artificial translations from the project's default-language `.resw` files. +It makes localization problems visible before real translations are available: + +- accented characters reveal hard-coded UI strings +- expanded values reveal clipped text and fixed-width layouts +- mirrored text reveals right-to-left layout assumptions +- visible boundary markers reveal unintended trimming or concatenation + +The generated files are build artifacts under `obj`; ReswPlus never changes or adds files to the source +language folders. + +## Enable an accented pseudo-language + +Enable pseudo-localization with an MSBuild property: + +```xml + + Accented + +``` + +ReswPlus reads the `.resw` files under the project's `DefaultLanguage` folder and adds generated +`qps-ploc` resources to the PRI before Windows resource indexing. It also adds the pseudo-language to the +generated AppX manifest so Windows accepts it as an application language. For example: + +```text +Welcome, {0}! +``` + +becomes similar to: + +```text +⟦Ŵëŀçømë, {0}! ~~~~~~~⟧ +``` + +Composite-format placeholders, escaped braces, and XML-like markup inside values are preserved. +Resource names, comments, and `#Format` declarations are not transformed. + +## Test right-to-left layout + +Use `Mirrored` to generate the Windows `qps-plocm` pseudo-locale: + +```xml + + Mirrored + +``` + +Generate both pseudo-locales by separating the modes with a semicolon: + +```xml +Accented;Mirrored +``` + +| Mode | Windows language | Behavior | +| --- | --- | --- | +| `Accented` | `qps-ploc` | Accents characters, expands text, and adds boundary markers | +| `Mirrored` | `qps-plocm` | Applies the same transformation inside a right-to-left override | + +## Select the pseudo-language + +The generated resources participate in normal Windows resource resolution. Select one before creating UI +that reads resources, then restart the application: + +```csharp +Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = "qps-ploc"; +``` + +Use `qps-plocm` for mirrored testing. Clear the override to return to the user's normal language: + +```csharp +Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = ""; +``` + +An application with an in-app language picker can expose these identifiers only in development builds. +They do not need to appear in production UI. + +## Configure text expansion + +Accented and mirrored values expand by 30 percent by default. Adjust the percentage from 0 through 200: + +```xml +50 +``` + +## Use in CI + +Pseudo-localization is disabled unless `ReswPlusPseudoLocalization` is set. A CI job can enable it without +changing the project: + +```console +dotnet build -p:ReswPlusPseudoLocalization=Accented +``` + +This verifies that the generated pseudo-language remains packageable and that all source resources can be +parsed. UI automation can then launch the built application with `qps-ploc` selected and check for clipping, +overlap, untranslated strings, and right-to-left regressions. diff --git a/nuget/ReswPlus.SourceGenerator.NugetPackage.props b/nuget/ReswPlus.SourceGenerator.NugetPackage.props index 5f1fdfb..4df1a5a 100644 --- a/nuget/ReswPlus.SourceGenerator.NugetPackage.props +++ b/nuget/ReswPlus.SourceGenerator.NugetPackage.props @@ -23,6 +23,10 @@ + + diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index bf19306..24f5973 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -17,7 +17,18 @@ true false + + None + 30 + <_ReswPlusBuildTasksAssembly Condition="'$(_ReswPlusBuildTasksAssembly)' == '' And Exists('$(MSBuildThisFileDirectory)..\tools\netstandard2.0\ReswPlus.BuildTasks.dll')">$(MSBuildThisFileDirectory)..\tools\netstandard2.0\ReswPlus.BuildTasks.dll + <_ReswPlusBuildTasksAssembly Condition="'$(_ReswPlusBuildTasksAssembly)' == '' And Exists('$(MSBuildThisFileDirectory)..\src\ReswPlus.BuildTasks\bin\$(Configuration)\netstandard2.0\ReswPlus.BuildTasks.dll')">$(MSBuildThisFileDirectory)..\src\ReswPlus.BuildTasks\bin\$(Configuration)\netstandard2.0\ReswPlus.BuildTasks.dll + + @@ -25,4 +36,48 @@ true + + + + + + + + + + + + + + + <_ReswPlusPseudoManifestInput>$(IntermediateOutputPath)ReswPlus\PseudoLocalization\manifest.mode + + + + + <_GenerateCurrentProjectAppxManifestInput Include="$(_ReswPlusPseudoManifestInput)" /> + + + + + + \ No newline at end of file diff --git a/src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs b/src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs new file mode 100644 index 0000000..c1341b8 --- /dev/null +++ b/src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace ReswPlus.BuildTasks; + +public sealed class AddPseudoLanguagesToAppxManifest : Task +{ + [Required] + public string ManifestPath { get; set; } = ""; + + [Required] + public string Modes { get; set; } = ""; + + public override bool Execute() + { + try + { + var document = XDocument.Load(ManifestPath, LoadOptions.PreserveWhitespace); + var package = document.Root; + var resources = package?.Elements().FirstOrDefault(element => element.Name.LocalName == "Resources"); + + if (resources is null) + { + Log.LogError("The generated AppX manifest '{0}' has no Resources element.", ManifestPath); + return false; + } + + var existing = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var resource in resources.Elements().Where(element => element.Name.LocalName == "Resource")) + { + var language = resource.Attribute("Language")?.Value; + if (language is not null) + { + existing.Add(language); + } + } + + var changed = false; + foreach (var mode in PseudoLocalizer.ParseModes(Modes)) + { + if (existing.Add(mode.Language)) + { + resources.Add(new XElement(resources.Name.Namespace + "Resource", new XAttribute("Language", mode.Language))); + changed = true; + } + } + + if (changed) + { + using var writer = XmlWriter.Create(ManifestPath, new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = true, + OmitXmlDeclaration = document.Declaration is null, + }); + document.Save(writer); + } + + return true; + } + catch (ArgumentException exception) + { + Log.LogError(exception.Message); + return false; + } + catch (XmlException exception) + { + Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + return false; + } + catch (IOException exception) + { + Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + return false; + } + catch (UnauthorizedAccessException exception) + { + Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + return false; + } + } +} \ No newline at end of file diff --git a/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs new file mode 100644 index 0000000..8659d90 --- /dev/null +++ b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Xml; +using System.Xml.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace ReswPlus.BuildTasks; + +public sealed class GeneratePseudoResources : Task +{ + [Required] + public ITaskItem[] Resources { get; set; } = Array.Empty(); + + [Required] + public string DefaultLanguage { get; set; } = ""; + + [Required] + public string ProjectDirectory { get; set; } = ""; + + [Required] + public string IntermediateOutputPath { get; set; } = ""; + + [Required] + public string Modes { get; set; } = ""; + + public int ExpansionPercentage { get; set; } = 30; + + [Output] + public ITaskItem[] GeneratedResources { get; private set; } = Array.Empty(); + + public override bool Execute() + { + if (ExpansionPercentage is < 0 or > 200) + { + Log.LogError( + "ReswPlus pseudo-localization expansion must be between 0 and 200, but was {0}.", + ExpansionPercentage); + return false; + } + + IReadOnlyList<(PseudoLocalizationMode Mode, string Language)> modes; + + try + { + modes = PseudoLocalizer.ParseModes(Modes); + } + catch (ArgumentException exception) + { + Log.LogError(exception.Message); + return false; + } + + var sourceResources = Resources + .Where(resource => Path.GetExtension(resource.ItemSpec).Equals(".resw", StringComparison.OrdinalIgnoreCase)) + .Select(resource => (Item: resource, LogicalPath: GetLogicalPath(resource))) + .Where(resource => ContainsLanguageFolder(resource.LogicalPath, DefaultLanguage)) + .ToArray(); + + if (sourceResources.Length == 0) + { + Log.LogError( + "ReswPlus pseudo-localization could not find a .resw file in the default-language folder '{0}'.", + DefaultLanguage); + return false; + } + + var generated = new List(); + var outputPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var source in sourceResources) + { + foreach (var mode in modes) + { + var logicalPath = ReplaceLanguageFolder(source.LogicalPath, DefaultLanguage, mode.Language); + var outputPath = Path.GetFullPath(Path.Combine( + IntermediateOutputPath, + "ReswPlus", + "PseudoLocalization", + ToSafeRelativePath(logicalPath))); + + if (!outputPaths.Add(outputPath)) + { + Log.LogError( + "Two resources would generate the same pseudo-localized file '{0}'. Set Link metadata to give linked resources distinct paths.", + logicalPath); + return false; + } + + if (!TryGenerate(source.Item.ItemSpec, outputPath, mode.Mode)) + { + return false; + } + + var output = new TaskItem(outputPath); + output.SetMetadata("Link", logicalPath); + output.SetMetadata("TargetPath", logicalPath); + output.SetMetadata("ReswPlusPseudoLocalization", mode.Mode.ToString()); + generated.Add(output); + } + } + + GeneratedResources = generated.ToArray(); + return !Log.HasLoggedErrors; + } + + private bool TryGenerate(string sourcePath, string outputPath, PseudoLocalizationMode mode) + { + try + { + var document = XDocument.Load(sourcePath, LoadOptions.PreserveWhitespace); + + foreach (var value in document + .Descendants() + .Where(element => element.Name.LocalName == "value" && + element.Parent?.Name.LocalName == "data")) + { + value.Value = PseudoLocalizer.Transform(value.Value, mode, ExpansionPercentage); + } + + var bytes = Serialize(document); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + if (!File.Exists(outputPath) || !File.ReadAllBytes(outputPath).SequenceEqual(bytes)) + { + File.WriteAllBytes(outputPath, bytes); + } + + return true; + } + catch (XmlException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + catch (IOException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + catch (UnauthorizedAccessException exception) + { + Log.LogError("Could not pseudo-localize '{0}': {1}", sourcePath, exception.Message); + return false; + } + } + + private string GetLogicalPath(ITaskItem resource) + { + var link = resource.GetMetadata("Link"); + if (!string.IsNullOrWhiteSpace(link)) + { + return link; + } + + var sourcePath = Path.GetFullPath(resource.ItemSpec); + var projectPath = Path.GetFullPath(ProjectDirectory) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + if (sourcePath.StartsWith(projectPath, StringComparison.OrdinalIgnoreCase)) + { + return sourcePath.Substring(projectPath.Length); + } + + var languageDirectory = Path.GetDirectoryName(sourcePath); + var resourceDirectory = Path.GetDirectoryName(languageDirectory); + return Path.Combine( + Path.GetFileName(resourceDirectory) ?? "Strings", + Path.GetFileName(languageDirectory) ?? DefaultLanguage, + Path.GetFileName(sourcePath)); + } + + private static bool ContainsLanguageFolder(string path, string language) + { + return SplitPath(path).Any(segment => segment.Equals(language, StringComparison.OrdinalIgnoreCase)); + } + + private static string ReplaceLanguageFolder(string path, string sourceLanguage, string targetLanguage) + { + var segments = SplitPath(path); + + for (var index = segments.Length - 1; index >= 0; index--) + { + if (segments[index].Equals(sourceLanguage, StringComparison.OrdinalIgnoreCase)) + { + segments[index] = targetLanguage; + return string.Join(Path.DirectorySeparatorChar.ToString(), segments); + } + } + + throw new InvalidOperationException($"The path '{path}' does not contain the language '{sourceLanguage}'."); + } + + private static string[] SplitPath(string path) + { + return path.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + } + + private static string ToSafeRelativePath(string path) + { + return string.Join( + Path.DirectorySeparatorChar.ToString(), + SplitPath(path).Where(segment => segment != "." && segment != ".." && !segment.Contains(':'))); + } + + private static byte[] Serialize(XDocument document) + { + using var stream = new MemoryStream(); + using (var writer = XmlWriter.Create(stream, new XmlWriterSettings + { + Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), + Indent = false, + OmitXmlDeclaration = document.Declaration is null, + })) + { + document.Save(writer); + } + + return stream.ToArray(); + } +} diff --git a/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs b/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs new file mode 100644 index 0000000..6f236d1 --- /dev/null +++ b/src/ReswPlus.BuildTasks/PseudoLocalizationMode.cs @@ -0,0 +1,7 @@ +namespace ReswPlus.BuildTasks; + +internal enum PseudoLocalizationMode +{ + Accented, + Mirrored, +} diff --git a/src/ReswPlus.BuildTasks/PseudoLocalizer.cs b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs new file mode 100644 index 0000000..2cb02e9 --- /dev/null +++ b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.RegularExpressions; + +namespace ReswPlus.BuildTasks; + +internal static class PseudoLocalizer +{ + private static readonly Regex ProtectedToken = new( + @"(\{\{|\}\}|\{[^{}\r\n]*\}|<[^>\r\n]+>)", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + public static string Transform(string value, PseudoLocalizationMode mode, int expansionPercentage) + { + if (value.Length == 0) + { + return value; + } + + var transformed = new StringBuilder(value.Length); + var letterCount = 0; + var currentIndex = 0; + + foreach (Match match in ProtectedToken.Matches(value)) + { + AppendAccented(value, currentIndex, match.Index - currentIndex, transformed, ref letterCount); + transformed.Append(match.Value); + currentIndex = match.Index + match.Length; + } + + AppendAccented(value, currentIndex, value.Length - currentIndex, transformed, ref letterCount); + + var paddingLength = (int)Math.Ceiling(letterCount * expansionPercentage / 100d); + if (paddingLength > 0) + { + transformed.Append(' '); + transformed.Append('~', paddingLength); + } + + return mode == PseudoLocalizationMode.Mirrored + ? "\u202e\u27e6" + transformed + "\u27e7\u202c" + : "\u27e6" + transformed + "\u27e7"; + } + + public static IReadOnlyList<(PseudoLocalizationMode Mode, string Language)> ParseModes(string modes) + { + var parsed = new List<(PseudoLocalizationMode, string)>(); + var seen = new HashSet(); + + foreach (var value in modes.Split(new[] { ';', ',' }, StringSplitOptions.RemoveEmptyEntries)) + { + var normalized = value.Trim(); + PseudoLocalizationMode mode; + + if (normalized.Equals("true", StringComparison.OrdinalIgnoreCase) || + normalized.Equals("accented", StringComparison.OrdinalIgnoreCase)) + { + mode = PseudoLocalizationMode.Accented; + } + else if (normalized.Equals("mirrored", StringComparison.OrdinalIgnoreCase)) + { + mode = PseudoLocalizationMode.Mirrored; + } + else + { + throw new ArgumentException( + $"Unknown pseudo-localization mode '{normalized}'. Use Accented, Mirrored, or both separated by a semicolon.", + nameof(modes)); + } + + if (seen.Add(mode)) + { + parsed.Add((mode, mode == PseudoLocalizationMode.Accented ? "qps-ploc" : "qps-plocm")); + } + } + + return parsed; + } + + private static void AppendAccented( + string value, + int start, + int length, + StringBuilder output, + ref int letterCount) + { + for (var index = start; index < start + length; index++) + { + var character = value[index]; + output.Append(Accent(character)); + + if (char.IsLetter(character)) + { + letterCount++; + } + } + } + + private static char Accent(char character) + { + return character switch + { + 'A' => '\u00c5', + 'B' => '\u0243', + 'C' => '\u00c7', + 'D' => '\u00d0', + 'E' => '\u00cb', + 'F' => '\u0191', + 'G' => '\u011c', + 'H' => '\u0126', + 'I' => '\u00cf', + 'J' => '\u0134', + 'K' => '\u0136', + 'L' => '\u013f', + 'M' => '\u1e40', + 'N' => '\u00d1', + 'O' => '\u00d8', + 'P' => '\u00de', + 'Q' => '\u01ea', + 'R' => '\u0158', + 'S' => '\u0160', + 'T' => '\u0166', + 'U' => '\u00dc', + 'V' => '\u1e7c', + 'W' => '\u0174', + 'X' => '\u1e8a', + 'Y' => '\u0178', + 'Z' => '\u017d', + 'a' => '\u00e5', + 'b' => '\u0180', + 'c' => '\u00e7', + 'd' => '\u00f0', + 'e' => '\u00eb', + 'f' => '\u0192', + 'g' => '\u011d', + 'h' => '\u0127', + 'i' => '\u00ef', + 'j' => '\u0135', + 'k' => '\u0137', + 'l' => '\u0140', + 'm' => '\u1e41', + 'n' => '\u00f1', + 'o' => '\u00f8', + 'p' => '\u00fe', + 'q' => '\u01eb', + 'r' => '\u0159', + 's' => '\u0161', + 't' => '\u0167', + 'u' => '\u00fc', + 'v' => '\u1e7d', + 'w' => '\u0175', + 'x' => '\u1e8b', + 'y' => '\u00ff', + 'z' => '\u017e', + _ => character, + }; + } +} diff --git a/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj b/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj new file mode 100644 index 0000000..ee6fcb3 --- /dev/null +++ b/src/ReswPlus.BuildTasks/ReswPlus.BuildTasks.csproj @@ -0,0 +1,15 @@ + + + + netstandard2.0 + enable + false + + + + + + + + + diff --git a/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj b/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj index 8ff7ae8..1c44b69 100644 --- a/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj +++ b/src/ReswPlus.SourceGenerator/ReswPlus.SourceGenerator.csproj @@ -26,6 +26,9 @@ + diff --git a/tests/ReswPlusUnitTests/PseudoLocalization.cs b/tests/ReswPlusUnitTests/PseudoLocalization.cs new file mode 100644 index 0000000..f6e1fe4 --- /dev/null +++ b/tests/ReswPlusUnitTests/PseudoLocalization.cs @@ -0,0 +1,161 @@ +using System; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Build.Utilities; +using ReswPlus.BuildTasks; +using Xunit; + +namespace ReswPlusUnitTests; + +public class PseudoLocalization +{ + [Fact] + public void AccentedTextPreservesFormatPlaceholdersAndMarkup() + { + var transformed = PseudoLocalizer.Transform( + "Hello {0}, {name} {{literal}}", + PseudoLocalizationMode.Accented, + expansionPercentage: 30); + + Assert.StartsWith("\u27e6\u0126\u00eb\u0140\u0140\u00f8 ", transformed); + Assert.EndsWith("\u27e7", transformed); + Assert.Contains("{0}", transformed); + Assert.Contains("{name}", transformed); + Assert.Contains("{{\u0140\u00ef\u0167\u00eb\u0159\u00e5\u0140}}", transformed); + Assert.Contains("~", transformed); + } + + [Fact] + public void MirroredTextCarriesRightToLeftControlCharacters() + { + var transformed = PseudoLocalizer.Transform( + "Save", + PseudoLocalizationMode.Mirrored, + expansionPercentage: 0); + + Assert.Equal("\u202e\u27e6\u0160\u00e5\u1e7d\u00eb\u27e7\u202c", transformed); + } + + [Fact] + public void ModesMapToWindowsPseudoLocales() + { + var modes = PseudoLocalizer.ParseModes("Accented;Mirrored;Accented"); + + Assert.Equal( + [(PseudoLocalizationMode.Accented, "qps-ploc"), (PseudoLocalizationMode.Mirrored, "qps-plocm")], + modes); + } + + [Fact] + public void UnknownModesAreRejected() + { + var exception = Assert.Throws(() => PseudoLocalizer.ParseModes("Expanded")); + + Assert.Contains("Accented", exception.Message); + Assert.Contains("Mirrored", exception.Message); + } + + [Fact] + public void TheBuildTaskGeneratesIntermediateResourcesFromTheDefaultLanguage() + { + var root = Path.Combine(Path.GetTempPath(), "ReswPlusUnitTests", Guid.NewGuid().ToString("N")); + var projectDirectory = Path.Combine(root, "Project"); + var sourcePath = Path.Combine(projectDirectory, "Strings", "en-US", "Resources.resw"); + var outputDirectory = Path.Combine(root, "obj"); + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(sourcePath)!); + File.WriteAllText(sourcePath, ReswTestHelpers.CreateResw( + ("Welcome", "Welcome, {0}!", "#Format[String name]"))); + + var source = new TaskItem(sourcePath); + source.SetMetadata("Link", @"Strings\en-US\Resources.resw"); + + var task = new GeneratePseudoResources + { + Resources = [source], + DefaultLanguage = "en-US", + ProjectDirectory = projectDirectory, + IntermediateOutputPath = outputDirectory, + Modes = "Accented;Mirrored", + ExpansionPercentage = 30, + }; + + Assert.True(task.Execute()); + Assert.Equal(2, task.GeneratedResources.Length); + + var accented = Assert.Single(task.GeneratedResources, item => + item.GetMetadata("Link") == @"Strings\qps-ploc\Resources.resw"); + var mirrored = Assert.Single(task.GeneratedResources, item => + item.GetMetadata("Link") == @"Strings\qps-plocm\Resources.resw"); + + Assert.Equal(accented.GetMetadata("Link"), accented.GetMetadata("TargetPath")); + Assert.Equal("Accented", accented.GetMetadata("ReswPlusPseudoLocalization")); + Assert.Equal("Mirrored", mirrored.GetMetadata("ReswPlusPseudoLocalization")); + + var document = XDocument.Load(accented.ItemSpec); + var value = document.Descendants("data").Single().Element("value")!.Value; + var comment = document.Descendants("comment").Single().Value; + + Assert.Contains("\u0174\u00eb\u0140\u00e7\u00f8\u1e41\u00eb", value); + Assert.Contains("{0}", value); + Assert.Equal("#Format[String name]", comment); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + + [Fact] + public void PseudoLanguagesAreAddedToTheGeneratedAppxManifest() + { + var root = Path.Combine(Path.GetTempPath(), "ReswPlusUnitTests", Guid.NewGuid().ToString("N")); + var manifestPath = Path.Combine(root, "AppxManifest.xml"); + + try + { + Directory.CreateDirectory(root); + File.WriteAllText( + manifestPath, + """ + + + + + + + """); + + var task = new AddPseudoLanguagesToAppxManifest + { + ManifestPath = manifestPath, + Modes = "Accented;Mirrored", + }; + + Assert.True(task.Execute()); + Assert.True(task.Execute()); + + var document = XDocument.Load(manifestPath); + var languages = document + .Descendants() + .Where(element => element.Name.LocalName == "Resource") + .Select(element => element.Attribute("Language")!.Value) + .ToArray(); + + Assert.Equal(["en-US", "qps-ploc", "qps-plocm"], languages); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } +} \ No newline at end of file diff --git a/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj b/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj index 4424e26..c6a16da 100644 --- a/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj +++ b/tests/ReswPlusUnitTests/ReswPlusUnitTests.csproj @@ -7,6 +7,7 @@ + @@ -22,6 +23,7 @@ ReswPlus.SourceGenerator + From 664eb5be824ca9fb4a11e92f3f4b7cdbaa38db94 Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Sat, 22 Aug 2026 01:03:03 -0700 Subject: [PATCH 2/3] Document pseudo-locale system availability - Clarify that modern Windows needs no language-pack installation - Explain why qps locales are selected per app instead of in Settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Pseudo-localization.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/Pseudo-localization.md b/docs/Pseudo-localization.md index 4acda10..04813a6 100644 --- a/docs/Pseudo-localization.md +++ b/docs/Pseudo-localization.md @@ -77,6 +77,23 @@ Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = ""; An application with an in-app language picker can expose these identifiers only in development builds. They do not need to appear in production UI. +### System-level availability + +Nothing needs to be installed or enabled at the system level on Windows 10 version 1803 and newer, +including Windows 11. Windows includes the `qps-*` pseudo-locales in its National Language Support +APIs, but intentionally hides them from language enumeration. They are not display-language packs, +so they cannot be selected as the Windows display language through Settings. + +On these Windows versions, registry edits do not make pseudo-locales appear in the system language +list. Select `qps-ploc` or `qps-plocm` inside the application as shown above instead. This also keeps +pseudo-localization isolated to the application under test. + +Windows 10 version 1709 and older allowed pseudo-locales to be exposed for enumeration by adding +their LCIDs under `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Nls\Locale`. ReswPlus supports +modern UWP and Windows App SDK targets, so this legacy system configuration is not required. See +[Using pseudo-locales for localizability testing](https://learn.microsoft.com/windows/win32/intl/using-pseudo-locales-for-localization-testing) +for the legacy registry values and NLS details. + ## Configure text expansion Accented and mirrored values expand by 30 percent by default. Adjust the percentage from 0 through 200: From e25ccd1089ec4bfc8a77d4a9d9f46e7b3acb1a16 Mon Sep 17 00:00:00 2001 From: Rudy Huyn Date: Sat, 22 Aug 2026 01:21:30 -0700 Subject: [PATCH 3/3] Force pseudo-language test builds - Replace packaged string languages with one selected pseudo mode - Index transformed strings as neutral resources for clean UWP packaging - Remove the need for application or system language overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/Pseudo-localization.md | 43 +++++++------------ nuget/ReswPlus.targets | 11 ++++- .../GeneratePseudoResources.cs | 14 ++++-- src/ReswPlus.BuildTasks/PseudoLocalizer.cs | 16 ++++++- ...cs => SetPseudoLanguagesInAppxManifest.cs} | 43 ++++++++++--------- tests/ReswPlusUnitTests/PseudoLocalization.cs | 35 ++++++++------- 6 files changed, 91 insertions(+), 71 deletions(-) rename src/ReswPlus.BuildTasks/{AddPseudoLanguagesToAppxManifest.cs => SetPseudoLanguagesInAppxManifest.cs} (62%) diff --git a/docs/Pseudo-localization.md b/docs/Pseudo-localization.md index 04813a6..77cbd45 100644 --- a/docs/Pseudo-localization.md +++ b/docs/Pseudo-localization.md @@ -22,8 +22,11 @@ Enable pseudo-localization with an MSBuild property: ``` ReswPlus reads the `.resw` files under the project's `DefaultLanguage` folder and adds generated -`qps-ploc` resources to the PRI before Windows resource indexing. It also adds the pseudo-language to the -generated AppX manifest so Windows accepts it as an application language. For example: +`qps-ploc` resources to the PRI before Windows resource indexing. For that build, it removes the original +`.resw` items from PRI indexing and replaces the generated AppX manifest languages with the enabled +pseudo-language. The transformed intermediate resources are indexed as the package's neutral strings, which +avoids retaining a real default-language fallback. This makes the pseudo-localized resources the only strings +Windows can select. For example: ```text Welcome, {0}! @@ -48,12 +51,6 @@ Use `Mirrored` to generate the Windows `qps-plocm` pseudo-locale: ``` -Generate both pseudo-locales by separating the modes with a semicolon: - -```xml -Accented;Mirrored -``` - | Mode | Windows language | Behavior | | --- | --- | --- | | `Accented` | `qps-ploc` | Accents characters, expands text, and adds boundary markers | @@ -61,21 +58,11 @@ Generate both pseudo-locales by separating the modes with a semicolon: ## Select the pseudo-language -The generated resources participate in normal Windows resource resolution. Select one before creating UI -that reads resources, then restart the application: - -```csharp -Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = "qps-ploc"; -``` - -Use `qps-plocm` for mirrored testing. Clear the override to return to the user's normal language: - -```csharp -Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = ""; -``` +No application code or system-language change is required. ReswPlus accepts one pseudo-localization mode per +build, excludes the original `.resw` languages, and advertises only the selected pseudo-language in the +generated AppX manifest. Windows therefore selects it automatically when the application starts. -An application with an in-app language picker can expose these identifiers only in development builds. -They do not need to appear in production UI. +Build once with `Accented` and once with `Mirrored` when a test suite needs to exercise both modes. ### System-level availability @@ -85,8 +72,8 @@ APIs, but intentionally hides them from language enumeration. They are not displ so they cannot be selected as the Windows display language through Settings. On these Windows versions, registry edits do not make pseudo-locales appear in the system language -list. Select `qps-ploc` or `qps-plocm` inside the application as shown above instead. This also keeps -pseudo-localization isolated to the application under test. +list. A ReswPlus pseudo-localization build needs no override because its real localized strings are +excluded and the selected pseudo-language is the only packaged string language. Windows 10 version 1709 and older allowed pseudo-locales to be exposed for enumeration by adding their LCIDs under `HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\Nls\Locale`. ReswPlus supports @@ -104,13 +91,13 @@ Accented and mirrored values expand by 30 percent by default. Adjust the percent ## Use in CI -Pseudo-localization is disabled unless `ReswPlusPseudoLocalization` is set. A CI job can enable it without -changing the project: +Pseudo-localization is disabled unless `ReswPlusPseudoLocalization` is set. Because enabling it replaces the +packaged string languages, keep it scoped to test builds. A CI job can enable it without changing the project: ```console dotnet build -p:ReswPlusPseudoLocalization=Accented ``` This verifies that the generated pseudo-language remains packageable and that all source resources can be -parsed. UI automation can then launch the built application with `qps-ploc` selected and check for clipping, -overlap, untranslated strings, and right-to-left regressions. +parsed. UI automation can then launch the built application and check for clipping, overlap, untranslated +strings, and right-to-left regressions. diff --git a/nuget/ReswPlus.targets b/nuget/ReswPlus.targets index 24f5973..5d0e2cf 100644 --- a/nuget/ReswPlus.targets +++ b/nuget/ReswPlus.targets @@ -26,7 +26,7 @@ - + + + $(_ReswPlusPseudoLanguage) + $(_ReswPlusPseudoLanguage) + + @@ -77,7 +84,7 @@ - \ No newline at end of file diff --git a/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs index 8659d90..bba3ab3 100644 --- a/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs +++ b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs @@ -32,6 +32,9 @@ public sealed class GeneratePseudoResources : Task [Output] public ITaskItem[] GeneratedResources { get; private set; } = Array.Empty(); + [Output] + public string PseudoLanguage { get; private set; } = ""; + public override bool Execute() { if (ExpansionPercentage is < 0 or > 200) @@ -54,6 +57,8 @@ public override bool Execute() return false; } + PseudoLanguage = modes[0].Language; + var sourceResources = Resources .Where(resource => Path.GetExtension(resource.ItemSpec).Equals(".resw", StringComparison.OrdinalIgnoreCase)) .Select(resource => (Item: resource, LogicalPath: GetLogicalPath(resource))) @@ -75,7 +80,7 @@ public override bool Execute() { foreach (var mode in modes) { - var logicalPath = ReplaceLanguageFolder(source.LogicalPath, DefaultLanguage, mode.Language); + var logicalPath = RemoveLanguageFolder(source.LogicalPath, DefaultLanguage); var outputPath = Path.GetFullPath(Path.Combine( IntermediateOutputPath, "ReswPlus", @@ -179,7 +184,7 @@ private static bool ContainsLanguageFolder(string path, string language) return SplitPath(path).Any(segment => segment.Equals(language, StringComparison.OrdinalIgnoreCase)); } - private static string ReplaceLanguageFolder(string path, string sourceLanguage, string targetLanguage) + private static string RemoveLanguageFolder(string path, string sourceLanguage) { var segments = SplitPath(path); @@ -187,8 +192,9 @@ private static string ReplaceLanguageFolder(string path, string sourceLanguage, { if (segments[index].Equals(sourceLanguage, StringComparison.OrdinalIgnoreCase)) { - segments[index] = targetLanguage; - return string.Join(Path.DirectorySeparatorChar.ToString(), segments); + return string.Join( + Path.DirectorySeparatorChar.ToString(), + segments.Where((_, segmentIndex) => segmentIndex != index)); } } diff --git a/src/ReswPlus.BuildTasks/PseudoLocalizer.cs b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs index 2cb02e9..e7f0643 100644 --- a/src/ReswPlus.BuildTasks/PseudoLocalizer.cs +++ b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs @@ -65,16 +65,30 @@ public static string Transform(string value, PseudoLocalizationMode mode, int ex else { throw new ArgumentException( - $"Unknown pseudo-localization mode '{normalized}'. Use Accented, Mirrored, or both separated by a semicolon.", + $"Unknown pseudo-localization mode '{normalized}'. Use Accented or Mirrored.", nameof(modes)); } if (seen.Add(mode)) { + if (seen.Count > 1) + { + throw new ArgumentException( + "Choose one pseudo-localization mode per build: Accented or Mirrored.", + nameof(modes)); + } + parsed.Add((mode, mode == PseudoLocalizationMode.Accented ? "qps-ploc" : "qps-plocm")); } } + if (parsed.Count == 0) + { + throw new ArgumentException( + "Choose one pseudo-localization mode per build: Accented or Mirrored.", + nameof(modes)); + } + return parsed; } diff --git a/src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs similarity index 62% rename from src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs rename to src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs index c1341b8..4f9f2ed 100644 --- a/src/ReswPlus.BuildTasks/AddPseudoLanguagesToAppxManifest.cs +++ b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs @@ -9,7 +9,7 @@ namespace ReswPlus.BuildTasks; -public sealed class AddPseudoLanguagesToAppxManifest : Task +public sealed class SetPseudoLanguagesInAppxManifest : Task { [Required] public string ManifestPath { get; set; } = ""; @@ -31,28 +31,29 @@ public override bool Execute() return false; } - var existing = new System.Collections.Generic.HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var resource in resources.Elements().Where(element => element.Name.LocalName == "Resource")) - { - var language = resource.Attribute("Language")?.Value; - if (language is not null) - { - existing.Add(language); - } - } + var languages = PseudoLocalizer.ParseModes(Modes) + .Select(mode => mode.Language) + .ToArray(); + var existing = resources + .Elements() + .Where(element => element.Name.LocalName == "Resource") + .Select(element => element.Attribute("Language")?.Value ?? "") + .ToArray(); + var changed = !existing.SequenceEqual(languages, StringComparer.OrdinalIgnoreCase); - var changed = false; - foreach (var mode in PseudoLocalizer.ParseModes(Modes)) + if (changed) { - if (existing.Add(mode.Language)) + resources.Elements() + .Where(element => element.Name.LocalName == "Resource") + .Remove(); + + foreach (var language in languages) { - resources.Add(new XElement(resources.Name.Namespace + "Resource", new XAttribute("Language", mode.Language))); - changed = true; + resources.Add(new XElement( + resources.Name.Namespace + "Resource", + new XAttribute("Language", language))); } - } - if (changed) - { using var writer = XmlWriter.Create(ManifestPath, new XmlWriterSettings { Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), @@ -71,17 +72,17 @@ public override bool Execute() } catch (XmlException exception) { - Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); return false; } catch (IOException exception) { - Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); return false; } catch (UnauthorizedAccessException exception) { - Log.LogError("Could not add pseudo-languages to '{0}': {1}", ManifestPath, exception.Message); + Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message); return false; } } diff --git a/tests/ReswPlusUnitTests/PseudoLocalization.cs b/tests/ReswPlusUnitTests/PseudoLocalization.cs index f6e1fe4..7baff86 100644 --- a/tests/ReswPlusUnitTests/PseudoLocalization.cs +++ b/tests/ReswPlusUnitTests/PseudoLocalization.cs @@ -40,11 +40,20 @@ public void MirroredTextCarriesRightToLeftControlCharacters() [Fact] public void ModesMapToWindowsPseudoLocales() { - var modes = PseudoLocalizer.ParseModes("Accented;Mirrored;Accented"); + var accented = Assert.Single(PseudoLocalizer.ParseModes("Accented")); + var mirrored = Assert.Single(PseudoLocalizer.ParseModes("Mirrored")); - Assert.Equal( - [(PseudoLocalizationMode.Accented, "qps-ploc"), (PseudoLocalizationMode.Mirrored, "qps-plocm")], - modes); + Assert.Equal((PseudoLocalizationMode.Accented, "qps-ploc"), accented); + Assert.Equal((PseudoLocalizationMode.Mirrored, "qps-plocm"), mirrored); + } + + [Fact] + public void MultipleModesAreRejected() + { + var exception = Assert.Throws(() => + PseudoLocalizer.ParseModes("Accented;Mirrored")); + + Assert.Contains("one pseudo-localization mode", exception.Message); } [Fact] @@ -79,21 +88,17 @@ public void TheBuildTaskGeneratesIntermediateResourcesFromTheDefaultLanguage() DefaultLanguage = "en-US", ProjectDirectory = projectDirectory, IntermediateOutputPath = outputDirectory, - Modes = "Accented;Mirrored", + Modes = "Accented", ExpansionPercentage = 30, }; Assert.True(task.Execute()); - Assert.Equal(2, task.GeneratedResources.Length); - - var accented = Assert.Single(task.GeneratedResources, item => - item.GetMetadata("Link") == @"Strings\qps-ploc\Resources.resw"); - var mirrored = Assert.Single(task.GeneratedResources, item => - item.GetMetadata("Link") == @"Strings\qps-plocm\Resources.resw"); + var accented = Assert.Single(task.GeneratedResources); + Assert.Equal("qps-ploc", task.PseudoLanguage); + Assert.Equal(@"Strings\Resources.resw", accented.GetMetadata("Link")); Assert.Equal(accented.GetMetadata("Link"), accented.GetMetadata("TargetPath")); Assert.Equal("Accented", accented.GetMetadata("ReswPlusPseudoLocalization")); - Assert.Equal("Mirrored", mirrored.GetMetadata("ReswPlusPseudoLocalization")); var document = XDocument.Load(accented.ItemSpec); var value = document.Descendants("data").Single().Element("value")!.Value; @@ -132,10 +137,10 @@ public void PseudoLanguagesAreAddedToTheGeneratedAppxManifest() """); - var task = new AddPseudoLanguagesToAppxManifest + var task = new SetPseudoLanguagesInAppxManifest { ManifestPath = manifestPath, - Modes = "Accented;Mirrored", + Modes = "Accented", }; Assert.True(task.Execute()); @@ -148,7 +153,7 @@ public void PseudoLanguagesAreAddedToTheGeneratedAppxManifest() .Select(element => element.Attribute("Language")!.Value) .ToArray(); - Assert.Equal(["en-US", "qps-ploc", "qps-plocm"], languages); + Assert.Equal(["qps-ploc"], languages); } finally {