master ohmu/fastfetch-openbsd / src / logo / image / sixel.c
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