moonimage

    Image codecs for MoonBit: PNG, QOI, BMP, TGA, JPEG, GIF and WebP, and the pixel arithmetic between them.

    image
    png
    jpeg
    gif
    webp
    qoi
    bmp
    tga
    moonbit
    Download zip
    Version
    0.1.1
    License
    Apache-2.0
    Last updated
    yesterday
    Downloads
    17

    #moonimage

    Image codecs for MoonBit: PNG, QOI, BMP, TGA, JPEG, GIF and WebP read, all but WebP written as well, with the pixel arithmetic that sits between them.

    No I/O, no dependencies beyond the deflate stream a PNG needs, all four backends.

    let image = @moonimage.decode(bytes) // whichever format the bytes are
    image.width // 640
    image.height // 480
    image.format // @moonimage.PixelFormat::Rgba8

    let small = image.scale(160, 120, sampling=@moonimage.Sampling::Bilinear)
    let file = @moonimage.encode(small, @moonimage.Format::Png)

    Two runs to start from: moon run src/examples/01-tour walks one picture through every format and every operation, and moon run src/examples/02-guide is the program behind the guide.

    #Reading and writing

    decode reads the signature and hands the bytes to the package that knows that format. It takes the limits a reader works under, which are checked before anything is allocated:

    let image = @moonimage.decode(bytes, limits=@moonimage.Limits::strict())

    A file that is damaged, truncated, or larger than the limits raises Refused, which says where it stopped and what it wanted:

    let refused : @moonimage.Refused = try {
    ignore(@moonimage.decode(b"not an image"))
    abort("unreachable")
    } catch {
    error => error
    }
    println("\{refused}") // at 0: 6e 6f 74 20 61 6e is not a format this library reads

    Unexpected and Checksum mean damaged bytes, Truncated means the file ends early, OverLimit means the caller's limits refused it, Unknown means it is not a format this library reads, and Unsupported means a part of a format it does not implement.

    encode writes the format the caller names, with that format's defaults. A caller that wants to choose how it is written goes to the format's own package, which takes its options record.

    FormatReadWriteAnimationPackage
    PNGevery colour type and depth, palette and tRNS, Adam7, five filters, CRC, gAMA cHRM sRGB iCCP tEXt zTXt iTXt8 and 16 bits, palette when it is smaller, adaptive filtering, interlaced on request, metadataAPNG@moonimage/png
    QOIall six opcodesthe reference encoder's choices, byte for byte—@moonimage/qoi
    BMPevery header version, 1/4/8/16/24/32 bits, BI_RGB BI_RLE4 BI_RLE8 BI_BITFIELDS, both row orders24 and 32 bits—@moonimage/bmp
    TGAcolour mapped, true colour and grey, run lengths, 8/15/16/24/32 bits, both row orders24 and 32 bits, run lengths on request—@moonimage/tga
    JPEGbaseline and progressive, every subsampling, greyscale, EXIF orientationbaseline, quality 1–100—@moonimage/jpeg
    GIFpalettes, transparency, interlace, run lengthspalette by exact colours or median cut, ditheringyes@moonimage/gif
    WebPlossless (VP8L) with its four transforms, colour cache and prefix code groups, and lossy (VP8) with its boolean coder, fourteen predictors, loop filter and chroma upsampling; alpha in either; animated fileslossless, with a predictor transform and LZ77yes@moonimage/webp

    Both halves of WebP are read; the lossless one, VP8L, is also written. VP8 is the intra frame of a video codec, so its picture is rebuilt rather than decoded: a boolean arithmetic coder over prediction modes and transform coefficients, ten predictors for four-by-four blocks and four for sixteen-by-sixteen ones, a residual carried as quantised coefficients, a loop filter along every macroblock and transform edge, and chroma interpolated between the two rows it sits between. What comes out is the reconstruction the format defines, sample for sample — a lossy decoder that is "nearly right" is one with a bug in it.

    A lossy picture has no alpha in VP8 itself, so a transparent one carries a separate ALPH chunk beside the picture: compressed with the lossless codec, then filtered along its rows. That is read too, in a still picture and inside an animation frame, and comes back as four samples per pixel.

    The writer produces VP8L, the half that can be exact. It picks one predictor transform per block group by residual cost and finds repeats with a hash of the pixel value; it does not write a palette, a colour transform, or per-block prefix code groups, which cost size and not correctness.

    #The image

    An image is a rectangle of samples, the format that says how to read them, and the metadata that travelled with them.

    pub(all) struct Image {
    width : Int
    height : Int
    format : PixelFormat
    stride : Int
    pixels : Bytes
    premultiplied : Bool
    metadata : Metadata
    }

    The row stride is separate from the width because a caller uploading to a GPU needs rows padded to a multiple of 256 bytes. Pixels are immutable: every operation returns a new image rather than writing into the old one.

    Pixel formatSamplesBytes a pixel
    Gray8, GrayAlpha8, Rgb8, Rgba88-bit1, 2, 3, 4
    Gray16, GrayAlpha16, Rgb16, Rgba1616-bit, big-endian2, 4, 6, 8
    RgbaF32floats, 0–1, little-endian16

    #Moving pixels

    Conversions widen, narrow, add or drop alpha, and turn grey into three equal channels. Narrowing rounds rather than truncates, and colour becoming grey is a luminance rather than one of the channels:

    let grey = image.convert(@moonimage.PixelFormat::Gray8)
    let wide = image.convert(@moonimage.PixelFormat::Rgba16)
    let flat = image.to_premultiplied() // colour × alpha
    let back = flat.to_straight()

    The three operations that are pure permutation keep the format and the metadata:

    image.flip_horizontal()
    image.flip_vertical()
    image.rotate(quarters=1) // clockwise, and it wraps
    image.crop(16, 16, 64, 64) // raises if it does not fit

    Scaling has three filters. Nearest is what pixel art wants, bilinear what a photograph wants when it is scaled a little, and Lanczos what it wants when it is scaled a lot:

    let thumb = image.scale(160, 120, sampling=@moonimage.Sampling::Lanczos)

    Alpha is resampled premultiplied even when the image is not stored that way, so a transparent pixel's colour does not bleed into its neighbours.

    #Metadata

    What a file says about itself travels with the image, and goes back out when it is written. Nothing here is interpreted: colour management is not this library's business.

    pub(all) struct Metadata {
    gamma : Double?
    chromaticities : Chromaticities?
    intent : Intent?
    icc : Icc?
    texts : Array[Text]
    }

    Text keeps the flavour of the chunk it came from — Latin for tEXt, Compressed for zTXt, International for iTXt — so that writing the image back produces the same kind of chunk.

    #Animation

    Three formats animate, and they agree on the shape even where they disagree on the arithmetic: a canvas, a rectangle per frame, a delay, a rule for what to do with the pixels underneath, and a rule for what to leave behind.

    let animation = @moonimage.decode_animation(bytes)
    animation.width // the canvas
    animation.frames.length() // 12
    animation.frames[3].delay // milliseconds
    animation.frames[3].dispose // Keep, Background or Previous
    animation.frames[3].blend // Source or Over
    animation.compose() // every frame as a full canvas

    compose applies the disposal and blending rules in order, which is what a viewer shows.

    #Limits

    Every reader takes a Limits record: the widest and tallest image, the most pixels, and the most bytes an expansion may produce.

    @moonimage.Limits::default() // 1 Mpx a side, 256 megapixels, 1 GiB
    @moonimage.Limits::strict() // 8192 a side, 64 megapixels, 256 MiB

    The defaults are what a desktop application can live with. A caller reading untrusted files should pass strict or a tighter record of its own.

    #Interface

    CallWhat it does
    decode(bytes, limits?)read an image, whichever format the bytes are
    decode_animation(bytes, limits?)read every frame; a still image is one frame
    encode(image, format)write an image, with that format's defaults
    encode_animation(animation, format)write the frames, or the first one where the format has no animation
    Format::of(bytes), Format::extension, Format::mimewhat the bytes are, and how they are named
    Image::new, Image::ofan image, blank or over bytes that already exist
    Image::row, Image::pixelone row, one pixel
    Image::convertthe same image in another sample format
    Image::to_premultiplied, Image::to_straightcolour × alpha, and back
    Image::flip_horizontal, Image::flip_vertical, Image::rotate, Image::cropmoving pixels without reading them
    Image::scalea new size, by nearest, bilinear or Lanczos
    Animation::new, Animation::composeframes, and every frame as a full canvas
    png.encode, png.decode, png.EncodeOptions, png.FilterPNG, with its compression level and row filter
    qoi.encode, qoi.decodeQOI
    bmp.encode, bmp.decode, bmp.EncodeOptions, bmp.DepthBMP, 24 or 32 bits
    tga.encode, tga.decode, tga.EncodeOptions, tga.DepthTGA, with run lengths
    jpeg.encode, jpeg.decode, jpeg.EncodeOptions, jpeg.Subsampling, jpeg.exif_orientationJPEG, and the orientation a camera wrote
    gif.encode, gif.encode_animation, gif.decode, gif.decode_animation, gif.EncodeOptionsGIF, with dithering and transparency
    webp.decode, webp.decode_animation, webp.encode, webp.encode_animationWebP: lossless and lossy read, lossless written
    webp.decode_lossy, webp.decode_alpha, webp.decode_lossless_streamone chunk at a time, for a caller that has already split the container

    #Licence

    Apache-2.0. See LICENSE.

    Animation

    A sequence of frames over one canvas.

    Blend

    How a frame's pixels meet what is already on the canvas.

    Chromaticities

    The white point and the three primaries, as chromaticity coordinates.

    Eight numbers rather than a matrix: this is what a file writes, and a matrix would be a conversion, which this library does not do.

    Dispose

    What to do with the frame's rectangle once the frame has been shown.

    Format

    The formats this library reads and writes.

    Frame

    One frame: a picture, where it goes, and how long it stays.

    Icc

    An embedded ICC profile, kept by name and bytes. Not applied: a caller that wants colour management hands these bytes to something that does it.

    Image

    An image: its shape, its layout, and the bytes.

    Intent

    What a column of pixels was meant to be seen in.

    Limits

    The ceilings a decoder works under.

    The defaults are the ones a desktop application can live with: a texture no larger than a modern GPU takes, and a megabyte of pixels at the very most. A caller reading files from the network should set them lower; a caller reading its own assets can leave them alone.

    Metadata

    Everything the file said about itself besides its pixels.

    Pixel

    One pixel, in the units every operation in the library works in: four samples from 0 to 1, alpha straight.

    A grey pixel has r, g and b equal, which is why there is no grey variant: a reader that has to branch on whether a pixel is grey is a reader that will forget to inflate one of the three channels.

    PixelFormat

    How the samples of an image are laid out.

    The names are the usual ones: Gray8 is one byte per pixel, Rgba8 four, Rgba16 eight with each channel big-endian, RgbaF32 four floats.

    Refused

    A refusal to read bytes as an image, or to write an image as bytes.

    The variants separate the three questions a caller asks: is this the wrong kind of file (Unknown), is the file damaged (Unexpected, Truncated, Checksum), or is the caller asking for more than it allowed (OverLimit). Unsupported is the fourth: a part of a format this library does not read.

    Sampling

    How a scaler decides what an unknown pixel is worth.

    Text

    A text note, in the flavour of the chunk it came from, so that writing the image back produces the same kind of chunk.

    decode

    The image in these bytes, whichever format they are.

    The limits default to Limits::default(); a caller reading untrusted files should pass Limits::strict() or a tighter record.

    decode_animation

    Every frame in these bytes, whichever format they are.

    A format that does not animate comes back as an animation of one frame over the whole canvas, so a caller that only draws animations does not need to branch.

    encode

    The image as bytes of the named format, with that format's defaults.

    A caller that wants to choose how it is written calls the format's own package, which takes its options record.

    encode_animation

    The animation as bytes of the named format, with that format's defaults.

    The formats that animate keep every frame; the ones that do not write the first, which is what a viewer of a still image shows.

    Source Files

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io