diff --git a/.markdownlint.json b/.markdownlint.json index 25e746e53..a94809e1d 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -4,7 +4,8 @@ "allowed_elements": [ "details", "summary", - "span" + "span", + "br" ] } } diff --git a/GW2SDK/Features/Markup/MarkupColorName.cs b/GW2SDK/Features/Markup/MarkupColorName.cs index 25ae64607..92650c474 100644 --- a/GW2SDK/Features/Markup/MarkupColorName.cs +++ b/GW2SDK/Features/Markup/MarkupColorName.cs @@ -1,4 +1,4 @@ -using static System.StringComparison; +using System.Collections.ObjectModel; namespace GuildWars2.Markup; @@ -6,31 +6,50 @@ namespace GuildWars2.Markup; [PublicAPI] public static class MarkupColorName { - /// The color for flavor text. + /// The color for flavor text. Color used in game: Aqua. public static string Flavor => "@flavor"; - /// The color for reminder text. + /// The color for reminder text. Color used in game: Gray. public static string Reminder => "@reminder"; - /// The color for ability type text. + /// The color for ability type text. Color used in game: Light Yellow. public static string AbilityType => "@abilitytype"; - /// The color for warning text. + /// The color for warning text. Color used in game: Red. public static string Warning => "@warning"; - /// The color for task text. + /// The color for task text. Color used in game: Gold. public static string Task => "@task"; + /// + /// A dictionary that maps color names to their corresponding hex color codes, based on colors picked from the game. + /// + /// + /// The dictionary is case-insensitive and contains the following default mappings: + /// + /// @abilitytype#ffee88 (light yellow) + /// @flavor#99dddd (aqua) + /// @reminder#aaaaaa (gray) + /// @task#ffcc55 (gold) + /// @warning#ff0000 (red) + /// + /// + public static readonly IReadOnlyDictionary DefaultColorMap = + new ReadOnlyDictionary(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [AbilityType] = "#ffee88", + [Flavor] = "#99dddd", + [Reminder] = "#aaaaaa", + [Task] = "#ffcc55", + [Warning] = "#ff0000" + }); + /// Determines whether the specified color name is defined. /// The name should include the leading '@' symbol, e.g. '@flavor'. /// The name of the color to check. /// true if the specified color name is defined; otherwise, false. public static bool IsDefined(string colorName) { - return string.Equals(colorName, Flavor, OrdinalIgnoreCase) - || string.Equals(colorName, Reminder, OrdinalIgnoreCase) - || string.Equals(colorName, AbilityType, OrdinalIgnoreCase) - || string.Equals(colorName, Warning, OrdinalIgnoreCase) - || string.Equals(colorName, Task, OrdinalIgnoreCase); + return DefaultColorMap.ContainsKey(colorName); } } diff --git a/GW2SDK/Features/Markup/MarkupConverter.cs b/GW2SDK/Features/Markup/MarkupConverter.cs index bac055370..b6e367e84 100644 --- a/GW2SDK/Features/Markup/MarkupConverter.cs +++ b/GW2SDK/Features/Markup/MarkupConverter.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace GuildWars2.Markup; /// Provides functionality to convert markup strings to other formats. @@ -27,7 +29,7 @@ public static string ToPlainText(string markup) return TextConverter.Convert(rootNode); } - /// Converts a markup string to a string with HTML formatting. + /// Converts a markup string to a string with HTML formatting using the . /// The markup string to convert. /// The HTML string. public static string ToHtml(string markup) @@ -42,4 +44,19 @@ public static string ToHtml(string markup) return HtmlConverter.Convert(rootNode); } + /// Converts a markup string to a string with HTML formatting using a custom color map. + /// The markup string to convert. + /// A dictionary mapping color names to their corresponding HTML color codes. + /// The HTML string. + public static string ToHtml(string markup, IReadOnlyDictionary colorMap) + { + if (string.IsNullOrWhiteSpace(markup)) + { + return markup; + } + + var tokens = Lexer.Tokenize(markup); + var rootNode = Parser.Parse(tokens); + return HtmlConverter.Convert(rootNode, colorMap); + } } diff --git a/GW2SDK/Features/Markup/MarkupHtmlConverter.cs b/GW2SDK/Features/Markup/MarkupHtmlConverter.cs index c66f0516f..290f0497d 100644 --- a/GW2SDK/Features/Markup/MarkupHtmlConverter.cs +++ b/GW2SDK/Features/Markup/MarkupHtmlConverter.cs @@ -9,22 +9,41 @@ namespace GuildWars2.Markup; public sealed class MarkupHtmlConverter { /// - /// Converts a to its HTML representation. + /// Converts a to its HTML representation using the . /// - /// The root node containing nodes to be converted. - /// A string representation of the nodes within the root node. + /// The root node of the markup syntax tree to convert. + /// A string containing the HTML representation of the markup syntax tree. public string Convert(RootNode root) { + return Convert(root, MarkupColorName.DefaultColorMap); + } + + /// Converts a and its children to an HTML string representation using a custom color map. + /// The root node of the markup syntax tree to convert. + /// A dictionary mapping color names to their corresponding HTML color codes. + /// A string containing the HTML representation of the markup syntax tree. + public string Convert(RootNode root, IReadOnlyDictionary? colorMap) + { + if (colorMap == null) + { + colorMap = MarkupColorName.DefaultColorMap; + } + else if (colorMap != MarkupColorName.DefaultColorMap) + { + // Ensure the key comparison is case-insensitive + colorMap = colorMap.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.OrdinalIgnoreCase); + } + var builder = new StringBuilder(); foreach (var node in root.Children) { - builder.Append(ConvertNode(node)); + builder.Append(ConvertNode(node, colorMap)); } return builder.ToString(); } - private string ConvertNode(MarkupNode node) + private string ConvertNode(MarkupNode node, IReadOnlyDictionary colorMap) { switch (node) { @@ -33,30 +52,20 @@ private string ConvertNode(MarkupNode node) case LineBreakNode: return "
"; case ColoredTextNode coloredText: - var content = string.Concat(coloredText.Children.Select(ConvertNode)); - if (coloredText.Color.StartsWith("#")) + var builder = new StringBuilder(); + foreach (var child in coloredText.Children) { - return $"{content}"; + builder.Append(ConvertNode(child, colorMap)); } - else if (string.Equals(coloredText.Color, MarkupColorName.Flavor, StringComparison.OrdinalIgnoreCase)) - { - return $"{content}"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Reminder, StringComparison.OrdinalIgnoreCase)) - { - return $"{content}"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.AbilityType, StringComparison.OrdinalIgnoreCase)) - { - return $"{content}"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Warning, StringComparison.OrdinalIgnoreCase)) + + var content = builder.ToString(); + if (coloredText.Color.StartsWith("#")) { - return $"{content}"; + return $"{content}"; } - else if (string.Equals(coloredText.Color, MarkupColorName.Task, StringComparison.OrdinalIgnoreCase)) + else if (colorMap.TryGetValue(coloredText.Color, out var color)) { - return $"{content}"; + return $"{content}"; } else { diff --git a/docs/guide/getting-started/formatted-text.md b/docs/guide/getting-started/formatted-text.md index 3f2046b46..ab27fd4a8 100644 --- a/docs/guide/getting-started/formatted-text.md +++ b/docs/guide/getting-started/formatted-text.md @@ -15,8 +15,8 @@ Double-click to apply to an unused infusion slot. Adds a festive glow. Which is rendered as: > Double-click to apply to an unused infusion slot. Adds a festive glow. -> Warning! -> Captain's Council recommends avoiding direct +> Warning! +> Captain's Council recommends avoiding direct contact with this substance. ## Converting formatted text to other formats @@ -30,59 +30,76 @@ it to other formats: ### Converting formatted text to plain text ```csharp -var lexer = new MarkupLexer(); -var parser = new MarkupParser(); -var converter = new MarkupTextConverter(); +using GuildWars2.Markup; var input = "Double-click to apply to an unused infusion slot. Adds a festive glow." + "\nWarning!" + "\nCaptain's Council recommends avoiding direct contact with this" + " substance."; -var tokens = lexer.Tokenize(input); -var syntax = parser.Parse(tokens); -var actual = converter.Convert(syntax); -Console.WriteLine(actual); + +var plainText = MarkupConverter.ToPlainText(input); +Console.WriteLine(plainText); ``` Output: ```text -Double-click to apply to an unused infusion slot. Adds a festive glow +Double-click to apply to an unused infusion slot. Adds a festive glow. Warning! -Captain's Council recommends avoiding direct contact with this substance. +Captain's Council recommends avoiding direct contact with this substance ``` ### Converting formatted text to HTML ```csharp -var lexer = new MarkupLexer(); -var parser = new MarkupParser(); -var converter = new MarkupHtmlConverter(); - var input = "Double-click to apply to an unused infusion slot. Adds a festive glow." + "\nWarning!" + "\nCaptain's Council recommends avoiding direct contact with this" + " substance."; -var tokens = lexer.Tokenize(input); -var syntax = parser.Parse(tokens); -var actual = converter.Convert(syntax); -Console.WriteLine(actual); + +var html = MarkupConverter.ToHtml(input); +Console.WriteLine(html); ``` Output: ```html Double-click to apply to an unused infusion slot. Adds a festive glow.
-Warning!
Captain's +Warning!
Captain's Council recommends avoiding direct contact with this substance. ``` +#### Overriding default colors + +Optionally, you can override the default colors. Start by cloning the default +color map, and then modify the values: + +```csharp +var input = "Double-click to apply to an unused infusion slot. Adds a festive glow." + + "\nWarning!" + + "\nCaptain's Council recommends avoiding direct contact with this" + + " substance."; + +var colorMap = new Dictionary(MarkupColorName.DefaultColorMap) +{ + [MarkupColorName.Flavor] = "hotpink" +}; + +var html = MarkupConverter.ToHtml(input, colorMap); +Console.WriteLine(html); +``` + +Output (rendered in HTML): + +> Double-click to apply to an unused infusion slot. Adds a festive glow.
+Warning!
+Captain's Council recommends avoiding direct contact with this substance. + ### Building a custom formatter -Depending on your UI framework, you may want to build a custom formatter to convert -the game's markup language to your UI framework's text formatting. You can use the -`MarkupLexer` and `MarkupParser` to tokenize and parse the markup language, and then -convert the syntax tree to your desired format. +You may want to build a custom formatter for your UI framework if it can't render +HTML. You can use the `MarkupLexer` and `MarkupParser` to tokenize and parse the +markup language, and then convert the syntax tree to the desired format. For example, to convert the markup language to the markup language used by [Spectre.Console](https://spectreconsole.net/markup): @@ -118,62 +135,83 @@ public class SpectreMarkupConverter private string ConvertNode(MarkupNode node) { // MarkupNode is the base type for all nodes in the syntax tree. - // Use pattern matching to convert each type of node to the desired format. - switch (node) + // Use a switch statement to convert each type of node to the desired format. + // You could also use pattern matching with C# 9. + switch (node.Type) { // TextNode is just a plain text node, no formatting. - case TextNode text: + case MarkupNodeType.Text: + var text = (TextNode)node; return Markup.Escape(text.Text); // LineBreakNode represents a line break, covers both \n and
- case LineBreakNode: + case MarkupNodeType.LineBreak: return Environment.NewLine; - // ColoredTextNode represents text with a color like text - // or text - case ColoredTextNode coloredText: - var content = string.Concat(coloredText.Children.Select(ConvertNode)); - if (coloredText.Color.StartsWith("#", StringComparison.Ordinal)) - { - return $"[${coloredText.Color}]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Flavor, - StringComparison.OrdinalIgnoreCase)) - { - return $"[#9BE8E4]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Reminder, - StringComparison.OrdinalIgnoreCase)) + // ColoredTextNode represents text with a color like text + // or text + case MarkupNodeType.ColoredText: + var coloredText = (ColoredTextNode)node; + var builder = new StringBuilder(); + foreach (var child in coloredText.Children) { - return $"[#B0B0B0]{content}[/]"; + builder.Append(ConvertNode(child)); } - else if (string.Equals(coloredText.Color, MarkupColorName.AbilityType, - StringComparison.OrdinalIgnoreCase)) - { - return $"[#FFEC8C]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Warning, - StringComparison.OrdinalIgnoreCase)) + + var content = builder.ToString(); + if (coloredText.Color.StartsWith("#", StringComparison.Ordinal)) { - return $"[#ED0002]{content}[/]"; + var colorCode = coloredText.Color; + return $"[{colorCode}]{content}[/]"; } - else if (string.Equals(coloredText.Color, MarkupColorName.Task, - StringComparison.OrdinalIgnoreCase)) + else if (ColorMap.TryGetValue(coloredText.Color, out var colorCode)) { - return $"[#FFC957]{content}[/]"; + return $"[{colorCode}]{content}[/]"; } else { return content; } + default: return ""; } } + + // A map of color names to hexadecimal RGB values. + // Note that the color names are case-insensitive. + private static readonly IReadOnlyDictionary ColorMap + = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [MarkupColorName.Flavor] = "#99dddd", + [MarkupColorName.Reminder] = "#aaaaaa", + [MarkupColorName.AbilityType] = "#ffee88", + [MarkupColorName.Warning] = "#ff0000", + [MarkupColorName.Task] = "#ffcc55", + }; } ``` +Usage: + +```csharp +// Set up the lexer, parser, and converter. +var lexer = new MarkupLexer(); +var parser = new MarkupParser(); +var converter = new SpectreMarkupConverter(); + +// Tokenize the input +var input = "... (markup text)"; +var tokens = lexer.Tokenize(input); + +// Convert the tokens to a syntax tree +var syntax = parser.Parse(tokens); + +// Convert the syntax tree to the desired format +var output = converter.Convert(syntax); +``` + ## Language reference The markup language is quite simple, it only supports a few tags: @@ -181,11 +219,11 @@ The markup language is quite simple, it only supports a few tags: - `c`: Changes the color of the text. The color is specified by a color name or a hexadecimal RGB value. For example, `Warning!` renders as: - > Warning! + > Warning! > A hexadecimal RGB value can be used instead of a color name. For example, - `Attention` renders as: - > Attention + `Attention` renders as: + > Attention > - `br`: Inserts a line break. For example, `Line 1
Line 2` renders as: > Line 1 @@ -201,7 +239,7 @@ Tags and attributes - HTML supports tags with attributes like `text`. - GW2 uses a simplified tag system with a single attribute name and value. - For example, `text`. + For example, `text`. Whitespace diff --git a/samples/MostVersatileMaterials/SpectreMarkupConverter.cs b/samples/MostVersatileMaterials/SpectreMarkupConverter.cs index 61705f597..0cfed347f 100644 --- a/samples/MostVersatileMaterials/SpectreMarkupConverter.cs +++ b/samples/MostVersatileMaterials/SpectreMarkupConverter.cs @@ -19,44 +19,50 @@ public string Convert(RootNode root) private string ConvertNode(MarkupNode node) { - switch (node) + switch (node.Type) { - case TextNode text: + case MarkupNodeType.Text: + var text = (TextNode)node; return Markup.Escape(text.Text); - case LineBreakNode: + + case MarkupNodeType.LineBreak: return Environment.NewLine; - case ColoredTextNode coloredText: - var content = string.Concat(coloredText.Children.Select(ConvertNode)); - if (coloredText.Color.StartsWith("#", StringComparison.Ordinal)) - { - return $"[${coloredText.Color}]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Flavor, StringComparison.OrdinalIgnoreCase)) - { - return $"[#9BE8E4]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.Reminder, StringComparison.OrdinalIgnoreCase)) - { - return $"[#B0B0B0]{content}[/]"; - } - else if (string.Equals(coloredText.Color, MarkupColorName.AbilityType, StringComparison.OrdinalIgnoreCase)) + + case MarkupNodeType.ColoredText: + var coloredText = (ColoredTextNode)node; + var builder = new StringBuilder(); + foreach (var child in coloredText.Children) { - return $"[#FFEC8C]{content}[/]"; + builder.Append(ConvertNode(child)); } - else if (string.Equals(coloredText.Color, MarkupColorName.Warning, StringComparison.OrdinalIgnoreCase)) + + var content = builder.ToString(); + if (coloredText.Color.StartsWith("#", StringComparison.Ordinal)) { - return $"[#ED0002]{content}[/]"; + var colorCode = coloredText.Color; + return $"[{colorCode}]{content}[/]"; } - else if (string.Equals(coloredText.Color, MarkupColorName.Task, StringComparison.OrdinalIgnoreCase)) + else if (ColorMap.TryGetValue(coloredText.Color, out var colorCode)) { - return $"[#FFC957]{content}[/]"; + return $"[{colorCode}]{content}[/]"; } else { return content; } + default: return ""; } } + + private static readonly IReadOnlyDictionary ColorMap + = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [MarkupColorName.Flavor] = "#99dddd", + [MarkupColorName.Reminder] = "#aaaaaa", + [MarkupColorName.AbilityType] = "#ffee88", + [MarkupColorName.Warning] = "#ff0000", + [MarkupColorName.Task] = "#ffcc55", + }; }