diff --git a/MarkupString.Ansi/Emitters/GifWriter.cs b/MarkupString.Ansi/Emitters/GifWriter.cs index 5bdc042..7dfc5ae 100644 --- a/MarkupString.Ansi/Emitters/GifWriter.cs +++ b/MarkupString.Ansi/Emitters/GifWriter.cs @@ -19,7 +19,7 @@ namespace MarkupString.Ansi; /// internal static class GifWriter { - private const int Bins = 32768; + private const int Bins = PicturePalette.Bins; /// /// , each × RGBA shown for its @@ -35,11 +35,11 @@ internal static byte[] Encode(IReadOnlyList frames, int wi for (var i = 0; i < pixels.Length; i += 4) { if (pixels[i + 3] < 128) transparent = true; - else histogram[Bin(pixels, i)]++; + else histogram[PicturePalette.Bin(pixels, i)]++; } } - var (palette, lookup) = MedianCut(histogram, transparent ? 255 : 256); + var (palette, lookup) = PicturePalette.MedianCut(histogram, transparent ? 255 : 256); var transparentIndex = palette.Count; var colours = palette.Count + (transparent ? 1 : 0); var bits = 1; @@ -69,7 +69,7 @@ internal static byte[] Encode(IReadOnlyList frames, int wi { var pixels = frame.Rgba.Span; for (int i = 0, p = 0; p < indices.Length; i += 4, p++) - indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[Bin(pixels, i)]; + indices[p] = pixels[i + 3] < 128 ? (byte)transparentIndex : lookup[PicturePalette.Bin(pixels, i)]; // Graphic control: restore to background after the frame, the delay in hundredths, transparency. var centiseconds = (int)Math.Clamp(Math.Round(frame.Shown.TotalMilliseconds / 10), 2, ushort.MaxValue); @@ -92,114 +92,12 @@ internal static byte[] Encode(IReadOnlyList frames, int wi return output.ToArray(); } - private static int Bin(ReadOnlySpan pixels, int offset) => - (pixels[offset] >> 3) << 10 | (pixels[offset + 1] >> 3) << 5 | pixels[offset + 2] >> 3; - private static void WriteShort(Stream output, int value) { output.WriteByte((byte)value); output.WriteByte((byte)(value >> 8)); } - /// - /// A palette of at most for the bins in , and each - /// bin's index in it: the box of bins with the widest channel, weighted by how many pixels it holds, is split - /// at its median until there are enough boxes, and each box's colour is the average of its pixels. - /// - private static (List<(byte R, byte G, byte B)> Palette, byte[] Lookup) MedianCut(int[] histogram, int maxColours) - { - var used = new List(); - for (var bin = 0; bin < Bins; bin++) - if (histogram[bin] > 0) used.Add(bin); - - var boxes = new List<(int Start, int Length)>(); - var bins = used.ToArray(); - if (bins.Length > 0) boxes.Add((0, bins.Length)); - - while (boxes.Count < maxColours) - { - var best = -1; - long bestScore = 0; - var bestChannel = 0; - for (var b = 0; b < boxes.Count; b++) - { - var (start, length) = boxes[b]; - if (length < 2) continue; - var (channel, range) = WidestChannel(bins.AsSpan(start, length)); - long pixels = 0; - for (var i = start; i < start + length; i++) pixels += histogram[bins[i]]; - var score = range * pixels; - if (range > 0 && score > bestScore) - { - (best, bestScore, bestChannel) = (b, score, channel); - } - } - - if (best < 0) break; - - var (boxStart, boxLength) = boxes[best]; - var span = bins.AsSpan(boxStart, boxLength); - var shift = 10 - bestChannel * 5; - span.Sort((x, y) => ((x >> shift) & 31).CompareTo((y >> shift) & 31)); - - long total = 0; - foreach (var bin in span) total += histogram[bin]; - long running = 0; - var split = 1; - for (var i = 0; i < span.Length - 1; i++) - { - running += histogram[span[i]]; - split = i + 1; - if (running * 2 >= total) break; - } - - boxes[best] = (boxStart, split); - boxes.Add((boxStart + split, boxLength - split)); - } - - var palette = new List<(byte, byte, byte)>(boxes.Count); - var lookup = new byte[Bins]; - foreach (var (start, length) in boxes) - { - long r = 0, g = 0, b = 0, count = 0; - for (var i = start; i < start + length; i++) - { - var bin = bins[i]; - long weight = histogram[bin]; - r += (((bin >> 10) & 31) * 8 + 4) * weight; - g += (((bin >> 5) & 31) * 8 + 4) * weight; - b += ((bin & 31) * 8 + 4) * weight; - count += weight; - lookup[bin] = (byte)palette.Count; - } - - palette.Add(((byte)(r / count), (byte)(g / count), (byte)(b / count))); - } - - return (palette, lookup); - } - - /// Which channel (0 red, 1 green, 2 blue) spans the most bins in , and by how much. - private static (int Channel, int Range) WidestChannel(ReadOnlySpan bins) - { - var (best, bestRange) = (0, -1); - for (var channel = 0; channel < 3; channel++) - { - var shift = 10 - channel * 5; - int low = 31, high = 0; - foreach (var bin in bins) - { - var value = (bin >> shift) & 31; - low = Math.Min(low, value); - high = Math.Max(high, value); - } - - if (high - low > bestRange) (best, bestRange) = (channel, high - low); - } - - return (best, bestRange); - } - /// /// LZW-compressed as GIF image data, in sub-blocks of at most 255 bytes and a /// terminator. The code table is cleared when it is full. diff --git a/MarkupString.Ansi/Emitters/PicturePalette.cs b/MarkupString.Ansi/Emitters/PicturePalette.cs new file mode 100644 index 0000000..1ff7b15 --- /dev/null +++ b/MarkupString.Ansi/Emitters/PicturePalette.cs @@ -0,0 +1,110 @@ +namespace MarkupString.Ansi; + +/// A bounded, adaptive palette shared by GIF and Sixel encoders. +internal static class PicturePalette +{ + internal const int Bins = 32768; + + internal static int Bin(ReadOnlySpan pixels, int offset) => + (pixels[offset] >> 3) << 10 | (pixels[offset + 1] >> 3) << 5 | pixels[offset + 2] >> 3; + + /// + /// A palette of at most for the bins in , and each + /// bin's index in it: the box of bins with the widest channel, weighted by how many pixels it holds, is split + /// at its median until there are enough boxes, and each box's colour is the average of its pixels. + /// + internal static (List<(byte R, byte G, byte B)> Palette, byte[] Lookup) MedianCut(int[] histogram, int maxColours) + { + var used = new List(); + for (var bin = 0; bin < Bins; bin++) + if (histogram[bin] > 0) used.Add(bin); + + var boxes = new List<(int Start, int Length)>(); + var bins = used.ToArray(); + if (bins.Length > 0) boxes.Add((0, bins.Length)); + + while (boxes.Count < maxColours) + { + var best = -1; + long bestScore = 0; + var bestChannel = 0; + for (var b = 0; b < boxes.Count; b++) + { + var (start, length) = boxes[b]; + if (length < 2) continue; + var (channel, range) = WidestChannel(bins.AsSpan(start, length)); + long pixels = 0; + for (var i = start; i < start + length; i++) pixels += histogram[bins[i]]; + var score = range * pixels; + if (range > 0 && score > bestScore) + { + (best, bestScore, bestChannel) = (b, score, channel); + } + } + + if (best < 0) break; + + var (boxStart, boxLength) = boxes[best]; + var span = bins.AsSpan(boxStart, boxLength); + var shift = 10 - bestChannel * 5; + span.Sort((x, y) => ((x >> shift) & 31).CompareTo((y >> shift) & 31)); + + long total = 0; + foreach (var bin in span) total += histogram[bin]; + long running = 0; + var split = 1; + for (var i = 0; i < span.Length - 1; i++) + { + running += histogram[span[i]]; + split = i + 1; + if (running * 2 >= total) break; + } + + boxes[best] = (boxStart, split); + boxes.Add((boxStart + split, boxLength - split)); + } + + var palette = new List<(byte, byte, byte)>(boxes.Count); + var lookup = new byte[Bins]; + foreach (var (start, length) in boxes) + { + long r = 0, g = 0, b = 0, count = 0; + for (var i = start; i < start + length; i++) + { + var bin = bins[i]; + long weight = histogram[bin]; + r += (((bin >> 10) & 31) * 8 + 4) * weight; + g += (((bin >> 5) & 31) * 8 + 4) * weight; + b += ((bin & 31) * 8 + 4) * weight; + count += weight; + lookup[bin] = (byte)palette.Count; + } + + palette.Add(((byte)(r / count), (byte)(g / count), (byte)(b / count))); + } + + return (palette, lookup); + } + + /// Which channel (0 red, 1 green, 2 blue) spans the most bins in , and by how much. + private static (int Channel, int Range) WidestChannel(ReadOnlySpan bins) + { + var (best, bestRange) = (0, -1); + for (var channel = 0; channel < 3; channel++) + { + var shift = 10 - channel * 5; + int low = 31, high = 0; + foreach (var bin in bins) + { + var value = (bin >> shift) & 31; + low = Math.Min(low, value); + high = Math.Max(high, value); + } + + if (high - low > bestRange) (best, bestRange) = (channel, high - low); + } + + return (best, bestRange); + } + +} diff --git a/MarkupString.Ansi/Emitters/SixelWriter.cs b/MarkupString.Ansi/Emitters/SixelWriter.cs index b46c108..ec6bb69 100644 --- a/MarkupString.Ansi/Emitters/SixelWriter.cs +++ b/MarkupString.Ansi/Emitters/SixelWriter.cs @@ -4,8 +4,7 @@ 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, +/// Writes RGBA pixels as sixel graphics: an adaptive palette of up to 255 colours, a transparent background (P2 = 1) when any pixel is transparent, square pixels, /// and runs of a repeated column compressed with !. /// /// @@ -15,12 +14,16 @@ namespace MarkupString.Ansi; /// internal static class SixelWriter { - private const int Colours = 216; + private const int Colours = 255; /// Encodes ; is a whole number of six-pixel bands. internal static string Encode(byte[] pixels, int width, int height) { var pixelCount = width * height; + var histogram = new int[PicturePalette.Bins]; + for (var i = 0; i < pixelCount * 4; i += 4) + if (pixels[i + 3] >= 128) histogram[PicturePalette.Bin(pixels, i)]++; + var (palette, lookup) = PicturePalette.MedianCut(histogram, Colours); 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); @@ -38,7 +41,7 @@ internal static string Encode(byte[] pixels, int width, int height) continue; } - var entry = Level(pixels[o]) * 36 + Level(pixels[o + 1]) * 6 + Level(pixels[o + 2]); + var entry = lookup[PicturePalette.Bin(pixels, o)]; index[i] = (byte)entry; used[entry] = true; } @@ -52,7 +55,7 @@ internal static string Encode(byte[] pixels, int width, int height) { 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); + .Append(Percent(palette[entry].R)).Append(';').Append(Percent(palette[entry].G)).Append(';').Append(Percent(palette[entry].B)); } Span bandColours = stackalloc byte[Colours]; @@ -117,8 +120,8 @@ internal static string Encode(byte[] pixels, int width, int height) } } - /// A channel's nearest of the cube's six levels. - private static int Level(byte value) => (value * 5 + 127) / 255; + /// Sixel colour channels are integer percentages. + private static int Percent(byte value) => (value * 100 + 127) / 255; /// /// with each run of more than three equal sixels written !n, and the empty diff --git a/MarkupString.Ansi/PublicAPI.Unshipped.txt b/MarkupString.Ansi/PublicAPI.Unshipped.txt index 9288248..48cdd57 100644 --- a/MarkupString.Ansi/PublicAPI.Unshipped.txt +++ b/MarkupString.Ansi/PublicAPI.Unshipped.txt @@ -107,3 +107,4 @@ MarkupString.Ansi.Decoding.PictureStore.PictureStore(MarkupString.Ansi.Decoding. MarkupString.Ansi.Decoding.PicturesPlugin.Cache.get -> MarkupString.Ansi.Decoding.IPictureCache? MarkupString.Ansi.Decoding.PicturesPlugin.PicturesPlugin(MarkupString.Ansi.Decoding.IPictureCache? cache) -> void static MarkupString.Ansi.Decoding.PictureDecoding.WithPictures(this MarkupString.Decoding.DecoderRegistry! registry, MarkupString.Ansi.Decoding.IPictureCache! cache) -> MarkupString.Decoding.DecoderRegistry! +static MarkupString.Ansi.TerminalProfile.MudForge.get -> MarkupString.Ansi.TerminalProfile! diff --git a/MarkupString.Ansi/README.md b/MarkupString.Ansi/README.md index dd1a19d..20faf3f 100644 --- a/MarkupString.Ansi/README.md +++ b/MarkupString.Ansi/README.md @@ -167,3 +167,12 @@ so nothing depends on a type surviving the trimmer by name. ## Licence Apache-2.0. Source, guides and issues: [SharpMUSH/MarkupString](https://github.com/SharpMUSH/MarkupString). + +MudForge 2.0.5657 is recognized by `MUDFORGE` and supports still iTerm2 inline images and Sixel. +This profile records verified image support; it does not enable graphics without the host/player's +opt-in or imply support for animation or Kitty. Its web client currently does not forward terminal +query replies, so automatic cell-size discovery still requires a client fix. + +Sixel uses an adaptive palette of up to 255 colours, selected from the image's colour histogram, +with a reserved transparent index. The bounded median-cut palette implementation is shared with +GIF encoding; GIF's existing palette and animation behaviour are unchanged. diff --git a/MarkupString.Ansi/TerminalProfile.cs b/MarkupString.Ansi/TerminalProfile.cs index dadcfe2..8bdd4b0 100644 --- a/MarkupString.Ansi/TerminalProfile.cs +++ b/MarkupString.Ansi/TerminalProfile.cs @@ -173,9 +173,16 @@ public sealed record TerminalProfile(string Id, string Name, TerminalFeatures Fe Reports = ["tmux"], }; + /// MudForge 2.0.5657: iTerm2 inline images and Sixel, verified in its web client. + public static TerminalProfile MudForge { get; } = new("mudforge", "MudForge", + TerminalFeatures.InlineImages | TerminalFeatures.Sixel | TerminalFeatures.BlockArt) + { + Reports = ["mudforge"], + }; + /// Every terminal this package knows. public static IReadOnlyList Known { get; } = - [Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Rio, Mlterm, Alacritty, Vte, Tmux]; + [Kitty, Ghostty, WezTerm, ITerm2, Konsole, Foot, XTerm, WindowsTerminal, Mintty, VsCode, Contour, Rio, Mlterm, Alacritty, Vte, Tmux, MudForge]; /// The known terminal a player calls , compared without case, or null. public static TerminalProfile? Find(string? id) => diff --git a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs index 1d48171..cf05be3 100644 --- a/MarkupString.Tests/Ansi/TerminalFeatureTests.cs +++ b/MarkupString.Tests/Ansi/TerminalFeatureTests.cs @@ -613,14 +613,12 @@ public async Task SixelIsTheCellsSizeInWholeBands() 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, "#\\d+;2;").Count).IsEqualTo(2); 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 + /// Decoding the sixels gives back every pixel's approximate 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] @@ -643,7 +641,7 @@ public async Task SixelDecodesBackToThePicture() // 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; + expected[i] = r << 16 | g << 8 | b; } var options = new AnsiOutputOptions(Features: TerminalFeatures.Sixel) @@ -655,14 +653,25 @@ public async Task SixelDecodesBackToThePicture() 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); + var actual = Decode(body, width, height); + for (var i = 0; i < expected.Length; i++) + { + if (expected[i] < 0) await Assert.That(actual[i]).IsEqualTo(-1); + else + { + await Assert.That(actual[i]).IsGreaterThanOrEqualTo(0); + foreach (var shift in new[] { 0, 8, 16 }) + await Assert.That(Math.Abs(((actual[i] >> shift) & 255) - ((expected[i] >> shift) & 255))).IsLessThanOrEqualTo(6); + } + } } - /// The palette entry each pixel of sets, -1 for none. + /// The RGB colour 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 palette = new Dictionary(); var x = 0; var top = 0; for (var i = 0; i < body.Length;) @@ -672,6 +681,11 @@ private static int[] Decode(string body, int width, int height) { var match = Regex.Match(body[(i + 1)..], "^(\\d+)(;2;\\d+;\\d+;\\d+)?"); colour = int.Parse(match.Groups[1].Value); + if (match.Groups[2].Success) + { + var channels = match.Groups[2].Value.Split(';').Skip(2).Select(value => (int.Parse(value) * 255 + 50) / 100).ToArray(); + palette[colour] = channels[0] << 16 | channels[1] << 8 | channels[2]; + } i += 1 + match.Length; continue; } @@ -690,7 +704,7 @@ private static int[] Decode(string body, int width, int height) 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; + if (((c - '?') & (1 << bit)) != 0) pixels[(top + bit) * width + x] = palette[colour]; } return pixels; diff --git a/MarkupString.Tests/Ansi/TerminalProfileTests.cs b/MarkupString.Tests/Ansi/TerminalProfileTests.cs index 49c0fde..e6c8255 100644 --- a/MarkupString.Tests/Ansi/TerminalProfileTests.cs +++ b/MarkupString.Tests/Ansi/TerminalProfileTests.cs @@ -4,6 +4,8 @@ public class TerminalProfileTests { [Test] + [Arguments("MUDFORGE", "mudforge")] + [Arguments("MudForge 2.0.5657", "mudforge")] [Arguments("kitty(0.35.2)", "kitty")] [Arguments("xterm-kitty", "kitty")] [Arguments("WezTerm 20240203-110809-5046fc22", "wezterm")] diff --git a/MarkupString.Tests/Decoding/PictureDecodingTests.cs b/MarkupString.Tests/Decoding/PictureDecodingTests.cs index 800b8f8..23f46a8 100644 --- a/MarkupString.Tests/Decoding/PictureDecodingTests.cs +++ b/MarkupString.Tests/Decoding/PictureDecodingTests.cs @@ -122,6 +122,27 @@ public async Task Sixel_Overlay_RoundTrip() await Assert.That(decoder.Diagnostics.PicturesFailed).IsEqualTo(0); } + [Test] + public async Task Sixel_PreservesSubtleGreyShades() + { + const int width = 32, height = 30; + var pixels = new byte[width * height * 4]; + for (var y = 0; y < height; y++) + for (var x = 0; x < width; x++) + { + var offset = (y * width + x) * 4; + pixels[offset] = pixels[offset + 1] = pixels[offset + 2] = (byte)(x * 8 + 4); + pixels[offset + 3] = 255; + } + + var (lines, decoder) = Decode(Wire(TerminalFeatures.Sixel, new TerminalPicture("grey", width, height, pixels))); + var image = lines.SelectMany(line => line.Content.Runs).SelectMany(run => run.Markups).OfType().First(); + await Assert.That(decoder.Pictures().TryGet(image, out var stored)).IsTrue(); + var actual = stored!.Pixels!.Rgba.ToArray(); + var error = Enumerable.Range(0, width * height).Average(i => Math.Abs(actual[i * 4] - pixels[i * 4])); + await Assert.That(error).IsLessThan(3.0); + } + [Test] public async Task Sixel_DecodesColoursAndSize() {