⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.252+86dc5ce25What's newOn an older Zig?Blog

Projects and Presets

Updated

A PNG is for sharing a finished picture. It is a bad way to keep working on one.

A PNG is flat. When it is saved, the layers are merged into one image. Opened again, nothing can say which paint was on which layer, so the eraser can no longer reveal the bottom layer. Two layers plus the size of the page can rebuild the picture you were working on. One merged PNG cannot.

So the studio has two more kinds of file:

  • Save project keeps every layer, and which ones are visible.
  • Save brush keeps a brush’s settings as a preset: a name and the numbers that make the brush behave the way it does. A preset holds no pixels.

The project format

Before any code, the format is written down, field by field, at the top of the file that implements it:

header, 16 bytes
  0  magic      4 bytes  "ZPNT"
  4  version    u16      1
  6  width      u32
 10  height     u32
 14  layers     u8       1 to 8
 15  active     u8       less than layers

each layer
  0  visible    u8       0 or 1
  1  size       u32      bytes of compressed pixels that follow
  5  pixels     size bytes, zlib: width * height premultiplied RGBA

The Binary Files chapter builds a format the same way. The magic bytes say what kind of file this is. The version says which edition of the format, so a later program can change the format and still recognise an old file.

Each layer’s pixels are compressed with zlib, the same compressor the PNG writer uses. An empty layer of 640 by 400 is 1,024,000 bytes of zeros, and compresses to about a kilobyte.

examples/paint/src/engine/project.zigsave
pub fn save(gpa: std.mem.Allocator, doc: *const Document, out: *std.Io.Writer) !void {
    try out.writeAll(magic);
    try out.writeInt(u16, version, .little);
    try out.writeInt(u32, doc.width, .little);
    try out.writeInt(u32, doc.height, .little);
    try out.writeInt(u8, @intCast(doc.layers.len), .little);
    try out.writeInt(u8, @intCast(doc.active_layer), .little);

    const window = try gpa.alloc(u8, flate.max_window_len);
    defer gpa.free(window);
    const compressor = try gpa.create(flate.Compress);
    defer gpa.destroy(compressor);

    for (doc.layers, doc.visible) |layer, visible| {
        var packed_layer: std.Io.Writer.Allocating = try .initCapacity(gpa, 4096);
        defer packed_layer.deinit();
        compressor.* = try .init(&packed_layer.writer, window, .zlib, .default);
        try compressor.writer.writeAll(std.mem.sliceAsBytes(layer));
        try compressor.finish();

        try out.writeInt(u8, @intFromBool(visible), .little);
        try out.writeInt(u32, @intCast(packed_layer.written().len), .little);
        try out.writeAll(packed_layer.written());
    }
}

Opening is reading someone else’s bytes

Saving is easy, because our program wrote the data and trusts it.

Opening is different. The file might be from an older version, cut short by a failed download, damaged on a disk, or not a project at all. Opening reads input from outside the program, so every field is checked before it is used.

examples/paint/src/engine/project.zigOpenError
pub const OpenError = error{
    /// The first four bytes are not "ZPNT".
    NotAProject,
    /// Written by a later version of this program.
    NewerVersion,
    /// Width or height is 0 or larger than `max_side`.
    BadSize,
    /// No layers, too many, or an active layer that does not exist.
    BadLayers,
    /// The file ends before everything it promised.
    Truncated,
    /// The compressed pixels do not decompress to exactly one layer.
    BadPixels,
    /// A pixel's colour is larger than its alpha, which premultiplied
    /// pixels can never be.
    NotPremultiplied,
    /// Bytes after the last layer.
    TrailingBytes,
    OutOfMemory,
};

Each way a file can be wrong has its own error. The studio turns each one into a sentence a person can act on, and leaves the picture on screen untouched.

Build the new document first

A failed open must not damage the work on screen.

So open builds a complete new document, separate from the one that is open. Only if every layer decodes and every check passes does the studio swap the new one in. If anything fails, errdefer frees the half-built document and the open picture was never touched.

examples/paint/src/engine/project.zigopen
/// Build a new document from a project file. On any error nothing is
/// returned and nothing is changed.
pub fn open(gpa: std.mem.Allocator, bytes: []const u8, history_budget: usize) OpenError!Document {
    var in: Cursor = .{ .bytes = bytes };

    if (!std.mem.eql(u8, try in.take(4), magic)) return error.NotAProject;
    const file_version = try in.int(u16);
    if (file_version > version) return error.NewerVersion;
    if (file_version != version) return error.NotAProject;

    const width = try in.int(u32);
    const height = try in.int(u32);
    if (width == 0 or height == 0 or width > max_side or height > max_side) return error.BadSize;
    const layer_count = try in.int(u8);
    const active = try in.int(u8);
    if (layer_count == 0 or layer_count > max_layers or active >= layer_count) return error.BadLayers;

    // Walk the layer table once before allocating anything. Eight layers of
    // 4096 by 4096 is 512 MB, and a 16-byte header can ask for that. A file
    // whose layers are not all there, or that has bytes after them, fails
    // here, cheaply, before the big allocation is made.
    {
        var scan = in;
        for (0..layer_count) |_| {
            _ = try scan.int(u8);
            _ = try scan.take(try scan.int(u32));
        }
        if (scan.at != bytes.len) return error.TrailingBytes;
    }

    var doc: Document = try .init(gpa, width, height, layer_count, history_budget);
    errdefer doc.deinit();
    doc.active_layer = active;

    const window = try gpa.alloc(u8, flate.max_window_len);
    defer gpa.free(window);

    for (doc.layers, doc.visible) |layer, *visible| {
        const flag = try in.int(u8);
        if (flag > 1) return error.BadLayers;
        visible.* = flag == 1;

        const size = try in.int(u32);
        const compressed = try in.take(size);

        // Decompress into the layer itself, then ask for one more byte. A
        // layer that is short fails the first read; one that is long has a
        // byte left over for the second.
        var reader: std.Io.Reader = .fixed(compressed);
        var inflate: flate.Decompress = .init(&reader, .zlib, window);
        inflate.reader.readSliceAll(std.mem.sliceAsBytes(layer)) catch return error.BadPixels;
        var extra: [1]u8 = undefined;
        if (inflate.reader.readSliceShort(&extra)) |n| {
            if (n != 0) return error.BadPixels;
        } else |_| return error.BadPixels;

        for (layer) |p| {
            if (p.r > p.a or p.g > p.a or p.b > p.a) return error.NotPremultiplied;
        }
    }

    doc.stale = .{ .x0 = 0, .y0 = 0, .x1 = width, .y1 = height };
    return doc;
}

A small file can ask for a lot of memory

The header says how big the picture is, and the document is allocated from that.

Eight layers of 4096 by 4096 pixels is 512 MB. A file only 16 bytes long can ask for that, with nothing behind its header.

So before allocating anything, open walks the layer table once. It reads each layer’s size and checks that the bytes are really there, and that nothing follows the last layer. A file that lies about its contents fails here, before the big allocation.

A test proves it with an allocator that refuses every request. If open asked for memory before checking, the test would see OutOfMemory instead of Truncated.

Checks inside the pixels

Two more checks look inside the compressed pixels.

A layer must decompress to exactly width × height × 4 bytes. open reads that many, then asks for one more. A layer that is short fails the first read. One that is long has a byte left for the second.

And every pixel must be premultiplied, as the Layers chapter explains: no colour channel larger than the alpha. The engine never writes such a pixel, so finding one means the file is damaged or was not written by this program.

Brush presets

A preset is small, and a person may want to read or edit one. So presets are JSON:

{
  "version": 1,
  "name": "Fine sketch",
  "brush": {
    "radius": 4,
    "flow": 0.7,
    "color": "#26334d"
  }
}

Fields the file leaves out take the default brush’s values. Fields this program does not know are skipped, so a preset from a later version still opens if the fields it knows are valid.

0.2, not 0.20000000298023224

The first preset this code wrote said "aspect": 0.20000000298023224.

The brush stores its settings as f32. std.json writes an f32 by first widening it to f64, and the f32 nearest to 0.2 is not exactly 0.2. Widened, the difference shows.

The file was correct, and it read back to the same brush. But a preset is meant to be read and edited by a person, and that number is not what anyone typed.

So write turns each setting into the shortest decimal that reads back as the same f32, which is what Zig’s {d} format prints, and stores that as an f64. JSON then prints 0.2:

examples/paint/src/engine/preset.zigdecimal
/// The shortest decimal that reads back as `x`, as an `f64`: 0.2 for the
/// `f32` nearest to 0.2, where widening would give 0.20000000298023224.
fn decimal(x: f32) f64 {
    var buf: [64]u8 = undefined;
    const text = std.mem.print(&buf, "{d}", .{x}) catch unreachable;
    return std.fmt.parseFloat(f64, text) catch unreachable;
}

Reading the file narrows 0.2 back to an f32, and gets the same bits the brush had. The round-trip test checks that.

Refuse, do not repair

The brush already has a function that moves every setting into range: sanitized, in brush.zig. Every stroke runs its brush through it, so a slider can never hand the engine a value it cannot draw with.

A preset file must not rely on it. A file with a radius of −3 is a damaged file. Quietly turning −3 into 0.5 would hide the damage and give the person a brush they never made.

But sanitized is still useful here, as the definition of a valid brush. A brush is valid exactly when sanitizing it changes nothing:

examples/paint/src/engine/preset.zigparse
/// Read a preset. The name in the result points into `name_buf`.
pub fn parse(gpa: std.mem.Allocator, json: []const u8, name_buf: *[max_name]u8) ParseError!Preset {
    const parsed = std.json.parseFromSlice(File, gpa, json, .{ .ignore_unknown_fields = true }) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return error.NotAPreset,
    };
    defer parsed.deinit();
    const file = parsed.value;

    if (file.version > version) return error.NewerVersion;
    if (file.version != version) return error.NotAPreset;

    if (file.name.len == 0 or file.name.len > max_name) return error.BadName;
    for (file.name) |c| if (c < 0x20 or c == 0x7f) return error.BadName;

    const s = file.brush;
    const brush: Brush = .{
        .radius = @floatCast(s.radius),
        .hardness = @floatCast(s.hardness),
        .flow = @floatCast(s.flow),
        .opacity = @floatCast(s.opacity),
        .spacing = @floatCast(s.spacing),
        .follow = @floatCast(s.follow),
        .pressure = @floatCast(s.pressure),
        .jitter = @floatCast(s.jitter),
        .scatter = @floatCast(s.scatter),
        .aspect = @floatCast(s.aspect),
        .angle = @floatCast(s.angle),
        .follow_direction = s.follow_direction,
        .contact = @floatCast(s.contact),
        .bristles = s.bristles,
        .pickup = @floatCast(s.pickup),
        .drain = @floatCast(s.drain),
        .color = try parseColor(s.color),
    };
    // Valid means the brush is already one the engine would draw with
    // unchanged. Anything `sanitized` would have to move is out of range,
    // and NaN never equals itself, so it fails here too.
    if (!std.meta.eql(brush.sanitized(), brush)) return error.OutOfRange;

    @memcpy(name_buf[0..file.name.len], file.name);
    return .{ .name = name_buf[0..file.name.len], .brush = brush };
}

NaN never equals itself, so a NaN setting fails the same comparison. No list of ranges is repeated, so the preset check and the sliders cannot disagree.

What JSON costs

The browser build was 49 KB before files. It is 146 KB after.

Taking out one function at a time and building again shows where the 97 KB went:

partadds
reading a preset with std.json67 KB
opening a project (zlib decompression)15 KB
writing presets and projects, and the rest15 KB

std.json is general: it can parse any JSON into any Zig type, which is what makes parseFromSlice(File, ...) one line. That generality is most of the cost. A hand-written parser for exactly this one shape would be smaller, and would be a second piece of code to keep correct.

The page loads the engine only when you press Start, so the cost is paid once, by people who use the studio. For a program that loaded on every page, it would be worth writing the small parser.

Checks

A saved project opens with the same layers, visibility and active layer:

examples/paint/src/engine/project.ziga saved project opens with the same layers, visibility and active layer
test "a saved project opens with the same layers, visibility and active layer" {
    const gpa = testing.allocator;
    var doc: Document = try .init(gpa, 48, 32, 2, 1 << 20);
    defer doc.deinit();
    doc.beginStroke(.{ .radius = 6, .color = .{ 200, 40, 40 } }, .brush, .{ .x = 4, .y = 4 });
    doc.moveStroke(.{ .x = 44, .y = 28 });
    doc.endStroke();
    _ = doc.setActiveLayer(1);
    _ = try doc.fillAt(40, 2, .{ 30, 90, 200 });
    doc.setVisible(0, false);

    var out: std.Io.Writer.Allocating = .init(gpa);
    defer out.deinit();
    try save(gpa, &doc, &out.writer);

    var back = try open(gpa, out.written(), 1 << 20);
    defer back.deinit();
    try testing.expectEqual(doc.width, back.width);
    try testing.expectEqual(@as(u32, 1), back.active_layer);
    try testing.expectEqualSlices(bool, doc.visible, back.visible);
    for (doc.layers, back.layers) |a, b| try testing.expectEqualSlices(Rgba, a, b);
}

Each kind of damage gives its own error:

examples/paint/src/engine/project.zigeach kind of damage is rejected with its own error
test "each kind of damage is rejected with its own error" {
    const gpa = testing.allocator;
    const good = try sample(gpa);
    defer gpa.free(good);
    const bad = try gpa.dupe(u8, good);
    defer gpa.free(bad);

    const Case = struct { offset: usize, value: u8, want: OpenError };
    const cases = [_]Case{
        .{ .offset = 0, .value = 'X', .want = error.NotAProject },
        .{ .offset = 4, .value = 2, .want = error.NewerVersion },
        .{ .offset = 6, .value = 0, .want = error.BadSize }, // width 48 -> 0
        .{ .offset = 14, .value = 9, .want = error.BadLayers }, // 9 layers
        .{ .offset = 15, .value = 2, .want = error.BadLayers }, // active 2 of 2
        .{ .offset = 16, .value = 7, .want = error.BadLayers }, // visible = 7
    };
    for (cases) |c| {
        @memcpy(bad, good);
        bad[c.offset] = c.value;
        try testing.expectError(c.want, open(gpa, bad, 1 << 20));
    }
    try testing.expectError(error.Truncated, open(gpa, good[0 .. good.len - 1], 1 << 20));

    const longer = try std.mem.concat(gpa, u8, &.{ good, "!" });
    defer gpa.free(longer);
    try testing.expectError(error.TrailingBytes, open(gpa, longer, 1 << 20));
}

The file cut at every possible length, and every byte flipped in turn. A cut file must never open. A flipped byte may still open, if it lands somewhere that decompresses to valid pixels, but nothing may crash, and the testing allocator fails the test if anything leaks:

examples/paint/src/engine/project.zigevery truncation and every flipped byte is rejected or opens cleanly, and nothing leaks
test "every truncation and every flipped byte is rejected or opens cleanly, and nothing leaks" {
    const gpa = testing.allocator;
    const good = try sample(gpa);
    defer gpa.free(good);

    // Cut the file at every length.
    for (0..good.len) |n| {
        if (open(gpa, good[0..n], 1 << 20)) |doc| {
            var d = doc;
            d.deinit();
            return error.TestUnexpectedResult; // a cut file must never open
        } else |_| {}
    }

    // Flip each byte in turn. Some flips land in compressed data that still
    // decodes to valid premultiplied pixels, so a flip may open. It must
    // never crash, and the testing allocator checks it never leaks.
    const bad = try gpa.dupe(u8, good);
    defer gpa.free(bad);
    for (0..good.len) |i| {
        @memcpy(bad, good);
        bad[i] ^= 0xff;
        if (open(gpa, bad, 1 << 20)) |doc| {
            var d = doc;
            d.deinit();
        } else |_| {}
    }
}

The 512 MB header:

examples/paint/src/engine/project.ziga header asking for 512 MB with no data behind it allocates nothing
test "a header asking for 512 MB with no data behind it allocates nothing" {
    var header: [16]u8 = undefined;
    @memcpy(header[0..4], magic);
    std.mem.writeInt(u16, header[4..6], version, .little);
    std.mem.writeInt(u32, header[6..10], max_side, .little);
    std.mem.writeInt(u32, header[10..14], max_side, .little);
    header[14] = max_layers;
    header[15] = 0;
    // A failing allocator that refuses everything: if `open` tried to
    // allocate before checking, this would report OutOfMemory instead.
    var none: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = 0 });
    try testing.expectError(error.Truncated, open(none.allocator(), &header, 0));
}

There is also a fuzz test. zig build test --fuzz runs it with inputs the fuzzer generates and mutates, looking for any byte sequence that crashes open. An ordinary test run gives it only the valid file it starts from.

Presets written the way a person would type them:

examples/paint/src/engine/preset.zigsettings are written as typed: 0.2, not 0.20000000298023224
test "settings are written as typed: 0.2, not 0.20000000298023224" {
    var out: std.Io.Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    try write(&out.writer, "Nib", .{ .aspect = 0.2, .hardness = 0.95 });
    try testing.expect(std.mem.find(u8, out.written(), "\"aspect\": 0.2,") != null);
    try testing.expect(std.mem.find(u8, out.written(), "\"hardness\": 0.95,") != null);
}

And presets, refused for each reason:

examples/paint/src/engine/preset.ziga bad preset is refused with a reason, never repaired
test "a bad preset is refused with a reason, never repaired" {
    const Case = struct { json: []const u8, want: ParseError };
    const cases = [_]Case{
        .{ .json = "not json", .want = error.NotAPreset },
        .{ .json = "{\"version\":2,\"name\":\"x\",\"brush\":{}}", .want = error.NewerVersion },
        .{ .json = "{\"version\":1,\"name\":\"\",\"brush\":{}}", .want = error.BadName },
        .{ .json = "{\"version\":1,\"name\":\"x\",\"brush\":{\"color\":\"red\"}}", .want = error.BadColor },
        .{ .json = "{\"version\":1,\"name\":\"x\",\"brush\":{\"radius\":-3}}", .want = error.OutOfRange },
        .{ .json = "{\"version\":1,\"name\":\"x\",\"brush\":{\"flow\":7}}", .want = error.OutOfRange },
        .{ .json = "{\"version\":1,\"name\":\"x\",\"brush\":{\"bristles\":99}}", .want = error.OutOfRange },
    };
    var name: [max_name]u8 = undefined;
    for (cases) |c| try testing.expectError(c.want, parse(testing.allocator, c.json, &name));
}

Explain before reading the answer

A project file from version 2 of this program arrives. Version 2 added a field to the header.

Should version 1 try to open it, skipping the field it does not know?

No. Version 1 cannot know where the new field is or how long it is, so it cannot know where the layers start. It refuses with NewerVersion, which tells the person exactly what to do: update the program. A preset is different, because JSON names every field, so an unknown one can be skipped safely.