Guide

From an empty project

Nine steps: a picture made from nothing, written, read, examined, resized, written in six more formats, animated, and finally refused. Every block of output on this page is what the program in src/examples/02-guide prints when it is run.

01

Add the package

One package holds the codecs and the arithmetic between them. The sub-packages exist for callers who want one format and its options without the rest.

moon add moonbitstack/moonimage

Then, in a package that uses it:

import {
  "moonbitstack/moonimage",
  "moonbitstack/moonimage/raster",
}

02

Make a picture

A picture is a width, a height, a layout and the bytes. Here is a four-by-three one, written out pixel by pixel so that what follows has numbers that can be checked by eye.

let bytes : FixedArray[Byte] = FixedArray::make(4 * 3 * 4, b'\x00')
for y in 0..<3 {
  for x in 0..<4 {
    let at = (y * 4 + x) * 4
    bytes[at] = r
    bytes[at + 1] = g
    bytes[at + 2] = b
    bytes[at + 3] = a
  }
}
let image = @raster.Image::of(
  4, 3, @raster.PixelFormat::Rgba8,
  Bytes::from_array(bytes.clamped_view()),
)
4 x 3, Rgba8, 48 bytes

Image::of checks that the byte count matches the shape and raises Refused if it does not, which is why the call is wrapped in a catch everywhere it appears below.

03

Read and write

encode takes a picture and a format; decode takes bytes and works out the format itself. There is no format argument on the reading side, because a file already says what it is.

let file = @moonimage.encode(image, @moonimage.Format::Png)
let back = @moonimage.decode(file) catch {
  error => abort("the file is one this library wrote: \{error}")
}
println("\{file.length()} bytes of PNG, read back as \{back.width} x \{back.height} \{back.format}")
116 bytes of PNG, read back as 4 x 3 Rgba8

If the format is known and the file is too, the sub-package is a smaller door: @png.decode(file), @qoi.encode(image), and so on. Both routes end in the same function.

04

Look at a pixel

pixel gives four numbers from nought to one, alpha straight rather than multiplied in. A grey pixel has all three of red, green and blue equal, so there is no third case to handle.

let pixel = back.pixel(1, 0) catch {
  error => abort("inside the picture: \{error}")
}
let eighth = fn(value : Double) -> Int { (value * 255.0 + 0.5).to_int() }
println("\{eighth(pixel.r)} \{eighth(pixel.g)} \{eighth(pixel.b)} \{eighth(pixel.a)}")
122 58 44 255

A coordinate outside the picture is not a panic and not a clamp: it raises Refused, so a loop that runs off the edge stops where it went wrong.

05

Change the layout

convert rewrites the samples in another of the nine layouts. Going from colour to grey is the weighted sum the eye uses, not an average of the three channels.

let grey = back.convert(@raster.PixelFormat::Gray8)
let shade = grey.pixel(1, 0) catch {
  error => abort("inside the picture: \{error}")
}
println("as grey: \{grey.format}, \{eighth(shade.r)}")
as grey: Gray8, 76

The eight-bit layouts are the common case; sixteen-bit ones keep the full depth a PNG or a TGA can carry, and RgbaF32 is there for arithmetic that would rather not round.

06

Move it about

Four operations, each returning a new picture: rotate by quarter turns, mirror, crop, scale. Scaling defaults to bilinear and also takes Nearest and Lanczos.

let turned = back.rotate(quarters=1)
let mirrored = back.flip_horizontal()
let cut = back.crop(0, 0, 2, 2) catch {
  error => abort("the rectangle fits: \{error}")
}
let small = back.scale(8, 6) catch {
  error => abort("the size is legal: \{error}")
}
turned 3 x 4, mirrored 4 x 3, cut 2 x 2, scaled 8 x 6

crop takes the left, top, width and height of the rectangle; a rectangle that reaches outside raises rather than trimming itself to fit.

07

Write every format

The same four-by-three picture, written seven ways. The sizes differ by an order of magnitude and none of them is wrong.

for entry in formats {
  let (format, name) = entry
  let written = @moonimage.encode(back, format)
  println("\{name}: \{written.length()} bytes")
}
png: 116 bytes
qoi: 56 bytes
bmp: 170 bytes
tga: 67 bytes
jpeg: 729 bytes
gif: 65 bytes
webp: 102 bytes

BMP is uncompressed, so it is the size of the pixels plus a header; JPEG carries its tables in every file, which for a picture this small costs more than it saves. WebP is written lossless — the half of the format that can be exact.

Formats with options take them as a record with a default: @png.encode(image, options=@png.EncodeOptions::best()), @jpeg.encode(image, options=@jpeg.EncodeOptions::{ quality: 92, subsampling: @jpeg.Subsampling::Yuv444 }).

08

Animation

Frames carry an offset, a delay, and what to do with the canvas afterwards. compose applies those rules in order and returns the pictures a viewer would show.

let animation = @moonimage.Animation::new(
  4, 3,
  [@moonimage.Frame::new(back, delay=100), @moonimage.Frame::new(turned, delay=50)],
  loops=0,
)
let gif = @moonimage.encode_animation(animation, @moonimage.Format::Gif)
let again = @moonimage.decode_animation(gif) catch {
  error => abort("the file is one this library wrote: \{error}")
}
let frames = again.compose()
136 bytes of GIF, 2 frames, first delay 100, 2 composed

The same shape reads and writes GIF, APNG and animated WebP. A frame smaller than the canvas is placed at its offset and blended according to its rule, which is how a real animation saves the space it does.

09

When it refuses

A file that is not one this library reads raises Refused, and the message says where the reading stopped and what it wanted. Half a PNG, for instance:

let broken = file.clamped_view(start=0, end=file.length() / 2).to_owned()
let refused = try {
  ignore(@moonimage.decode(broken))
  "it did not"
} catch {
  error => error.to_string()
}
println("half a file: \{refused}")
half a file: at 41: 42 bytes wanted

A header can also ask for more than the caller is willing to give. Limits is checked before the allocation, not after:

let limits = @raster.Limits::{ width: 2, height: 2, pixels: 4, bytes: 1024 }
let capped = try {
  ignore(@moonimage.decode(file, limits~))
  "it did not"
} catch {
  error => error.to_string()
}
println("two by two limit: \{capped}")
two by two limit: over limit: 4 beyond 2

That is the whole error surface: Unknown, Unsupported, Truncated, OverLimit, Unexpected and Checksum, each carrying the offset it happened at.

Next: the API reference, every type and function with the arguments they take.