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