Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Imagine

The imagine module is Zuri’s image library: decoding, drawing, transforming and encoding raster images.

It covers the whole path an image takes through a program. A photograph arrives as an upload, gets checked, oriented, resized and sharpened, has a watermark composited onto it, and goes back out as a WebP. A chart is built from nothing but shapes and text. An avatar is cropped to a square, rounded off, and cached as a data URL. None of that needs anything outside the standard library.

Every image on this page is the output of the code beside it, produced by docs/book/src/imagine/figures.zu. Re-run that script and the figures follow whatever the module actually does.

Blocks in this chapter that list several calls together — three ways to save, four filters, a family of methods — are reference listings, not programs. They show the shape of each call rather than a sequence you could run, and several would conflict if pasted into one file. Anything presented as a complete program on this page runs as written.

Following Along

Most examples below operate on a file called photo.jpg. Use your own, or make one — the module can draw its own test subject:

import imagine { Image }

Image(320, 240, '#1e3a5f')
  .fill_circle(220, 70, 40, '#ffd166')
  .fill_rect(0, 170, 320, 70, '#2a9d8f')
  .fill_polygon([[40, 170], [110, 80], [180, 170]], '#264653')
  .save('photo.jpg')

echo Image.open('photo.jpg').size()
{width: 320, height: 240}

Everything from here on assumes that file exists in the working directory.

Introduction

A First Image

import imagine { Image }

Image.open('photo.jpg')
  .thumbnail(400, 400)
  .save('thumb.webp')

Three lines: decode, shrink, encode. The format on the way in comes from the file’s contents, and the format on the way out comes from the extension you saved it as.

Building an image from scratch works the same way.

import imagine { Image, Color }

Image(400, 200, '#0f172a')
  .fill_circle(200, 100, 70, '#38bdf8')
  .circle(200, 100, 70, 'white', { thickness: 3 })
  .save('badge.png')

Almost every method returns an image, so operations chain.

Copying and Mutation

There is one rule in this module that is worth learning before anything else, because everything else follows from it:

Operations that change the image’s size return a new image. Everything else changes the image in place.

resize(), crop(), rotate(), flip(), transpose() and pad() all leave the original untouched and hand back a new one. Drawing, filters and compositing all modify the image you called them on and return it so the chain continues.

var original = Image.open('photo.jpg')

var small = original.thumbnail(200, 200)   # original is untouched
small.grayscale()                          # small is now grey

echo original.size()                       # still the full size, still colour

That means a mixed chain reads correctly, and it means clone() is what you reach for when you want to keep an image before filtering it.

var greyed = photo.clone().grayscale()     # photo keeps its colour

Colours

Writing a Colour

Anywhere a colour is expected, five spellings are accepted. These are all the same red:

image.fill(Color(255, 0, 0))
image.fill('#ff0000')
image.fill('red')
image.fill(0xFF0000FF)
image.fill([255, 0, 0])

Hexadecimal strings take all four CSS lengths, with or without the leading #: #f00, #f00c, #ff0000, #ff0000cc. Names are the full CSS Color Level 4 list, matched ignoring case, spaces and hyphens, so 'Dark Sea Green' and 'darkseagreen' are the same colour.

Alpha runs 0 (transparent) to 255 (opaque), as in CSS and PNG.

The Color Class

Color is an immutable value type. Every method that would change one returns a new colour, so a colour held in a variable is safe to pass around.

import imagine { Color }

var brand = Color.hex('#4f46e5')

brand.r                    # 79
brand.to_hex()             # '#4f46e5'
brand.to_packed()          # 0x4F46E5FF

brand.with_alpha(128)      # half transparent
brand.fade(0.5)            # halves whatever alpha it already had
brand.lighten(20)          # 20 percentage points of HSL lightness
brand.darken(20)
brand.saturate(15)
brand.desaturate(15)
brand.rotate_hue(180)
brand.mix('white', 0.25)   # a quarter of the way to white
brand.invert()
brand.to_grayscale()

over() flattens a translucent colour against a background, which is what happens when it is drawn onto something solid:

Color(255, 0, 0, 128).over('white')    # '#ff7f7f'

Colour Spaces

Conversions come from the colors module, so the conventions are the same ones it and CSS use: hue in degrees, everything else in percentage points from 0 to 100.

Color.hsl(240, 100, 50)            # '#0000ff'
Color.hsv(120, 100, 100)           # '#00ff00'
Color.hwb(0, 0, 0)                 # '#ff0000'
Color.cmyk(0, 100, 100, 0)         # '#ff0000'
Color.lab(40.7, 50.6, -79.1)       # perceptually uniform
Color.xyz(0.2, 0.15, 0.7)

brand.to_hsl()      # { h: 243.4, s: 75.4, l: 58.6, a: 255 }
brand.to_hsv()
brand.to_hwb()
brand.to_cmyk()
brand.to_lab()
brand.to_xyz()

Lab is the right space for interpolating between two colours when the intermediate steps need to look evenly spaced to the eye. CMYK here is the naive conversion with no output profile, so it is right for generating colours and wrong for predicting a printing press.

Contrast and Accessibility

var background = Color.hex('#1e293b')

background.luminance()                  # 0 to 1, WCAG relative luminance
background.contrast_ratio('#ffffff')    # 1 to 21
background.is_dark()                    # true
background.best_contrast()              # white, since the background is dark

WCAG asks for a ratio of at least 4.5 for normal text and 3 for large text, so best_contrast() is the quick way to pick a legible foreground for a colour you did not choose:

var label = swatch.best_contrast('#ffffff', '#111111')

card.text(20, 20, name, font, label)

Loading and Saving

Opening an Image

var photo = Image.open('photo.jpg')          # from a path
var photo = Image.open(file('photo.jpg'))    # from an open file
var photo = Image.decode(upload)             # from bytes in memory

The format is detected from the contents rather than the name, so a mislabelled file still opens. When the contents cannot be identified — TGA carries no signature of its own — Image.open() falls back to the file extension.

Image.decode() takes options:

Image.decode(upload, { format: 'png' })   # fail unless it really is a PNG
Image.decode(raw, { orient: false })      # skip EXIF auto-rotation

Naming the format explicitly is worth doing when you already know it from a Content-Type or an extension: a file claiming to be a PNG that is really something else then fails loudly instead of being decoded as whatever it actually is.

Checking Before Decoding

An image costs four bytes per pixel once decoded, so a 6000x4000 photograph occupies 96 MB in memory however small its file was. For anything arriving from outside the program, read the header first:

import imagine
import imagine { Image }

var header = imagine.probe(upload)

if header == nil {
  raise Exception('that is not an image')
}

if header.width * header.height > 40000000 {
  raise Exception('image is too large')
}

var photo = imagine.decode(upload)

probe() returns {format, width, height} or nil. It reads only the header, which costs microseconds where decoding the same file costs tens of milliseconds. imagine.detect() is the same check when you only want the format name.

Saving

photo.save('out.png')
photo.save('out.jpg', { quality: 90 })
photo.save('out.dat', { format: 'webp' })   # extension overridden

The format comes from the extension unless format says otherwise. To get the bytes instead of a file:

var data = photo.encode('webp')
var data = photo.to_png()
var data = photo.to_jpeg(90)

encode() with no format uses whatever the image was decoded from, falling back to PNG for an image built in memory.

Format Reference

FormatReadWriteAlphaNotes
PNGyesyesyesLossless. The safe default.
JPEGyesyesnoLossy. Photographs only.
WebPyesyesyesWritten lossless, so smaller than PNG but larger than a lossy WebP.
AVIFnoyesyesSmallest files, slowest to encode.
GIFyesyes1-bit256 colours. The only animated format that can be written.
BMPyesyesyesUncompressed and enormous.
TIFFyesyesyesCommon in printing and scanning.
TGAyesyesyesNo signature; needs an extension or an explicit format.
QOIyesyesyesLossless, several times faster than PNG.
ICOyesyesyesIcon container.
PNMyesyesnoTrivially simple, trivially large.
WBMPyesyesnoOne bit per pixel.

Three entries need explaining.

AVIF is write-only: opening one raises DecodeError.

TGA has no magic number, so it can only be identified by its file extension or by naming the format outright.

WebP is written losslessly. Reading handles both lossy and lossless WebP, but the encoder here only writes lossless, so quality has no effect on it and a photograph saved as WebP will be larger than one saved by a tool that can write lossy WebP. For a photograph where size matters, JPEG or AVIF is the better target; WebP here is a smaller-than-PNG lossless format with alpha.

Never assume; ask:

import imagine

var can = imagine.capabilities()

echo can.decode.contains('png')
echo can.encode.contains('webp')
echo can.animated.contains('gif')
true
true
true

decode, encode and animated are each a list of format names, so contains() answers the question you actually have.

Encoding Options

OptionFormatsMeaning
qualityJPEG, AVIF1 to 100. Defaults to 85 for JPEG, 80 for AVIF.
compressionPNG'fast', 'default' or 'best'. All lossless.
speedAVIF, GIFAVIF 1-10, lower is slower and smaller. GIF 1-30, lower is slower and picks better colours; 15 by default.
backgroundJPEGWhat transparent pixels are flattened against. Defaults to white.
thresholdWBMPThe brightness cut, 0 to 255.

JPEG has no alpha channel, so transparency has to go somewhere on the way out. It is flattened against background rather than silently dropped, because dropping it turns transparent pixels black:

logo.save('logo.jpg', { background: '#ffffff' })

To see the result first, or to choose the colour once and keep it, do it explicitly:

logo.flatten('#ffffff').save('logo.jpg')

Data URLs

var url = icon.to_data_url('png')
# 'data:image/png;base64,iVBORw0...'

Base64 costs a third more bytes than the raw image, so this suits icons and small graphics rather than photographs.

Resizing and Reshaping

Choosing a Resize

There are six, and picking the right one is most of the work:

MethodAspect ratioEnlarges?Result
resize(w, h)not keptyesexactly w x h, possibly distorted
scale(factor)keptyesthe same image, scaled
thumbnail(w, h)keptnofits inside the box
fit(w, h)keptyesfits inside the box
cover(w, h)keptyesfills the box, cropping the overflow
contain(w, h)keptyesfits the box, padding the remainder

thumbnail() is the one you usually want for thumbnails, and the refusal to enlarge is the reason: scaling a small image up to fill a thumbnail box only makes it blurry. Use fit() when you do want it scaled up.

cover() and contain() both produce exactly the size asked for. They differ in what they sacrifice — cover() loses part of the image, contain() adds bars:

photo.cover(300, 300)                              # crops to a square
photo.cover(300, 300, { anchor: TOP })             # keeps the top, crops the bottom
photo.contain(300, 300, { background: 'white' })   # letterboxes instead

The same 320x180 image asked for a 120x120 result four ways:

thumbnail() keeps the proportions and does not fill the box. cover() fills it and loses the sides. contain() fills it and adds bars. resize() fills it by distorting the picture.

The anchors are TOP_LEFT, TOP, TOP_RIGHT, LEFT, CENTER, RIGHT, BOTTOM_LEFT, BOTTOM and BOTTOM_RIGHT. CENTER is the default, and TOP is what you want for photographs of people, where the face is rarely in the bottom third.

Resampling Filters

FilterSpeedUse
NEARESTfastestPixel art, QR codes, anything whose edges must stay hard.
BILINEARfastWhen speed matters more than sharpness.
BICUBICmoderateA good default for photographs.
GAUSSIANmoderateDeliberately soft; for noisy input, or before sharpening.
LANCZOSslowestThumbnails and any large reduction. The default.
photo.thumbnail(200, 200, LANCZOS)
sprite.scale(4, NEAREST)             # keeps pixel art crisp

A 16x16 sprite enlarged seven times over, so the differences are visible at all:

This is the case where NEAREST is right and everything else is wrong. Shrinking a photograph inverts that judgement entirely.

LANCZOS is the default because most resizing is shrinking, and that is where it earns its cost. It can produce faint ringing next to very high-contrast edges, which is the price of its sharpness; BICUBIC is the fallback when that shows.

Cropping, Padding and Trimming

photo.crop(100, 50, 400, 300)      # x, y, width, height
photo.pad(20)                      # 20 pixels on every side
photo.pad(10, 20, 10, 20, 'white') # top, right, bottom, left, colour
photo.trim()                       # remove a uniform border
photo.trim({ tolerance: 8 })       # allow for JPEG noise in the border

crop() requires its rectangle to fit inside the image and raises BoundsError otherwise. Clamping a crop that runs off the edge would hand back different dimensions than were asked for, which is a worse surprise than an error. Pad first when the region really is meant to extend past the edge.

trim() takes the border colour from the top-left pixel unless you name one. Scanned documents and screenshots almost always want a tolerance, because a “white” border from a lossy format is not exactly white.

Rotating and Mirroring

photo.rotate_90()                  # lossless
photo.rotate_180()
photo.rotate_270()
photo.rotate(37, 'white')          # resamples, grows the canvas

photo.flip_horizontal()
photo.flip_vertical()
photo.flip(FLIP_BOTH)
photo.transpose()                  # reflect across the main diagonal

Quarter turns are exact: every pixel is moved, none is resampled. Any other angle interpolates, and grows the canvas to hold the rotated corners with background filling the gaps.

EXIF Orientation

Phone cameras usually store the sensor’s orientation in the file rather than rotating the pixels, so a photograph whose bytes are sideways is meant to be displayed upright. Image.open() and Image.decode() handle this for you.

Image.open('photo.jpg')                       # upright
Image.open('photo.jpg', { orient: false })    # exactly as stored

All eight orientation values are handled, including the four mirrored ones that scanners and some front-facing cameras produce.

Filters

Filters change the image in place and return it, so they chain.

photo.grayscale().contrast(15).sharpen(0.5)

Tone and Exposure

photo.brightness(20)         # -255 to 255, added to each channel
photo.contrast(15)           # -100 to 100, around mid-grey
photo.gamma(1.2)             # above 1 lifts midtones, below 1 lowers them
photo.levels(20, 235)        # stretch this input range to full scale
photo.levels(20, 235, 1.1)   # ...with a midtone curve
photo.threshold(128)         # every pixel to black or white
photo.posterize(6)           # six evenly spaced steps per channel
photo.invert()               # a photographic negative
photo.opacity(0.5)           # scale the alpha channel

levels() is the single most useful correction for a flat or washed-out photograph. Everything at or below the black point becomes black, everything at or above the white point becomes white, and the range between is stretched to fill the scale.

Note the difference between posterize() and quantize(): posterize() spaces its levels evenly, quantize() picks the colours to suit the image, so a photograph survives far fewer of them.

photo.quantize(32)                # 32 well-chosen colours
photo.quantize(16, true)          # ...with dithering

auto_levels() does the same job as levels() without being told where the endpoints are; it is shown alongside the detail filters below.

Colour

photo.grayscale()
photo.sepia()
photo.saturate(1.4)               # 0 removes colour, 1 is unchanged
photo.hue_rotate(45)
photo.tint('#ff8800', 0.3)        # blend a flat colour in
photo.colorize('#4f46e5')         # monochrome in one colour
photo.duotone('#1e1b4b', '#fbbf24')
photo.flatten('white')            # composite over a colour, drop alpha

grayscale() weights the channels for perceived brightness, so a bright yellow comes out light and a deep blue comes out dark. That is different from saturate(0), which keeps HSL lightness and makes both mid-grey.

Blur, Sharpen and Detail

photo.blur(3)                # Gaussian; the argument is its sigma
photo.sharpen(0.8)
photo.smooth()               # a cheap, harsher 3x3 average
photo.emboss()
photo.edges()
photo.mean_removal()
photo.pixelate(12)

blur() is a true separable Gaussian, so a large radius costs linearly rather than quadratically. Its argument is the standard deviation: about two thirds of each pixel’s contribution falls within that distance, and the visible spread is roughly three times it.

Alpha is premultiplied for the duration of a blur, so blurring a shape on a transparent background does not drag a dark halo into its edge.

Writing Your Own Filter

Nearly every filter above is one of three primitives with different numbers in it, and all three are available directly.

A lookup table covers any per-channel tone curve. The table is built once whatever the image’s size, then applied at memory speed.

import imagine { filters }

# A gentle S-curve: more contrast, but the highlights survive.
var curve = filters.build_lut(@(value) {
  var t = value / 255
  return 255 * t * t * (3 - 2 * t)
})

photo.apply_lut(curve, curve, curve, nil)   # nil leaves alpha alone

A colour matrix covers anything that mixes channels — saturation, hue rotation, channel swaps, tinting. It is the 4x5 matrix SVG and CSS filters use, written row by row, with the last entry of each row a constant in 0-255 units.

# Swap the red and blue channels.
photo.apply_matrix([
  0, 0, 1, 0, 0,
  0, 1, 0, 0, 0,
  1, 0, 0, 0, 0,
  0, 0, 0, 1, 0,
])

Applying several matrices in a row is both slower and less accurate than combining them and applying the result once:

var both = filters.combine_matrices(
  filters.grayscale_matrix(),
  filters.saturation_matrix(1.2)
)

photo.apply_matrix(both)

A convolution kernel covers anything that reads a pixel’s neighbours.

photo.convolve([
  0, -1, 0,
  -1, 5, -1,
  0, -1, 0,
])

The kernel must be square with an odd side. The default divisor of 0 means “divide by the kernel’s own sum”, which is what nearly every published kernel expects.

One trap is worth knowing. Alpha goes through the kernel along with the colour channels, which is right for a blur and wrong for anything whose weights do not sum to one. A Laplacian over a uniformly opaque image sums to zero, which would make the whole result invisible:

photo.convolve(filters.edge_kernel(), { divisor: 1, keep_alpha: true })

The built-in edges(), emboss(), sharpen() and mean_removal() already do this.

The S-curve and the channel-swap matrix from this section, run against the same picture:

edge controls what happens off the image’s border: EDGE_CLAMP (the default) repeats the nearest edge pixel, EDGE_TRANSPARENT treats the outside as empty, and EDGE_WRAP wraps to the opposite side for images meant to tile.

Drawing

Shapes

Coordinates start at the top-left corner. Integer coordinates fall on pixel corners rather than centres, so a rectangle from (0, 0) to (10, 10) covers exactly the first ten pixels in each direction.

image.pixel(x, y, color)                        # one pixel, blended
image.line(x1, y1, x2, y2, color)
image.rect(x, y, w, h, color)                   # outline
image.fill_rect(x, y, w, h, color)
image.rounded_rect(x, y, w, h, radius, color)
image.fill_rounded_rect(x, y, w, h, radius, color)
image.circle(cx, cy, r, color)
image.fill_circle(cx, cy, r, color)
image.ellipse(cx, cy, rx, ry, color)
image.fill_ellipse(cx, cy, rx, ry, color)
image.arc(cx, cy, rx, ry, start, end, color)
image.pie(cx, cy, rx, ry, start, end, color)    # closed through the centre
image.chord(cx, cy, rx, ry, start, end, color)  # closed along the chord
image.bezier(x1, y1, cx1, cy1, cx2, cy2, x2, y2, color)

Angles are in degrees, measured clockwise from three o’clock, matching the direction the y axis runs.

Drawing outside the image is never an error; anything that falls outside is clipped away. That is what makes it safe to draw a shape that only partly overlaps.

Strokes and Anti-aliasing

image.thickness(4)         # applies to every subsequent stroke
image.antialias(false)     # hard edges

Both are settings on the image rather than per-call arguments, though a single call can override the thickness:

image.line(0, 0, 100, 100, 'black', { thickness: 8 })

Strokes are centred on the path, so a thickness of 4 puts 2 pixels on each side. Joints and the ends of an open path are rounded.

Anti-aliasing is on by default. Turn it off for output that has to be pixel-exact — barcodes, QR codes, anything that will be thresholded afterwards.

Stroke widths of 1, 3, 6 and 12:

Line Caps

How an open stroke finishes at its two ends is a separate choice from its width:

image.cap(CAP_ROUND)     # a half-disc. The default.
image.cap(CAP_SQUARE)    # a square, reaching the same distance
image.cap(CAP_BUTT)      # stops dead at the endpoint

The red marks are the exact coordinates the line was given. A round or square cap reaches half the stroke’s width past them; a butt cap does not.

CAP_BUTT is the one to reach for whenever the coordinates have to mean exactly what they say — segments meeting end to end, a scale bar of a known length, the pieces of a dashed line. CAP_SQUARE gives the same reach as round with a blunt finish.

A single call can override the surface’s setting:

image.line(40, 200, 40, 40, '#334155', { thickness: 6, cap: CAP_BUTT })

Joins between a path’s segments are always round, and closed outlines have no ends, so neither is affected by this.

Paths and Polygons

Every filled shape in this module becomes a polygon and goes through one scanline rasterizer, and every outline becomes the polygon around its stroke and goes through the same one. A shape not listed above can be drawn by supplying the points:

image.fill_polygon([[10, 10], [90, 30], [50, 80]], '#4f46e5')
image.polygon([[10, 10], [90, 30], [50, 80]], 'black')     # outline, closed
image.polyline([[10, 10], [90, 30], [50, 80]], 'black')    # not closed

Points may be a list of [x, y] pairs or a flat list of alternating values; both read naturally depending on where the points came from. The outline is closed for you, so the last point does not need to repeat the first.

Self-intersecting outlines are filled by the non-zero winding rule, which fills a five-pointed star solid. Pass { even_odd: true } for the other convention, which leaves its middle empty.

Filling Areas

image.fill('#0f172a')          # every pixel, ignoring the clip
image.clear()                  # every pixel to transparent
image.flood_fill(x, y, color)
image.flood_fill(x, y, color, 12)   # with a tolerance

Gradients get their own section below.

fill() replaces rather than blends, so filling with a transparent colour empties the image instead of leaving it unchanged.

Flood fill spreads four-connected — up, down, left and right, but not diagonally — through pixels within tolerance of the colour at the starting point. A tolerance of 0 spreads only through exactly equal pixels; on a photograph or anything anti-aliased you will want more.

Gradients

image.linear_gradient(x1, y1, x2, y2, stops)
image.radial_gradient(cx, cy, radius, stops)

A linear gradient runs along the vector from the first point to the second. Everything before that vector takes the first stop’s colour and everything past it takes the last stop’s, so a short vector across a large area gives a hard transition with flat bands on either side.

# top to bottom
image.linear_gradient(0, 0, 0, image.height(), ['#0f172a', '#334155'])

# diagonal, three colours
image.linear_gradient(0, 0, 400, 200, ['#4f46e5', '#f472b6', '#fbbf24'])

Stops are either bare colours, spaced evenly, or [offset, colour] pairs with the offset running 0 to 1:

image.linear_gradient(0, 0, 200, 0, [
  [0, 'black'],
  [0.25, 'red'],
  [1, 'white'],
])

By default a gradient replaces what is there. Pass { blend: true } to composite it instead, which is what makes overlays and vignettes work:

# a vignette over an existing photograph
photo.radial_gradient(
  photo.width() / 2,
  photo.height() / 2,
  photo.width() * 0.7,
  [[0, Color(0, 0, 0, 0)], [1, Color(0, 0, 0, 180)]],
  { blend: true }
)

# a caption scrim along the bottom
photo.linear_gradient(0, photo.height() - 120, 0, photo.height(), [
  Color(0, 0, 0, 0),
  Color(0, 0, 0, 200),
], { blend: true })

{ rect: {x, y, width, height} } confines the fill to a rectangle instead of covering the whole surface.

Both of those are the snippets above, run against the picture on the left.

Clipping

image.clip(20, 20, 100, 100)
image.fill_rect(0, 0, 500, 500, 'red')   # only the clip is painted
image.clear_clip()

A clip confines every drawing operation until it is cleared. It does not affect reading: get_pixel() sees the whole image either way.

Text

The Built-in Font

imagine ships no font file, so everything in the next section depends on what is installed on the machine and can fail. One font always works:

import imagine { Image, StrokeFont }

Image(300, 70, 'white')
  .text(16, 20, 'Always available', StrokeFont(28), '#0f172a')
  .save('label.png')

Font.builtin(size) is the same thing under a name you will find from Font.

It is not a font file. Every glyph is defined as geometry — centre-line strokes rather than filled outlines — in libs/imagine/strokefont.zu, and drawn through the same anti-aliased rasterizer as everything else. So it scales cleanly to any size, and its weight is a parameter rather than part of the design:

StrokeFont(24)              # regular
StrokeFont(24).weight(0.13) # bold
StrokeFont(24).weight(0.05) # light

Here is the whole thing:

What it is for. Labels, chart axes, watermarks, diagrams, placeholder text, and any output that has to work on a machine with no fonts installed. The look is geometric and single-weight, closer to a technical drawing than to a typeface, because that is what centre-line strokes give you honestly.

What it is not for. Body text, headlines, or anything where the shapes themselves matter. Load a real font for those.

Coverage is printable ASCII, from space through ~. Anything else draws the empty box a font uses for a glyph it does not have, so text in another script comes out visibly missing rather than silently blank.

A StrokeFont has the same interface as a Font — size(), metrics(), measure(), render(), wrap() — so anywhere a font is accepted, either works.

Loading a Font

import imagine { Font }

var font = Font.load('assets/Inter.ttf', 24)
var font = Font.from_bytes(embedded_font, 24)
var font = Font.system('DejaVu Sans', 24)
var font = Font.sans(24)

Font.load() reads a TrueType or OpenType file and caches the parsed face by path, so loading the same file twice in one process parses it once. Font.system() searches the platform’s font directories by family name, matching ignoring case, spaces and hyphens.

Font.sans() finds whichever common sans-serif font is installed. It is a convenience for scripts and tests, not something to rely on for output that must look the same everywhere: which font it lands on depends on the machine, and a container image with no fonts installed has none to find. It raises FontError in that case, pointing at Font.builtin(), which never depends on what is installed.

A Font is immutable and cheap to copy. size() returns the same face at another size, sharing the parsed data:

var title = font.size(32)
var body = font.size(14)

Drawing Text

image.text(20, 20, 'Hello', font, '#111111')

x and y are the top-left corner of the text’s box, not its baseline, because the corner is what you know when placing text in a layout. Pass { baseline: true } when you want y to mean the first line’s baseline instead.

A \n starts a new line.

card.text(24, 24, 'Quarterly report\n2026', title, '#111111', {
  align: ALIGN_CENTER,
  line_height: 1.4,
  tracking: 0.5,
})

align positions the lines against each other, not against the image. line_height is a multiplier on the font’s own recommended spacing, and tracking adds pixels between characters.

Measuring and Positioning

var box = image.text_size('Hello', font)
# { width: 58, height: 28, baseline: 22.3, lines: 1 }

Measuring costs a fraction of drawing, so it is the right way to lay text out before committing to it — centring, wrapping, or sizing a background to fit.

var box = card.text_size(label, font)

card.fill_rounded_rect(16, 16, box.width + 24, box.height + 16, 8, '#1e293b')
card.text(28, 24, label, font, 'white')

To position by anchor instead of coordinates:

card.place_text('SOLD OUT', font, 'white', CENTER)
card.place_text('v2.1', font, '#94a3b8', BOTTOM_RIGHT, { margin: 12 })

Wrapping

var body = Font.load('assets/Inter.ttf', 16)

page.text(40, 120, article, body, '#334155', { width: 520 })

The width option wraps the text to that many pixels before drawing it. text_size() takes the same option, so measuring wrapped text gives the box it will actually occupy.

To get the broken text itself — to store it, or to draw it in pieces — call wrap() on the font:

var lines = body.wrap(article, 520).split('\n')

echo '${lines.length()} lines'

Newlines already in the text are kept as paragraph breaks, and runs of spaces are collapsed. Words are kept whole where they can be; a single word too long for the width is broken between characters rather than allowed to overflow, since text spilling out of an image cannot be scrolled to.

What Text Layout Does Not Do

Glyphs are positioned by advance width with kerning applied. That covers Latin, Greek, Cyrillic and anything else written left to right without contextual shaping.

It does not do complex shaping. Arabic letters will not join, Indic clusters will not reorder, and ligatures are not substituted. Those need a shaping engine, and a standard library that pretended to do them would be worse than one that says plainly it does not.

Compositing

Drawing One Image Onto Another

photo.draw_image(logo, 20, 20)
photo.draw_image(logo, 20, 20, { opacity: 0.6 })
photo.place(logo, BOTTOM_RIGHT, { margin: 16, opacity: 0.5 })

The source is clipped to the destination, and its position may be negative, so a sprite hanging off the top-left corner draws correctly. Compositing an image onto itself works; it is copied first.

Blend Modes

photo.draw_image(texture, 0, 0, { blend: BLEND_MULTIPLY })
ModeEffect
BLEND_NORMALOrdinary alpha compositing. The default.
BLEND_MULTIPLYNever lighter than either input. Shadows.
BLEND_SCREENNever darker than either input. Glows.
BLEND_OVERLAYMultiply in the shadows, screen in the highlights.
BLEND_DARKEN / BLEND_LIGHTENKeep the darker or lighter channel.
BLEND_COLOR_DODGE / BLEND_COLOR_BURNStrong brighten or darken.
BLEND_HARD_LIGHT / BLEND_SOFT_LIGHTOverlay with the roles swapped; and a gentler version.
BLEND_DIFFERENCE / BLEND_EXCLUSIONAbsolute difference; and a lower-contrast variant.
BLEND_ADD / BLEND_SUBTRACTAdd or subtract, clamped.

The formulas are the ones in the CSS compositing specification, which is also what every image editor implements.

BLEND_DIFFERENCE makes a quick visual diff: identical images blended this way come out black.

A red circle drawn onto a gradient under eight of the modes:

var diff = before.clone().draw_image(after, 0, 0, { blend: BLEND_DIFFERENCE })

Drawing operations — lines, shapes, text — always use ordinary alpha compositing. To draw a shape under a blend mode, draw it on a layer and composite the layer.

Masks

var stencil = photo.layer()
stencil.fill_circle(200, 200, 150, 'white')

photo.mask(stencil)     # everything outside the circle becomes transparent

Each pixel keeps its colour and takes its transparency from the mask: opaque white shows the image through fully, black or transparent hides it, and greys give partial transparency. The mask’s own alpha counts, so a shape drawn on a transparent background works as a mask without being filled in first.

The mask must be the same size as the image.

Layers

layer() gives a transparent image of the same size. Building a composite out of layers is how you get effects that a single pass cannot, and it is the answer whenever you want a blend mode or an opacity applied to a group of operations rather than one:

var glow = photo.layer()

glow.fill_circle(200, 150, 80, '#fbbf24')
glow.blur(30)

photo.draw_image(glow, 0, 0, { blend: BLEND_SCREEN, opacity: 0.7 })

Inspecting an Image

photo.width()          # dimensions
photo.height()
photo.size()           # { width, height }
photo.bounds()         # { x: 0, y: 0, width, height }
photo.format()         # what it was decoded from, or nil
photo.is_opaque()      # walks the alpha channel
photo.info()           # all of the above, plus a pixel count

Beyond the shape, four methods read what is actually in the image.

histogram() counts how many pixels hold each value, as four 256-entry lists: red, green, blue and luma. Fully transparent pixels are skipped, since their colour is not visible. A histogram is what tells you an image is underexposed (everything bunched at the low end), flat (bunched in the middle), or clipped (a spike at 0 or 255).

auto_levels() is the automatic form of levels(): it finds the black and white points from the brightness histogram and stretches the range between them, ignoring a small fraction at each end so a handful of stray pixels cannot decide the result.

photo.auto_levels()
photo.auto_levels({ clip: 0.02, gamma: 1.1 })

average_color() returns the mean colour, weighting each pixel by its alpha so a mostly transparent image reports the colour of the part you can see.

dominant_colors() returns the colours occupying the most of the image, most common first. The image is shrunk and reduced to a small palette first, so it costs about the same whatever the original size.

var accent = photo.dominant_colors(1)[0]

page.fill(accent.darken(40))       # a placeholder while the photo loads

difference() scores how far two images are apart, from 0 (identical) to 1, which makes it usable as a rendering assertion:

if rendered.difference(expected) > 0.01 {
  raise Exception('the rendering changed')
}

Animation

import imagine { Animation }

var animation = Animation.open('loading.gif')

animation.length()      # frame count
animation.duration()    # milliseconds for one pass
animation.frame(0)      # one frame, as an Image
animation.frames()      # the live list

Frames come back already composited against whatever preceded them, so frame 5 can be used on its own without replaying the first four. The disposal and transparency rules an animated GIF is built from never surface.

Building and transforming:

var frames = []

iter var i = 0; i < 30; i++ {
  var frame = Image(200, 200, 'black')
  frame.fill_circle(100, 100, i * 3, '#38bdf8')
  frames.append(frame)
}

Animation(frames, 40, 0).save('pulse.gif')     # 40ms per frame, loops forever

animation
  .map(@(frame) {
    return frame.grayscale().blur(1)
  })
  .repeat(3)
  .save('out.gif')

map() copies each frame before the function sees it, so a filter chain works directly as the body and the original animation is left alone. That copy is why map() costs as much memory again as the animation itself; to filter in place, walk frames() and change each image directly.

Animated GIF and animated WebP can both be read, and only GIF can be written. A still image decodes as a one-frame animation rather than an error, so code handling both does not need to branch.

Every frame must be the same size, since animated formats have one canvas that each frame paints into.

Working With Pixels Directly

pixels() hands back the live buffer. It is 8-bit RGBA with straight (not premultiplied) alpha, laid out row by row with no padding, so pixel (x, y) begins at byte (y * width + x) * 4.

var buffer = image.pixels()
var total = buffer.length()

iter var at = 0; at < total; at += 4 {
  buffer[at] = 255 - buffer[at]        # invert red only
}

This is deliberate, and it is much faster than a method call per pixel, so an operation this module does not provide can still be written efficiently in Zuri. Annotating a function’s parameters pays off here:

def darken_edges(pixels: bytes, width: number, height: number) {
  iter var y = 0; y < height; y++ {
    iter var x = 0; x < width; x++ {
      var at = (y * width + x) * 4
      # ...
    }
  }
}

set_pixels() takes a buffer back, which is how you save a copy before a destructive filter and restore it afterwards:

var saved = image.pixels().clone()

image.blur(8)
image.set_pixels(saved)     # back where it started

Image.from_pixels() builds an image around a buffer that came from somewhere else entirely.

Errors

Failures fall into two groups. An argument of the wrong type raises TypeError, from the parameter’s own type declaration. Everything else descends from ImageError, so one catch covers it:

import imagine { Image, ImageError, DecodeError }

catch {
  var photo = Image.open(path)
} as e {
  if instance_of(e, DecodeError) {
    echo 'not a readable image'
  } else {
    echo 'something else went wrong: ${e.message}'
  }
}
ErrorRaised when
ImageErrorThe base class. Also raised directly for bad arguments.
DecodeErrorThe data is not an image, is a format this build cannot read, or is truncated.
EncodeErrorThe image cannot be written in the requested format.
FormatErrorA format name or file extension is not one this module knows.
BoundsErrorA rectangle, crop or resize falls outside the image, or a dimension is below 1.
FontErrorA font cannot be parsed, found, or laid out with.

Two kinds of failure sit outside that hierarchy on purpose.

Wrong argument type raises TypeError. Parameters declare their types, so the check happens at the boundary and the message names the parameter:

image.rotate('sideways')
# TypeError: rotate() expects parameter 'degrees' (argument 1)
#            to be a number, got string

A malformed colour raises ValueError, because that is what [[colors]] reports for it and relabelling would lose the distinction between “not a colour” and “not a string”:

Color.hex('nonsense')        # ValueError, from colors
Color.named('chartroose')    # ValueError, from colors
Color.hex(42)                # TypeError, from the type declaration

A value of the right type but the wrong range is still an ImageError subclass, since that is a judgement this module makes rather than a type the runtime can check:

Image(0, 100)                # BoundsError, not TypeError
filters.gamma_lut(-1)        # ImageError

DecodeError is the one to always be ready for, since anything arriving from outside the program can raise it.

Drawing operations do not raise BoundsError. A line running off the edge of the canvas is clipped, which is what every drawing API does; it is only the operations that must return an image of an exact size that have no sensible way to continue.

Performance and Memory

Four bytes per pixel, always. A 6000x4000 photograph is 96 MB decoded however small its file was, and a 100-frame 500x500 animation is 100 MB. probe() before decoding anything whose size you do not control.

Some rough guidance on what costs what:

  • Decoding and encoding dominate almost every pipeline. PNG at 'best' compression is several times slower than at 'default' for a few percent of size; AVIF is slower still.
  • Resizing costs roughly in proportion to the output size, so shrinking is cheap and enlarging is not. LANCZOS costs a few times NEAREST.
  • Blur is linear in its radius, not quadratic, because it is separable. A 3x3 convolve() is cheaper than blur(1), but by blur(5) the Gaussian has won by a wide margin.
  • Lookup tables and colour matrices are memory-bound and about as fast as touching every pixel can be. Chain as many as you like, but combine matrices with combine_matrices() rather than applying them one at a time — that is both faster and more accurate, since the intermediate result is never rounded back to 8 bits.
  • Drawing costs in proportion to the area covered, and anti-aliasing samples each pixel row four times over. Turning it off is a real saving on very large fills.

Resize before filtering whenever the result is going to be smaller anyway. Filtering a 24-megapixel photograph and then shrinking it to a thumbnail does the same visual work at forty times the cost.

Recipes

A thumbnail pipeline for uploads

import imagine
import imagine { Image, DecodeError, LANCZOS, TOP }

def make_thumbnail(upload) {
  var header = imagine.probe(upload)

  if header == nil {
    raise DecodeError('not an image')
  }

  if header.width * header.height > 50000000 {
    raise DecodeError('image too large')
  }

  return Image.decode(upload)
    .cover(400, 400, { anchor: TOP, filter: LANCZOS })
    .sharpen(0.4)
    .encode('webp')
}

A rounded avatar with a border

import imagine { Image }

def avatar(source, size) {
  var photo = Image.decode(source).cover(size, size)

  var stencil = photo.layer()
  stencil.fill_circle(size / 2, size / 2, size / 2 - 2, 'white')
  photo.mask(stencil)

  photo.circle(size / 2, size / 2, size / 2 - 2, '#e2e8f0', { thickness: 3 })

  return photo
}

A social card

import imagine { Image, Font, Color, ALIGN_LEFT }

def card(title, subtitle) {
  var image = Image(1200, 630, '#0f172a')
  var heading = Font.load('assets/Inter-Bold.ttf', 64)
  var body = heading.size(30)

  image.fill_rect(0, 0, 1200, 8, '#38bdf8')

  var box = image.text_size(title, heading, { align: ALIGN_LEFT })
  image.text(80, 200, title, heading, 'white', { align: ALIGN_LEFT })
  image.text(80, 200 + box.height + 24, subtitle, body, '#94a3b8')

  return image.to_png()
}

Serving a generated image over HTTP

import http
import imagine { Image, Font, CENTER }
import imagine.formats

var server = http.server(8000)
var font = Font.load('assets/Inter.ttf', 20)

server.get('/badge/{label}', @(request, response) {
  var image = Image(220, 60, '#1e293b')

  image.fill_rounded_rect(0, 0, 220, 60, 8, '#334155')
  image.place_text(request.param('label'), font, 'white', CENTER)

  response.content_type(formats.mime_for('png'))
  response.cache_for(86400)
  response.write(image.to_png())
})

server.listen()

Comparing two images

import imagine { BLEND_DIFFERENCE }

def differs(a, b) {
  if a.size() != b.size() {
    return true
  }

  var diff = a.clone().draw_image(b, 0, 0, { blend: BLEND_DIFFERENCE })
  var pixels = diff.pixels()
  var total = pixels.length()

  iter var at = 0; at < total; at += 4 {
    if pixels[at] > 8 or pixels[at + 1] > 8 or pixels[at + 2] > 8 {
      return true
    }
  }

  return false
}