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 @@
+
\ 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
-
+
+