diff --git a/SpineViewerCLI/CanvasAscii.cs b/SpineViewerCLI/CanvasAscii.cs
new file mode 100644
index 0000000..bdb5052
--- /dev/null
+++ b/SpineViewerCLI/CanvasAscii.cs
@@ -0,0 +1,188 @@
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SpineViewerCLI
+{
+ public class CanvasAscii : Renderable
+ {
+ private readonly Color?[,] _pixels;
+
+ ///
+ /// Gets the width of the canvas.
+ ///
+ public int Width { get; }
+
+ ///
+ /// Gets the height of the canvas.
+ ///
+ public int Height { get; }
+
+ ///
+ /// Gets or sets the render width of the canvas.
+ ///
+ public int? MaxWidth { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether or not
+ /// to scale the canvas when rendering.
+ ///
+ public bool Scale { get; set; } = true;
+
+ ///
+ /// Gets or sets the pixel width.
+ ///
+ public int PixelWidth { get; set; } = 2;
+
+ ///
+ /// Gets or sets the pixel lettters, ordered by transparency.
+ ///
+ public string PixelLetters { get; set; } = "$@B%8&WM#*oahkbdpqwmZO0QLCJUYXzcvunxrjft/|()1{}[]?-_+~<>i!lI;:,^`'."; // ".:-=+*#%@" ... "@%#*+=-:."
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The canvas width.
+ /// The canvas height.
+ public CanvasAscii(int width, int height)
+ {
+ if (width < 1)
+ {
+ throw new ArgumentException("Must be > 1", nameof(width));
+ }
+
+ if (height < 1)
+ {
+ throw new ArgumentException("Must be > 1", nameof(height));
+ }
+
+ Width = width;
+ Height = height;
+
+ _pixels = new Color?[Width, Height];
+ }
+
+ ///
+ /// Sets a pixel with the specified color in the canvas at the specified location.
+ ///
+ /// The X coordinate for the pixel.
+ /// The Y coordinate for the pixel.
+ /// The pixel color.
+ /// The same instance so that multiple calls can be chained.
+ public CanvasAscii SetPixel(int x, int y, Color color)
+ {
+ _pixels[x, y] = color;
+ return this;
+ }
+
+ ///
+ protected override Measurement Measure(RenderOptions options, int maxWidth)
+ {
+ if (PixelWidth < 0)
+ {
+ throw new InvalidOperationException("Pixel width must be greater than zero.");
+ }
+
+ var width = MaxWidth ?? Width;
+
+ if (maxWidth < width * PixelWidth)
+ {
+ return new Measurement(maxWidth, maxWidth);
+ }
+
+ return new Measurement(width * PixelWidth, width * PixelWidth);
+ }
+
+ ///
+ protected override IEnumerable Render(RenderOptions options, int maxWidth)
+ {
+ if (PixelWidth < 0)
+ {
+ throw new InvalidOperationException("Pixel width must be greater than zero.");
+ }
+
+ if (PixelLetters.Length <= 0)
+ {
+ throw new InvalidOperationException("Pixel letters can't be empty.");
+ }
+
+ var pixels = _pixels;
+ var emptyPixel = new string(' ', PixelWidth);
+ var width = Width;
+ var height = Height;
+
+ // Got a max width?
+ if (MaxWidth != null)
+ {
+ height = (int)(height * ((float)MaxWidth.Value) / Width);
+ width = MaxWidth.Value;
+ }
+
+ // Exceed the max width when we take pixel width into account?
+ if (width * PixelWidth > maxWidth)
+ {
+ height = (int)(height * (maxWidth / (float)(width * PixelWidth)));
+ width = maxWidth / PixelWidth;
+
+ // If it's not possible to scale the canvas sufficiently, it's too small to render.
+ if (height == 0)
+ {
+ yield break;
+ }
+ }
+
+ // Need to rescale the pixel buffer?
+ if (Scale && (width != Width || height != Height))
+ {
+ pixels = ScaleDown(width, height);
+ }
+
+ for (var y = 0; y < height; y++)
+ {
+ for (var x = 0; x < width; x++)
+ {
+ var color = pixels[x, y];
+ if (color.HasValue)
+ {
+ yield return new Segment(emptyPixel, new Style(background: color));
+ }
+ else
+ {
+ yield return new Segment(emptyPixel);
+ }
+ }
+
+ yield return Segment.LineBreak;
+ }
+ }
+
+ private Color?[,] ScaleDown(int newWidth, int newHeight)
+ {
+ var buffer = new Color?[newWidth, newHeight];
+ var xRatio = ((Width << 16) / newWidth) + 1;
+ var yRatio = ((Height << 16) / newHeight) + 1;
+
+ for (var i = 0; i < newHeight; i++)
+ {
+ for (var j = 0; j < newWidth; j++)
+ {
+ buffer[j, i] = _pixels[(j * xRatio) >> 16, (i * yRatio) >> 16];
+ }
+ }
+
+ return buffer;
+ }
+
+ private static float GetBrightness(Color c) => (0.299f * c.R + 0.587f * c.G + 0.114f * c.B) / 255f;
+
+ private string GetPixelLetter(Color c)
+ {
+ var index = Math.Min((int)(GetBrightness(c) * PixelLetters.Length), PixelLetters.Length - 1);
+ return new(PixelLetters[index], PixelWidth);
+ }
+ }
+}
diff --git a/SpineViewerCLI/CanvasImageAscii.cs b/SpineViewerCLI/CanvasImageAscii.cs
new file mode 100644
index 0000000..16d9f09
--- /dev/null
+++ b/SpineViewerCLI/CanvasImageAscii.cs
@@ -0,0 +1,151 @@
+using SkiaSharp;
+using Spectre.Console;
+using Spectre.Console.Rendering;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace SpineViewerCLI
+{
+ internal class CanvasImageAscii : Renderable
+ {
+ private static readonly SKSamplingOptions _defaultSamplingOptions = new(new SKCubicResampler());
+
+ ///
+ /// Gets the image width.
+ ///
+ public int Width => Image.Width;
+
+ ///
+ /// Gets the image height.
+ ///
+ public int Height => Image.Height;
+
+ ///
+ /// Gets or sets the render width of the canvas.
+ ///
+ public int? MaxWidth { get; set; }
+
+ ///
+ /// Gets or sets the render width of the canvas.
+ ///
+ public int PixelWidth { get; set; } = 2;
+
+ ///
+ /// Gets or sets the that should
+ /// be used when scaling the image. Defaults to bicubic sampling.
+ ///
+ public SKSamplingOptions? SamplingOptions { get; set; }
+
+ internal SKBitmap Image { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The image filename.
+ public CanvasImageAscii(string filename)
+ {
+ Image = SKBitmap.Decode(filename);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Buffer containing an image.
+ public CanvasImageAscii(ReadOnlySpan data)
+ {
+ Image = SKBitmap.Decode(data);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Stream containing an image.
+ public CanvasImageAscii(Stream data)
+ {
+ Image = SKBitmap.Decode(data);
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The object.
+ public CanvasImageAscii(SKImage image)
+ {
+ Image = SKBitmap.FromImage(image);
+ }
+
+ ///
+ protected override Measurement Measure(RenderOptions options, int maxWidth)
+ {
+ if (PixelWidth < 0)
+ {
+ throw new InvalidOperationException("Pixel width must be greater than zero.");
+ }
+
+ var width = MaxWidth ?? Width;
+ if (maxWidth < width * PixelWidth)
+ {
+ return new Measurement(maxWidth, maxWidth);
+ }
+
+ return new Measurement(width * PixelWidth, width * PixelWidth);
+ }
+
+ ///
+ protected override IEnumerable Render(RenderOptions options, int maxWidth)
+ {
+ var image = Image;
+
+ var width = Width;
+ var height = Height;
+
+ // Got a max width?
+ if (MaxWidth != null)
+ {
+ height = (int)(height * ((float)MaxWidth.Value) / Width);
+ width = MaxWidth.Value;
+ }
+
+ // Exceed the max width when we take pixel width into account?
+ if (width * PixelWidth > maxWidth)
+ {
+ height = (int)(height * (maxWidth / (float)(width * PixelWidth)));
+ width = maxWidth / PixelWidth;
+ }
+
+ // Need to rescale the pixel buffer?
+ if (width != Width || height != Height)
+ {
+ var samplingOptions = SamplingOptions ?? _defaultSamplingOptions;
+ image = image.Resize(new SKSizeI(width, height), samplingOptions);
+ }
+
+ var canvas = new CanvasAscii(width, height)
+ {
+ MaxWidth = MaxWidth,
+ PixelWidth = PixelWidth,
+ Scale = false,
+ };
+
+ // XXX: 也许是 SkiaSharp@3.119.0 的 bug, 此处像素值一定是非预乘的格式
+ for (var x = 0; x < image.Width; x++)
+ {
+ for (var y = 0; y < image.Height; y++)
+ {
+ var p = image.GetPixel(x, y);
+ if (p.Alpha == 0) continue;
+ float a = p.Alpha / 255f;
+ byte r = (byte)(p.Red * a);
+ byte g = (byte)(p.Green * a);
+ byte b = (byte)(p.Blue * a);
+ canvas.SetPixel(x, y, new(r, g, b));
+ }
+ }
+
+ return ((IRenderable)canvas).Render(options, maxWidth);
+ }
+ }
+}
diff --git a/SpineViewerCLI/PreviewCommand.cs b/SpineViewerCLI/PreviewCommand.cs
index 8b0fa56..3a8c954 100644
--- a/SpineViewerCLI/PreviewCommand.cs
+++ b/SpineViewerCLI/PreviewCommand.cs
@@ -99,8 +99,7 @@ namespace SpineViewerCLI
using var exporter = GetExporterFilledWithArgs(result, spine);
using var skImage = exporter.ExportMemoryImage(spine);
- using var skData = skImage.Encode();
- var img = new CanvasImage(skData.AsSpan());
+ var img = new CanvasImageAscii(skImage);
AnsiConsole.Write(img);
}
diff --git a/SpineViewerCLI/SpineViewerCLI.csproj b/SpineViewerCLI/SpineViewerCLI.csproj
index a40d269..a924239 100644
--- a/SpineViewerCLI/SpineViewerCLI.csproj
+++ b/SpineViewerCLI/SpineViewerCLI.csproj
@@ -18,7 +18,6 @@
-