⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.246+05671ced9What's newOn an older Zig?Blog

Saving

Updated

Save PNG in the studio writes the picture as you see it: every visible layer over white paper.

The Writing a PNG chapter builds a PNG writer by hand: the signature, the chunks, a CRC on each, and zlib compression. Read it first. This chapter is what changes for a real document.

Two things change. The writer takes an allocator, and it chooses a filter for every row.

Flattening

A PNG could store the layers’ transparency. But the picture people expect to save is the one on the screen, with the white paper behind it.

The screen buffer already holds exactly that, so saving starts from it:

examples/paint/src/engine/document.zigwritePng
    /// The picture as a PNG, flattened onto white paper.
    pub fn writePng(d: *Document, out: *std.Io.Writer) !void {
        d.stale = .{ .x0 = 0, .y0 = 0, .x1 = d.width, .y1 = d.height };
        _ = d.render();
        try png.write(d.gpa, out, d.display, d.width, d.height);
    }

It marks the whole screen stale and renders it first. Normally the engine redraws only what changed, and a saved file must not depend on what happened to be drawn last.

An allocator, not globals

The chapter’s writer kept its buffers in global variables. That works for one fixed size of picture.

A document can be any size, so this writer takes an allocator and asks for what it needs:

  • the compressor’s state, about 224 KB, which is too big for a small stack
  • the 64 KB window the compressor needs
  • two rows of pixels and five filtered copies of a row
  • the compressed output, which grows as it is written

Each allocation has a defer right below it, so every one is freed on every path out of the function, including the error paths.

A filter for every row

The chapter tried two filters on the whole picture and kept the smaller file. A gradient shrank from 3801 bytes to 133 with Sub. The flat test scene got bigger.

A real picture is both. The sky in a painting is smooth and the edge of a stroke is not. So the best filter changes from row to row.

There are five filters. Each one predicts a byte from bytes already written, and stores the difference:

  • None predicts 0, so it stores the byte as it is.
  • Sub predicts the byte to the left.
  • Up predicts the byte above.
  • Average predicts the average of left and above.
  • Paeth predicts whichever of left, above and above-left is closest to left + above − above-left.
examples/paint/src/engine/png.zigpaeth
/// Whichever of left, above and above-left is closest to `left + up - up_left`.
/// Ties go to left, then above, as the specification says.
fn paeth(left: u8, up: u8, up_left: u8) u8 {
    const p = @as(i16, left) + up - up_left;
    const pa = @abs(p - left);
    const pb = @abs(p - up);
    const pc = @abs(p - up_left);
    if (pa <= pb and pa <= pc) return left;
    if (pb <= pc) return up;
    return up_left;
}

Choosing

The writer tries all five on every row and keeps one.

It cannot afford to compress every row five ways, so it uses a guess. A good prediction leaves differences near zero. So it adds up the differences, reading each byte as a signed number from −128 to 127, and keeps the filter with the smallest total.

examples/paint/src/engine/png.zigwrite
/// Write `pixels` as an 8-bit RGB PNG. Alpha is dropped: pass pixels that are
/// already flattened onto the paper.
pub fn write(
    gpa: std.mem.Allocator,
    out: *std.Io.Writer,
    pixels: []const Rgba,
    width: u32,
    height: u32,
) !void {
    const stride = @as(usize, width) * 3;

    // The compressor is about 224 KB, which is more than a small stack can
    // hold, so it goes on the heap with its 64 KB window.
    const compressor = try gpa.create(flate.Compress);
    defer gpa.destroy(compressor);
    const window = try gpa.alloc(u8, flate.max_window_len);
    defer gpa.free(window);

    // Two rows of plain RGB, this one and the one above, and one filtered
    // candidate per filter.
    const rows = try gpa.alloc(u8, stride * 2 + (stride + 1) * 5);
    defer gpa.free(rows);
    var above = rows[0..stride];
    var current = rows[stride .. stride * 2];
    @memset(above, 0); // the row above the first row counts as zeros

    var idat: std.Io.Writer.Allocating = try .initCapacity(gpa, 4096);
    defer idat.deinit();
    compressor.* = try .init(&idat.writer, window, .zlib, .default);

    for (0..height) |y| {
        for (0..width) |x| {
            const p = pixels[y * width + x];
            current[x * 3 ..][0..3].* = .{ p.r, p.g, p.b };
        }

        // Try all five filters and keep the one whose bytes are smallest when
        // read as signed numbers. Small numbers mean values close to their
        // prediction, which is what compresses well. The rule is a guess, but
        // it is the guess libpng makes, and it is cheap.
        var best: []const u8 = undefined;
        var best_score: u64 = std.math.maxInt(u64);
        inline for (all_filters, 0..) |filter, k| {
            const candidate = rows[stride * 2 + k * (stride + 1) ..][0 .. stride + 1];
            candidate[0] = @backingInt(filter);
            var score: u64 = 0;
            for (0..stride) |i| {
                const left: u8 = if (i >= 3) current[i - 3] else 0;
                const up = above[i];
                const up_left: u8 = if (i >= 3) above[i - 3] else 0;
                const predict: u8 = switch (filter) {
                    .none => 0,
                    .sub => left,
                    .up => up,
                    .average => @intCast((@as(u16, left) + up) / 2),
                    .paeth => paeth(left, up, up_left),
                };
                const v = current[i] -% predict;
                candidate[1 + i] = v;
                score += @abs(@as(i8, @bitCast(v)));
            }
            if (score < best_score) {
                best_score = score;
                best = candidate;
            }
        }
        try compressor.writer.writeAll(best);
        std.mem.swap([]u8, &above, &current);
    }
    try compressor.finish();

    try out.writeAll(signature);
    var ihdr: [13]u8 = undefined;
    std.mem.writeInt(u32, ihdr[0..4], width, .big);
    std.mem.writeInt(u32, ihdr[4..8], height, .big);
    ihdr[8..13].* = .{ 8, 2, 0, 0, 0 }; // 8-bit, RGB, zlib, standard filters, no interlace
    try writeChunk(out, "IHDR", &ihdr);
    try writeChunk(out, "IDAT", idat.written());
    try writeChunk(out, "IEND", "");
}

The guess is the one the PNG specification suggests and libpng uses. It is cheap: one pass over the row for each filter.

inline for runs the loop body once for each of the five filters, at compile time. The switch on the filter inside it is then decided by the compiler, so each copy of the body does one kind of prediction with no branch in the inner loop.

Handing the file to the browser

The engine writes the PNG into its own memory. The studio copies the bytes out, wraps them in a Blob, and starts a download:

const length = e.paint_export_png();
const bytes = new Uint8Array(e.memory.buffer, e.paint_png(), length).slice();
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));

The .slice() copies the bytes. Without it, the Blob would point into the engine’s memory, which the next export frees and reuses.

Checks

A picture with flat rows, smooth rows and noise. It must come back exactly, and the writer must have chosen more than one filter for it:

examples/paint/src/engine/png.zigpixels survive the round trip, whatever filters were chosen
test "pixels survive the round trip, whatever filters were chosen" {
    const gpa = testing.allocator;
    const w = 37;
    const h = 23;
    var pixels: [w * h]Rgba = undefined;
    // Smooth areas, flat areas and noise, so that more than one filter wins.
    var r = std.Random.DefaultPrng.init(3);
    for (0..h) |y| for (0..w) |x| {
        const noise = r.random().int(u8);
        pixels[y * w + x] = .{
            .r = @intCast((x * 7) % 256),
            .g = if (y < 10) 40 else noise,
            .b = @intCast((x * y) % 256),
            .a = 255,
        };
    };

    var png: std.Io.Writer.Allocating = .init(gpa);
    defer png.deinit();
    try write(gpa, &png.writer, &pixels, w, h);

    const decoded = try decodeForTest(gpa, png.written(), w, h);
    defer gpa.free(decoded.rgb);
    for (pixels, 0..) |p, i| {
        try testing.expectEqualSlices(u8, &.{ p.r, p.g, p.b }, decoded.rgb[i * 3 ..][0..3]);
    }

    // The picture mixes flat rows, smooth rows and noise, so the per-row
    // choice should not have settled on one filter for everything.
    var kinds: usize = 0;
    for (decoded.used) |u| kinds += @intFromBool(u);
    try testing.expect(kinds >= 2);
}

The tie-breaking rule for Paeth, which the specification fixes. A decoder that broke ties differently would decode a different picture:

examples/paint/src/engine/png.zigpaeth breaks ties in the order the specification gives
test "paeth breaks ties in the order the specification gives" {
    try testing.expectEqual(@as(u8, 10), paeth(10, 10, 10));
    try testing.expectEqual(@as(u8, 20), paeth(10, 20, 10)); // p = 20, closest is up
    try testing.expectEqual(@as(u8, 10), paeth(10, 20, 30)); // p = 0, closest is left
}

The engine’s own reader agreeing with its own writer does not prove much. They could share a mistake. So the site’s browser test presses Save PNG and decodes the file with a separate decoder, written for the site’s tests from the PNG specification, with zlib from Node. Its pixels must match the canvas.

Explain before reading the answer

A row is 640 pixels of one flat colour, directly under a row of another flat colour.

Which filter wins?

Sub or Paeth. After the first pixel, both store zeros: Sub predicts the byte to the left, and Paeth picks the left byte too, because the row above is flat.

They differ only on the first pixel. It has nothing to its left, so Sub stores its colour as it is, while Paeth predicts the pixel above and stores the difference. Whichever of those three bytes is closer to zero decides it.

Up loses. It stores the difference between the two colours on every one of the 1,920 bytes.