Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add SVG encoder #780

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 2 additions & 2 deletions 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
Expand Down
16 changes: 15 additions & 1 deletion src/Spectre.Console/AnsiConsole.Recording.cs
Expand Up @@ -44,6 +44,20 @@ public static string ExportHtml()
return _recorder.ExportHtml();
}

/// <summary>
/// Exports all recorded console output as SVG text.
/// </summary>
/// <returns>The recorded output as SVG text.</returns>
public static string ExportSvg()
{
if (_recorder == null)
{
throw new InvalidOperationException("Cannot export SVG since a recording hasn't been started.");
}

return _recorder.ExportSvg();
}

/// <summary>
/// Exports all recorded console output using a custom encoder.
/// </summary>
Expand All @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions src/Spectre.Console/Color.cs
Expand Up @@ -242,6 +242,23 @@ public static Color FromConsoleColor(ConsoleColor color)
};
}

/// <summary>
/// Converts a hexadecimal format string to a <see cref="Color"/>.
/// </summary>
/// <param name="hex">The hexadecimal format string to parse.</param>
/// <returns>A <see cref="Color"/> representing the hexadecimal format string.</returns>
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;
}

/// <summary>
/// Converts the color to a markup string.
/// </summary>
Expand Down
17 changes: 17 additions & 0 deletions src/Spectre.Console/Extensions/RecorderExtensions.cs
Expand Up @@ -37,4 +37,21 @@ public static string ExportHtml(this Recorder recorder)

return recorder.Export(_htmlEncoder);
}

/// <summary>
/// Exports the recorded content as SVG.
/// </summary>
/// <param name="recorder">The recorder.</param>
/// <returns>The recorded content as HTML.</returns>
public static string ExportSvg(this Recorder recorder)
{
if (recorder is null)
{
throw new ArgumentNullException(nameof(recorder));
}

return recorder.Export(
new SvgEncoder(
new SvgEncoderSettings()));
}
}
4 changes: 3 additions & 1 deletion src/Spectre.Console/Internal/Text/Encoding/HtmlEncoder.cs
@@ -1,3 +1,5 @@
using System.Web;

namespace Spectre.Console.Internal;

internal sealed class HtmlEncoder : IAnsiConsoleEncoder
Expand Down Expand Up @@ -42,7 +44,7 @@ public string Encode(IAnsiConsole console, IEnumerable<IRenderable> renderables)
}

builder.Append('>');
builder.Append(line);
builder.Append(HttpUtility.HtmlEncode(line));
builder.Append("</span>");

if (parts.Length > 1 && !last)
Expand Down
62 changes: 62 additions & 0 deletions 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<string>();

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);
}
}
90 changes: 90 additions & 0 deletions 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<IRenderable> 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<string>();
foreach (var segmentLine in segmentLines)
{
var line = new List<string>();

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 = $"<span style=\"color: #{_settings.Theme.ForegroundColor.ToHex()};{rule}; \">{text}</span>";
}
else
{
text = $"<span style=\"color: #{_settings.Theme.ForegroundColor.ToHex()}; \">{text}</span>";
}

line.Add(text);
}

lines.Add($"<div>{string.Concat(line)}</div>");
}

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;
}
}
@@ -0,0 +1,35 @@
namespace Spectre.Console.Internal;

/// <summary>
/// Represents settings for the SVG encoder.
/// </summary>
internal sealed class SvgEncoderSettings
{
/// <summary>
/// Gets or sets the font size.
/// Default value is 18.
/// </summary>
public int FontSize { get; set; } = 18;

/// <summary>
/// Gets or sets the line height.
/// Default value is 22.
/// </summary>
public int LineHeight { get; set; } = 22;

/// <summary>
/// Gets or sets the font width scale.
/// Default value is 0.6.
/// </summary>
public float FontWidthScale { get; set; } = 0.6f;

/// <summary>
/// Gets or sets the margin (in pixels).
/// </summary>
public int Margin { get; set; } = 140;

/// <summary>
/// Gets or sets the theme to use.
/// </summary>
public SvgTheme Theme { get; set; } = new DefaultTheme();
}
87 changes: 87 additions & 0 deletions src/Spectre.Console/Internal/Text/Encoding/Svg/SvgTheme.cs
@@ -0,0 +1,87 @@
namespace Spectre.Console.Internal;

/// <summary>
/// Represents a theme used for SVG rendering.
/// </summary>
internal abstract class SvgTheme
{
private readonly Dictionary<Color, Color> _lookup;
private readonly Dictionary<byte, Color> _lookupNumbers;
private readonly Dictionary<string, Color> _lookupNames;

/// <summary>
/// Initializes a new instance of the <see cref="SvgTheme"/> class.
/// </summary>
protected SvgTheme()
{
_lookup = new Dictionary<Color, Color>();
_lookupNumbers = new Dictionary<byte, Color>();
_lookupNames = new Dictionary<string, Color>();
}

/// <summary>
/// Gets the background color.
/// </summary>
public abstract Color BackgroundColor { get; }

/// <summary>
/// Gets the foreground color.
/// </summary>
public abstract Color ForegroundColor { get; }

/// <summary>
/// Overrides a color with the specified one.
/// </summary>
/// <param name="color">The color to override.</param>
/// <param name="newColor">The color to use instead of the overridden color.</param>
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;
}
}