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..77cbd45
--- /dev/null
+++ b/docs/Pseudo-localization.md
@@ -0,0 +1,103 @@
+# 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. 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}!
+```
+
+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
+
+```
+
+| 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
+
+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.
+
+Build once with `Accented` and once with `Mirrored` when a test suite needs to exercise both modes.
+
+### 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. 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
+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:
+
+```xml
+50
+```
+
+## Use in CI
+
+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 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..5d0e2cf 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,55 @@
true
+
+
+
+
+
+
+
+
+ $(_ReswPlusPseudoLanguage)
+ $(_ReswPlusPseudoLanguage)
+
+
+
+
+
+
+
+
+
+
+ <_ReswPlusPseudoManifestInput>$(IntermediateOutputPath)ReswPlus\PseudoLocalization\manifest.mode
+
+
+
+
+ <_GenerateCurrentProjectAppxManifestInput Include="$(_ReswPlusPseudoManifestInput)" />
+
+
+
+
+
+
\ 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..bba3ab3
--- /dev/null
+++ b/src/ReswPlus.BuildTasks/GeneratePseudoResources.cs
@@ -0,0 +1,233 @@
+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();
+
+ [Output]
+ public string PseudoLanguage { get; private set; } = "";
+
+ 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;
+ }
+
+ PseudoLanguage = modes[0].Language;
+
+ 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 = RemoveLanguageFolder(source.LogicalPath, DefaultLanguage);
+ 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 RemoveLanguageFolder(string path, string sourceLanguage)
+ {
+ var segments = SplitPath(path);
+
+ for (var index = segments.Length - 1; index >= 0; index--)
+ {
+ if (segments[index].Equals(sourceLanguage, StringComparison.OrdinalIgnoreCase))
+ {
+ return string.Join(
+ Path.DirectorySeparatorChar.ToString(),
+ segments.Where((_, segmentIndex) => segmentIndex != index));
+ }
+ }
+
+ 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..e7f0643
--- /dev/null
+++ b/src/ReswPlus.BuildTasks/PseudoLocalizer.cs
@@ -0,0 +1,173 @@
+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 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;
+ }
+
+ 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.BuildTasks/SetPseudoLanguagesInAppxManifest.cs b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs
new file mode 100644
index 0000000..4f9f2ed
--- /dev/null
+++ b/src/ReswPlus.BuildTasks/SetPseudoLanguagesInAppxManifest.cs
@@ -0,0 +1,89 @@
+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 SetPseudoLanguagesInAppxManifest : 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 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);
+
+ if (changed)
+ {
+ 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", language)));
+ }
+
+ 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 set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message);
+ return false;
+ }
+ catch (IOException exception)
+ {
+ Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message);
+ return false;
+ }
+ catch (UnauthorizedAccessException exception)
+ {
+ Log.LogError("Could not set pseudo-languages in '{0}': {1}", ManifestPath, exception.Message);
+ return false;
+ }
+ }
+}
\ No newline at end of file
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..7baff86
--- /dev/null
+++ b/tests/ReswPlusUnitTests/PseudoLocalization.cs
@@ -0,0 +1,166 @@
+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 accented = Assert.Single(PseudoLocalizer.ParseModes("Accented"));
+ var mirrored = Assert.Single(PseudoLocalizer.ParseModes("Mirrored"));
+
+ 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]
+ 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",
+ ExpansionPercentage = 30,
+ };
+
+ Assert.True(task.Execute());
+ 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"));
+
+ 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 SetPseudoLanguagesInAppxManifest
+ {
+ ManifestPath = manifestPath,
+ Modes = "Accented",
+ };
+
+ 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(["qps-ploc"], 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
+