diff --git a/CHANGELOG.md b/CHANGELOG.md index f281523..d487f49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ and `MarkupString.Pueblo`. The packages share one version and are released toget The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- **Terminal features beyond colour.** `AnsiOutputOptions` describes one client: its colour depth, + its `TerminalFeatures`, and where pictures' pixels come from (`ITerminalPictureSource`). + `WithAnsiOutput(options)` builds its registry; the `(depth, hyperlinks)` overload is that with + `Hyperlinks` or `None`. + - `CommandLinks` writes a command link as an MSLP link (`ESC ] 68 ; 1 ; SEND ; …`), which a client + advertising MTTS's MSLP bit sends back when clicked. A command holding a control character is + written as its text. + - **Pictures in a figure's cells.** A `Figure` laid out under `LayoutContext.Pictures` reserves the + cells its picture takes (its art's, or `PictureCells` for one with none) and marks each row with + `PictureCellsMarkup`. In `MarkupFormat.Ansi` such a row is drawn as the picture for a client with + `KittyGraphics` (Unicode placeholders, the picture sent once per connection), `InlineImages` + (iTerm2), `Sixel`, or `BlockArt` (half blocks), and as the art otherwise. The rows are the same + width either way, so a box or a flex row around the figure stays aligned. + - Each encoding is made once per picture and size and kept with the `TerminalPicture`, so a picture + shown to many connections is resized and compressed once. PNGs (iTerm2, and Kitty's `f=100`) are + RGB when the picture is opaque, filtered Up, at zlib level 2; sixel reads each band once and writes a + colour only over the columns it reaches. + ## 2.11.2 — 2026-10-07 ### Fixed diff --git a/MarkupString.Ansi/AnsiColorDepth.cs b/MarkupString.Ansi/AnsiColorDepth.cs index 0a7fbd3..2262c96 100644 --- a/MarkupString.Ansi/AnsiColorDepth.cs +++ b/MarkupString.Ansi/AnsiColorDepth.cs @@ -2,7 +2,7 @@ namespace MarkupString.Ansi; /// /// How much colour a client can display. A registry built with -/// writes every colour at the depth it is given, so a client +/// writes every colour at the depth it is given, so a client /// is never sent a sequence it cannot read. /// public enum AnsiColorDepth diff --git a/MarkupString.Ansi/AnsiOutputOptions.cs b/MarkupString.Ansi/AnsiOutputOptions.cs new file mode 100644 index 0000000..4cfa441 --- /dev/null +++ b/MarkupString.Ansi/AnsiOutputOptions.cs @@ -0,0 +1,24 @@ +namespace MarkupString.Ansi; + +/// What one client is sent in : how much colour, and what else its terminal can do. +/// The colour the client can display; every colour is written at this depth. +/// What the terminal can do beyond colour. +public sealed record AnsiOutputOptions( + AnsiColorDepth ColorDepth = AnsiColorDepth.TrueColor, + TerminalFeatures Features = TerminalFeatures.Hyperlinks) +{ + /// + /// Where pictures' pixels come from, for a client with any of . Without + /// one, a picture is its text art whatever the client can draw. + /// + public ITerminalPictureSource? Pictures { get; init; } + + /// + /// The width of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 10 when unknown, + /// which a terminal that does not know its own reports as 0: a value below 1 is taken as unknown. + /// + public int CellWidth { get; init => field = value > 0 ? value : 10; } = 10; + + /// The height of the terminal's character cell in pixels, for a picture drawn in pixels (sixel). 20 when unknown, as is a value below 1. + public int CellHeight { get; init => field = value > 0 ? value : 20; } = 20; +} diff --git a/MarkupString.Ansi/AnsiRegistration.cs b/MarkupString.Ansi/AnsiRegistration.cs index 51f4b61..e651ba1 100644 --- a/MarkupString.Ansi/AnsiRegistration.cs +++ b/MarkupString.Ansi/AnsiRegistration.cs @@ -50,13 +50,24 @@ public static MarkupRegistry WithAnsi(this MarkupRegistry registry) /// Whether a URL link is written as an OSC 8 hyperlink in ; when false, its /// text is written alone. Pueblo and MXP write links as their own tags either way. /// - public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true) + public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiColorDepth colorDepth, bool hyperlinks = true) => + registry.WithAnsiOutput(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None)); + + /// + /// Returns a registry that writes for one client as describe it: every colour at its + /// depth in , and , + /// and, in , links and pictures in the forms its terminal reads. + /// + /// The registry to add to, one has already filled. + /// What the client is sent. + public static MarkupRegistry WithAnsiOutput(this MarkupRegistry registry, AnsiOutputOptions options) { ArgumentNullException.ThrowIfNull(registry); + ArgumentNullException.ThrowIfNull(options); return registry - .With(new AnsiSetEmitter(colorDepth, hyperlinks)) - .With(new AnsiPuebloEmitter(colorDepth)) - .With(new AnsiMxpEmitter(colorDepth)); + .With(new AnsiSetEmitter(options)) + .With(new AnsiPuebloEmitter(options.ColorDepth)) + .With(new AnsiMxpEmitter(options.ColorDepth)); } } diff --git a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs index 8d309e0..dc80fab 100644 --- a/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs +++ b/MarkupString.Ansi/Emitters/AnsiEmitterSupport.cs @@ -194,26 +194,55 @@ internal static void WriteWrapped( } /// - /// Writes the body inside an OSC 8 hyperlink when the style carries a navigable URL. OSC 8 can - /// only navigate, so a command link — and any URL with a scheme - /// rejects — is written as plain text. + /// Writes the body as the link the style carries, in whichever form allows: a + /// navigable URL inside an OSC 8 hyperlink, a command as an MSLP link (ESC ] 68 ; 1 ; SEND ; command BEL + /// before the text, which MSLP delimits with underline). Anything else — no link, a form the client does not + /// read, a URL with a scheme rejects, a command holding a control + /// character that would end the sequence early — is written as plain text. /// - internal static void WriteHyperlinked(in AnsiStyle style, ReadOnlySpan body, IBufferWriter output) + internal static void WriteLinked(in AnsiStyle style, ReadOnlySpan body, TerminalFeatures features, IBufferWriter output) { - if (style.LinkKind != LinkKind.Url - || style.LinkUrl is not { Length: > 0 } url - || !UrlSafety.IsSafeNavigableUrl(url)) + if (style.LinkUrl is not { Length: > 0 } target) { output.Write(body); return; } - output.Write(Osc8); - output.Write(url); - output.Write(Bel); + if (style.LinkKind == LinkKind.Url && (features & TerminalFeatures.Hyperlinks) != 0 && UrlSafety.IsSafeNavigableUrl(target)) + { + output.Write(Osc8); + output.Write(target); + output.Write(Bel); + output.Write(body); + output.Write(Osc8); + output.Write(Bel); + return; + } + + if (style.LinkKind == LinkKind.Command && (features & TerminalFeatures.CommandLinks) != 0 && !HasControl(target)) + { + output.Write(MslpSend); + output.Write(target); + output.Write(Bel); + output.Write(UnderlineOn); + output.Write(body); + // The underline is the link's extent. A run that was underlined anyway stays so. + output.Write(style.Underlined ? UnderlineOff + UnderlineOn : UnderlineOff); + return; + } + output.Write(body); - output.Write(Osc8); - output.Write(Bel); + } + + private const string MslpSend = "\e]68;1;SEND;"; + private const string UnderlineOn = "\e[4m"; + private const string UnderlineOff = "\e[24m"; + + private static bool HasControl(string text) + { + foreach (var c in text) + if (char.IsControl(c)) return true; + return false; } /// diff --git a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs index 4aec8f0..8724a0a 100644 --- a/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs +++ b/MarkupString.Ansi/Emitters/AnsiSetEmitter.cs @@ -7,15 +7,36 @@ namespace MarkupString.Ansi; /// the stream carries only what actually changes. The state is closed with ESC[0m when the /// run is the last one or plain text follows it; between adjacent runs the next run's diff does it. /// -/// The colour the client can display; each style is written at this depth. -/// Whether a URL link is written as an OSC 8 hyperlink, or as its text alone. -public sealed class AnsiSetEmitter(AnsiColorDepth colorDepth, bool hyperlinks) : IMarkupSetEmitter +/// +/// Links and pictures are written as the client's allow: a URL link +/// as OSC 8, a command link as MSLP, a row of a picture's cells () as the +/// picture. Without the feature, a link is its text and a picture its text art. +/// +/// What the client is sent: its colour depth and what else its terminal can do. +public sealed class AnsiSetEmitter(AnsiOutputOptions options) : IMarkupSetEmitter { /// Every colour as it is, and URL links as OSC 8 hyperlinks. - public AnsiSetEmitter() : this(AnsiColorDepth.TrueColor, hyperlinks: true) + public AnsiSetEmitter() : this(new AnsiOutputOptions()) { } + /// Every colour at , and URL links as OSC 8 hyperlinks or as their text alone. + /// The colour the client can display; each style is written at this depth. + /// Whether a URL link is written as an OSC 8 hyperlink, or as its text alone. + public AnsiSetEmitter(AnsiColorDepth colorDepth, bool hyperlinks) + : this(new AnsiOutputOptions(colorDepth, hyperlinks ? TerminalFeatures.Hyperlinks : TerminalFeatures.None)) + { + } + + private readonly AnsiOutputOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + // Half blocks are colour and nothing else: at a depth without colour they are a grid of identical blocks, + // and the figure's text art says more. + private readonly TerminalFeatures _pictureMethod = options.Pictures is null + ? TerminalFeatures.None + : TerminalPictureWriter.Method(options.ColorDepth is AnsiColorDepth.Attributes or AnsiColorDepth.None + ? options.Features & ~TerminalFeatures.BlockArt + : options.Features); + /// public MarkupFormat Format => MarkupFormat.Ansi; @@ -25,13 +46,23 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte ArgumentNullException.ThrowIfNull(set); ArgumentNullException.ThrowIfNull(output); - var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(colorDepth); - var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(colorDepth); + var depth = _options.ColorDepth; + var effective = AnsiEmitterSupport.Fold(set, context.Format).AtDepth(depth); + var previous = AnsiEmitterSupport.Fold(context.Previous, context.Format).AtDepth(depth); using var core = new PooledCharWriter(body.Length + 32); SgrWriter.Transition(previous, effective, core); - if (hyperlinks) AnsiEmitterSupport.WriteHyperlinked(effective, body, core); - else core.Write(body); + if (Picture(set) is { } picture) + { + // The run that starts the row draws all of it; the others in the same row draw nothing, so the row is + // as many cells as the text it stands over. + if (context.StartsRegion(picture.Cells)) + TerminalPictureWriter.Write(_pictureMethod, picture.Cells, picture.Pixels, effective, _options, core, output); + } + else + { + AnsiEmitterSupport.WriteLinked(effective, body, _options.Features, core); + } // Nothing follows that would diff this state away, so close it here rather than leaving the // terminal coloured for whatever the connection writes next. @@ -40,4 +71,19 @@ public bool TryEmit(MarkupSet set, ReadOnlySpan body, in EmitContext conte AnsiEmitterSupport.WriteWrapped(set, core.WrittenSpan, context, output); return true; } + + /// The picture this run is a row of, when this client draws it and its pixels are to hand. + private (PictureCellsMarkup Cells, TerminalPicture Pixels)? Picture(MarkupSet set) + { + if (_pictureMethod == TerminalFeatures.None) return null; + for (var i = 0; i < set.Count; i++) + { + if (set[i] is not PictureCellsMarkup cells) continue; + return TerminalPictureWriter.CanDraw(_pictureMethod, cells) + && _options.Pictures!.TryGetPicture(cells.Image, out var pixels) + ? (cells, pixels) + : null; + } + return null; + } } diff --git a/MarkupString.Ansi/Emitters/PictureEncodings.cs b/MarkupString.Ansi/Emitters/PictureEncodings.cs new file mode 100644 index 0000000..c26adf4 --- /dev/null +++ b/MarkupString.Ansi/Emitters/PictureEncodings.cs @@ -0,0 +1,70 @@ +using System.Runtime.CompilerServices; +namespace MarkupString.Ansi; + +/// +/// What a picture has been encoded as, kept with the picture so every connection shown it at the same size +/// shares one encoding. A host typically holds one per picture and shows it to +/// many connections; resizing, compressing and encoding it is far dearer than copying the result. +/// +/// +/// The encodings live exactly as long as the picture does (a ), +/// so a host that drops a picture drops them too. Each picture keeps at most , the +/// least recently used going first, since a picture is seldom shown at more than a size or two. +/// +internal static class PictureEncodings +{ + /// How many encodings one picture keeps. + internal const int PerPicture = 64; + + private static readonly ConditionalWeakTable Caches = new(); + + /// + /// The encoding of that names, made by + /// the first time. Two threads asking at once may both make it; one result is kept. + /// + internal static T GetOrAdd(TerminalPicture picture, TKey key, Func make) + where TKey : notnull + where T : class + { + var cache = Caches.GetValue(picture, static _ => new Cache()); + lock (cache) + { + if (cache.TryGet(key) is T held) return held; + } + + var made = make(picture, key); + lock (cache) + { + if (cache.TryGet(key) is T raced) return raced; + cache.Add(key, made); + } + + return made; + } + + /// A picture's encodings, most recently used last. Guarded by its own lock. + private sealed class Cache + { + private readonly List<(object Key, object Value)> _entries = []; + + public object? TryGet(object key) + { + for (var i = _entries.Count - 1; i >= 0; i--) + { + if (!_entries[i].Key.Equals(key)) continue; + var entry = _entries[i]; + _entries.RemoveAt(i); + _entries.Add(entry); + return entry.Value; + } + + return null; + } + + public void Add(object key, object value) + { + if (_entries.Count >= PerPicture) _entries.RemoveAt(0); + _entries.Add((key, value)); + } + } +} diff --git a/MarkupString.Ansi/Emitters/PictureScaler.cs b/MarkupString.Ansi/Emitters/PictureScaler.cs new file mode 100644 index 0000000..11ea355 --- /dev/null +++ b/MarkupString.Ansi/Emitters/PictureScaler.cs @@ -0,0 +1,97 @@ +namespace MarkupString.Ansi; + +/// Resizing a picture's RGBA pixels for a terminal. +internal static class PictureScaler +{ + /// + /// The largest size within by with the picture's shape, + /// at least a pixel each way; no larger than the picture itself unless . + /// + internal static (int Width, int Height) FitWithin(int width, int height, int boxWidth, int boxHeight, bool upscale) + { + var scale = Math.Min((double)boxWidth / width, (double)boxHeight / height); + if (!upscale) scale = Math.Min(1, scale); + return (Math.Max(1, (int)Math.Round(width * scale)), Math.Max(1, (int)Math.Round(height * scale))); + } + + /// + /// resized to by : each new pixel the + /// average of the pixels it covers, weighted by how opaque they are so a transparent edge does not darken. + /// + internal static byte[] Scale(ReadOnlySpan source, int sourceWidth, int sourceHeight, int width, int height) + { + var result = new byte[width * height * 4]; + if (width == sourceWidth && height == sourceHeight) + { + source.CopyTo(result); + return result; + } + + // Each output column's span of source columns, worked out once rather than for every row. + var spans = new int[width + 1]; + for (var x = 0; x <= width; x++) spans[x] = (int)((long)x * sourceWidth / width); + + for (var y = 0; y < height; y++) + { + var y0 = (int)((long)y * sourceHeight / height); + var y1 = Math.Max(y0 + 1, (int)((long)(y + 1) * sourceHeight / height)); + for (var x = 0; x < width; x++) + { + var x0 = spans[x]; + var x1 = Math.Max(x0 + 1, spans[x + 1]); + var o = (y * width + x) * 4; + + // Enlarging, or a reduction small enough that a pixel covers one source pixel: a copy. + if (y1 - y0 == 1 && x1 - x0 == 1) + { + source.Slice((y0 * sourceWidth + x0) * 4, 4).CopyTo(result.AsSpan(o, 4)); + continue; + } + + long r = 0, g = 0, b = 0, a = 0; + for (var sy = y0; sy < y1; sy++) + { + var row = source.Slice((sy * sourceWidth + x0) * 4, (x1 - x0) * 4); + for (var i = 0; i < row.Length; i += 4) + { + var alpha = row[i + 3]; + r += row[i] * alpha; + g += row[i + 1] * alpha; + b += row[i + 2] * alpha; + a += alpha; + } + } + + if (a > 0) + { + result[o] = (byte)(r / a); + result[o + 1] = (byte)(g / a); + result[o + 2] = (byte)(b / a); + } + result[o + 3] = (byte)(a / ((long)(y1 - y0) * (x1 - x0))); + } + } + + return result; + } + + /// + /// as large as fits by with its + /// shape kept, centred, the rest transparent. + /// + internal static byte[] Letterbox(TerminalPicture picture, int width, int height) + { + var (fitWidth, fitHeight) = FitWithin(picture.Width, picture.Height, width, height, upscale: true); + fitWidth = Math.Min(fitWidth, width); + fitHeight = Math.Min(fitHeight, height); + var scaled = Scale(picture.Rgba.Span, picture.Width, picture.Height, fitWidth, fitHeight); + if (fitWidth == width && fitHeight == height) return scaled; + + var result = new byte[width * height * 4]; + var left = (width - fitWidth) / 2; + var top = (height - fitHeight) / 2; + for (var y = 0; y < fitHeight; y++) + scaled.AsSpan(y * fitWidth * 4, fitWidth * 4).CopyTo(result.AsSpan(((top + y) * width + left) * 4)); + return result; + } +} diff --git a/MarkupString.Ansi/Emitters/PngWriter.cs b/MarkupString.Ansi/Emitters/PngWriter.cs new file mode 100644 index 0000000..52e0dbe --- /dev/null +++ b/MarkupString.Ansi/Emitters/PngWriter.cs @@ -0,0 +1,140 @@ +using System.Buffers; +using System.Buffers.Binary; +using System.IO.Compression; +using System.Numerics; +namespace MarkupString.Ansi; + +/// +/// A small, fast PNG writer: 8-bit RGB, or RGBA when any pixel is not opaque, every row but the first +/// filtered Up, in one IDAT. +/// +/// +/// Measured on 512x384 pictures, Up filtering roughly halves the compressed size against no filter and +/// makes zlib faster too, and dropping an alpha channel nobody uses saves a quarter of the input. zlib +/// level 2 is the knee: within 5-15% of level 6's size at a third to a half of its time. A per-row filter +/// choice (as libpng makes) would compress a little better at several times the cost; fpng makes the same +/// Up-only choice. +/// +internal static class PngWriter +{ + private static readonly byte[] Signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + private static readonly uint[] CrcTable = BuildCrcTable(); + + /// The zlib level pictures are compressed at. + internal const int CompressionLevel = 2; + + /// , by , as a PNG file. + internal static byte[] Encode(ReadOnlySpan rgba, int width, int height) + { + var opaque = true; + for (var i = 3; i < rgba.Length && opaque; i += 4) opaque = rgba[i] == 255; + var channels = opaque ? 3 : 4; + var stride = width * channels; + var rawLength = (stride + 1) * height; + var raw = ArrayPool.Shared.Rent(rawLength); + var previous = ArrayPool.Shared.Rent(stride); + var current = ArrayPool.Shared.Rent(stride); + try + { + for (var y = 0; y < height; y++) + { + var source = rgba.Slice(y * width * 4, width * 4); + var row = current.AsSpan(0, stride); + if (opaque) + { + for (int i = 0, o = 0; i < source.Length; i += 4, o += 3) + { + row[o] = source[i]; + row[o + 1] = source[i + 1]; + row[o + 2] = source[i + 2]; + } + } + else + { + source.CopyTo(row); + } + + var line = raw.AsSpan(y * (stride + 1), stride + 1); + if (y == 0) + { + line[0] = 0; // None + row.CopyTo(line[1..]); + } + else + { + line[0] = 2; // Up: each byte less the one above it. + var above = previous.AsSpan(0, stride); + var filtered = line[1..]; + var i = 0; + if (Vector.IsHardwareAccelerated) + { + for (; i <= stride - Vector.Count; i += Vector.Count) + (new Vector(row[i..]) - new Vector(above[i..])).CopyTo(filtered[i..]); + } + + for (; i < stride; i++) filtered[i] = (byte)(row[i] - above[i]); + } + + (previous, current) = (current, previous); + } + + byte[] compressed; + using (var memory = new MemoryStream(rawLength / 2 + 64)) + { + using (var zlib = new ZLibStream(memory, new ZLibCompressionOptions { CompressionLevel = CompressionLevel }, leaveOpen: true)) + zlib.Write(raw, 0, rawLength); + compressed = memory.ToArray(); + } + + var header = new byte[13]; + BinaryPrimitives.WriteInt32BigEndian(header, width); + BinaryPrimitives.WriteInt32BigEndian(header.AsSpan(4), height); + header[8] = 8; // bit depth + header[9] = (byte)(opaque ? 2 : 6); // RGB or RGBA + + using var png = new MemoryStream(compressed.Length + 64); + png.Write(Signature); + WriteChunk(png, "IHDR"u8, header); + WriteChunk(png, "IDAT"u8, compressed); + WriteChunk(png, "IEND"u8, []); + return png.ToArray(); + } + finally + { + ArrayPool.Shared.Return(raw); + ArrayPool.Shared.Return(previous); + ArrayPool.Shared.Return(current); + } + } + + private static void WriteChunk(Stream png, ReadOnlySpan type, ReadOnlySpan data) + { + Span word = stackalloc byte[4]; + BinaryPrimitives.WriteInt32BigEndian(word, data.Length); + png.Write(word); + png.Write(type); + png.Write(data); + var crc = Crc(Crc(0xFFFFFFFFu, type), data) ^ 0xFFFFFFFFu; + BinaryPrimitives.WriteUInt32BigEndian(word, crc); + png.Write(word); + } + + private static uint Crc(uint crc, ReadOnlySpan data) + { + foreach (var b in data) crc = CrcTable[(crc ^ b) & 0xFF] ^ (crc >> 8); + return crc; + } + + private static uint[] BuildCrcTable() + { + var table = new uint[256]; + for (uint n = 0; n < 256; n++) + { + var c = n; + for (var k = 0; k < 8; k++) c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1; + table[n] = c; + } + return table; + } +} diff --git a/MarkupString.Ansi/Emitters/SixelWriter.cs b/MarkupString.Ansi/Emitters/SixelWriter.cs new file mode 100644 index 0000000..b46c108 --- /dev/null +++ b/MarkupString.Ansi/Emitters/SixelWriter.cs @@ -0,0 +1,150 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +namespace MarkupString.Ansi; + +/// +/// Writes RGBA pixels as sixel graphics: the 216-colour cube for a palette (each pixel's entry is arithmetic, +/// with no search), a transparent background (P2 = 1) when any pixel is transparent, square pixels, +/// and runs of a repeated column compressed with !. +/// +/// +/// Each six-pixel band is read once, setting each pixel's bit in the column of its colour's line, so the +/// work is the pixels plus one line per colour the band uses rather than a pass over the band per colour. +/// Only the colours a band uses are written for it. +/// +internal static class SixelWriter +{ + private const int Colours = 216; + + /// Encodes ; is a whole number of six-pixel bands. + internal static string Encode(byte[] pixels, int width, int height) + { + var pixelCount = width * height; + var index = ArrayPool.Shared.Rent(pixelCount); + // Each colour's sixel line for the band being written: one byte of six bits per column. + var lines = ArrayPool.Shared.Rent(Colours * width); + var line = ArrayPool.Shared.Rent(width); + try + { + // Each pixel's palette entry, or 255 where it is transparent and left as the screen was. + Span used = stackalloc bool[Colours]; + for (var i = 0; i < pixelCount; i++) + { + var o = i * 4; + if (pixels[o + 3] < 128) + { + index[i] = byte.MaxValue; + continue; + } + + var entry = Level(pixels[o]) * 36 + Level(pixels[o + 1]) * 6 + Level(pixels[o + 2]); + index[i] = (byte)entry; + used[entry] = true; + } + + var text = new StringBuilder(pixelCount / 4 + 4096); + // P2 = 1 leaves unset pixels as the screen was, which a picture with transparent parts or a letterbox + // needs; an opaque one is sent P2 = 0, which some terminals (foot) draw faster. + var transparent = index.AsSpan(0, pixelCount).Contains(byte.MaxValue); + text.Append(CultureInfo.InvariantCulture, $"\eP0;{(transparent ? 1 : 0)};0q\"1;1;{width};{height}"); + for (var entry = 0; entry < Colours; entry++) + { + if (!used[entry]) continue; + text.Append('#').Append(entry).Append(";2;") + .Append(entry / 36 * 20).Append(';').Append(entry / 6 % 6 * 20).Append(';').Append(entry % 6 * 20); + } + + Span bandColours = stackalloc byte[Colours]; + Span inBand = stackalloc bool[Colours]; + // The columns each colour of the band reaches, so a colour in one corner is not a line the band's width. + Span first = stackalloc int[Colours]; + Span last = stackalloc int[Colours]; + for (var top = 0; top < height; top += 6) + { + var count = 0; + var bottom = Math.Min(top + 6, height); + for (var y = top; y < bottom; y++) + { + var bit = (byte)(1 << (y - top)); + var row = y * width; + for (var x = 0; x < width; x++) + { + var colour = index[row + x]; + if (colour == byte.MaxValue) continue; + if (!inBand[colour]) + { + inBand[colour] = true; + bandColours[count++] = colour; + lines.AsSpan(colour * width, width).Clear(); + first[colour] = x; + last[colour] = x; + } + else if (x < first[colour]) first[colour] = x; + else if (x > last[colour]) last[colour] = x; + + lines[colour * width + x] |= bit; + } + } + + for (var c = 0; c < count; c++) + { + var colour = bandColours[c]; + inBand[colour] = false; + var from = first[colour]; + var to = last[colour] + 1; + var bits = lines.AsSpan(colour * width + from, to - from); + for (var x = 0; x < bits.Length; x++) line[x] = (char)('?' + bits[x]); + + text.Append('#').Append(colour); + if (from > 3) text.Append('!').Append(from).Append('?'); + else text.Append('?', from); + AppendRuns(line.AsSpan(0, bits.Length), text); + if (c < count - 1) text.Append('$'); + } + + if (bottom < height) text.Append('-'); + } + + text.Append("\e\\"); + return text.ToString(); + } + finally + { + ArrayPool.Shared.Return(index); + ArrayPool.Shared.Return(lines); + ArrayPool.Shared.Return(line); + } + } + + /// A channel's nearest of the cube's six levels. + private static int Level(byte value) => (value * 5 + 127) / 255; + + /// + /// with each run of more than three equal sixels written !n, and the empty + /// sixels at its end left off, since the next line starts from the band's left edge anyway. + /// + private static void AppendRuns(ReadOnlySpan line, StringBuilder text) + { + var end = line.Length; + while (end > 0 && line[end - 1] == '?') end--; + + // Sixels that are not part of a long run are copied in stretches rather than one at a time. + var literal = 0; + var x = 0; + while (x < end) + { + var run = 1; + while (x + run < end && line[x + run] == line[x]) run++; + if (run > 3) + { + text.Append(line[literal..x]).Append('!').Append(run).Append(line[x]); + literal = x + run; + } + + x += run; + } + + text.Append(line[literal..end]); + } +} diff --git a/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs new file mode 100644 index 0000000..4c9bdcb --- /dev/null +++ b/MarkupString.Ansi/Emitters/TerminalPictureWriter.cs @@ -0,0 +1,299 @@ +using System.Buffers; +using System.Globalization; +using System.Text; +namespace MarkupString.Ansi; + +/// +/// Draws one row of a picture's cells () in a terminal, in whichever way +/// says the client draws pictures. +/// +/// +/// Every run of a row carries the same layer; the first draws the whole row and the rest draw nothing, +/// so the row is the same number of cells whichever way it is drawn. +/// +internal static class TerminalPictureWriter +{ + private const string Esc = "\e"; + private const string StringTerminator = "\e\\"; + + /// Kitty's placeholder character. + private const int Placeholder = 0x10EEEE; + + /// Kitty's base64 chunk ceiling. + private const int KittyChunk = 4096; + + /// + /// The diacritics Kitty reads as a row or column number, in order: the first is 0. From kitty's + /// gen/rowcolumn-diacritics.txt; a picture larger than this many cells either way is not drawn. + /// + private static readonly int[] Diacritics = + [ + 0x0305, 0x030D, 0x030E, 0x0310, 0x0312, 0x033D, 0x033E, 0x033F, 0x0346, 0x034A, 0x034B, 0x034C, + 0x0350, 0x0351, 0x0352, 0x0357, 0x035B, 0x0363, 0x0364, 0x0365, 0x0366, 0x0367, 0x0368, 0x0369, + 0x036A, 0x036B, 0x036C, 0x036D, 0x036E, 0x036F, 0x0483, 0x0484, 0x0485, 0x0486, 0x0487, 0x0592, + 0x0593, 0x0594, 0x0595, 0x0597, 0x0598, 0x0599, 0x059C, 0x059D, 0x059E, 0x059F, 0x05A0, 0x05A1, + 0x05A8, 0x05A9, 0x05AB, 0x05AC, 0x05AF, 0x05C4, 0x0610, 0x0611, 0x0612, 0x0613, 0x0614, 0x0615, + 0x0616, 0x0617, 0x0657, 0x0658, 0x0659, 0x065A, 0x065B, 0x065D, 0x065E, 0x06D6, 0x06D7, 0x06D8, + 0x06D9, 0x06DA, 0x06DB, 0x06DC, 0x06DF, 0x06E0, 0x06E1, 0x06E2, 0x06E4, 0x06E7, 0x06E8, 0x06EB, + 0x06EC, 0x0730, 0x0732, 0x0733, 0x0735, 0x0736, 0x073A, 0x073D, 0x073F, 0x0740, 0x0741, 0x0743, + 0x0745, 0x0747, 0x0749, 0x074A, 0x07EB, 0x07EC, 0x07ED, 0x07EE, 0x07EF, 0x07F0, 0x07F1, 0x07F3, + 0x0816, 0x0817, 0x0818, 0x0819, 0x081B, 0x081C, 0x081D, 0x081E, 0x081F, 0x0820, 0x0821, 0x0822, + 0x0823, 0x0825, 0x0826, 0x0827, 0x0829, 0x082A, 0x082B, 0x082C, 0x082D, 0x0951, 0x0953, 0x0954, + 0x0F82, 0x0F83, 0x0F86, 0x0F87, 0x135D, 0x135E, 0x135F, 0x17DD, 0x193A, 0x1A17, 0x1A75, 0x1A76, + 0x1A77, 0x1A78, 0x1A79, 0x1A7A, 0x1A7B, 0x1A7C, 0x1B6B, 0x1B6D, 0x1B6E, 0x1B6F, 0x1B70, 0x1B71, + 0x1B72, 0x1B73, 0x1CD0, 0x1CD1, 0x1CD2, 0x1CDA, 0x1CDB, 0x1CE0, 0x1DC0, 0x1DC1, 0x1DC3, 0x1DC4, + 0x1DC5, 0x1DC6, 0x1DC7, 0x1DC8, 0x1DC9, 0x1DCB, 0x1DCC, 0x1DD1, 0x1DD2, 0x1DD3, 0x1DD4, 0x1DD5, + 0x1DD6, 0x1DD7, 0x1DD8, 0x1DD9, 0x1DDA, 0x1DDB, 0x1DDC, 0x1DDD, 0x1DDE, 0x1DDF, 0x1DE0, 0x1DE1, + 0x1DE2, 0x1DE3, 0x1DE4, 0x1DE5, 0x1DE6, 0x1DFE, 0x20D0, 0x20D1, 0x20D4, 0x20D5, 0x20D6, 0x20D7, + 0x20DB, 0x20DC, 0x20E1, 0x20E7, 0x20E9, 0x20F0, 0x2CEF, 0x2CF0, 0x2CF1, 0x2DE0, 0x2DE1, 0x2DE2, + 0x2DE3, 0x2DE4, 0x2DE5, 0x2DE6, 0x2DE7, 0x2DE8, 0x2DE9, 0x2DEA, 0x2DEB, 0x2DEC, 0x2DED, 0x2DEE, + 0x2DEF, 0x2DF0, 0x2DF1, 0x2DF2, 0x2DF3, 0x2DF4, 0x2DF5, 0x2DF6, 0x2DF7, 0x2DF8, 0x2DF9, 0x2DFA, + 0x2DFB, 0x2DFC, 0x2DFD, 0x2DFE, 0x2DFF, 0xA66F, 0xA67C, 0xA67D, 0xA6F0, 0xA6F1, 0xA8E0, 0xA8E1, + 0xA8E2, 0xA8E3, 0xA8E4, 0xA8E5, 0xA8E6, 0xA8E7, 0xA8E8, 0xA8E9, 0xA8EA, 0xA8EB, 0xA8EC, 0xA8ED, + 0xA8EE, 0xA8EF, 0xA8F0, 0xA8F1, 0xAAB0, 0xAAB2, 0xAAB3, 0xAAB7, 0xAAB8, 0xAABE, 0xAABF, 0xAAC1, + 0xFE20, 0xFE21, 0xFE22, 0xFE23, 0xFE24, 0xFE25, 0xFE26, 0x10A0F, 0x10A38, 0x1D185, 0x1D186, 0x1D187, + 0x1D188, 0x1D189, 0x1D1AA, 0x1D1AB, 0x1D1AC, 0x1D1AD, 0x1D242, 0x1D243, 0x1D244 + ]; + + /// The way this client draws pictures, best first, or none. + internal static TerminalFeatures Method(TerminalFeatures features) => + (features & TerminalFeatures.KittyGraphics) != 0 ? TerminalFeatures.KittyGraphics + : (features & TerminalFeatures.InlineImages) != 0 ? TerminalFeatures.InlineImages + : (features & TerminalFeatures.Sixel) != 0 ? TerminalFeatures.Sixel + : (features & TerminalFeatures.BlockArt) != 0 ? TerminalFeatures.BlockArt + : TerminalFeatures.None; + + /// Whether can be drawn by at all. + internal static bool CanDraw(TerminalFeatures method, PictureCellsMarkup cells) => + cells.Columns > 0 && cells.Rows > 0 && cells.Row >= 0 && cells.Row < cells.Rows + && (method != TerminalFeatures.KittyGraphics || (cells.Columns <= Diacritics.Length && cells.Rows <= Diacritics.Length)); + + /// + /// Writes the row stands for. The terminal is in before + /// and is left in it after. + /// + /// How the client draws pictures. + /// The row. + /// The pixels. + /// The run's style. + /// What the client is sent. + /// Where the row's own text goes. + /// + /// Where a picture's transmission goes, ahead of the row's text: it can be megabytes, and depends on no style + /// or wrapper, so it is written once to the final output rather than copied through the run's buffers. + /// + internal static void Write( + TerminalFeatures method, + PictureCellsMarkup cells, + TerminalPicture picture, + in AnsiStyle effective, + AnsiOutputOptions options, + IBufferWriter output, + IBufferWriter ahead) + { + switch (method) + { + case TerminalFeatures.KittyGraphics: + WriteKitty(cells, picture, effective, options, output, ahead); + break; + case TerminalFeatures.InlineImages: + case TerminalFeatures.Sixel: + WriteOverlay(method, cells, picture, options, output, ahead); + break; + case TerminalFeatures.BlockArt: + WriteBlockArt(cells, picture, effective, options.ColorDepth, output); + break; + } + } + + /// + /// The Kitty image id for drawn in : a hash of its key and + /// its size in cells, since each size is its own virtual placement. 24 bits, so it fits a truecolor + /// foreground; never zero, which Kitty reads as no id. + /// + internal static uint KittyId(TerminalPicture picture, PictureCellsMarkup cells) + { + var hash = 2166136261u; + foreach (var c in picture.Key) hash = (hash ^ c) * 16777619u; + hash = (hash ^ (uint)cells.Columns) * 16777619u; + hash = (hash ^ (uint)cells.Rows) * 16777619u; + var id = (hash ^ (hash >> 24)) & 0xFFFFFF; + return id == 0 ? 1 : id; + } + + private static void WriteKitty(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiOutputOptions options, + IBufferWriter output, IBufferWriter ahead) + { + var id = KittyId(picture, cells); + if (options.Pictures?.MarkTransmitted(id) != false) + ahead.Write(PictureEncodings.GetOrAdd(picture, new KittyKey(id, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight), EncodeKitty)); + + // The image id is the placeholder's foreground. It is written at truecolor whatever the client's depth: + // it is not a colour anyone sees, and a terminal that reads Kitty graphics reads 24-bit SGR. + var placeholder = new AnsiStyle { Foreground = new AnsiColor.Rgb((byte)(id >> 16), (byte)(id >> 8), (byte)id) }; + SgrWriter.Transition(effective, placeholder, output); + + // Each cell is the placeholder and two diacritics, at most six UTF-16 units. + var span = output.GetSpan(cells.Columns * 6); + var written = 0; + var row = new Rune(Diacritics[cells.Row]); + for (var column = 0; column < cells.Columns; column++) + { + written += new Rune(Placeholder).EncodeToUtf16(span[written..]); + written += row.EncodeToUtf16(span[written..]); + written += new Rune(Diacritics[column]).EncodeToUtf16(span[written..]); + } + output.Advance(written); + SgrWriter.Transition(placeholder, effective, output); + } + + private readonly record struct KittyKey(uint Id, int Columns, int Rows, int CellWidth, int CellHeight); + + /// + /// The picture as a Kitty image with a virtual placement of the cells' size, sent quietly (q=2) so + /// nothing comes back into the player's input. Kitty fits it to the cells keeping its shape; it is scaled + /// down first to no more pixels than those cells hold. The whole transmission, every chunk, is one string, + /// so a connection shown the picture after the first is sent a copy. + /// + /// + /// Sent as PNG (f=100) rather than zlib-compressed raw pixels (f=32,o=z): the PNG's row + /// filtering and dropped alpha make it about half the size for the same compression time, and every + /// terminal that speaks the protocol decodes PNG. + /// + private static string EncodeKitty(TerminalPicture picture, KittyKey key) + { + var (width, height) = PictureScaler.FitWithin(picture.Width, picture.Height, + key.Columns * key.CellWidth, key.Rows * key.CellHeight, upscale: false); + var pixels = PictureScaler.Scale(picture.Rgba.Span, picture.Width, picture.Height, width, height); + var payload = Convert.ToBase64String(PngWriter.Encode(pixels, width, height)); + var chunks = (payload.Length + KittyChunk - 1) / KittyChunk; + + var text = new StringBuilder(payload.Length + 96 + chunks * 16); + var offset = 0; + do + { + var length = Math.Min(KittyChunk, payload.Length - offset); + var more = offset + length < payload.Length ? 1 : 0; + text.Append(Esc + "_G"); + if (offset == 0) + { + text.Append(CultureInfo.InvariantCulture, + $"a=T,U=1,i={key.Id},f=100,c={key.Columns},r={key.Rows},q=2,m={more}"); + } + else + { + text.Append("m=").Append(more).Append(",q=2"); + } + + text.Append(';').Append(payload, offset, length).Append(StringTerminator); + offset += length; + } + while (offset < payload.Length); + + return text.ToString(); + } + + /// + /// iTerm2 and sixel pictures are pixels laid over the screen, not text, so they are drawn over cells the text + /// leaves alone. On the picture's first row the cursor makes room below for every row (ESC D, which + /// scrolls at the bottom of the screen and keeps the column, and one row more for a sixel terminal that moves + /// down past the picture), comes back up, and draws the picture between a cursor save and restore. Every row + /// then steps over the picture's cells (CSI n C) rather than writing spaces that would erase it. + /// + private static void WriteOverlay(TerminalFeatures method, PictureCellsMarkup cells, TerminalPicture picture, AnsiOutputOptions options, + IBufferWriter output, IBufferWriter ahead) + { + if (cells.Row == 0) + { + for (var row = 0; row < cells.Rows; row++) ahead.Write(Esc + "D"); + ahead.Write($"{Esc}[{cells.Rows}A"); + ahead.Write(Esc + "7"); + var key = new OverlayKey(method, cells.Columns, cells.Rows, options.CellWidth, options.CellHeight); + ahead.Write(PictureEncodings.GetOrAdd(picture, key, + static (picture, key) => key.Method == TerminalFeatures.InlineImages ? EncodeInlineImage(picture, key) : EncodeSixel(picture, key))); + ahead.Write(Esc + "8"); + } + + output.Write($"{Esc}[{cells.Columns}C"); + } + + private readonly record struct OverlayKey(TerminalFeatures Method, int Columns, int Rows, int CellWidth, int CellHeight); + + /// An iTerm2 inline image, a PNG sized in cells, its shape kept. + private static string EncodeInlineImage(TerminalPicture picture, OverlayKey key) + { + var (width, height) = PictureScaler.FitWithin(picture.Width, picture.Height, + key.Columns * key.CellWidth, key.Rows * key.CellHeight, upscale: false); + var png = PngWriter.Encode(PictureScaler.Scale(picture.Rgba.Span, picture.Width, picture.Height, width, height), width, height); + return string.Create(CultureInfo.InvariantCulture, + $"{Esc}]1337;File=inline=1;size={png.Length};width={key.Columns};height={key.Rows};preserveAspectRatio=1:{Convert.ToBase64String(png)}\a"); + } + + /// + /// A sixel picture exactly the cells' size in pixels — its height down to a whole number of six-pixel bands + /// so it never spills into the row below — the picture centred in it, the rest left transparent. + /// + private static string EncodeSixel(TerminalPicture picture, OverlayKey key) + { + var boxWidth = key.Columns * key.CellWidth; + var boxHeight = Math.Max(6, key.Rows * key.CellHeight / 6 * 6); + return SixelWriter.Encode(PictureScaler.Letterbox(picture, boxWidth, boxHeight), boxWidth, boxHeight); + } + + /// + /// One row of cells as upper and lower half blocks, each cell two pixels tall: the upper pixel the + /// foreground of ▀, the lower its background. A transparent half shows the run's own background. + /// Every row of the picture is made at once and kept, since the rows are drawn one after another. + /// + private static void WriteBlockArt(PictureCellsMarkup cells, TerminalPicture picture, in AnsiStyle effective, AnsiColorDepth depth, IBufferWriter output) + { + var rows = PictureEncodings.GetOrAdd(picture, new BlockKey(cells.Columns, cells.Rows, depth, effective), EncodeBlockArt); + output.Write(rows[cells.Row]); + } + + private readonly record struct BlockKey(int Columns, int Rows, AnsiColorDepth Depth, AnsiStyle Effective); + + /// Every row of the picture as half blocks, each starting and ending in the run's own style. + private static string[] EncodeBlockArt(TerminalPicture picture, BlockKey key) + { + var width = key.Columns; + var pixels = PictureScaler.Letterbox(picture, width, key.Rows * 2); + var plain = key.Effective with { Foreground = null }; + var rows = new string[key.Rows]; + using var line = new PooledCharWriter(width * 40); + + for (var row = 0; row < key.Rows; row++) + { + line.Clear(); + var current = key.Effective; + var top = row * 2 * width * 4; + var bottom = top + width * 4; + for (var column = 0; column < width; column++) + { + var upper = Pixel(pixels, top + column * 4); + var lower = Pixel(pixels, bottom + column * 4); + var (style, glyph) = (upper, lower) switch + { + ({ } u, { } l) => (plain with { Foreground = u, Background = l }, '▀'), + ({ } u, null) => (plain with { Foreground = u }, '▀'), + (null, { } l) => (plain with { Foreground = l }, '▄'), + _ => (plain, ' '), + }; + style = style.AtDepth(key.Depth); + SgrWriter.Transition(current, style, line); + current = style; + line.GetSpan(1)[0] = glyph; + line.Advance(1); + } + + SgrWriter.Transition(current, key.Effective, line); + rows[row] = line.WrittenSpan.ToString(); + } + + return rows; + } + + /// The colour of the pixel at , or null when it is mostly transparent. + private static AnsiColor? Pixel(byte[] pixels, int offset) => + pixels[offset + 3] < 128 ? null : new AnsiColor.Rgb(pixels[offset], pixels[offset + 1], pixels[offset + 2]); +} diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 7dc5c58..ddcf9fa 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -1 +1,41 @@ #nullable enable +MarkupString.Ansi.AnsiOutputOptions +MarkupString.Ansi.AnsiOutputOptions.$() -> MarkupString.Ansi.AnsiOutputOptions! +MarkupString.Ansi.AnsiOutputOptions.AnsiOutputOptions(MarkupString.Ansi.AnsiColorDepth ColorDepth = MarkupString.Ansi.AnsiColorDepth.TrueColor, MarkupString.Ansi.TerminalFeatures Features = MarkupString.Ansi.TerminalFeatures.Hyperlinks) -> void +MarkupString.Ansi.AnsiOutputOptions.CellHeight.get -> int +MarkupString.Ansi.AnsiOutputOptions.CellHeight.init -> void +MarkupString.Ansi.AnsiOutputOptions.CellWidth.get -> int +MarkupString.Ansi.AnsiOutputOptions.CellWidth.init -> void +MarkupString.Ansi.AnsiOutputOptions.ColorDepth.get -> MarkupString.Ansi.AnsiColorDepth +MarkupString.Ansi.AnsiOutputOptions.ColorDepth.init -> void +MarkupString.Ansi.AnsiOutputOptions.Deconstruct(out MarkupString.Ansi.AnsiColorDepth ColorDepth, out MarkupString.Ansi.TerminalFeatures Features) -> void +MarkupString.Ansi.AnsiOutputOptions.Equals(MarkupString.Ansi.AnsiOutputOptions? other) -> bool +MarkupString.Ansi.AnsiOutputOptions.Features.get -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.AnsiOutputOptions.Features.init -> void +MarkupString.Ansi.AnsiOutputOptions.Pictures.get -> MarkupString.Ansi.ITerminalPictureSource? +MarkupString.Ansi.AnsiOutputOptions.Pictures.init -> void +MarkupString.Ansi.AnsiSetEmitter.AnsiSetEmitter(MarkupString.Ansi.AnsiOutputOptions! options) -> void +MarkupString.Ansi.ITerminalPictureSource +MarkupString.Ansi.ITerminalPictureSource.MarkTransmitted(uint imageId) -> bool +MarkupString.Ansi.ITerminalPictureSource.TryGetPicture(MarkupString.ImageMarkup! image, out MarkupString.Ansi.TerminalPicture? picture) -> bool +MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.BlockArt = 32 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.CommandLinks = 2 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.Hyperlinks = 1 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.InlineImages = 8 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.KittyGraphics = 4 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.None = 0 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.Pictures = MarkupString.Ansi.TerminalFeatures.KittyGraphics | MarkupString.Ansi.TerminalFeatures.InlineImages | MarkupString.Ansi.TerminalFeatures.Sixel | MarkupString.Ansi.TerminalFeatures.BlockArt -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalFeatures.Sixel = 16 -> MarkupString.Ansi.TerminalFeatures +MarkupString.Ansi.TerminalPicture +MarkupString.Ansi.TerminalPicture.Height.get -> int +MarkupString.Ansi.TerminalPicture.Key.get -> string! +MarkupString.Ansi.TerminalPicture.Rgba.get -> System.ReadOnlyMemory +MarkupString.Ansi.TerminalPicture.TerminalPicture(string! key, int width, int height, System.ReadOnlyMemory rgba) -> void +MarkupString.Ansi.TerminalPicture.Width.get -> int +override MarkupString.Ansi.AnsiOutputOptions.Equals(object? obj) -> bool +override MarkupString.Ansi.AnsiOutputOptions.GetHashCode() -> int +override MarkupString.Ansi.AnsiOutputOptions.ToString() -> string! +static MarkupString.Ansi.AnsiOutputOptions.operator !=(MarkupString.Ansi.AnsiOutputOptions? left, MarkupString.Ansi.AnsiOutputOptions? right) -> bool +static MarkupString.Ansi.AnsiOutputOptions.operator ==(MarkupString.Ansi.AnsiOutputOptions? left, MarkupString.Ansi.AnsiOutputOptions? right) -> bool +static MarkupString.Ansi.AnsiRegistration.WithAnsiOutput(this MarkupString.MarkupRegistry! registry, MarkupString.Ansi.AnsiOutputOptions! options) -> MarkupString.MarkupRegistry! diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index d7e6a89..71bb88b 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -57,6 +57,45 @@ A client that cannot show every colour gets its own registry: `None` with no SGR. It covers `Ansi`, `Pueblo` and `Mxp`. Pass `hyperlinks: false` for a terminal that prints OSC 8 instead of reading it. +### Terminal features: links and pictures + +`WithAnsiOutput(new AnsiOutputOptions(depth, features) { Pictures = source })` describes one client +fully. `TerminalFeatures` says what its terminal reads beyond colour: + +| Feature | Written as | +|---|---| +| `Hyperlinks` | a URL link as OSC 8 (`ESC ] 8 ; ; url ST`) | +| `CommandLinks` | a command link as MSLP (`ESC ] 68 ; 1 ; SEND ; command BEL`, then the underlined text) | +| `KittyGraphics` | a picture through the Kitty graphics protocol, with Unicode placeholders | +| `InlineImages` | a picture as an iTerm2 inline image (`ESC ] 1337 ; File=…`) | +| `Sixel` | a picture as sixel graphics | +| `BlockArt` | a picture as coloured `▀`/`▄` half blocks, plain text any UTF-8 colour terminal shows | + +Pictures are drawn into the cells a `Figure` reserves when it is laid out for such a reader +(`LayoutContext.Pictures`, which answers the cells a picture takes). Each row is marked with +`PictureCellsMarkup` over the figure's text art, so a box, a flex row or a table around it lines up +whichever way it ends up drawn. The pixels come from the host's `ITerminalPictureSource`: this package +neither fetches nor decodes a file. Without the pixels, or without the feature, the row is its art. + +- **Kitty** sends the picture once per connection (`a=T,U=1`, a PNG in 4096-byte chunks, `q=2` so + nothing comes back as input) and writes each cell as `U+10EEEE` with row and column + diacritics, the image id as a truecolor foreground. Placeholders are text: they wrap, scroll and are + erased like it. `MarkTransmitted` is how the source says whether the terminal already has it. +- **iTerm2 and sixel** are pixels over the screen. On the picture's first row the cursor makes room + below (`ESC D` per row), comes back up, and draws the picture between `ESC 7` and `ESC 8`; every row + then steps over its cells with `CSI n C`, so no text is written over it. Sixel is the cells' size in + pixels (`CellWidth` × `CellHeight`, 10 × 20 unless the host knows better; 0, which a terminal + reports when it does not know, counts as unknown). +- **Half blocks** letterbox the picture into two pixels a cell, at the client's colour depth. A client + with no colour (`Attributes` or `None`) is sent the text art instead. + +Each encoding (the PNG, the Kitty transmission, the sixels, the half-block rows) is made once per +picture and size and kept with the `TerminalPicture`, for as long as the host keeps the picture: every +other connection shown it at that size is sent a copy. So a host should hand out one +`TerminalPicture` per picture rather than a new one per render. + +None of these is sent on a guess: a MUD client that is not a terminal emulator may print them. + ## Styling the HTML output The HTML-family emitters write `ms-*` classes for the attributes with a fixed rendering (bold, diff --git a/MarkupString.Ansi/TerminalFeatures.cs b/MarkupString.Ansi/TerminalFeatures.cs new file mode 100644 index 0000000..15c0704 --- /dev/null +++ b/MarkupString.Ansi/TerminalFeatures.cs @@ -0,0 +1,52 @@ +namespace MarkupString.Ansi; + +/// +/// What a terminal can do beyond colour, each of which writes only to a +/// client that has it. What a client has is its host's to work out — from the terminal type it reported, +/// from asking the terminal, or from the player — and belongs to that one connection. +/// +/// +/// Every sequence here is one a terminal that does not know it ignores, but a MUD client that is not a +/// terminal emulator may print it, which is why none of them is sent on a guess. +/// +[Flags] +public enum TerminalFeatures +{ + /// Colour and attributes only: a link is its text, a picture its text art. + None = 0, + + /// A URL link as an OSC 8 hyperlink (ESC ] 8 ; ; url ST). + Hyperlinks = 1, + + /// + /// A command link as an MSLP link (ESC ] 68 ; 1 ; SEND ; command BEL before the underlined text), + /// which a client advertising MTTS's MSLP bit sends back to the game when clicked. + /// + CommandLinks = 2, + + /// + /// Pictures through the Kitty graphics protocol, drawn with Unicode placeholders: the picture is sent + /// once per connection, and each cell it covers is a placeholder character, which wraps, scrolls and is + /// erased like text. + /// + KittyGraphics = 4, + + /// + /// Pictures through iTerm2's inline images (ESC ] 1337 ; File=…), drawn over the cells the + /// picture covers after the cursor has made room for them. + /// + InlineImages = 8, + + /// Pictures as sixel graphics, drawn over the cells the picture covers like . + Sixel = 16, + + /// + /// Pictures as coloured half-block characters (▀, ▄), two pixels to a cell: ordinary text + /// any UTF-8 terminal with colour shows. The lowest rung of pictures, and the only one with no + /// protocol at all. + /// + BlockArt = 32, + + /// Every way of drawing a picture. + Pictures = KittyGraphics | InlineImages | Sixel | BlockArt, +} diff --git a/MarkupString.Ansi/TerminalPicture.cs b/MarkupString.Ansi/TerminalPicture.cs new file mode 100644 index 0000000..1f2420e --- /dev/null +++ b/MarkupString.Ansi/TerminalPicture.cs @@ -0,0 +1,68 @@ +using System.Diagnostics.CodeAnalysis; + +namespace MarkupString.Ansi; + +/// +/// A picture's pixels, ready to be drawn in a terminal: 8-bit RGBA, row by row from the top left. +/// Decoding a file and fetching it are the host's; this package only draws what it is given. +/// +public sealed class TerminalPicture +{ + /// Creates a picture from , four bytes a pixel. + /// + /// What identifies these pixels, such as a hash of the file. Two pictures with the same key are taken to be + /// the same picture, which is how a terminal that already holds one is not sent it again. + /// + /// Its width in pixels. + /// Its height in pixels. + /// The pixels, × × 4 bytes. + /// is empty, or is not the size the dimensions give. + /// A dimension is not positive. + public TerminalPicture(string key, int width, int height, ReadOnlyMemory rgba) + { + ArgumentException.ThrowIfNullOrEmpty(key); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + if ((long)width * height * 4 != rgba.Length) + throw new ArgumentException($"A {width}x{height} picture is {(long)width * height * 4} bytes of RGBA, not {rgba.Length}.", nameof(rgba)); + + Key = key; + Width = width; + Height = height; + Rgba = rgba; + } + + /// What identifies these pixels. + public string Key { get; } + + /// Its width in pixels. + public int Width { get; } + + /// Its height in pixels. + public int Height { get; } + + /// The pixels: RGBA, row by row from the top left. + public ReadOnlyMemory Rgba { get; } +} + +/// +/// Where a terminal render finds a picture's pixels, and what it knows about the one connection it is +/// rendering for. A host builds one per connection and hands it to . +/// +public interface ITerminalPictureSource +{ + /// + /// The pixels of , or false when they are not to hand — not fetched yet, refused, or + /// not a picture at all — in which case the client is sent the text art instead. Called while rendering, so + /// it must answer from what it already has rather than fetch. + /// + bool TryGetPicture(ImageMarkup image, [NotNullWhen(true)] out TerminalPicture? picture); + + /// + /// Records that the terminal is being sent the Kitty image , and answers whether it + /// was new: true the first time, so it is transmitted, and false afterwards, when its placeholders alone are + /// enough. A source that forgets — a restarted host — answers true again, and the terminal replaces the + /// image it had under that id. + /// + bool MarkTransmitted(uint imageId); +} diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs new file mode 100644 index 0000000..5a3a9bb --- /dev/null +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -0,0 +1,468 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text; +using System.Text.RegularExpressions; +using MarkupString.Ansi; +using MarkupString.Layout; + +/// +/// What a terminal is sent beyond colour, by what its connection says it can do: OSC 8 and MSLP links, and +/// pictures drawn into a figure's cells as Kitty placeholders, iTerm2 or sixel images, or half-block art. +/// +public class TerminalFeatureTests +{ + private const string Esc = "\e"; + private const string Bel = "\u0007"; + private const string Placeholder = "\U0010EEEE"; + + private sealed class Source(TerminalPicture? picture) : ITerminalPictureSource + { + public HashSet Sent { get; } = []; + + public bool TryGetPicture(ImageMarkup image, [NotNullWhen(true)] out TerminalPicture? found) + { + found = picture; + return found is not null; + } + + public bool MarkTransmitted(uint imageId) => Sent.Add(imageId); + } + + /// A picture of two columns, red on the left and blue on the right, opaque. + private static TerminalPicture RedBlue(int width = 4, int height = 4) + { + var rgba = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var o = (y * width + x) * 4; + if (x < width / 2) rgba[o] = 255; + else rgba[o + 2] = 255; + rgba[o + 3] = 255; + } + return new TerminalPicture("red-blue", width, height, rgba); + } + + private static readonly ImageMarkup Cat = new("cat.png", "A cat"); + + private static MarkupText Render(MarkupText text, AnsiOutputOptions options) => + MarkupText.Plain(text.Render(MarkupFormat.Ansi, MarkupRegistry.Empty.WithAnsi().WithAnsiOutput(options))); + + private static string RenderString(MarkupText text, AnsiOutputOptions options) => + text.Render(MarkupFormat.Ansi, MarkupRegistry.Empty.WithAnsi().WithAnsiOutput(options)); + + /// A figure laid out for a reader whose client draws pictures big. + private static MarkupText Laid(Block block, int width, PictureCells? cells = null) => + BlockLayout.Build(block, width, context: new LayoutContext { Pictures = (_, _) => cells ?? new PictureCells(4, 2) }); + + // ── Links ─────────────────────────────────────────────────────────────────── + + [Test] + public async Task AUrlLinkIsAnOsc8HyperlinkOnlyWhenTheClientReadsThem() + { + var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "https://example.com"), "site"); + + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.Hyperlinks))) + .IsEqualTo($"{Esc}]8;;https://example.com{Bel}site{Esc}]8;;{Bel}"); + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.None))).IsEqualTo("site"); + } + + [Test] + public async Task ACommandLinkIsAnMslpLinkForAClientThatReadsThem() + { + var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "look north", linkKind: LinkKind.Command), "north"); + + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.CommandLinks))) + .IsEqualTo($"{Esc}]68;1;SEND;look north{Bel}{Esc}[4mnorth{Esc}[24m"); + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.Hyperlinks))).IsEqualTo("north"); + } + + [Test] + public async Task AnUnderlinedCommandLinkStaysUnderlinedPastTheLink() + { + var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: "look", linkKind: LinkKind.Command, underlined: true), "here"); + + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.CommandLinks))) + .IsEqualTo($"{Esc}[4m{Esc}]68;1;SEND;look{Bel}{Esc}[4mhere{Esc}[24m{Esc}[4m{Esc}[0m"); + } + + /// A control character would end the sequence early and let the rest of the command reach the terminal. + [Test] + public async Task ACommandWithAControlCharacterIsItsTextAlone() + { + var link = MarkupText.Wrap(AnsiMarkup.Create(linkUrl: $"look{Bel}{Esc}]52;c;evil", linkKind: LinkKind.Command), "here"); + + await Assert.That(RenderString(link, new AnsiOutputOptions(Features: TerminalFeatures.CommandLinks))).IsEqualTo("here"); + } + + // ── Layout ────────────────────────────────────────────────────────────────── + + [Test] + public async Task AFigureForAReaderWithPicturesMarksEachRowOfItsArt() + { + var figure = new Figure(Cat, MarkupText.Plain("/\\_/\\\n( o.o )")); + + var laid = Laid(figure, 20); + + await Assert.That(laid.ToPlainText()).IsEqualTo(BlockLayout.Build(figure, 20).ToPlainText()); + var rows = laid.Runs.SelectMany(run => run.Markups).OfType().Distinct().ToArray(); + await Assert.That(rows.Select(r => (r.Row, r.Rows, r.Columns))).IsEquivalentTo(new[] { (0, 2, 7), (1, 2, 7) }); + } + + [Test] + public async Task AFigureWithNoArtReservesTheCellsItIsGiven() + { + var figure = new Figure(Cat, MarkupText.Empty); + + var lines = Laid(figure, 20, new PictureCells(9, 3)).ToPlainText().Split('\n'); + + await Assert.That(lines.Select(l => l.TrimEnd())).IsEquivalentTo(new[] { "", "[A cat]", "" }); + } + + [Test] + public async Task AFigureIsItsArtWhenTheReaderGetsNoPicture() + { + var figure = new Figure(Cat, MarkupText.Plain("=^.^=")); + + var laid = BlockLayout.Build(figure, 20, context: new LayoutContext { Pictures = (_, _) => null }); + + await Assert.That(laid.Runs.SelectMany(run => run.Markups).OfType()).IsEmpty(); + } + + [Test] + public async Task PictureCellsKeepThePictureShape() + { + await Assert.That(PictureCells.Fit(400, 200, 20)).IsEqualTo(new PictureCells(20, 5)); + await Assert.That(PictureCells.Fit(10, 1000, 2)).IsEqualTo(new PictureCells(2, 100)); + await Assert.That(PictureCells.Fit(1000, 10, 4)).IsEqualTo(new PictureCells(4, 1)); + } + + // ── Kitty ─────────────────────────────────────────────────────────────────── + + [Test] + public async Task KittySendsThePictureOnceThenPlaceholders() + { + var source = new Source(RedBlue()); + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = source }; + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10); + + var first = RenderString(laid, options); + var second = RenderString(laid, options); + + await Assert.That(Regex.Matches(first, "a=T,U=1").Count).IsEqualTo(1); + await Assert.That(first).Contains("f=100,c=4,r=2,q=2"); + await Assert.That(second).DoesNotContain("a=T"); + await Assert.That(Regex.Matches(second, Placeholder).Count).IsEqualTo(8); + await Assert.That(source.Sent.Count).IsEqualTo(1); + } + + [Test] + public async Task KittyPlaceholdersCarryTheirRowAndColumnAndTheIdAsForeground() + { + var picture = RedBlue(); + var source = new Source(picture); + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = source }; + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 2)); + + var lines = RenderString(laid, options).Split('\n'); + var id = source.Sent.Single(); + + await Assert.That(lines[1]).Contains($"{Esc}[38;2;{id >> 16 & 255};{id >> 8 & 255};{id & 255}m"); + // Row 1 (U+030D), columns 0 (U+0305) and 1 (U+030D). + await Assert.That(lines[1]).Contains($"{Placeholder}̍̅{Placeholder}̍̍"); + } + + /// The rows of a box round a picture stay the same width whether the picture is drawn or not. + [Test] + public async Task KittyKeepsABoxAroundThePictureAligned() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = new Source(RedBlue()) }; + var laid = Laid(new Frame(new Figure(Cat, MarkupText.Plain("=^.^=\n( )")) { Float = FigureFloat.Left, Beside = new TextBlock(MarkupText.Plain("A cat")) }), 20); + + var widths = RenderString(laid, options).Split('\n').Select(VisibleCells).Distinct().ToArray(); + + await Assert.That(widths).IsEquivalentTo(new[] { 20 }); + } + + [Test] + public async Task KittyWithoutThePixelsSendsTheArt() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = new Source(null) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Plain("=^.^=")), 10), options); + + await Assert.That(output).StartsWith("=^.^="); + await Assert.That(output).DoesNotContain(Placeholder); + } + + [Test] + public async Task KittyTransmissionIsChunkedAtFourKilobytes() + { + var random = new Random(7); + var noise = new byte[64 * 64 * 4]; + random.NextBytes(noise); + var options = new AnsiOutputOptions(Features: TerminalFeatures.KittyGraphics) { Pictures = new Source(new TerminalPicture("noise", 64, 64, noise)) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 40, new PictureCells(8, 4)), options); + var chunks = Regex.Matches(output, $"{Esc}_G([^;]*);([^{Esc}]*){Esc}\\\\").ToArray(); + + await Assert.That(chunks.Length).IsGreaterThan(1); + await Assert.That(chunks.All(c => c.Groups[2].Value.Length <= 4096)).IsTrue(); + await Assert.That(chunks[^1].Groups[1].Value).IsEqualTo("m=0,q=2"); + await Assert.That(chunks.SkipLast(1).Skip(1).All(c => c.Groups[1].Value == "m=1,q=2")).IsTrue(); + var payload = Convert.FromBase64String(string.Concat(chunks.Select(c => c.Groups[2].Value))); + await Assert.That(payload.Take(4)).IsEquivalentTo(new byte[] { 0x89, 0x50, 0x4E, 0x47 }); // a PNG + } + + // ── iTerm2 and sixel ──────────────────────────────────────────────────────── + + [Test] + public async Task InlineImagesMakeRoomDrawAndStepOverTheCells() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) { Pictures = new Source(RedBlue()) }; + + var lines = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(4, 2)), options).Split('\n'); + + await Assert.That(lines[0]).StartsWith($"{Esc}D{Esc}D{Esc}[2A{Esc}7{Esc}]1337;File=inline=1;size="); + await Assert.That(lines[0]).Contains($";width=4;height=2;preserveAspectRatio=1:"); + await Assert.That(lines[0]).Contains($"{Bel}{Esc}8{Esc}[4C"); + await Assert.That(lines[1]).StartsWith($"{Esc}[4C"); + var base64 = Regex.Match(lines[0], ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value; + var png = Convert.FromBase64String(base64); + await Assert.That(png.Take(8)).IsEquivalentTo(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }); + // IEND's CRC is fixed: the encoder's checksum is right if this is. + await Assert.That(png.TakeLast(4)).IsEquivalentTo(new byte[] { 0xAE, 0x42, 0x60, 0x82 }); + } + + [Test] + public async Task SixelIsTheCellsSizeInWholeBands() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.Sixel) { Pictures = new Source(RedBlue()), CellWidth = 8, CellHeight = 16 }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 2)), options); + var sixel = Regex.Match(output, $"{Esc}P0;[01];0q\"1;1;(\\d+);(\\d+)(.*?){Esc}\\\\").Groups; + + await Assert.That(sixel[1].Value).IsEqualTo("16"); + await Assert.That(sixel[2].Value).IsEqualTo("30"); + // Red is cube entry 180 (5,0,0), blue entry 5 (0,0,5). + await Assert.That(sixel[3].Value).Contains("#180;2;100;0;0"); + await Assert.That(sixel[3].Value).Contains("#5;2;0;0;100"); + await Assert.That(Regex.Matches(sixel[3].Value, "-").Count).IsEqualTo(4); + } + + /// + /// Decoding the sixels gives back every pixel's cube colour, and leaves the transparent ones unset: the + /// band is read once and each colour written only over the columns it reaches, which this pins down. + /// + [Test] + public async Task SixelDecodesBackToThePicture() + { + const int width = 16, height = 12; + var rgba = new byte[width * height * 4]; + var expected = new int[width * height]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var i = y * width + x; + var o = i * 4; + if ((x + y) % 7 == 0) + { + expected[i] = -1; + continue; + } + + // A colour in one corner only, one band-wide stripe, and a checker elsewhere. + var (r, g, b) = x < 3 && y < 3 ? (0, 255, 0) : y == 7 ? (255, 255, 0) : (x + y) % 2 == 0 ? (255, 0, 0) : (0, 0, 255); + (rgba[o], rgba[o + 1], rgba[o + 2], rgba[o + 3]) = ((byte)r, (byte)g, (byte)b, 255); + expected[i] = r / 51 * 36 + g / 51 * 6 + b / 51; + } + + var options = new AnsiOutputOptions(Features: TerminalFeatures.Sixel) + { + Pictures = new Source(new TerminalPicture("sixel-roundtrip", width, height, rgba)), + CellWidth = 8, + CellHeight = 12 + }; + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 1)), options); + var body = Regex.Match(output, $"{Esc}P0;1;0q\"1;1;16;12(.*?){Esc}\\\\").Groups[1].Value; + + await Assert.That(Decode(body, width, height)).IsEquivalentTo(expected); + } + + /// The palette entry each pixel of sets, -1 for none. + private static int[] Decode(string body, int width, int height) + { + var pixels = Enumerable.Repeat(-1, width * height).ToArray(); + var colour = 0; + var x = 0; + var top = 0; + for (var i = 0; i < body.Length;) + { + var c = body[i]; + if (c == '#') + { + var match = Regex.Match(body[(i + 1)..], "^(\\d+)(;2;\\d+;\\d+;\\d+)?"); + colour = int.Parse(match.Groups[1].Value); + i += 1 + match.Length; + continue; + } + + var repeat = 1; + if (c == '!') + { + var match = Regex.Match(body[(i + 1)..], "^\\d+"); + repeat = int.Parse(match.Value); + i += 1 + match.Length; + c = body[i]; + } + + i++; + if (c == '$') { x = 0; continue; } + if (c == '-') { x = 0; top += 6; continue; } + for (var n = 0; n < repeat; n++, x++) + for (var bit = 0; bit < 6; bit++) + if (((c - '?') & (1 << bit)) != 0) pixels[(top + bit) * width + x] = colour; + } + + return pixels; + } + + /// + /// The PNG inflates and unfilters back to the picture's pixels: RGB with no alpha when every pixel is + /// opaque, RGBA when any is not, rows after the first filtered Up. + /// + [Test] + [Arguments(true)] + [Arguments(false)] + public async Task ThePngDecodesBackToThePicture(bool opaque) + { + const int width = 7, height = 5; + var rgba = new byte[width * height * 4]; + for (var i = 0; i < rgba.Length; i++) rgba[i] = (byte)(i * 37 % 251); + for (var i = 3; i < rgba.Length; i += 4) rgba[i] = opaque ? (byte)255 : (byte)(i % 256); + var options = new AnsiOutputOptions(Features: TerminalFeatures.InlineImages) + { + Pictures = new Source(new TerminalPicture($"png-{opaque}", width, height, rgba)) + }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(4, 2)), options); + var png = Convert.FromBase64String(Regex.Match(output, ":([A-Za-z0-9+/=]+)\u0007").Groups[1].Value); + + var channels = png[25] == 2 ? 3 : 4; + await Assert.That(png[25]).IsEqualTo(opaque ? (byte)2 : (byte)6); + var idatLength = System.Buffers.Binary.BinaryPrimitives.ReadInt32BigEndian(png.AsSpan(33)); + using var inflate = new System.IO.Compression.ZLibStream(new MemoryStream(png, 41, idatLength), System.IO.Compression.CompressionMode.Decompress); + using var raw = new MemoryStream(); + inflate.CopyTo(raw); + var data = raw.ToArray(); + + var stride = width * channels; + var decoded = new byte[width * height * 4]; + var previous = new byte[stride]; + for (var y = 0; y < height; y++) + { + var filter = data[y * (stride + 1)]; + var row = data.AsSpan(y * (stride + 1) + 1, stride).ToArray(); + if (filter == 2) for (var i = 0; i < stride; i++) row[i] += previous[i]; + for (var x = 0; x < width; x++) + for (var c = 0; c < 4; c++) + decoded[(y * width + x) * 4 + c] = c < channels ? row[x * channels + c] : (byte)255; + previous = row; + } + + await Assert.That(decoded).IsEquivalentTo(rgba); + } + + /// A picture is encoded once for every connection shown it at one size: each is sent the same. + [Test] + public async Task EveryConnectionShownAPictureIsSentTheSameEncoding() + { + var picture = RedBlue(32, 32); + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(4, 2)); + var bigger = Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(6, 3)); + + foreach (var feature in new[] { TerminalFeatures.KittyGraphics, TerminalFeatures.InlineImages, TerminalFeatures.Sixel, TerminalFeatures.BlockArt }) + { + string Send(MarkupText text) => + RenderString(text, new AnsiOutputOptions(Features: feature) { Pictures = new Source(picture) }); + + await Assert.That(Send(laid)).IsEqualTo(Send(laid)).Because($"{feature} for a second connection"); + await Assert.That(Send(bigger)).IsNotEqualTo(Send(laid)).Because($"{feature} at another size"); + } + } + + // ── Half blocks ───────────────────────────────────────────────────────────── + + [Test] + public async Task BlockArtDrawsTwoPixelsACell() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.BlockArt) { Pictures = new Source(RedBlue()) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 1)), options); + + await Assert.That(output).IsEqualTo($"{Esc}[38;2;255;0;0;48;2;255;0;0m▀{Esc}[38;2;0;0;255;48;2;0;0;255m▀{Esc}[0m" + new string(' ', 8)); + } + + [Test] + public async Task BlockArtFollowsTheColourDepth() + { + var options = new AnsiOutputOptions(AnsiColorDepth.Standard, TerminalFeatures.BlockArt) { Pictures = new Source(RedBlue()) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 2, new PictureCells(2, 1)), options); + + await Assert.That(output).DoesNotContain("38;2"); + await Assert.That(output.Count(c => c == '▀')).IsEqualTo(2); + } + + [Test] + [Arguments(AnsiColorDepth.Attributes)] + [Arguments(AnsiColorDepth.None)] + public async Task BlockArtWithoutColourIsTheTextArt(AnsiColorDepth depth) + { + var options = new AnsiOutputOptions(depth, TerminalFeatures.BlockArt) { Pictures = new Source(RedBlue()) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 1)), options); + + await Assert.That(output).DoesNotContain("▀"); + await Assert.That(output).DoesNotContain("▄"); + } + + /// A terminal that does not know its cell size reports 0; that is drawn at the default size, not divided by. + [Test] + [Arguments(TerminalFeatures.Sixel)] + [Arguments(TerminalFeatures.InlineImages)] + [Arguments(TerminalFeatures.KittyGraphics)] + public async Task AnUnknownCellSizeIsTheDefault(TerminalFeatures feature) + { + var laid = Laid(new Figure(Cat, MarkupText.Empty), 10, new PictureCells(2, 2)); + var unknown = new AnsiOutputOptions(Features: feature) { Pictures = new Source(RedBlue()), CellWidth = 0, CellHeight = -1 }; + var known = new AnsiOutputOptions(Features: feature) { Pictures = new Source(RedBlue()) }; + + await Assert.That(unknown.CellWidth).IsEqualTo(10); + await Assert.That(unknown.CellHeight).IsEqualTo(20); + await Assert.That(RenderString(laid, unknown)).IsEqualTo(RenderString(laid, known)); + } + + [Test] + public async Task KittyIsPreferredWhenTheClientHasSeveralWays() + { + var options = new AnsiOutputOptions(Features: TerminalFeatures.Pictures) { Pictures = new Source(RedBlue()) }; + + var output = RenderString(Laid(new Figure(Cat, MarkupText.Empty), 10), options); + + await Assert.That(output).Contains(Placeholder); + await Assert.That(output).DoesNotContain("1337"); + } + + /// The cells a line covers on a Kitty terminal: escape sequences none, a placeholder and its diacritics one. + private static int VisibleCells(string line) + { + var text = Regex.Replace(line, $"{Esc}_G.*?{Esc}\\\\|{Esc}\\[[0-9;]*m", ""); + var cells = 0; + foreach (var rune in text.EnumerateRunes()) + { + if (Rune.GetUnicodeCategory(rune) == System.Globalization.UnicodeCategory.NonSpacingMark) continue; + cells++; + } + return cells; + } +} diff --git a/MarkupString/Elements/PictureCellsMarkup.cs b/MarkupString/Elements/PictureCellsMarkup.cs new file mode 100644 index 0000000..64021f3 --- /dev/null +++ b/MarkupString/Elements/PictureCellsMarkup.cs @@ -0,0 +1,19 @@ +namespace MarkupString; + +/// +/// One row of the cells a picture covers in a laid-out block, over the text a client that draws no +/// pictures shows there instead: a row of the figure's text art, or blanks. +/// lays one out per row for a reader whose client draws pictures (). +/// +/// +/// The text under it is exactly cells wide, so the block around it lines up +/// whether the picture is drawn or not. A terminal emitter that can draw the picture writes it into +/// those cells in place of the text; every other emitter, and every string operation, sees the text. +/// Only a terminal format draws it, and only when its host gave it the picture's pixels. There is +/// no codec: it exists between a reader's relayout and the render, and is never stored. +/// +/// The picture. +/// Which row of the picture this is, from 0. +/// How many rows the picture covers. +/// How many cells wide the picture is. +public sealed record PictureCellsMarkup(ImageMarkup Image, int Row, int Rows, int Columns) : IMarkup; diff --git a/MarkupString/Layout/Blocks/Block.cs b/MarkupString/Layout/Blocks/Block.cs index 6a7ce1b..b4f384d 100644 --- a/MarkupString/Layout/Blocks/Block.cs +++ b/MarkupString/Layout/Blocks/Block.cs @@ -82,6 +82,15 @@ public sealed record LayoutContext /// Where text with no alignment of its own sits: a table column's, or one set with . public Alignment TextAlignment { get; init; } = Alignment.Left; + /// + /// For a reader whose client draws pictures in its cells, the cells a picture would take at most + /// the given number of columns, or null for one it will not be drawing; null for a reader whose + /// client draws none. A the answer is not null for reserves those cells, marked + /// with , and keeps its text art in them for a client the picture + /// does not reach after all. + /// + public Func? Pictures { get; init; } + /// Draws at cells, for this reader. public void Draw(Block block, int width, IList lines) { @@ -313,3 +322,26 @@ public LayoutTheme Over(LayoutTheme below) /// The theme's piece, or the default's. internal MarkupText Piece(Func piece) => piece(this) ?? piece(Defaults)!; } + +/// The cells a picture covers in a laid-out block. +/// How many cells wide. +/// How many lines tall. +public readonly record struct PictureCells(int Columns, int Rows) +{ + /// + /// The cells a picture of by pixels takes when + /// drawn cells wide, on a terminal whose cell is + /// by pixels: the rows that keep its shape, + /// at least one. + /// + public static PictureCells Fit(int width, int height, int columns, int cellWidth = 10, int cellHeight = 20) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(width); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(height); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(columns); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(cellWidth); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(cellHeight); + var rows = (int)Math.Round((double)height * columns * cellWidth / ((double)width * cellHeight), MidpointRounding.AwayFromZero); + return new PictureCells(columns, Math.Max(1, rows)); + } +} diff --git a/MarkupString/Layout/Blocks/Figure.cs b/MarkupString/Layout/Blocks/Figure.cs index b972ef3..9fff88e 100644 --- a/MarkupString/Layout/Blocks/Figure.cs +++ b/MarkupString/Layout/Blocks/Figure.cs @@ -21,14 +21,21 @@ public sealed record Figure(ImageMarkup Image, MarkupText Art) : Block /// public override void Draw(LayoutContext context, int width, IList lines) { - if (Art.Length == 0) + var art = Art.Length == 0 + ? null + : Art.Split("\n").Select(line => line.Text.EndsWith('\r') ? line.Substring(0, line.Length - 1) : line).ToArray(); + + if (context.Pictures is { } pictures + && pictures(Image, Math.Min(width, art?.Max(line => line.DisplayWidth) ?? width)) is { Columns: > 0, Rows: > 0 } cells) + art = PictureRows(art, cells, width); + + if (art is null) { lines.AddRange(MarkupText.Plain($"[{Description}]").FormatColumn(BlockText.Column(width, context.TextAlignment))); if (Beside is { } after) context.Draw(after, width, lines); return; } - var art = Art.Split("\n").Select(line => line.Text.EndsWith('\r') ? line.Substring(0, line.Length - 1) : line).ToArray(); var artWidth = Math.Min(width, art.Max(line => line.DisplayWidth)); var narrow = width - artWidth - Math.Max(0, Gap); @@ -76,6 +83,27 @@ public override void Draw(LayoutContext context, int width, IList li } } + /// + /// The rows of cells the picture is drawn in, each marked with over + /// what a client the picture does not reach shows there: the art, which keeps its own size, or, with no + /// art, blank cells of with the description on the middle row. + /// + private MarkupText[]? PictureRows(MarkupText[]? art, PictureCells cells, int width) + { + var columns = Math.Min(width, art?.Max(line => line.DisplayWidth) ?? cells.Columns); + var rows = art?.Length ?? cells.Rows; + if (columns <= 0) return art; + var under = art is not null + ? art.Select(line => BlockText.Fit(line, columns)).ToArray() + : Enumerable.Range(0, rows).Select(row => row == rows / 2 + ? BlockText.Fit(MarkupText.Plain($"[{Description}]"), columns) + : BlockText.Blank(columns)).ToArray(); + + for (var row = 0; row < rows; row++) + under[row] = MarkupText.Wrap(new PictureCellsMarkup(Image, row, rows, columns), under[row]); + return under; + } + /// public override void DrawLinear(LayoutContext context, int width, IList lines) { diff --git a/MarkupString/PublicAPI.Unshipped.txt b/MarkupString/PublicAPI.Unshipped.txt index 7dc5c58..2be5a3e 100644 --- a/MarkupString/PublicAPI.Unshipped.txt +++ b/MarkupString/PublicAPI.Unshipped.txt @@ -1 +1,36 @@ #nullable enable +MarkupString.Layout.LayoutContext.Pictures.get -> System.Func? +MarkupString.Layout.LayoutContext.Pictures.init -> void +MarkupString.Layout.PictureCells +MarkupString.Layout.PictureCells.Columns.get -> int +MarkupString.Layout.PictureCells.Columns.init -> void +MarkupString.Layout.PictureCells.Deconstruct(out int Columns, out int Rows) -> void +MarkupString.Layout.PictureCells.Equals(MarkupString.Layout.PictureCells other) -> bool +MarkupString.Layout.PictureCells.PictureCells() -> void +MarkupString.Layout.PictureCells.PictureCells(int Columns, int Rows) -> void +MarkupString.Layout.PictureCells.Rows.get -> int +MarkupString.Layout.PictureCells.Rows.init -> void +MarkupString.PictureCellsMarkup +MarkupString.PictureCellsMarkup.$() -> MarkupString.PictureCellsMarkup! +MarkupString.PictureCellsMarkup.Columns.get -> int +MarkupString.PictureCellsMarkup.Columns.init -> void +MarkupString.PictureCellsMarkup.Deconstruct(out MarkupString.ImageMarkup! Image, out int Row, out int Rows, out int Columns) -> void +MarkupString.PictureCellsMarkup.Equals(MarkupString.PictureCellsMarkup? other) -> bool +MarkupString.PictureCellsMarkup.Image.get -> MarkupString.ImageMarkup! +MarkupString.PictureCellsMarkup.Image.init -> void +MarkupString.PictureCellsMarkup.PictureCellsMarkup(MarkupString.ImageMarkup! Image, int Row, int Rows, int Columns) -> void +MarkupString.PictureCellsMarkup.Row.get -> int +MarkupString.PictureCellsMarkup.Row.init -> void +MarkupString.PictureCellsMarkup.Rows.get -> int +MarkupString.PictureCellsMarkup.Rows.init -> void +override MarkupString.Layout.PictureCells.GetHashCode() -> int +override MarkupString.PictureCellsMarkup.Equals(object? obj) -> bool +override MarkupString.PictureCellsMarkup.GetHashCode() -> int +override MarkupString.PictureCellsMarkup.ToString() -> string! +static MarkupString.Layout.PictureCells.Fit(int width, int height, int columns, int cellWidth = 10, int cellHeight = 20) -> MarkupString.Layout.PictureCells +static MarkupString.Layout.PictureCells.operator !=(MarkupString.Layout.PictureCells left, MarkupString.Layout.PictureCells right) -> bool +static MarkupString.Layout.PictureCells.operator ==(MarkupString.Layout.PictureCells left, MarkupString.Layout.PictureCells right) -> bool +static MarkupString.PictureCellsMarkup.operator !=(MarkupString.PictureCellsMarkup? left, MarkupString.PictureCellsMarkup? right) -> bool +static MarkupString.PictureCellsMarkup.operator ==(MarkupString.PictureCellsMarkup? left, MarkupString.PictureCellsMarkup? right) -> bool +~override MarkupString.Layout.PictureCells.Equals(object obj) -> bool +~override MarkupString.Layout.PictureCells.ToString() -> string