From 2e97aef84272c60a9ddbd1b3e1047849d5c9a64c Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Sun, 15 Feb 2026 23:51:21 +0100 Subject: [PATCH] media: png chunk structs and decoders --- packages/media/src/png.zig | 479 +++++++++++++++++++++++++++++-------- 1 file changed, 379 insertions(+), 100 deletions(-) diff --git a/packages/media/src/png.zig b/packages/media/src/png.zig index 3080604..faebbaf 100644 --- a/packages/media/src/png.zig +++ b/packages/media/src/png.zig @@ -2,7 +2,7 @@ const std = @import("std"); const Format = @import("Format.zig"); -const format: Format = .{ +pub const format: Format = .{ .magic_length = magic.len, // 4B - chunk length // 4B - chunk type @@ -16,15 +16,84 @@ const format: Format = .{ const magic = "\x89PNG\r\n\x1A\n"; -const BitDepth = enum(u8) { +// --- IHDR -------------------------------------------------------------------- + +pub const Header = struct { + width: u32, + height: u32, + bit_depth: BitDepth, + color_type: ColorType, + compression_method: CompressionMethod, + filter_method: FilterMethod, + interlace_method: InterlaceMethod, + + pub fn decode(chunk: Chunk) Header { + std.debug.assert(chunk.chunk_type == .IHDR); + + if (chunk.data.len != 13) return error.InvalidPng; + const width = std.mem.readInt(u32, chunk.data[0..4], .big); + const height = std.mem.readInt(u32, chunk.data[4..8], .big); + const bit_depth = chunk.data[8]; + const color_type = chunk.data[9]; + const compression_method = chunk.data[10]; + const filter_method = chunk.data[11]; + const interlace_method = chunk.data[12]; + + if (width == 0 or width > 0x7FFF_FFFF or + height == 0 or height > 0x7FFF_FFFF or + bit_depth == 0 or bit_depth > 16 or !std.math.isPowerOfTwo(bit_depth) or + color_type == 1 or color_type == 5 or color_type > 6 or + compression_method != 0 or + filter_method != 0 or + interlace_method > 1) + { + return error.InvalidPng; + } + + switch (color_type) { + 0 => { + // all bit depths allowed + }, + 2 => { + if (bit_depth < 8) return error.InvalidPng; + }, + 3 => { + if (bit_depth > 8) return error.InvalidPng; + }, + 4 => { + if (bit_depth < 8) return error.InvalidPng; + }, + 6 => { + if (bit_depth < 8) return error.InvalidPng; + }, + else => unreachable, + } + + return .{ + .width = width, + .height = height, + .bit_depth = @enumFromInt(bit_depth), + .color_type = @enumFromInt(color_type), + .compression_method = @enumFromInt(compression_method), + .filter_method = @enumFromInt(filter_method), + .interlace_method = @enumFromInt(interlace_method), + }; + } +}; + +pub const BitDepth = enum(u8) { @"1" = 1, @"2" = 2, @"4" = 4, @"8" = 8, @"16" = 16, + + pub fn range(self: BitDepth) usize { + return @as(usize, 2) << @intFromEnum(@intFromEnum(self)); + } }; -const ColorType = enum(u8) { +pub const ColorType = enum(u8) { grayscale = 0, rgb = 2, palette = 3, @@ -44,30 +113,279 @@ const ColorType = enum(u8) { } }; -const CompressionMethod = enum(u8) { +pub const CompressionMethod = enum(u8) { flate = 0, }; -const FilterMethod = enum(u8) { +pub const FilterMethod = enum(u8) { adaptive = 0, }; -const InterlaceMethod = enum(u8) { +pub const InterlaceMethod = enum(u8) { none = 0, adam7 = 1, }; -const Header = struct { - width: u32, - height: u32, - bit_depth: BitDepth, - color_type: ColorType, - compression_method: CompressionMethod, - filter_method: FilterMethod, - interlace_method: InterlaceMethod, +// --- PLTE -------------------------------------------------------------------- + +pub const Palette = struct { + len: u16, + entries: [256][3]u8, + + pub fn asSlice(self: *const Palette) []const [3]u8 { + return self.entries[0..self.len]; + } + + pub fn decode(chunk: Chunk, ctx: Header) !Palette { + std.debug.assert(chunk.chunk_type == .PLTE); + + const len = std.math.divExact(usize, chunk.data.len, 3) catch return error.InvalidPng; + + switch (ctx.color_type) { + .grayscale => return error.InvalidPng, + .rgb => if (len < 1 or len > 256) return error.InvalidPng, + .palette => if (len < 1 or len > ctx.bit_depth.range()) return error.InvalidPng, + .grayscale_alpha => return error.InvalidPng, + .rgba => if (len < 1 or len > 256) return error.InvalidPng, + } + + var entries: [256][3]u8 = undefined; + @memcpy(entries[0..len], @as([]const [3]u8, @ptrCast(chunk.data))); + return .{ + .len = @intCast(len), + .entries = entries, + }; + } }; -const ChunkType = enum(u32) { +// --- tRNS -------------------------------------------------------------------- + +pub const Transparency = union(enum) { + grayscale: u16, + rgb: [3]u16, + palette: TransparencyPalette, + + pub fn initGrayscale(grayscale: u16) Transparency { + return .{ .grayscale = grayscale }; + } + + pub fn initRgb(rgb: [3]u16) Transparency { + return .{ .rgb = rgb }; + } + + pub fn initPalette(palette: TransparencyPalette) Transparency { + return .{ .palette = palette }; + } + + pub fn decode(chunk: Chunk, header: Header, maybe_palette: ?Palette) !Transparency { + std.debug.assert(chunk.chunk_type == .tRNS); + + return switch (header.color_type) { + .grayscale => { + if (chunk.data.len != 2) return error.InvalidPng; + const grayscale = std.mem.readInt(u16, chunk.data[0..2], .big); + return .initGrayscale(grayscale); + }, + .rgb => { + if (chunk.data.len != 6) return error.InvalidPng; + const r = std.mem.readInt(u16, chunk.data[0..2], .big); + const g = std.mem.readInt(u16, chunk.data[2..4], .big); + const b = std.mem.readInt(u16, chunk.data[4..6], .big); + return .initRgb(.{ r, g, b }); + }, + .palette => if (maybe_palette) |palette| { + const len = chunk.data.len; + if (len < 1 or len > palette.len) return error.InvalidPng; + var entries: [256]u8 = undefined; + @memcpy(entries[0..len], chunk.data[0..len]); + return .initPalette(.{ + .len = @intCast(len), + .entries = entries, + }); + } else { + return error.InvalidPng; + }, + .grayscale_alpha => error.InvalidPng, + .rgba => error.InvalidPng, + }; + } +}; + +const TransparencyPalette = struct { + len: u16, + entries: [256]u8, + + pub fn asSlice(self: *const TransparencyPalette) []const u8 { + return self.entries[0..self.len]; + } +}; + +// --- gAMA -------------------------------------------------------------------- + +const Gamma = struct { + /// Gamma times `scale`, i.e. 100000, For example, a gamma of 1/2.2 would be + /// stored as 45455. + gamma: u32, + + const scale = 100000; + + pub fn initExponent(exponent: f32) Gamma { + const gamma: u32 = @intFromFloat(@round(exponent * scale)); + return .{ .gamma = gamma }; + } + + pub fn asExponent(self: Gamma) f32 { + return @as(f32, @floatFromInt(self.gamma)) / scale; + } + + pub fn decode(chunk: Chunk) Gamma { + std.debug.assert(chunk.chunk_type == .gAMA); + + if (chunk.data.len != 4) return error.InvalidPng; + const gamma = std.mem.readInt(u32, chunk.data[0..4], .big); + return .{ .gamma = gamma }; + } +}; + +// --- cHRM -------------------------------------------------------------------- + +const Chromaticities = struct { + white_point_x: u32, + white_point_y: u32, + red_x: u32, + red_y: u32, + green_x: u32, + green_y: u32, + blue_x: u32, + blue_y: u32, + + pub fn decode(chunk: Chunk) !Chunk { + std.debug.assert(chunk.chunk_type == .cHRM); + + if (chunk.data.len != 32) return error.InvalidPng; + return .{ + .white_point_x = std.mem.readInt(u32, chunk.data[0..4], .big), + .white_point_y = std.mem.readInt(u32, chunk.data[4..8], .big), + .red_x = std.mem.readInt(u32, chunk.data[8..12], .big), + .red_y = std.mem.readInt(u32, chunk.data[12..16], .big), + .green_x = std.mem.readInt(u32, chunk.data[16..20], .big), + .green_y = std.mem.readInt(u32, chunk.data[20..24], .big), + .blue_x = std.mem.readInt(u32, chunk.data[24..28], .big), + .blue_y = std.mem.readInt(u32, chunk.data[28..32], .big), + }; + } +}; + +// --- sRGB -------------------------------------------------------------------- + +const RenderingIntent = enum(u8) { + /// Perceptual intent is for images preferring good adaptation to the output + /// device gamut at the expense of colorimetric accuracy, like photographs. + perceptual = 0, + /// Relative colorimetric intent is for images requiring color appearance + /// matching (relative to the output device white point), like logos. + relative_colorimetric = 1, + /// Saturation intent is for images preferring preservation of saturation at + /// the expense of hue and lightness, like charts and graphs. + saturation = 2, + /// Absolute colorimetric intent is for images requiring preservation of + /// absolute colorimetry, like proofs (previews of images destined for a + /// different output device). + absolute_colorimetric = 3, + + pub fn decode(chunk: Chunk) RenderingIntent { + std.debug.assert(chunk.chunk_type == .sRGB); + + if (chunk.data.len != 1) return error.InvalidPng; + const rendering_intent = chunk.data[0]; + if (rendering_intent > 3) return error.InvalidPng; + return @enumFromInt(rendering_intent); + } +}; + +// --- pHYs -------------------------------------------------------------------- + +pub const PhysicalPixelDimensions = struct { + pixels_per_unit_x: u32, + pixels_per_unit_y: u32, + unit: PhysicalUnit, + + pub fn decode(chunk: Chunk) PhysicalPixelDimensions { + std.debug.assert(chunk.chunk_type == .pHYs); + + if (chunk.data.len != 9) return error.InvalidPng; + const pixels_per_unit_x = std.mem.readInt(u32, chunk.data[0..4], .big); + const pixels_per_unit_y = std.mem.readInt(u32, chunk.data[4..8], .big); + const unit = chunk.data[8]; + + if (pixels_per_unit_x == 0 or pixels_per_unit_y == 0 or unit > 1) { + return error.InvalidPng; + } + + return .{ + .pixels_per_unit_x = pixels_per_unit_x, + .pixels_per_unit_y = pixels_per_unit_y, + .unit = @enumFromInt(unit), + }; + } +}; + +pub const PhysicalUnit = enum(u8) { + unknown = 0, + meter = 1, +}; + +// --- tIME -------------------------------------------------------------------- + +pub const LastModificationTime = struct { + year: u16, + /// 1 - 12 + month: u8, + /// 1 - 31 + day: u8, + /// 0 - 23 + hour: u8, + /// 0 - 59 + minute: u8, + /// 0 - 60 (may include leap seconds) + second: u8, + + pub fn decode(chunk: Chunk) !LastModificationTime { + std.debug.assert(chunk.chunk_type == .tIME); + + if (chunk.data.len == 7) return error.InvalidPng; + const year = std.mem.readInt(u16, chunk.data[0..2], .big); + const month = chunk.data[2]; + const day = chunk.data[3]; + const hour = chunk.data[4]; + const minute = chunk.data[5]; + const second = chunk.data[6]; + + // NOTE This implementation is intentionally trying not to be too clever + // with validation. + if (month < 1 or month > 12 or + day < 1 or day > 31 or + hour > 23 or + minute > 59 or + second > 60) + { + return error.InvalidPng; + } + + return .{ + .year = year, + .month = month, + .day = day, + .hour = hour, + .minute = minute, + .second = second, + }; + } +}; + +// ----------------------------------------------------------------------------- + +pub const ChunkType = enum(u32) { IHDR = @bitCast("IHDR".*), PLTE = @bitCast("PLTE".*), IDAT = @bitCast("IDAT".*), @@ -101,12 +419,12 @@ const ChunkType = enum(u32) { } }; -const Chunk = struct { +pub const Chunk = struct { chunk_type: ChunkType, data: []const u8, }; -const StandardKeyword = enum { +pub const StandardKeyword = enum { /// Short (one line) title or caption for image Title, /// Name of image’s creator @@ -159,68 +477,54 @@ pub fn info(buffer: []const u8) ?Header { return null; } - const chunk = buffer[format.magic_length..format.info_length]; - - const length = std.mem.readInt(u32, chunk[0..4], .big); - const chunk_type: ChunkType = @bitCast(chunk[4..8].*); - - if (length != 13 or chunk_type != .IHDR) { + const chunk, _ = decodeChunk(buffer[format.magic_length..]) catch return null orelse return null; + if (chunk.chunk_type != .IHDR) { return null; } - const data = chunk[8..21]; - const crc = std.mem.readInt(u32, chunk[21..25], .big); + const header = Header.decode(chunk) catch return null; + return header; +} + +pub fn decodeChunk(buffer: []const u8) !?struct { Chunk, []const u8 } { + var rest: []const u8 = buffer; + + if (rest.len < 8) { + // Not enough data + return null; + } + + const length = std.mem.readInt(u32, rest[0..4], .big); + const chunk_type: ChunkType = @bitCast(rest[4..8].*); + rest = rest[8..]; + + if (length > 0x7FFF_FFFF) { + return error.InvalidPng; + } + + if (rest.len < length) { + // Not enough data + return null; + } + + const data = rest[0..length]; + rest = rest[length..]; + + if (rest.len < 4) { + // Not enough data + return null; + } + + const crc = std.mem.readInt(u32, rest[0..4], .big); + rest = rest[4..]; if (std.hash.crc.Crc32IsoHdlc.hash(data) != crc) { - return null; - } - - const width = std.mem.readInt(u32, data[0..4], .big); - const height = std.mem.readInt(u32, data[4..8], .big); - const bit_depth = data[8]; - const color_type = data[9]; - const compression_method = data[10]; - const filter_method = data[11]; - const interlace_method = data[12]; - - if (width == 0 or width > 0x7FFF_FFFF or - height == 0 or height > 0x7FFF_FFFF or - bit_depth == 0 or bit_depth > 16 or !std.math.isPowerOfTwo(bit_depth) or - color_type == 1 or color_type == 5 or color_type > 6 or - compression_method != 0 or - filter_method != 0 or - interlace_method > 1) - { - return null; - } - - switch (color_type) { - 0 => { - // all bit depths allowed - }, - 2 => { - if (bit_depth < 8) return null; - }, - 3 => { - if (bit_depth > 8) return null; - }, - 4 => { - if (bit_depth < 8) return null; - }, - 6 => { - if (bit_depth < 8) return null; - }, - else => unreachable, + return error.InvalidPng; } return .{ - .width = width, - .height = height, - .bit_depth = @enumFromInt(bit_depth), - .color_type = @enumFromInt(color_type), - .compression_method = @enumFromInt(compression_method), - .filter_method = @enumFromInt(filter_method), - .interlace_method = @enumFromInt(interlace_method), + .{ .chunk_type = chunk_type, .data = data }, + rest, }; } @@ -233,41 +537,16 @@ pub fn decodeChunks(buffer: []const u8, chunks: ?[]Chunk) !usize { var rest: []const u8 = buffer[format.magic_length..]; while (rest.len > 0) : (index += 1) { - if (rest.len < 8) { - return error.InvalidPng; - } - - const length = std.mem.readInt(u32, rest[0..4], .big); - const chunk_type: ChunkType = @bitCast(rest[4..8].*); - rest = rest[8..]; - - if (rest.len < length or length > 0x7FFF_FFFF) { - return error.InvalidPng; - } - - const data = rest[0..length]; - rest = rest[length..]; - - if (rest.len < 4) { - return error.InvalidPng; - } - - const crc = std.mem.readInt(u32, rest[0..4], .big); - rest = rest[4..]; - - if (std.hash.crc.Crc32IsoHdlc.hash(data) != crc) { - return error.InvalidPng; - } + const chunk, rest = try decodeChunk(rest) orelse return error.InvalidPng; if (chunks) |ck| { if (index < ck.len) { - ck[index] = .{ - .chunk_type = chunk_type, - .data = data, - }; + ck[index] = chunk; } } } + + return index; } pub fn decodeChunksAlloc(buffer: []const u8, allocator: std.mem.Allocator) ![]Chunk {