From 29d7e8d9d8efaf58d8f269415252cc839159922a Mon Sep 17 00:00:00 2001 From: ww-rm Date: Mon, 27 Oct 2025 22:26:33 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E4=BE=9D=E8=B5=96=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SpineViewerCLI/CanvasAscii.cs | 188 +++++++++++++++++++++++++++ SpineViewerCLI/CanvasImageAscii.cs | 151 +++++++++++++++++++++ SpineViewerCLI/PreviewCommand.cs | 3 +- SpineViewerCLI/SpineViewerCLI.csproj | 1 - 4 files changed, 340 insertions(+), 3 deletions(-) create mode 100644 SpineViewerCLI/CanvasAscii.cs create mode 100644 SpineViewerCLI/CanvasImageAscii.cs 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 @@ -