← imaging · API reference · version 0.4.4
4 file(s), 576 lines. This is the source the published version ships, and it is loft — the library is written in the same language you call it from.
Public items
A worked example is a call site the author chose as the thing to read first. Call sites are every line naming the item, found mechanically over the source with comments excluded — so an item with no worked example is not an item nobody uses.
| Item | Defined | Worked example | Call sites |
|---|---|---|---|
Pixel struct | 21 | IMG-004 | 33 40 50 63 57 25 … (44 total) |
r const | 22 | — | 34 41 52 57 58 65 … (71 total) |
g const | 23 | — | 34 41 53 57 65 79 … (65 total) |
b const | 24 | — | 34 41 54 57 65 80 … (62 total) |
a const | 25 | — | 41 51 171 173 186 30 … (23 total) |
value fn | 33 | — | 34 36 38 40 42 44 … (15 total) |
rgba fn | 40 | — | 160 70 71 72 73 74 … (8 total) |
opaque fn | 50 | — | 180 182 186 76 79 80 … (11 total) |
Image struct | 59 | IMG-001 | 68 74 78 94 101 40 … (25 total) |
save_png fn | 78 | — | 56 57 73 88 108 128 … (10 total) |
png fn | 94 | IMG-002 | 40 43 88 89 49 55 … (42 total) |
src/imaging.loft
// Copyright (c) 2024 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// --- Imaging ---
// Types and functions for reading PNG images.
// Data per pixel of a PNG image.
// Fields r, g, b hold red, green and blue intensity and `a` holds opacity, each
// 0–255. The channels are const, so a pixel is REPLACED (`img.data[i] =
// Pixel{…}`), never edited in place, and `limit(0, 255)` is a range rather than a
// clamp — a value outside it stores 0.
//
// **`a` defaults to 255, and the default is load-bearing.** An omitted field takes
// its type's zero, and zero alpha is INVISIBLE — so without the declared default
// every `Pixel{r, g, b}` ever written would silently become fully transparent
// (loft#914's `omitted-field-zero` in its purest form). With it, code that
// predates alpha keeps meaning what it meant: opaque.
// Example: @IMG-003 — replacing a pixel, and why the local you read it into is a
// view of its slot rather than a snapshot.
// Example: @IMG-004 — the range is not a clamp: doubling a bright channel stores
// 0, so brightening turns the brightest pixels black.
pub struct Pixel {
pub const r: integer limit(0, 255),
pub const g: integer limit(0, 255),
pub const b: integer limit(0, 255),
pub const a: integer limit(0, 255) = 255
}
// Returns the pixel colour as a packed 24-bit integer (0xRRGGBB).
// Use for fast colour comparison or storage. Alpha is NOT in it — this is the
// pre-alpha contract and stays 24-bit, so a comparison written against it keeps
// answering what it answered. For opacity read `p.a`, or `rgba()` for the packed
// 32-bit form.
pub fn value(self: Pixel) -> integer {
self.r * 0x10000 + self.g * 0x100 + self.b
}
// Returns the pixel as a packed 32-bit integer (0xAARRGGBB) — the byte order
// `graphics::Canvas` uses, so a decoded image can be handed to a canvas without a
// per-channel loop at the call site.
pub fn rgba(self: Pixel) -> integer {
self.a * 0x1000000 + self.r * 0x10000 + self.g * 0x100 + self.b
}
// Is this texel opaque enough to count as covered?
//
// A collision proxy, a trim box and a colour key all want the same question, and
// they want it with a THRESHOLD rather than `a == 255`: antialiased art has a
// fringe of partly-transparent texels that belong to the silhouette. Default 128
// — half opacity — which is the midpoint of that fringe.
pub fn opaque(self: Pixel, cutoff: integer = 128) -> boolean {
self.a >= cutoff
}
// Database structure with image data.
// The actual data is in height rows of width length: `data` is one FLAT vector in
// row-major order, so the pixel at (x, y) is `data[y * width + x]`, and
// `len(data) == width * height` always holds on an image `png()` handed back.
// Example: @IMG-001 — the addressing rule, and what the null check buys.
pub struct Image {
pub name: text,
pub width: integer,
pub height: integer,
pub data: vector<Pixel>
}
// Native function: decode a PNG file into an Image struct.
// Bare `#native` → symbol `n_load_png` by convention (the fn name).
fn load_png(path: text, image: Image) -> boolean;
#native
// Native function: encode an Image struct as a PNG file.
// Explicit override: the loft wrapper is `save_png_native` but the
// native symbol is `n_save_png` (a pure-loft `save_png` wraps it).
fn save_png_native(image: const Image, path: text) -> boolean;
#native "n_save_png"
// Write this image to a PNG file. Returns true on success.
pub fn save_png(self: const Image, path: text) -> boolean {
save_png_native(self, path)
}
// Decodes a PNG file and returns an Image.
// Returns null when there is nothing to decode — the path does not exist, names
// a directory, or holds bytes this decoder cannot read. So the answer is either
// a complete image (`len(data) == width * height`) or null; there is no
// half-filled Image to guard against. Any PNG colour type loads: greyscale and
// palette files are expanded to RGBA, and **alpha is carried** — a file with no
// alpha channel arrives fully opaque (`a == 255`), never transparent.
// Use to load sprite sheets or textures.
// Example: @IMG-001 — the three ways to get null, and the completeness the null
// check buys past it.
// Example: @IMG-002 — every colour type arrives as RGBA, and a source without an
// alpha channel is opaque rather than invisible.
pub fn png(self: File) -> Image? {
// Early returns, not a nested `if … { result } else { null }`: on a `-> Image?`
// function that nesting hands back the half-built `result` instead of null in
// loft releases before the fix, so an undecodable file read as an empty image.
if self.format != Format.TextFile {
return null;
}
result = Image { };
if !load_png(self.path, result) {
return null;
}
result
}
tests/14-image.loft
// Copyright (c) 2024-2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
// @NAME: Image
// @TITLE: Reading and examining PNG files
// Loft can load PNG files and access every pixel. Loading takes one function
// call; after that you can read dimensions, iterate pixels, and examine
// individual channels.
// ## Loading an Image
//
// Call `file(path).png()` to load a PNG into memory.
// The entire file is decoded at this point — width, height, and a flat
// vector of Pixel records are all available immediately.
// ## Dimensions and Name
//
// `img.width` and `img.height` give the pixel dimensions.
// `img.name` is the file name without the directory path.
// ## The Pixel Struct
//
// Each element of `img.data` is a `Pixel` with three fields:
// const r: integer limit(0, 255) — red channel
// const g: integer limit(0, 255) — green channel
// const b: integer limit(0, 255) — blue channel
// Values range from 0 (no intensity) to 255 (full intensity). The range is not
// a clamp — a channel built outside it stores 0 — and the fields are const, so a
// pixel is replaced whole (`img.data[i] = Pixel{...}`) rather than edited.
// ## Pixel Access
//
// `img.data` is a `vector<Pixel>`. Access by coordinate:
// img.data[y * img.width + x]
// Iterate with a for loop to scan every pixel.
// ## Error Handling
//
// `png()` answers `Image?` — null when the file does not exist or cannot be
// decoded — so discharge it (`?? Image { }`, or test `== null`) before reading
// a field.
use imaging::*;
fn main() {
// ## Loading an Image
img = file("map.png").png() ?? Image { };
assert(img.width == 256, "width={img.width}");
assert(img.height == 256, "height={img.height}");
assert(img.name == "map.png", "name={img.name}");
// ## Pixel Count
// Total pixels = width * height.
assert(img.data.len() == img.width * img.height, "pixel count");
// ## Reading Individual Pixels
// The top-left pixel is at index 0.
px = img.data[0];
assert(px.r >= 0 and px.r <= 255, "r in range");
assert(px.g >= 0 and px.g <= 255, "g in range");
assert(px.b >= 0 and px.b <= 255, "b in range");
// Read a pixel at (x=128, y=128) — the center.
center = img.data[128 * img.width + 128] ?? Pixel{r: 0, g: 0, b: 0};
assert(center.r >= 0, "center pixel r valid");
// ## Scanning Pixels
// Count pixels brighter than a threshold. A simple brightness
// approximation: (r + g + b) / 3 > threshold.
bright_count = 0;
for img_px in img.data {
brightness = (img_px.r + img_px.g + img_px.b) / 3;
if brightness > 200 {
bright_count += 1;
}
}
assert(bright_count >= 0, "bright pixel count: {bright_count}");
// ## Finding Dominant Channel
// Walk every pixel and find which channel has the highest total value.
total_r = 0;
total_g = 0;
total_b = 0;
for ch_px in img.data {
total_r += ch_px.r as integer;
total_g += ch_px.g as integer;
total_b += ch_px.b as integer;
}
dominant = if total_r >= total_g and total_r >= total_b { "red" }
else if total_g >= total_b { "green" }
else { "blue" };
assert(dominant.len() > 0, "dominant channel: {dominant}");
// ## Error Handling
bad = file("no_such_image.png").png();
assert(bad == null, "png of missing file is null");
}
tests/15-regression.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Regression tests for lib/imaging — pin the API surface so the
// library can extract independently of the loft monorepo
// (lib_plans/12 Phase 6t Tier 5). The companion doc-example
// (14-image.loft) demonstrates "load a PNG and read pixels"; this
// file covers the rest: Pixel.value() packing, save_png + load_png
// round-trip across small / non-square / extreme / solid-colour
// shapes, the (x, y) → y*w+x addressing contract, and the save_png
// failure modes (zero-pixel image, bad output path).
//
// Round-trip tests write to `tests/_tmp_*.png`; those names are
// gitignored. Each test re-creates its tmp file, so leftover
// artefacts from a previous run are harmless.
use imaging::*;
// Construct a deterministic w×h image where pixel (x,y) carries a
// known colour pattern. Shared by the round-trip tests.
fn make_test_image(name: text, w: integer, h: integer) -> Image {
img = Image { name: name, width: w, height: h, data: [] };
for mty in 0..h {
for mtx in 0..w {
img.data += [Pixel{r: mtx & 255, g: mty & 255, b: (mtx + mty) & 255}];
}
}
img
}
// Pixel.value() packs RGB into a 24-bit integer (0xRRGGBB).
fn test_pixel_value_packed() {
px = Pixel{r: 0x12, g: 0x34, b: 0x56};
assert(px.value() == 0x123456, "packed 0x123456, got {px.value()}");
black = Pixel{r: 0, g: 0, b: 0};
assert(black.value() == 0, "black packs to 0, got {black.value()}");
white = Pixel{r: 255, g: 255, b: 255};
assert(white.value() == 0xFFFFFF, "white packs to 0xFFFFFF, got {white.value()}");
red = Pixel{r: 255, g: 0, b: 0};
assert(red.value() == 0xFF0000, "red packs to 0xFF0000, got {red.value()}");
green = Pixel{r: 0, g: 255, b: 0};
assert(green.value() == 0x00FF00, "green packs to 0x00FF00, got {green.value()}");
blue = Pixel{r: 0, g: 0, b: 255};
assert(blue.value() == 0x0000FF, "blue packs to 0x0000FF, got {blue.value()}");
}
// Loading a missing file returns null (Format.NotExists → png() returns null).
fn test_load_missing_returns_null() {
bad = file("no_such_file_a8b3c7d.png").png();
assert(bad == null, "missing file → null");
}
// save_png + load_png is byte-exact for small RGB images.
fn test_save_load_roundtrip_small() {
orig = make_test_image("rt4.png", 4, 4);
ok = orig.save_png("_tmp_rt4.png");
assert(ok, "save_png returned true");
loaded = file("_tmp_rt4.png").png() ?? Image { };
assert(loaded.width == 4, "loaded width 4, got {loaded.width}");
assert(loaded.height == 4, "loaded height 4, got {loaded.height}");
assert(loaded.data.len() == 16, "loaded pixel count 16, got {loaded.data.len()}");
for rti in 0..16 {
op = orig.data[rti] ?? Pixel{r: 0, g: 0, b: 0};
lp = loaded.data[rti] ?? Pixel{r: 0, g: 0, b: 0};
assert(op.r == lp.r && op.g == lp.g && op.b == lp.b,
"pixel {rti}: orig=({op.r},{op.g},{op.b}) loaded=({lp.r},{lp.g},{lp.b})");
}
}
// Width ≠ height is preserved across save/load (no row/column swap).
fn test_save_load_non_square() {
orig = make_test_image("rt83.png", 8, 3);
ok = orig.save_png("_tmp_rt83.png");
assert(ok, "save 8x3 returned true");
loaded = file("_tmp_rt83.png").png() ?? Image { };
assert(loaded.width == 8, "width 8, got {loaded.width}");
assert(loaded.height == 3, "height 3, got {loaded.height}");
assert(loaded.data.len() == 24, "24 pixels, got {loaded.data.len()}");
}
// Extreme pixel values (0 and 255 in each channel) round-trip exactly.
fn test_extreme_pixel_values() {
img = Image { name: "extremes.png", width: 2, height: 2, data: [] };
img.data += [Pixel{r: 0, g: 0, b: 0}];
img.data += [Pixel{r: 255, g: 255, b: 255}];
img.data += [Pixel{r: 255, g: 0, b: 0}];
img.data += [Pixel{r: 0, g: 255, b: 0}];
ok = img.save_png("_tmp_extremes.png");
assert(ok, "save extremes returned true");
loaded = file("_tmp_extremes.png").png() ?? Image { };
assert(loaded.data.len() == 4, "4 pixels, got {loaded.data.len()}");
p0 = loaded.data[0] ?? Pixel{r: 1, g: 1, b: 1};
p1 = loaded.data[1] ?? Pixel{r: 0, g: 0, b: 0};
p2 = loaded.data[2] ?? Pixel{r: 0, g: 1, b: 1};
p3 = loaded.data[3] ?? Pixel{r: 1, g: 0, b: 1};
assert(p0.r == 0 && p0.g == 0 && p0.b == 0, "black preserved");
assert(p1.r == 255 && p1.g == 255 && p1.b == 255, "white preserved");
assert(p2.r == 255 && p2.g == 0 && p2.b == 0, "pure red preserved");
assert(p3.r == 0 && p3.g == 255 && p3.b == 0, "pure green preserved");
}
// A solid-colour image round-trips every pixel to the same value.
fn test_solid_color_image() {
img = Image { name: "solid.png", width: 5, height: 5, data: [] };
for _ in 0..25 {
img.data += [Pixel{r: 100, g: 150, b: 200}];
}
ok = img.save_png("_tmp_solid.png");
assert(ok, "save solid returned true");
loaded = file("_tmp_solid.png").png() ?? Image { };
assert(loaded.data.len() == 25, "25 pixels, got {loaded.data.len()}");
for sli in 0..25 {
p = loaded.data[sli] ?? Pixel{r: 0, g: 0, b: 0};
assert(p.r == 100 && p.g == 150 && p.b == 200,
"pixel {sli} is (100,150,200), got ({p.r},{p.g},{p.b})");
}
}
// Pixel addressing is row-major: index = y * width + x.
fn test_pixel_index_addressing() {
// 3x2 image; (x,y) = (2,1) is at index 1*3+2 = 5.
img = Image { name: "addr.png", width: 3, height: 2, data: [] };
for pay in 0..2 {
for pax in 0..3 {
img.data += [Pixel{r: (pax * 10)?, g: (pay * 10)?, b: 0}];
}
}
ok = img.save_png("_tmp_addr.png");
assert(ok, "save addr returned true");
loaded = file("_tmp_addr.png").png() ?? Image { };
// (2, 1) is at y * width + x = 1 * 3 + 2 = 5
p = loaded.data[5] ?? Pixel{r: 0, g: 0, b: 0};
assert(p.r == 20 && p.g == 10,
"(2,1) pixel = (20,10,0); got ({p.r},{p.g},{p.b})");
// (0, 0) is at index 0
origin = loaded.data[0] ?? Pixel{r: 99, g: 99, b: 99};
assert(origin.r == 0 && origin.g == 0, "(0,0) pixel = (0,0,0)");
}
// save_png on a 0x0 image fails fast — n_save_png guards w/h/data_rec.
fn test_save_empty_image_returns_false() {
empty = Image { name: "empty.png", width: 0, height: 0, data: [] };
ok = empty.save_png("_tmp_empty.png");
assert(!ok, "save of 0x0 image returns false");
}
// save_png to a path inside a non-existent directory returns false
// (the PNG encoder reports the I/O error back to the caller).
fn test_save_bad_path_returns_false() {
img = make_test_image("bad.png", 2, 2);
ok = img.save_png("no_such_dir_qx9z/out.png");
assert(!ok, "save to nonexistent dir returns false");
}
// The encoder chooses RGB or RGBA from the PIXELS, so gaining a channel did not
// change what an all-opaque image writes. A saved file that round-trips is the
// claim; the colour type it round-trips THROUGH is what keeps every existing
// consumer's output the size it was.
fn test_save_picks_its_colour_type_from_the_pixels() {
src_file = file("fixture-rgba.png").png();
assert(src_file != null, "fixture decodes");
src = src_file ?? Image { };
// Any alpha below 255 -> RGBA, and the alpha survives the round trip.
assert(src.save_png("/tmp/imaging_rt_alpha.png"), "save with alpha");
back_file = file("/tmp/imaging_rt_alpha.png").png();
assert(back_file != null, "reload");
back = back_file ?? Image { };
assert(len(back.data) == len(src.data), "same pixel count");
b2 = back.data[2]?;
assert(b2.a == 0, "the transparent texel is still transparent after a round trip");
b1 = back.data[1]?;
assert(b1.a == 128 && b1.r == 40, "and the partly-transparent one keeps both");
// Every texel opaque -> RGB, which is byte-for-byte what this wrote before
// alpha existed. Measured as a SIZE: an RGBA file of four pixels is larger.
op: vector<Pixel> = [];
for i in 0..4 { op += [Pixel{r: (10 * i)?, g: 20, b: 30}]; }
solid = Image{name: "solid", width: 2, height: 2, data: op};
assert(solid.save_png("/tmp/imaging_rt_opaque.png"), "save all-opaque");
reread_file = file("/tmp/imaging_rt_opaque.png").png();
assert(reread_file != null, "reload opaque");
reread = reread_file ?? Image { };
r0 = reread.data[0]?;
r3 = reread.data[3]?;
assert(r0.a == 255 && r3.a == 255, "an RGB file reads back opaque");
assert(r3.r == 30 && r3.g == 20 && r3.b == 30, "colours intact");
}
tests/worked-examples.loft
// Copyright (c) 2026 Jurjen Stellingwerff
// SPDX-License-Identifier: LGPL-3.0-or-later
//
// Worked examples for the `imaging` library (@PLN141). Each `test_*` below is a
// REAL, run-every-CI demonstration of a contract the signature cannot state; the
// function documents itself by CITING the tag with `// Example: @IMG-00N`, and
// `check_doc_drift.sh examples` fails if a citation goes dangling (no test carries
// the tag). These are teaching examples, distinct from the round-trip and
// colour-type assertion suite in `15-regression.loft`.
//
// The surface is three fields and two calls, and every trap here is in what those
// answers MEAN: `png()` answers `Image?`, `data` is one flat vector with
// an addressing rule written nowhere in its type, the decoder throws away a
// channel the file had, and `limit(0, 255)` is not a clamp.
use imaging::*;
// @IMG-001 — `png()` answers a WHOLE image or null, never a partial one. The
// three ways to have nothing to decode — no such path, a directory, bytes that
// are not a PNG — all give the same null, so a single `== null` covers them, and
// the return TYPE is `Image?`, so a caller discharges it before reading a field
// (until 0.4.0 it was declared `Image`, which let a consumer skip the check and
// read fields off null). What the check buys is the other
// half: past it, `len(data) == width * height` holds, so there is no half-filled
// image to defend against downstream. `data` is one FLAT vector in row-major
// order — `data[y * width + x]` — which its `vector<Pixel>` type cannot say and
// which column-major addressing gets wrong on every non-square image and on
// half the pixels of a square one.
fn test_load_answers_a_whole_image_or_null() {
missing = file("no_such_image_qx9z.png").png();
assert(missing == null, "a path that does not exist is null");
dir = file(".").png();
assert(dir == null, "a directory is null");
junk = file("_tmp_not_a_png.png");
junk.write("this is plainly not a PNG");
undecodable = file("_tmp_not_a_png.png").png();
assert(undecodable == null, "a real file holding no PNG is null too");
// Past the null check the image is complete.
decoded = file("fixture-palette.png").png();
assert(decoded != null, "a readable PNG decodes");
img = decoded ?? Image { };
assert(img.width == 2 && img.height == 2, "2x2");
assert(len(img.data) == img.width * img.height, "every pixel is present");
assert(img.name == "fixture-palette.png", "name is the basename, not the path");
// Row-major: index = y * width + x. This fixture is red, green / blue, yellow,
// so column-major would report (0,1) as green — the two orders disagree here.
top_left = img.data[0 * img.width + 0] ?? Pixel{r: 0, g: 0, b: 0};
top_right = img.data[0 * img.width + 1] ?? Pixel{r: 0, g: 0, b: 0};
bottom_left = img.data[1 * img.width + 0] ?? Pixel{r: 0, g: 0, b: 0};
bottom_right = img.data[1 * img.width + 1] ?? Pixel{r: 0, g: 0, b: 0};
assert(top_left.value() == 0xFF0000, "(0,0) is red");
assert(top_right.value() == 0x00FF00, "(1,0) is green");
assert(bottom_left.value() == 0x0000FF, "(0,1) is blue — not green");
assert(bottom_right.value() == 0xFFFF00, "(1,1) is yellow");
}
// @IMG-002 — every PNG colour type loads, and every one of them arrives as 8-bit
// RGBA. A `Pixel` has four channels and a loft `Image` is exactly
// `width * height` of them, so the decoder expands each file to that: greyscale is
// replicated across r/g/b, a palette is resolved to its entries, 16-bit samples
// drop to their high byte — and **alpha is carried**, so a sprite sheet can be
// tested for transparency instead of needing a colour key.
//
// The case to hold on to is the file with NO alpha channel. Its texels arrive at
// `a == 255`, never 0: an omitted channel means opaque, and defaulting it to zero
// would make every RGB PNG in the world decode to nothing at all.
fn test_every_colour_type_arrives_as_rgba() {
// RGBA — alphas in the file are 255, 128, 0, 255.
rgba = file("fixture-rgba.png").png() ?? Image { };
assert(len(rgba.data) == 4, "four pixels — not the byte stream re-cut into fours");
rgba0 = rgba.data[0]?;
rgba1 = rgba.data[1]?;
rgba2 = rgba.data[2]?;
rgba3 = rgba.data[3]?;
assert(rgba0.r == 10 && rgba0.g == 20 && rgba0.b == 30 && rgba0.a == 255, "opaque");
assert(rgba1.r == 40 && rgba1.g == 50 && rgba1.b == 60 && rgba1.a == 128, "half");
assert(rgba2.r == 70 && rgba2.g == 80 && rgba2.b == 90 && rgba2.a == 0,
"FULLY transparent, and now distinguishable from an opaque pixel of that colour");
assert(rgba3.a == 255, "last pixel opaque");
// `opaque()` is the question a proxy / trim / colour-key actually asks, and it
// takes a THRESHOLD because antialiased art has a partly-transparent fringe.
assert(rgba0.opaque() && rgba1.opaque() && !rgba2.opaque(), "default cutoff 128");
assert(!rgba1.opaque(129), "the cutoff is a parameter, not a constant");
assert(rgba0.rgba() == 0xFF0A141E, "packed 0xAARRGGBB, the Canvas byte order");
// Greyscale — one sample becomes r == g == b, and NO alpha channel means opaque.
grey = file("fixture-gray.png").png() ?? Image { };
assert(len(grey.data) == 4, "four pixels, not one");
grey1 = grey.data[1]?;
assert(grey1.r == 85 && grey1.g == 85 && grey1.b == 85, "luminance replicated");
assert(grey1.a == 255, "a source with no alpha channel is OPAQUE, not invisible");
// Grey + alpha carries both.
greya = file("fixture-gray-alpha.png").png();
greya2 = greya.data[2]?;
assert(greya2.r == 170 && greya2.g == 170 && greya2.b == 170, "luminance");
assert(greya2.a == 0, "and its alpha, which used to be dropped here");
// A palette is resolved to its entries, never handed over as indices.
pal = file("fixture-palette.png").png();
pal2 = pal.data[2]?;
assert(pal2.value() == 0x0000FF, "palette index 2 resolved to blue, not left as 2");
assert(pal2.value() == 0x0000FF && pal2.a == 255, "`value()` stays 24-bit RGB");
// 16-bit samples drop to their high byte: 0x0000 0x5500 0xAA00 0xFFFF.
wide = file("fixture-gray16.png").png() ?? Image { };
assert(len(wide.data) == 4, "four pixels, not eight bytes' worth");
wide3 = wide.data[3]?;
assert(wide3.r == 255, "0xFFFF becomes 0xFF");
}
// @IMG-005 — a `Pixel` written without an alpha is OPAQUE.
//
// `a` carries a declared default of 255, and that default is the whole reason
// adding a channel did not break every consumer at once: an omitted field takes
// its type's ZERO, and zero alpha is invisible. Without the default, every
// `Pixel{r, g, b}` literal written before alpha existed would have started
// producing nothing on screen, silently, with the colour still right in the
// struct — loft#914's `omitted-field-zero` in its purest form.
fn test_a_pixel_written_without_an_alpha_is_opaque() {
p = Pixel{r: 1, g: 2, b: 3};
assert(p.a == 255, "the omitted channel defaults to opaque, not to zero");
assert(p.opaque(), "so a literal from before alpha existed still draws");
clear = Pixel{r: 1, g: 2, b: 3, a: 0};
assert(!clear.opaque(), "and naming it zero still means transparent");
}
// @IMG-003 — a pixel is REPLACED, never edited, and the local you read it into is
// a VIEW of its slot. `Pixel`'s channels are `const`, so `img.data[i].r = 5` does
// not compile ("cannot reassign const field 'r' of struct 'Pixel' — const fields
// are write-once-at-construction"); the edit door is a whole-element assignment,
// `img.data[i] = Pixel{...}`. The second half is the one that costs an
// afternoon: `p = img.data[i]` binds a view of slot `i`, so after that slot is
// overwritten the "old" pixel reads the NEW colour — a before/after comparison
// written the obvious way compares the new value with itself and reports no
// change. Copy the CHANNELS out (they are scalars, so they copy) when you need
// the previous colour.
fn test_a_pixel_is_replaced_not_edited() {
img = file("fixture-palette.png").png();
first = img.data[0] ?? Pixel{r: 0, g: 0, b: 0};
assert(first.value() == 0xFF0000, "(0,0) starts out red");
// Take the channels out BEFORE the write — these are real copies.
kept_r = first.r;
kept_g = first.g;
kept_b = first.b;
img.data[0] = Pixel{r: 1, g: 2, b: 3};
after = img.data[0] ?? Pixel{r: 0, g: 0, b: 0};
assert(after.r == 1 && after.g == 2 && after.b == 3, "the whole-element assignment landed");
// `first` was read before the write and still moved with the slot.
assert(first.r == 1 && first.g == 2 && first.b == 3,
"the earlier local is a VIEW of the slot, not a snapshot of it");
assert(kept_r == 255 && kept_g == 0 && kept_b == 0,
"the copied channels are what actually remember the old colour");
}
// @IMG-004 — a channel is `integer limit(0, 255)`, and that is a RANGE, not a clamp.
// Scaling the brightness is the most ordinary pixel edit there is, and a channel that
// leaves the range is refused at the construction site: you say what an out-of-range
// channel becomes, and the two ways to say it give opposite pictures.
//
// `(c * 2)?` takes the type's DEFAULT, which is 0 — black. So the
// brighter a pixel was, the more likely this turns it black,
// and a photograph comes back with holes in its highlights.
// `min(c * 2, 255)?` clamps, and the highlights saturate to white.
//
// Both compile and neither warns, because both are answers to the same question. The
// second is almost always the one a picture wants; the first is here so the difference
// is visible side by side.
fn test_channels_do_not_clamp() {
img = file("fixture-gray.png").png();
mid = img.data[1] ?? Pixel{r: 0, g: 0, b: 0};
hi = img.data[2] ?? Pixel{r: 0, g: 0, b: 0};
assert(mid.r == 85 && hi.r == 170, "the two greys this example doubles");
naive_mid = Pixel{r: (mid.r * 2)?, g: (mid.g * 2)?, b: (mid.b * 2)?};
assert(naive_mid.r == 170, "85 doubled is still in range, so it stores as itself");
naive_hi = Pixel{r: (hi.r * 2)?, g: (hi.g * 2)?, b: (hi.b * 2)?};
assert(naive_hi.r == 0, "340 leaves the range, so `?` gives the type default 0 — NOT 255");
assert(naive_hi.value() == 0, "so brightening the brighter pixel produced black");
clamped = Pixel{r: min(hi.r * 2, 255)?, g: min(hi.g * 2, 255)?, b: min(hi.b * 2, 255)?};
assert(clamped.value() == 0xFFFFFF, "clamped at the call site, it saturates to white");
// The other end of the range behaves the same way.
under = Pixel{r: (0 - 1)?, g: 128, b: 128};
assert(under.r == 0, "a channel below the range takes the same default");
assert(under.g == 128, "and only the offending channel is affected");
}