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.