diff --git a/src/Umbraco.Core/Strings/CharacterMappingFile.cs b/src/Umbraco.Core/Strings/CharacterMappingFile.cs
new file mode 100644
index 0000000000..c5e26b3a73
--- /dev/null
+++ b/src/Umbraco.Core/Strings/CharacterMappingFile.cs
@@ -0,0 +1,27 @@
+namespace Umbraco.Cms.Core.Strings;
+
+///
+/// Represents a character mapping JSON file.
+///
+internal sealed class CharacterMappingFile
+{
+ ///
+ /// Name of the mapping set.
+ ///
+ public required string Name { get; init; }
+
+ ///
+ /// Optional description.
+ ///
+ public string? Description { get; init; }
+
+ ///
+ /// Priority for override ordering. Higher values override lower.
+ ///
+ public int Priority { get; init; }
+
+ ///
+ /// Character to string mappings.
+ ///
+ public required Dictionary Mappings { get; init; }
+}
diff --git a/src/Umbraco.Core/Strings/CharacterMappingLoader.cs b/src/Umbraco.Core/Strings/CharacterMappingLoader.cs
new file mode 100644
index 0000000000..fa859dd03e
--- /dev/null
+++ b/src/Umbraco.Core/Strings/CharacterMappingLoader.cs
@@ -0,0 +1,147 @@
+using System.Collections.Frozen;
+using System.Text.Json;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Umbraco.Cms.Core.Strings;
+
+///
+/// Loads character mappings from embedded JSON files and user configuration.
+///
+public sealed class CharacterMappingLoader : ICharacterMappingLoader
+{
+ private static readonly string[] BuiltInFiles =
+ ["ligatures.json", "special-latin.json", "cyrillic.json"];
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip
+ };
+
+ private readonly IHostEnvironment _hostEnvironment;
+ private readonly ILogger _logger;
+
+ public CharacterMappingLoader(
+ IHostEnvironment hostEnvironment,
+ ILogger logger)
+ {
+ _hostEnvironment = hostEnvironment;
+ _logger = logger;
+ }
+
+ ///
+ public FrozenDictionary LoadMappings()
+ {
+ var allMappings = new List<(int Priority, string Name, Dictionary Mappings)>();
+
+ // 1. Load built-in mappings from embedded resources
+ foreach (var file in BuiltInFiles)
+ {
+ var mapping = LoadEmbeddedMapping(file);
+ if (mapping != null)
+ {
+ allMappings.Add((mapping.Priority, mapping.Name, mapping.Mappings));
+ _logger.LogDebug(
+ "Loaded built-in character mappings: {Name} ({Count} entries)",
+ mapping.Name, mapping.Mappings.Count);
+ }
+ }
+
+ // 2. Load user mappings from config directory
+ var userPath = Path.Combine(
+ _hostEnvironment.ContentRootPath,
+ "config",
+ "character-mappings");
+
+ if (Directory.Exists(userPath))
+ {
+ foreach (var file in Directory.GetFiles(userPath, "*.json"))
+ {
+ var mapping = LoadJsonFile(file);
+ if (mapping != null)
+ {
+ allMappings.Add((mapping.Priority, mapping.Name, mapping.Mappings));
+ _logger.LogInformation(
+ "Loaded user character mappings: {Name} ({Count} entries, priority {Priority})",
+ mapping.Name, mapping.Mappings.Count, mapping.Priority);
+ }
+ }
+ }
+
+ // 3. Merge by priority (higher priority wins)
+ return MergeMappings(allMappings);
+ }
+
+ private static FrozenDictionary MergeMappings(
+ List<(int Priority, string Name, Dictionary Mappings)> allMappings)
+ {
+ var merged = new Dictionary();
+
+ foreach (var (_, _, mappings) in allMappings.OrderBy(m => m.Priority))
+ {
+ foreach (var (key, value) in mappings)
+ {
+ if (key.Length == 1)
+ {
+ merged[key[0]] = value;
+ }
+ }
+ }
+
+ return merged.ToFrozenDictionary();
+ }
+
+ private CharacterMappingFile? LoadEmbeddedMapping(string fileName)
+ {
+ var assembly = typeof(CharacterMappingLoader).Assembly;
+ var resourceName = $"Umbraco.Cms.Core.Strings.CharacterMappings.{fileName}";
+
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ {
+ _logger.LogWarning(
+ "Built-in character mapping file not found: {ResourceName}",
+ resourceName);
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(stream, JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Failed to parse embedded mapping: {ResourceName}", resourceName);
+ return null;
+ }
+ }
+
+ private CharacterMappingFile? LoadJsonFile(string path)
+ {
+ try
+ {
+ var json = File.ReadAllText(path);
+ var mapping = JsonSerializer.Deserialize(json, JsonOptions);
+
+ if (mapping?.Mappings == null)
+ {
+ _logger.LogWarning(
+ "Invalid mapping file {Path}: missing 'mappings' property", path);
+ return null;
+ }
+
+ return mapping;
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, "Failed to parse character mappings from {Path}", path);
+ return null;
+ }
+ catch (IOException ex)
+ {
+ _logger.LogWarning(ex, "Failed to read character mappings from {Path}", path);
+ return null;
+ }
+ }
+}
diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs
new file mode 100644
index 0000000000..916239710f
--- /dev/null
+++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Strings/CharacterMappingLoaderTests.cs
@@ -0,0 +1,90 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using NUnit.Framework;
+using Umbraco.Cms.Core.Strings;
+
+namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Core.Strings;
+
+[TestFixture]
+public class CharacterMappingLoaderTests
+{
+ [Test]
+ public void LoadMappings_LoadsBuiltInMappings()
+ {
+ // Arrange
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ // Act
+ var mappings = loader.LoadMappings();
+
+ // Assert
+ Assert.IsNotNull(mappings);
+ Assert.That(mappings.Count, Is.GreaterThan(0), "Should have loaded mappings");
+ }
+
+ [Test]
+ public void LoadMappings_ContainsLigatures()
+ {
+ // Arrange
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ // Act
+ var mappings = loader.LoadMappings();
+
+ // Assert
+ Assert.AreEqual("OE", mappings['Œ']);
+ Assert.AreEqual("ae", mappings['æ']);
+ Assert.AreEqual("ss", mappings['ß']);
+ }
+
+ [Test]
+ public void LoadMappings_ContainsCyrillic()
+ {
+ // Arrange
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ // Act
+ var mappings = loader.LoadMappings();
+
+ // Assert
+ Assert.AreEqual("Shch", mappings['Щ']);
+ Assert.AreEqual("zh", mappings['ж']);
+ Assert.AreEqual("Ya", mappings['Я']);
+ }
+
+ [Test]
+ public void LoadMappings_ContainsSpecialLatin()
+ {
+ // Arrange
+ var hostEnv = new Mock();
+ hostEnv.Setup(h => h.ContentRootPath).Returns("/nonexistent");
+
+ var loader = new CharacterMappingLoader(
+ hostEnv.Object,
+ NullLogger.Instance);
+
+ // Act
+ var mappings = loader.LoadMappings();
+
+ // Assert
+ Assert.AreEqual("L", mappings['Ł']);
+ Assert.AreEqual("O", mappings['Ø']);
+ Assert.AreEqual("TH", mappings['Þ']);
+ }
+}