Zig 0.17.0

Spreadsheets (xlsx)

spider.xlsx writes and reads Excel .xlsx files: an export people open in Excel, LibreOffice or Google Sheets, and an import of a file they filled in. It is written in Zig, with no other library and no temporary files.

The code on this page is from examples/guide (source): a small server kept as tests, for what the example apps do not use.

Turning it on

It is one of the parts of Spider a project asks for by name, in build.zig, where the Spider dependency is declared:

zig
const std = @import("std");

// Builds nothing to run: `zig build test` is all there is. See README.md.
pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const spider_dep = b.dependency("spider", .{
        .target = target,
        .optimize = optimize,
        // The parts of Spider an app asks for by name.
        .xlsx = true,
        .qrcode = true,
        .r2 = true,
    });
    const spider_mod = spider_dep.module("spider");
    spider_mod.addImport("spider_config", b.createModule(.{
        .root_source_file = b.path("spider.config.zig"),
        .imports = &.{.{ .name = "spider", .module = spider_mod }},
    }));

    const tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("src/tests.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{.{ .name = "spider", .module = spider_mod }},
    }) });
    b.step("test", "Run the examples").dependOn(&b.addRunArtifact(tests).step);

    // Signing in at Keycloak needs a Keycloak: a step of its own, run by
    // login-test.sh, which starts one for the occasion.
    const login_tests = b.addTest(.{ .root_module = b.createModule(.{
        .root_source_file = b.path("login/tests.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{.{ .name = "spider", .module = spider_mod }},
    }) });
    b.step("test-login", "Run the login examples (needs login/docker-compose.yml up)").dependOn(&b.addRunArtifact(login_tests).step);
}

Without .xlsx = true, code that uses spider.xlsx does not compile.

An export

zig
const Order = struct { customer: []const u8, items: i64, total: f64 };

const orders = [_]Order{
    .{ .customer = "Ana Ribeiro", .items = 3, .total = 149.70 },
    .{ .customer = "Bo Larsen", .items = 1, .total = 49.90 },
    .{ .customer = "Cy Okafor", .items = 12, .total = 598.80 },
};

/// The orders as an Excel file the browser saves.
fn exportOrders(c: *spider.Ctx) !spider.Response {
    const book = try xlsx.Workbook.init(c.arena);
    defer book.deinit();

    const sheet = try book.addSheet("Orders");
    try sheet.setColumnWidth(0, 28);

    // Rows and columns count from zero: this is the first row.
    const heading: xlsx.Style = .{ .bold = true, .fill = 0xDDEEFF, .border = .thin };
    try sheet.setRow(0, 0, &.{ .{ .text = "Customer" }, .{ .text = "Items" }, .{ .text = "Total" } }, heading);

    for (orders, 1..) |order, row| {
        const r: u32 = @intCast(row);
        try sheet.set(r, 0, .{ .text = order.customer });
        try sheet.set(r, 1, .int(order.items));
        try sheet.setStyled(r, 2, .{ .number = order.total }, .{ .number_format = .thousands_decimal });
    }

    // The heading stays in view while the rows scroll, with filter buttons.
    try sheet.freeze(1, 0);
    try sheet.setAutoFilter(.{ .first_row = 0, .first_col = 0, .last_row = orders.len, .last_col = 2 });

    return c.download(try book.toOwnedSlice(c.arena), .{
        .filename = "orders.xlsx",
        .content_type = spider.content_types.xlsx,
    });
}
  • A workbook has sheets; a sheet has cells, by row and column, both counted from zero. Cells can be set in any order.
  • A value is .{ .text = ... }, .int(n), .{ .number = ... }, .{ .boolean = ... }, .{ .date = ... }, .{ .datetime = ... }, .{ .formula = "SUM(B2:B4)" } or .blank.
  • A style says how a cell looks: bold, italic, font_color, fill, border, h_align, wrap, and number_format (.integer, .decimal, .thousands, .thousands_decimal, .percent, .date, .time and others).
  • book.toOwnedSlice(allocator) is the file, as bytes. [c.download/docs/uploads sends it with the name the browser saves it under.

The sheet also takes column widths and row heights, merged cells, links, frozen rows, filter buttons, and how it prints (paper, orientation, margins, a heading repeated on every page).

The test reads the file back, the way the next section does:

zig
test "xlsx: the export is a file Excel opens, and reading it back gives the same rows" {
    const app = try spider.testing.start(run);
    var res = try app.get("/orders.xlsx");
    defer res.deinit();
    try res.expectStatus(200);
    try res.expectHeader("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
    try res.expectHeader("Content-Disposition", "attachment; filename=\"orders.xlsx\"");

    const book = try spider.xlsx.Reader.open(std.testing.allocator, res.body, .{});
    defer book.deinit();
    try std.testing.expectEqualStrings("Orders", book.sheets()[0].name);

    const rows = try book.rows(0, .{});
    defer rows.deinit();

    const heading = (try rows.next()).?;
    try std.testing.expectEqualStrings("Customer", heading.cells[0].value.text);

    const first = (try rows.next()).?;
    try std.testing.expectEqual(@as(u32, 1), first.number);
    try std.testing.expectEqualStrings("Ana Ribeiro", first.cells[0].value.text);
    try std.testing.expectEqual(@as(f64, 3), first.cells[1].value.number);
    try std.testing.expectEqual(@as(f64, 149.70), first.cells[2].value.number);
    // A number carries the format it is shown with.
    try std.testing.expectEqualStrings("#,##0.00", first.cells[2].format);
}

An import

A visitor uploads a file (Uploads shows the form), and the handler reads it one row at a time:

zig
/// Reads a spreadsheet someone uploaded: a heading, then one order per row.
fn importOrders(c: *spider.Ctx) !spider.Response {
    const form = try c.parseMultipart();
    const files = form.getFile("sheet") orelse return c.text("Choose a file.", .{ .status = .unprocessable_entity });

    // The default limits are for files from strangers: size, rows, and
    // how far a small file may expand.
    const book = xlsx.Reader.open(c.arena, files[0].data, .{}) catch {
        return c.text("That is not a spreadsheet this app can read.", .{ .status = .unprocessable_entity });
    };
    defer book.deinit();

    const rows = try book.rows(0, .{});
    defer rows.deinit();

    var count: usize = 0;
    var items: f64 = 0;
    while (try rows.next()) |row| {
        if (row.number == 0) continue; // the heading
        var customer: []const u8 = "";
        var quantity: f64 = 0;
        // A row only has the cells that were filled: go by `cell.column`,
        // not by position.
        for (row.cells) |cell| switch (cell.column) {
            0 => switch (cell.value) {
                .text => |text| customer = text,
                else => {},
            },
            1 => switch (cell.value) {
                .number => |n| quantity = n,
                else => {},
            },
            else => {},
        };
        if (customer.len == 0) continue;
        count += 1;
        items += quantity;
    }
    return c.json(.{ .orders = count, .items = items }, .{});
}
zig
/// A form with one file, as a browser sends it.
fn upload(arena: std.mem.Allocator, bytes: []const u8) ![]const u8 {
    return std.mem.concat(arena, u8, &.{
        "--edge\r\nContent-Disposition: form-data; name=\"sheet\"; filename=\"orders.xlsx\"\r\n\r\n",
        bytes,
        "\r\n--edge--\r\n",
    });
}

const multipart = &.{"Content-Type: multipart/form-data; boundary=edge"};

test "xlsx: an uploaded spreadsheet is read row by row" {
    const app = try spider.testing.start(run);
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();

    var exported = try app.get("/orders.xlsx");
    defer exported.deinit();

    var res = try app.request(.{ .method = "POST", .target = "/orders/import", .headers = multipart, .body = try upload(arena_state.allocator(), exported.body) });
    defer res.deinit();
    try res.expectStatus(200);
    try res.expectContains("\"orders\":3");
    try res.expectContains("\"items\":16");
}

What to know about a file someone else made:

  • Rows and cells have gaps. A row or a cell that was never filled is not in the file. Go by row.number and cell.column, never by the position in the loop.
  • A cell is what it is, not what the column should be. The same column often has numbers and numbers typed as text. A value is .text, .number, .boolean, .date, .time, .err or .empty: handle the ones the app accepts and say what is wrong with the rest.
  • Dates are numbers in the file. A cell is a .date when its format shows a date.
  • Formulas are not calculated. A formula cell gives the value the author's program last saved.
  • A row lasts until the next one. The row, its cells and their text are valid until the next rows.next(): copy what is kept.

A file from a stranger

An .xlsx is a zip, and a small one can expand into gigabytes. Reader.open takes limits, and .{} is the set meant for uploads: 16 MiB for the file, 100,000 rows a sheet, and a bound on how far the file may expand. Past a limit, or with a file that is not a spreadsheet, it fails instead of reading on:

zig
test "xlsx: a file that is not a spreadsheet is refused, not read" {
    const app = try spider.testing.start(run);
    var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena_state.deinit();

    var res = try app.request(.{ .method = "POST", .target = "/orders/import", .headers = multipart, .body = try upload(arena_state.allocator(), "name,items\nAna,3\n") });
    defer res.deinit();
    try res.expectStatus(422);
    try res.expectContains("That is not a spreadsheet this app can read.");
}

xlsx.ReadLimits.large is for big files from a source the app knows.