ohmu
·
2026-10-15
1#include "image.h"
2
3#ifdef FF_HAVE_SIXEL
4
5 #include <string.h>
6 #include <sixel.h>
7 // The transparent path needs two things the public header does not declare: the palette mapping
8 // -- which is where libsixel applies the Floyd-Steinberg dithering, so doing it by hand would
9 // band the gradients -- and the palette size the encoder's keycolor handling is built around.
10 // The vendored copy is pinned to the exact revision this build compiles, so there is no
11 // version skew to guard against.
12 #include "dither.h"
13
14static int sixelWriteCallback(char* data, int size, void* priv) {
15 ffStrbufAppendNS((FFstrbuf*) priv, (uint32_t) size, data);
16 return 1; // non-zero means "keep going"
17}
18
19// Only a fully transparent pixel is left out. A partially transparent one can not be represented --
20// a sixel has no way to blend it with whatever the terminal has behind it -- and painting it in its
21// unblended colour is closer to the source than dropping it would be.
22static bool hasTransparentPixels(const FFImageBuffer* buffer) {
23 const uint8_t* pixels = buffer->data;
24 const uint32_t pixelCount = buffer->width * buffer->height;
25 for (uint32_t i = 0; i < pixelCount; ++i) {
26 if (pixels[i * 4 + 3] == 0) {
27 return true;
28 }
29 }
30
31 return false;
32}
33
34// Encodes an image with transparent pixels as an indexed image whose transparent pixels carry a
35// keycolor: that entry is left out of the palette definition and its pixels are left out of the
36// pixel data, so nothing is drawn there and the terminal background shows through.
37static SIXELSTATUS sixelEncodeTransparent(sixel_dither_t* dither, const FFImageBuffer* buffer, sixel_output_t* output) {
38 const int width = (int) buffer->width;
39 const int height = (int) buffer->height;
40
41 // Map the pixels onto the palette through libsixel's own lookup, then hand the result back as
42 // an indexed image. This is the same mapping the opaque path gets from sixel_encode.
43 sixel_index_t* indices = sixel_dither_apply_palette(dither, buffer->data, width, height);
44 if (indices == nullptr) {
45 return SIXEL_RUNTIME_ERROR;
46 }
47
48 // The keycolor is the first index past the quantized palette, so it is never a colour that a
49 // pixel was mapped to. Asking for one colour less than the maximum is what guarantees the
50 // index exists.
51 const int keycolor = sixel_dither_get_num_of_palette_colors(dither);
52 const uint8_t* pixels = buffer->data;
53 for (uint32_t i = 0; i < (uint32_t) width * (uint32_t) height; ++i) {
54 if (pixels[i * 4 + 3] == 0) {
55 indices[i] = (sixel_index_t) keycolor;
56 }
57 }
58
59 // The encoder sizes its per-colour map from ncolors and skips the keycolor inside that range,
60 // so the keycolor has to be counted in it. Its own palette entry is never read: leaving the
61 // definition out is exactly what the keycolor means.
62 int ncolors = keycolor + 1;
63 if (ncolors < 3) {
64 // Two colours mean "the terminal's default black and white palette, no definitions needed"
65 // to the encoder, which would be wrong here: one of the two is the keycolor, not a colour.
66 // The padding entry is written out so no uninitialised palette byte reaches the output.
67 ncolors = 3;
68 memset(dither->palette + keycolor * 3, 0, (size_t) (ncolors - keycolor) * 3);
69 }
70 dither->ncolors = ncolors;
71
72 sixel_dither_set_transparent(dither, keycolor);
73 // PAL8 hands the indices over as they are, instead of mapping the pixels a second time
74 sixel_dither_set_pixelformat(dither, SIXEL_PIXELFORMAT_PAL8);
75
76 SIXELSTATUS status = sixel_encode(indices, width, height, 1, dither, output);
77
78 sixel_allocator_free(dither->allocator, indices);
79 return status;
80}
81
82bool ffSixelEncode(const FFImageBuffer* buffer, FFstrbuf* result, const char** error) {
83 // A source without fully transparent pixels takes the opaque path, unchanged
84 const bool transparent = hasTransparentPixels(buffer);
85
86 sixel_dither_t* dither = nullptr;
87 if (sixel_dither_new(&dither, transparent ? SIXEL_PALETTE_MAX - 1 : SIXEL_PALETTE_MAX, nullptr) != SIXEL_OK) {
88 if (error) {
89 *error = "sixel_dither_new() failed";
90 }
91 return false;
92 }
93
94 // Feed RGBA8 directly and let libsixel do the palette quantization and dithering
95 if (sixel_dither_initialize(dither, buffer->data, (int) buffer->width, (int) buffer->height, SIXEL_PIXELFORMAT_RGBA8888, SIXEL_LARGE_AUTO, SIXEL_REP_AUTO, SIXEL_QUALITY_HIGH) != SIXEL_OK) {
96 sixel_dither_unref(dither);
97 if (error) {
98 *error = "sixel_dither_initialize() failed";
99 }
100 return false;
101 }
102
103 sixel_output_t* output = nullptr;
104 if (sixel_output_new(&output, sixelWriteCallback, result, nullptr) != SIXEL_OK) {
105 sixel_dither_unref(dither);
106 if (error) {
107 *error = "sixel_output_new() failed";
108 }
109 return false;
110 }
111
112 // The depth parameter is unused by libsixel; the DCS envelope is emitted by default
113 SIXELSTATUS status = transparent
114 ? sixelEncodeTransparent(dither, buffer, output)
115 : sixel_encode(buffer->data, (int) buffer->width, (int) buffer->height, 4, dither, output);
116
117 sixel_output_unref(output);
118 sixel_dither_unref(dither);
119
120 if (status != SIXEL_OK || result->length == 0) {
121 if (error) {
122 *error = "sixel_encode() failed";
123 }
124 return false;
125 }
126 return true;
127}
128
129#endif