diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index e738a5614..d24568fe6 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30225.117 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 15.0.26124.0 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Spectre.Console", "Spectre.Console\Spectre.Console.csproj", "{80DCBEF3-99D6-46C0-9C5B-42B4534D9113}" EndProject diff --git a/src/Spectre.Console/AnsiConsole.Recording.cs b/src/Spectre.Console/AnsiConsole.Recording.cs index a398a3742..1aba8d560 100644 --- a/src/Spectre.Console/AnsiConsole.Recording.cs +++ b/src/Spectre.Console/AnsiConsole.Recording.cs @@ -44,6 +44,20 @@ public static string ExportHtml() return _recorder.ExportHtml(); } + /// + /// Exports all recorded console output as SVG text. + /// + /// The recorded output as SVG text. + public static string ExportSvg() + { + if (_recorder == null) + { + throw new InvalidOperationException("Cannot export SVG since a recording hasn't been started."); + } + + return _recorder.ExportSvg(); + } + /// /// Exports all recorded console output using a custom encoder. /// @@ -53,7 +67,7 @@ public static string ExportCustom(IAnsiConsoleEncoder encoder) { if (_recorder == null) { - throw new InvalidOperationException("Cannot export HTML since a recording hasn't been started."); + throw new InvalidOperationException("Cannot export since a recording hasn't been started."); } if (encoder is null) diff --git a/src/Spectre.Console/Color.cs b/src/Spectre.Console/Color.cs index 38825a991..e80c98837 100644 --- a/src/Spectre.Console/Color.cs +++ b/src/Spectre.Console/Color.cs @@ -242,6 +242,23 @@ public static Color FromConsoleColor(ConsoleColor color) }; } + /// + /// Converts a hexadecimal format string to a . + /// + /// The hexadecimal format string to parse. + /// A representing the hexadecimal format string. + public static Color FromHex(string hex) + { + var color = StyleParser.ParseHexColor(hex, out var error); + if (color == null || error != null) + { + error ??= "Could not parse hex color"; + throw new InvalidOperationException(error); + } + + return color.Value; + } + /// /// Converts the color to a markup string. /// diff --git a/src/Spectre.Console/Extensions/RecorderExtensions.cs b/src/Spectre.Console/Extensions/RecorderExtensions.cs index 459a2ec7e..d8844191f 100644 --- a/src/Spectre.Console/Extensions/RecorderExtensions.cs +++ b/src/Spectre.Console/Extensions/RecorderExtensions.cs @@ -37,4 +37,21 @@ public static string ExportHtml(this Recorder recorder) return recorder.Export(_htmlEncoder); } + + /// + /// Exports the recorded content as SVG. + /// + /// The recorder. + /// The recorded content as HTML. + public static string ExportSvg(this Recorder recorder) + { + if (recorder is null) + { + throw new ArgumentNullException(nameof(recorder)); + } + + return recorder.Export( + new SvgEncoder( + new SvgEncoderSettings())); + } } \ No newline at end of file diff --git a/src/Spectre.Console/Internal/Text/Encoding/HtmlEncoder.cs b/src/Spectre.Console/Internal/Text/Encoding/HtmlEncoder.cs index d021e2684..c30e1c7eb 100644 --- a/src/Spectre.Console/Internal/Text/Encoding/HtmlEncoder.cs +++ b/src/Spectre.Console/Internal/Text/Encoding/HtmlEncoder.cs @@ -1,3 +1,5 @@ +using System.Web; + namespace Spectre.Console.Internal; internal sealed class HtmlEncoder : IAnsiConsoleEncoder @@ -42,7 +44,7 @@ public string Encode(IAnsiConsole console, IEnumerable renderables) } builder.Append('>'); - builder.Append(line); + builder.Append(HttpUtility.HtmlEncode(line)); builder.Append(""); if (parts.Length > 1 && !last) diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgCssBuilder.cs b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgCssBuilder.cs new file mode 100644 index 000000000..126e79f58 --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgCssBuilder.cs @@ -0,0 +1,62 @@ +namespace Spectre.Console.Internal; + +internal static class SvgCssBuilder +{ + public static string BuildCss(SvgTheme theme, Style style) + { + var css = new List(); + + var foreground = theme.GetColor(style.Foreground); + var background = theme.GetColor(style.Background); + + if ((style.Decoration & Decoration.Invert) != 0) + { + var temp = foreground; + foreground = background; + background = temp; + } + + if ((style.Decoration & Decoration.Dim) != 0) + { + var blender = background; + if (blender.Equals(Color.Default)) + { + blender = Color.White; + } + + foreground = foreground.Blend(blender, 0.5f); + } + + if (!foreground.Equals(Color.Default)) + { + css.Add($"color: #{foreground.ToHex()}"); + } + + if (!background.Equals(Color.Default)) + { + css.Add($"background-color: #{background.ToHex()}"); + } + + if ((style.Decoration & Decoration.Bold) != 0) + { + css.Add("font-weight: bold"); + } + + if ((style.Decoration & Decoration.Bold) != 0) + { + css.Add("font-style: italic"); + } + + if ((style.Decoration & Decoration.Underline) != 0) + { + css.Add("text-decoration: underline"); + } + + if ((style.Decoration & Decoration.Strikethrough) != 0) + { + css.Add("text-decoration: line-through"); + } + + return string.Join(";", css); + } +} diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoder.cs b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoder.cs new file mode 100644 index 000000000..d0855e1d0 --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoder.cs @@ -0,0 +1,90 @@ +using System.Web; + +namespace Spectre.Console.Internal; + +internal sealed class SvgEncoder : IAnsiConsoleEncoder +{ + private readonly SvgEncoderSettings _settings; + + public SvgEncoder(SvgEncoderSettings settings) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + } + + public string Encode(IAnsiConsole console, IEnumerable renderable) + { + var template = GetTemplate(); + if (template == null) + { + throw new InvalidOperationException("Template could not be found"); + } + + var context = new RenderContext(new EncoderCapabilities(ColorSystem.TrueColor)); + var segmentLines = Segment.SplitLines( + renderable.SelectMany(r => r.Render(context, console.Profile.Width))); + + var lines = new List(); + foreach (var segmentLine in segmentLines) + { + var line = new List(); + + foreach (var segment in segmentLine) + { + if (segment.IsControlCode) + { + continue; + } + + var text = HttpUtility.HtmlEncode(segment.Text); + + if (segment.Style != Style.Plain) + { + var rule = SvgCssBuilder.BuildCss(_settings.Theme, segment.Style); + text = $"{text}"; + } + else + { + text = $"{text}"; + } + + line.Add(text); + } + + lines.Add($"
{string.Concat(line)}
"); + } + + var terminalPadding = 12; + var requiredCodeHeight = _settings.LineHeight * segmentLines.Count; + var terminalHeight = requiredCodeHeight + 60; + var terminalWidth = + (int)((console.Profile.Width * _settings.FontWidthScale * _settings.FontSize) + + (2 * terminalPadding) + + console.Profile.Width); + + var totalHeight = terminalHeight + (2 * _settings.Margin); + var totalWidth = terminalWidth + (2 * _settings.Margin); + + return template + .Replace("@(total_width)", totalWidth.ToString()) + .Replace("@(total_height)", totalHeight.ToString()) + .Replace("@(font_size)", _settings.FontSize.ToString()) + .Replace("@(theme_background_color)", "#" + _settings.Theme.BackgroundColor.ToHex()) + .Replace("@(theme_foreground_color)", "#" + _settings.Theme.ForegroundColor.ToHex()) + .Replace("@(line_height)", _settings.LineHeight.ToString()) + .Replace("@(code)", string.Concat(lines)); + } + + private static string? GetTemplate() + { + var stream = typeof(SvgEncoder).Assembly.GetManifestResourceStream("Spectre.Console.Svg.Templates.Simple.svg"); + if (stream != null) + { + using (var reader = new StreamReader(stream)) + { + return reader.ReadToEnd(); + } + } + + return null; + } +} diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoderSettings.cs b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoderSettings.cs new file mode 100644 index 000000000..8cb37c1ba --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgEncoderSettings.cs @@ -0,0 +1,35 @@ +namespace Spectre.Console.Internal; + +/// +/// Represents settings for the SVG encoder. +/// +internal sealed class SvgEncoderSettings +{ + /// + /// Gets or sets the font size. + /// Default value is 18. + /// + public int FontSize { get; set; } = 18; + + /// + /// Gets or sets the line height. + /// Default value is 22. + /// + public int LineHeight { get; set; } = 22; + + /// + /// Gets or sets the font width scale. + /// Default value is 0.6. + /// + public float FontWidthScale { get; set; } = 0.6f; + + /// + /// Gets or sets the margin (in pixels). + /// + public int Margin { get; set; } = 140; + + /// + /// Gets or sets the theme to use. + /// + public SvgTheme Theme { get; set; } = new DefaultTheme(); +} diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgTheme.cs b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgTheme.cs new file mode 100644 index 000000000..21986f596 --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/SvgTheme.cs @@ -0,0 +1,87 @@ +namespace Spectre.Console.Internal; + +/// +/// Represents a theme used for SVG rendering. +/// +internal abstract class SvgTheme +{ + private readonly Dictionary _lookup; + private readonly Dictionary _lookupNumbers; + private readonly Dictionary _lookupNames; + + /// + /// Initializes a new instance of the class. + /// + protected SvgTheme() + { + _lookup = new Dictionary(); + _lookupNumbers = new Dictionary(); + _lookupNames = new Dictionary(); + } + + /// + /// Gets the background color. + /// + public abstract Color BackgroundColor { get; } + + /// + /// Gets the foreground color. + /// + public abstract Color ForegroundColor { get; } + + /// + /// Overrides a color with the specified one. + /// + /// The color to override. + /// The color to use instead of the overridden color. + protected void Override(Color color, Color newColor) + { + if (color.Number != null) + { + _lookupNumbers[color.Number.Value] = newColor; + + var name = ColorTable.GetName(color.Number.Value); + if (name != null) + { + _lookupNames[name] = newColor; + } + } + + _lookup[color] = newColor; + } + + internal Color GetColor(Color color) + { + if (color.IsDefault) + { + return color; + } + + if (color.Number != null) + { + // Found by number? + if (_lookupNumbers.TryGetValue(color.Number.Value, out var numberResult)) + { + return numberResult; + } + + // Found by name? + var name = ColorTable.GetName(color.Number.Value); + if (name != null) + { + if (_lookupNames.TryGetValue(name, out var namedResult)) + { + return namedResult; + } + } + } + + // Exact match? + if (_lookup.TryGetValue(color, out var result)) + { + return result; + } + + return color; + } +} diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/Templates/Simple.svg b/src/Spectre.Console/Internal/Text/Encoding/Svg/Templates/Simple.svg new file mode 100644 index 000000000..b47257ef8 --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/Templates/Simple.svg @@ -0,0 +1,44 @@ + + + + +
+
+
+ @(code) +
+
+
+ +
+
\ No newline at end of file diff --git a/src/Spectre.Console/Internal/Text/Encoding/Svg/Themes/DefaultTheme.cs b/src/Spectre.Console/Internal/Text/Encoding/Svg/Themes/DefaultTheme.cs new file mode 100644 index 000000000..28cbd645e --- /dev/null +++ b/src/Spectre.Console/Internal/Text/Encoding/Svg/Themes/DefaultTheme.cs @@ -0,0 +1,36 @@ +namespace Spectre.Console.Internal; + +/// +/// The default SVG theme. +/// +internal sealed class DefaultTheme : SvgTheme +{ + /// + public override Color ForegroundColor { get; } = Color.FromHex("#CCCCCC"); + + /// + public override Color BackgroundColor { get; } = Color.FromHex("#2D2D2D"); + + /// + /// Initializes a new instance of the class. + /// + public DefaultTheme() + { + Override(Color.Black, Color.FromHex("#2D2D2D")); + Override(Color.Maroon, Color.FromHex("#F2777A")); + Override(Color.Green, Color.FromHex("#99CC99")); + Override(Color.Olive, Color.FromHex("#FFCC66")); + Override(Color.Navy, Color.FromHex("#6699CC")); + Override(Color.Purple, Color.FromHex("#CC99CC")); + Override(Color.Teal, Color.FromHex("#66CCCC")); + Override(Color.Silver, Color.FromHex("#CCCCCC")); + Override(Color.Grey, Color.FromHex("#999999")); + Override(Color.Red, Color.FromHex("#F2777A")); + Override(Color.Lime, Color.FromHex("#99CC99")); + Override(Color.Yellow, Color.FromHex("#FFCC66")); + Override(Color.Blue, Color.FromHex("#6699CC")); + Override(Color.Fuchsia, Color.FromHex("#CC99CC")); + Override(Color.Aqua, Color.FromHex("#66CCCC")); + Override(Color.White, Color.FromHex("#FFFFFF")); + } +} \ No newline at end of file diff --git a/src/Spectre.Console/Spectre.Console.csproj b/src/Spectre.Console/Spectre.Console.csproj index 2c90a3f09..135be9284 100644 --- a/src/Spectre.Console/Spectre.Console.csproj +++ b/src/Spectre.Console/Spectre.Console.csproj @@ -13,7 +13,9 @@ + + diff --git a/src/Spectre.Console/StyleParser.cs b/src/Spectre.Console/StyleParser.cs index c297555b1..b65c315df 100644 --- a/src/Spectre.Console/StyleParser.cs +++ b/src/Spectre.Console/StyleParser.cs @@ -149,7 +149,7 @@ public static bool TryParse(string text, out Style? style) effectiveLink); } - private static Color? ParseHexColor(string hex, out string? error) + public static Color? ParseHexColor(string hex, out string? error) { error = null; diff --git a/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj b/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj index dddacfcaa..350beb240 100644 --- a/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj +++ b/test/Spectre.Console.Tests/Spectre.Console.Tests.csproj @@ -1,19 +1,15 @@ - - - - - net6.0;net5.0;net48 - net6.0;net5.0 + net6.0 - + +