moonbit_image

Pure-MoonBit image decoder/encoder covering BMP / QOI / TGA / PNG / GIF / JPEG with zero external runtime dependencies. Decoders raise a structured `DecodeError` sub-error type so callers can branch on cause (UnsupportedFormat / TruncatedData / InvalidHeader / InvalidValue / EncodeNotImplemented).

image
codec
bmp
qoi
tga
png
gif
jpeg
moon add riantr/moonbit_image@0.3.3
Download zip
Author
Version
0.3.3
License
MIT OR Apache-2.0
Last updated
8 hours ago
Downloads
16
README

#riantr/moonbit_image

Pure-MoonBit image decoder / encoder covering BMP / QOI / TGA / PNG / GIF / JPEG / ICO / TIFF. Zero external runtime dependencies — every codec is hand-written in MoonBit and lives inside this single package.

This package is forked from lws/moonbit_image (MIT, 2025). The original sources were vendored inside moonbit-labeler/extensions/image/; this package repackages them as a standalone mooncakes.io library so the labeler and any other MoonBit project can depend on a single shared implementation.

#What's new in 0.3.3

Patch release — no functional changes from 0.3.2 / 0.3.1. Locking in the current state after a full benchmark pass on the consuming project (riantr/moonbit_labeler); 49/49 tests pass against this version, and the docs/benchmark.md baseline in moonbit-labeler measures each decoder / encoder at known reference sizes. The metrics are stable across re-runs, so 0.3.3 is safe to depend on for downstream production work.

#What's new in 0.3.0

0.3.0 adds two new formats — ICO (Windows icon / cursor container) and TIFF (Tagged Image File Format) — and ships 11 new tests covering them. Every existing 0.2.x test still passes.

  • decode_ico routes ICO entries through the existing BMP / PNG decoders:

    • Full BMP file (with the 14-byte BM header) — pass-through.
    • Full PNG file (the Vista+ form) — pass-through.
    • DIB-only BMP (older icons) — a synthetic 14-byte file header is prepended so the existing BMP decoder can take it.

    The largest entry by area (with the first entry as the tie-breaker) is returned.

  • decode_tiff handles baseline uncompressed TIFF only. The supported subset is documented explicitly so callers know what to expect:

    FieldSupported
    Byte orderII (little-endian); MM raises InvalidHeader
    Compression1 (none); LZW / Deflate / JPEG-in-TIFF rejected
    Photometric0 (WhiteIsZero), 1 (BlackIsZero), 2 (RGB), 3 (Palette)
    BitsPerSample1 / 2 / 4 / 8 / 16 (uniform across samples)
    SamplesPerPixel1 (greyscale / palette) or 3 (RGB)
    PlanarConfiguration1 (chunky); planar rejected
    Layoutsingle strip only
    Sample formatUINT

    16-bit samples are right-shifted to 8 bits for output — display-friendly but lossy for photographic TIFFs. The ColorMap tag is read and palette indices are expanded to RGBA8.

  • detect_format recognises both the new magic byte sequences (II*\0, MM\0* for TIFF; 00 00 01 00 NN … for ICO).

  • ImageFormat::is_decodable() now reports true for both new variants.

#What's new in 0.2.0

0.2.0 is a breaking-API revision that cleans up the public error story and fixes a real chroma-shearing bug in the JPEG decoder.

  • Structured DecodeError replaces the previous stringly-typed Failure::Failure("...") errors. Every public decoder / encoder entry point now raises one of:

    VariantWhen it fires
    DecodeError::UnsupportedFormat(String)magic bytes do not match any known format signature
    DecodeError::TruncatedData(String)a frame / chunk / scanline ended in the middle of a read
    DecodeError::InvalidHeader(String)a header field is out of range or has an unsupported value
    DecodeError::InvalidValue(String)a header / pixel-data value was readable but rejected downstream
    DecodeError::EncodeNotImplemented(String)encode() was called with a decode-only format

    Callers can match err { ... } on the variant, or call .to_string() for a one-line diagnostic. The per-codec helpers (decode_bmp, decode_qoi, …) and low-level byte readers (read_u32_le, …) keep raising the built-in Failure suberror; the public wrappers translate it.

  • Removed legacy convenience APIs: is_supported_format and is_encode_supported. Use the new ImageFormat::is_decodable() / is_encodable() instance methods instead — they take an ImageFormat and read better in match arms:

    match fmt {
    ImageFormat::BMP => ...
    f if f.is_decodable() => ...
    }

  • Fixed JPEG chroma positioning bug (the "花屏" issue): for chroma components in 4:2:0 / 4:2:2 subsampled JPEGs the decoder placed each chroma block at mx * sf_h * 8 instead of mx * max_h * 8, which mis-aligned Cb / Cr against the luma plane and produced visible colour fringing / colour banding on most real-world photos. The chroma sampler now uses the correct macro-grid stride.

  • Image::to_rgba8 simplified: removed a redundant early-return-then-match-again structure; the function now uses one guard over the source format and a single bulk conversion loop per source layout.

#Why a separate package

moonbit-labeler used to vendor the image codec under extensions/image/. That works, but every labeler checkout ships its own copy and any other MoonBit project that wants the same decoder has to vendor the sources again. Moving the codec to a dedicated mooncakes.io module:

  • lets moonbit-labeler depend on it via import "riantr/moonbit_image"
  • lets any other MoonBit project reuse the same decoder / encoder
  • isolates the codec under a clean test surface (BMP / QOI round-trip, pixel math, geometric transforms) that isn't tied to the labeler UI

#API surface

// one-shot auto-detect decoder; raises DecodeError
pub fn decode(data : Bytes) -> Image raise DecodeError

// header-only dimension read (no pixel decode)
pub fn image_dimensions(data : Bytes) -> (Int, Int, ImageFormat) raise DecodeError

// per-format decoders (still raise the lower-level Failure)
pub fn decode_bmp(data : Bytes) -> Image raise Failure
pub fn decode_qoi(data : Bytes) -> Image raise Failure
pub fn decode_tga(data : Bytes) -> Image raise Failure
pub fn decode_png(data : Bytes) -> Image raise Failure
pub fn decode_gif(data : Bytes) -> Image raise Failure
pub fn decode_jpeg(data : Bytes) -> Image raise Failure

// writers (QOI + BMP only); encode() raises DecodeError::EncodeNotImplemented
// for everything else
pub fn encode(image : Image, format : ImageFormat) -> Bytes raise DecodeError
pub fn encode_qoi(image : Image) -> Bytes raise Failure
pub fn encode_bmp(image : Image) -> Bytes raise Failure

Image is { width, height, format, data } where format is one of Gray8 / GrayA8 / RGB8 / RGBA8. The Color struct, Image::to_rgba8 / to_grayscale / flip_horizontal / rotate_* / resize_* / brighten / histogram / average_color, and the color-space conversion methods on Color (to_hsl / from_hsl / to_hsv / from_hsv / blend / lerp / distance / to_gray) are all available.

#Quick start

// decode a JPEG -> Image, resize, re-encode as QOI
let img = @moonbit_image.decode(jpeg_bytes) catch {
err => {
@log.warn("decode failed: \{err}")
abort("give up")
}
}
let half = img.resize_nearest(img.width / 2, img.height / 2)
let qoi = @moonbit_image.encode(half, ImageFormat::QOI)

#Tests

moon test --target native runs the white-box tests in lib_test.mbt (29 tests across format detection, header reading, decode round-trips for QOI + BMP, colour math, geometric transforms, and a fuzz sweep over random pixels / random crops). Black-box tests live in qa/.

#License

Dual-licensed under MIT or Apache-2.0, at your option.

The image codec sources in this package were originally published by lws as moonbit_image under the MIT license. To stay compatible with that upstream, this package keeps the MIT grant; we additionally offer Apache-2.0 so downstream consumers can pick whichever license fits their project. See LICENSE-MIT and LICENSE-APACHE for the full texts.

#Attribution

  • Original sources © 2025 lws, MIT — see upstream history at https://github.com/Milky2018/moonbit-image.
  • 0.2.0 revisions (DecodeError, JPEG chroma fix, API cleanup) © 2026 RiantR, MIT or Apache-2.0.

#
DecodeError

pub suberror DecodeError {
UnsupportedFormat(String)
TruncatedData(String)
InvalidHeader(String)
InvalidValue(String)
EncodeNotImplemented(String)
}

Structured error type raised by every public decoder / encoder entry point. Replaces the previous Failure::Failure("...") stringly-typed errors so callers can match on a specific cause and surface a useful message to the user.

Variants:

  • UnsupportedFormat: the byte stream did not match any known format signature, or the caller asked for a format that is recognised but not yet decodable in this build.
  • TruncatedData: the byte stream ended in the middle of a frame / chunk / scanline. Includes the byte offset that was being read.
  • InvalidHeader: a header field is out of range or has an unsupported value (e.g. negative dimensions, unknown colour model).
  • InvalidValue: a header or pixel-data value was technically readable but rejected by a downstream invariant (e.g. an unknown PNG filter byte, an impossible Huffman code in a JPEG segment).
  • EncodeNotImplemented: the caller asked for encode() with a format that is decode-only.

Note: TruncatedData and InvalidValue are reserved for future codec refinements; today every codec-internal Failure is wrapped as InvalidHeader. The unused variants are kept so downstream callers can match exhaustively without churn.

#
AnimatedImage

pub struct AnimatedImage {
frames : Array[Image]
delays : Array[Int]
width : Int
height : Int
loop_count : Int
}

A multi-frame animated image with per-frame delay timing

#
AnimatedImage::frame_count

fn AnimatedImage::frame_count(self : AnimatedImage) -> Int

#
AnimatedImage::new

fn AnimatedImage::new(frames : Array[Image], delays : Array[Int], width : Int, height : Int, loop_count : Int) -> AnimatedImage

#
Color

pub struct Color {
r : Int
g : Int
b : Int
a : Int
}

A color represented as 8-bit RGBA components

#
Color::blend

fn Color::blend(self : Color, other : Color) -> Color

Blend this color with another color using alpha compositing (over operator) The result replaces the background color with this (foreground) color on top alpha is in 0..255 range for per-pixel control

#
Color::blend_alpha

fn Color::blend_alpha(self : Color, other : Color, alpha : Int) -> Color

Alpha blend with a specific alpha override (0..255)

#
Color::default

fn Color::default() -> Color

Constructors and utilities for Color

#
Color::distance

fn Color::distance(self : Color, other : Color) -> Int

Compute a simple perceptual color distance (weighted Euclidean) Uses simplified CIE76-like formula with weighted RGB components. Returns 0..441 for maximum distance (black vs white at max alpha).

#
Color::from_gray

fn Color::from_gray(v : Int) -> Color

#
Color::from_hsl

fn Color::from_hsl(hue : Int, saturation : Int, lightness : Int) -> Color

Create a Color from HSL values (Hue 0..360, Saturation 0..100, Lightness 0..100)

#
Color::from_hsv

fn Color::from_hsv(hue : Int, saturation : Int, value : Int) -> Color

Create a Color from HSV values (Hue 0..360, Saturation 0..100, Value 0..100)

#
Color::from_rgb

fn Color::from_rgb(r : Int, g : Int, b : Int) -> Color

#
Color::invert

fn Color::invert(self : Color) -> Color

Invert this color (255 - each channel), preserves alpha

#
Color::lerp

fn Color::lerp(self : Color, other : Color, t : Int) -> Color

Linear interpolation between two colors (t in 0..255 fixed-point) Returns an integer-blended color without floating-point

#
Color::new

fn Color::new(r : Int, g : Int, b : Int, a : Int) -> Color

#
Color::to_gray

fn Color::to_gray(self : Color) -> Int

#
Color::to_hsl

fn Color::to_hsl(self : Color) -> (Int, Int, Int)

Convert an RGB color to HSL (Hue 0..360, Saturation 0..100, Lightness 0..100) Uses integer arithmetic to avoid floating-point precision issues

#
Color::to_hsv

fn Color::to_hsv(self : Color) -> (Int, Int, Int)

Convert RGB to HSV (Hue 0..360, Saturation 0..100, Value 0..100)

#
Color::with_alpha

fn Color::with_alpha(self : Color, a : Int) -> Color

#
Image

pub struct Image {
width : Int
height : Int
format : PixelFormat
data : Bytes
}

A decoded image with pixel data

#
Image::average_color

fn Image::average_color(self : Image) -> Color

Compute the average color of the image

#
Image::brighten

fn Image::brighten(self : Image, delta : Int) -> Image

Brighten the image by adding a delta to all channels Positive delta brightens, negative darkens. Clamped to 0..255. Returns a new Image (does not modify self).

#
Image::bytes_per_pixel

fn Image::bytes_per_pixel(self : Image) -> Int

Number of bytes per pixel for this format

#
Image::crop

fn Image::crop(self : Image, x : Int, y : Int, w : Int, h : Int) -> Image raise Failure

Extract a sub-rectangle from the image.

Errors

Raises Failure if the crop region is out of bounds or has non-positive dimensions.

#
Image::data_size

fn Image::data_size(self : Image) -> Int

Total size of pixel data in bytes

#
Image::flip_horizontal

fn Image::flip_horizontal(self : Image) -> Image

Flip the image horizontally (mirror left-to-right). Each row is reversed in-place into a new buffer.

#
Image::flip_vertical

fn Image::flip_vertical(self : Image) -> Image

Flip the image vertically (mirror top-to-bottom). Rows are copied in reverse order to a new buffer.

#
Image::get_pixel

fn Image::get_pixel(self : Image, x : Int, y : Int) -> Color

Get the color at a specific pixel coordinate

#
Image::histogram

fn Image::histogram(self : Image) -> Array[Int]

Compute a 256-bin luminance histogram from the image Returns an array[256] of pixel counts, indexed by luminance value (0-255)

#
Image::new

fn Image::new(width : Int, height : Int, format : PixelFormat, data : Bytes) -> Image

#
Image::resize_bilinear

fn Image::resize_bilinear(self : Image, new_w : Int, new_h : Int) -> Image

Resize the image using bilinear interpolation. Each output pixel is a weighted blend of its 4 nearest source neighbors. Interpolation is performed independently on each byte channel.

#
Image::resize_nearest

fn Image::resize_nearest(self : Image, new_w : Int, new_h : Int) -> Image

Resize the image using nearest-neighbor sampling. Uses integer arithmetic: src_x = dst_x * old_w / new_w.

#
Image::rotate_180

fn Image::rotate_180(self : Image) -> Image

Rotate the image 180 degrees. Implemented as flip horizontal then flip vertical.

#
Image::rotate_270

fn Image::rotate_270(self : Image) -> Image

Rotate the image 270 degrees clockwise (equivalent to 90 degrees counter-clockwise). New width = old height, new height = old width.

#
Image::rotate_90

fn Image::rotate_90(self : Image) -> Image

Rotate the image 90 degrees clockwise. New width = old height, new height = old width.

#
Image::stride

fn Image::stride(self : Image) -> Int

Number of bytes per row (stride) for this image

#
Image::to_grayscale

fn Image::to_grayscale(self : Image) -> Image

Convert an image to grayscale in-place (returns Gray8 or GrayA8)

#
Image::to_rgba8

fn Image::to_rgba8(self : Image) -> Image

Convert this image to RGBA8 format (always 4 bytes per pixel) Uses per-format bulk loops for performance (avoids per-pixel match dispatch)

#
ImageFormat

pub enum ImageFormat {
BMP
QOI
TGA
PNG
GIF
JPEG
ICO
TIFF
}

Image file formats that can be detected and decoded

#
ImageFormat::is_decodable

fn ImageFormat::is_decodable(self : ImageFormat) -> Bool

Whether this format can be decoded back into an Image

#
ImageFormat::is_encodable

fn ImageFormat::is_encodable(self : ImageFormat) -> Bool

Whether this format has an encoder (writes a fresh Bytes)

#
PixelFormat

pub enum PixelFormat {
Gray8
GrayA8
RGB8
RGBA8
}

All pixel formats supported by this library

#
decode

fn decode(data : Bytes) -> Image raise DecodeError

Auto-detect format and decode an image from raw bytes.

Raises DecodeError::UnsupportedFormat if the byte stream does not match any known signature. Wraps every codec-internal Failure as DecodeError::InvalidHeader so callers only need to match on one error type.

#
decode_bmp

fn decode_bmp(data : Bytes) -> Image raise Failure

Decode a BMP image from raw bytes

#
decode_by_format

fn decode_by_format(data : Bytes, format : ImageFormat) -> Image raise DecodeError

Decode an image with a known format. Internal codec failures are wrapped as DecodeError::InvalidHeader; no format-detection step happens, so UnsupportedFormat is not raised from this entry point.

#
decode_gif

fn decode_gif(data : Bytes) -> Image raise Failure

Decode a GIF image from raw bytes (returns the first frame)

#
decode_gif_all

fn decode_gif_all(data : Bytes) -> AnimatedImage raise Failure

Decode all frames from an animated GIF, returning an AnimatedImage. Parses Graphic Control Extensions for frame delays and transparency, and the Netscape Application Extension for loop count. Frames are composited onto the full canvas respecting disposal methods.

#
decode_ico

fn decode_ico(data : Bytes) -> Image raise Failure

Decode an ICO / CUR container, returning the largest available image. CUR is accepted but its hotspot metadata is discarded — the cursor is returned as a plain image.

#
decode_jpeg

fn decode_jpeg(data : Bytes) -> Image raise Failure

Decode a JPEG image from raw bytes. Supports grayscale (1-component) and YCbCr color (3-component) baseline JPEG.

#
decode_png

fn decode_png(data : Bytes) -> Image raise Failure

Decode a PNG image from raw bytes

#
decode_qoi

fn decode_qoi(data : Bytes) -> Image raise Failure

Decode a QOI image from raw bytes

#
decode_tga

fn decode_tga(data : Bytes) -> Image raise Failure

Decode a TGA image from raw bytes

#
decode_tiff

fn decode_tiff(data : Bytes) -> Image raise Failure

Decode a TIFF image. See file header for supported subset.

#
detect_format

fn detect_format(data : Bytes) -> ImageFormat?

Detect the image format from magic bytes (file signature) PNG and QOI have distinctive signatures, BMP has "BM", TGA uses heuristic + footer

#
encode

fn encode(img : Image, format : ImageFormat) -> Bytes raise DecodeError

Encode an image to the specified format. Currently BMP and QOI have encoders; other formats raise DecodeError::EncodeNotImplemented.

#
encode_bmp

fn encode_bmp(image : Image) -> Bytes raise Failure

Encode an Image as an uncompressed BMP file.

The image is converted to RGBA8 internally. If all pixels are fully opaque (alpha = 255 everywhere), the output is 24-bit BGR. Otherwise it is 32-bit BGRA to preserve transparency.

Pixel data is written top-down with a negative height in the DIB header, so most BMP readers display the image correctly without additional flipping.

Errors

Raises Failure if the image has zero width or height.

#
encode_qoi

fn encode_qoi(image : Image) -> Bytes raise Failure

Encode an image to QOI (Quite OK Image) format. Returns the encoded bytes ready to write to a file.

#
image_dimensions

fn image_dimensions(data : Bytes) -> (Int, Int, ImageFormat) raise DecodeError

Get image dimensions from a file header without decoding pixels. Much faster than decode() for inspecting image metadata.

#
image_format_name

fn image_format_name(format : ImageFormat) -> String

Get a human-readable string for an image format

#
jpeg_dimensions

fn jpeg_dimensions(data : Bytes) -> (Int, Int) raise Failure

Read JPEG dimensions from header without decoding pixels. Fast path: scans for SOF0 marker and extracts width/height.

#
pixel_format_name

fn pixel_format_name(format : PixelFormat) -> String

Get a human-readable string for a pixel format