diff --git a/build.zig b/build.zig index 7397a5b..c6e82d5 100644 --- a/build.zig +++ b/build.zig @@ -80,4 +80,18 @@ pub fn build(b: *std.Build) void { }) }); test_step.dependOn(&b.addRunArtifact(t).step); } + // The .pixi archive reader, with the vendored zip it reads through. + { + const module = b.createModule(.{ + .root_source_file = b.path("src/fizzy_zip.zig"), + .target = b.graph.host, + .optimize = optimize, + .link_libc = true, + }); + module.addImport("zip", b.createModule(.{ .root_source_file = b.path("src/deps/zip/zip.zig") })); + module.addIncludePath(b.path("src/deps/zip/src")); + module.addCSourceFile(.{ .file = b.path("src/deps/zip/src/zip.c"), .flags = &.{"-fno-sanitize=undefined"} }); + module.addAnonymousImport("real.pixi", .{ .root_source_file = b.path("assets/src/misc.pixi") }); + test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = module })).step); + } } diff --git a/src/fizzy_zip.zig b/src/fizzy_zip.zig new file mode 100644 index 0000000..6cd2760 --- /dev/null +++ b/src/fizzy_zip.zig @@ -0,0 +1,314 @@ +//! Reading a `.pixi` (`.fiz`) archive: a zip holding `fizzydata.json` (or the legacy +//! `pixidata.json`) beside one `.layer` (raw PMA pixels) or `.png` per layer. +//! +//! Every way an archive can be wrong — not a zip, truncated, no data entry, data that is not any +//! version of the format, a grid or animation that does not add up — is `error.FileLoadError`, +//! never a crash: fizzy hands pixi whatever bytes it has (a restart gives back what another +//! editor held), so nothing here may trust them. +//! +//! std-only (plus the vendored zip), so it tests with `zig build test`. +const std = @import("std"); +const builtin = @import("builtin"); +const zip = @import("zip"); + +const File = @import("File.zig"); +const Animation = @import("Animation.zig"); + +pub const Error = error{FileLoadError} || std.mem.Allocator.Error; + +/// The most pixels a canvas may have (16384 × 16384): more is a corrupt grid, not a sprite sheet. +pub const max_pixels: u64 = 1 << 28; + +/// An open archive, read-only. +pub const Archive = struct { + handle: *zip.struct_zip_t, + from_bytes: bool, + + /// Opens an archive held in memory. `bytes` must outlive the archive. + pub fn openBytes(bytes: []const u8) Error!Archive { + if (bytes.len == 0) return error.FileLoadError; + const handle = zip.zip_stream_open(bytes.ptr, bytes.len, 0, 'r') orelse return error.FileLoadError; + return .{ .handle = handle, .from_bytes = true }; + } + + pub fn openPath(path: [:0]const u8) Error!Archive { + const handle = zip.zip_open(path.ptr, 0, 'r') orelse return error.FileLoadError; + return .{ .handle = handle, .from_bytes = false }; + } + + pub fn close(archive: Archive) void { + if (archive.from_bytes) zip.zip_stream_close(archive.handle) else zip.zip_close(archive.handle); + } + + /// The whole of entry `name`, or null when there is no such entry or it does not read. + /// Free it with `freeEntry`. + pub fn readEntry(archive: Archive, name: [:0]const u8) ?[]u8 { + if (zip.zip_entry_open(archive.handle, name.ptr) != 0) return null; + defer _ = zip.zip_entry_close(archive.handle); + var buf: ?*anyopaque = null; + var size: usize = 0; + if (zip.zip_entry_read(archive.handle, &buf, &size) < 0) return null; + const b = buf orelse return null; + return @as([*]u8, @ptrCast(b))[0..size]; + } + + /// The document's data entry: `fizzydata.json`, or the pre-rename `pixidata.json`. + pub fn readData(archive: Archive) ?[]u8 { + return archive.readEntry("fizzydata.json") orelse archive.readEntry("pixidata.json"); + } +}; + +pub fn freeEntry(entry: []u8) void { + if (comptime builtin.target.cpu.arch == .wasm32) { + zip.fizzy_zip_free(entry.ptr); + } else { + std.c.free(entry.ptr); + } +} + +/// `content` (a data entry) as the current format, converting any older version, and checked: +/// a grid with no zero sides and at most `max_pixels`, at least one layer, and animation frames +/// that name sprites of the grid. Everything lives in the result's arena. +pub fn parseData(gpa: std.mem.Allocator, content: []const u8) Error!std.json.Parsed(File) { + var parsed: std.json.Parsed(File) = .{ + .arena = try gpa.create(std.heap.ArenaAllocator), + .value = undefined, + }; + parsed.arena.* = .init(gpa); + errdefer { + parsed.arena.deinit(); + gpa.destroy(parsed.arena); + } + parsed.value = try parseAnyVersion(parsed.arena.allocator(), content); + try validate(parsed.value); + return parsed; +} + +fn parseAnyVersion(arena: std.mem.Allocator, content: []const u8) Error!File { + const options: std.json.ParseOptions = .{ + .duplicate_field_behavior = .use_first, + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }; + + if (std.json.parseFromSliceLeaky(File, arena, content, options)) |file| return file else |_| {} + + // Older formats: animations by frame index at a fixed fps (v3), and before that a grid in + // pixels (v2), and before that animations as a run of sprites (v1). + if (std.json.parseFromSliceLeaky(File.FileV3, arena, content, options)) |old| { + return .{ + .version = old.version, + .columns = old.columns, + .rows = old.rows, + .column_width = old.column_width, + .row_height = old.row_height, + .layers = old.layers, + .sprites = old.sprites, + .animations = try animationsV2(arena, old.animations), + }; + } else |_| {} + + if (std.json.parseFromSliceLeaky(File.FileV2, arena, content, options)) |old| { + return .{ + .version = old.version, + .columns = try tiles(old.width, old.tile_width), + .rows = try tiles(old.height, old.tile_height), + .column_width = old.tile_width, + .row_height = old.tile_height, + .layers = old.layers, + .sprites = old.sprites, + .animations = try animationsV2(arena, old.animations), + }; + } else |_| {} + + if (std.json.parseFromSliceLeaky(File.FileV1, arena, content, options)) |old| { + const animations = try arena.alloc(Animation, old.animations.len); + for (animations, old.animations) |*animation, old_animation| { + const ms = try frameMs(old_animation.fps); + animation.name = old_animation.name; + animation.frames = try arena.alloc(Animation.Frame, old_animation.length); + for (animation.frames, 0..) |*frame, j| frame.* = .{ + .sprite_index = std.math.add(usize, old_animation.start, j) catch return error.FileLoadError, + .ms = ms, + }; + } + return .{ + .version = old.version, + .columns = try tiles(old.width, old.tile_width), + .rows = try tiles(old.height, old.tile_height), + .column_width = old.tile_width, + .row_height = old.tile_height, + .layers = old.layers, + .sprites = old.sprites, + .animations = animations, + }; + } else |_| {} + + return error.FileLoadError; +} + +fn animationsV2(arena: std.mem.Allocator, old: []const Animation.AnimationV2) Error![]Animation { + const animations = try arena.alloc(Animation, old.len); + for (animations, old) |*animation, old_animation| { + const ms = try frameMs(old_animation.fps); + animation.name = old_animation.name; + animation.frames = try arena.alloc(Animation.Frame, old_animation.frames.len); + for (animation.frames, old_animation.frames) |*frame, old_frame| frame.* = .{ .sprite_index = old_frame, .ms = ms }; + } + return animations; +} + +/// How many `tile`s make `size`, which must be a whole number of them. +fn tiles(size: u32, tile: u32) Error!u32 { + if (tile == 0 or size % tile != 0) return error.FileLoadError; + return size / tile; +} + +fn frameMs(fps: f32) Error!u32 { + if (!std.math.isFinite(fps) or fps <= 0) return error.FileLoadError; + return @intFromFloat(@min(1000 / fps, @as(f32, @floatFromInt(std.math.maxInt(u32))))); +} + +fn validate(file: File) Error!void { + if (file.columns == 0 or file.rows == 0 or file.column_width == 0 or file.row_height == 0) + return error.FileLoadError; + const w = @as(u64, file.columns) * file.column_width; + const h = @as(u64, file.rows) * file.row_height; + if (w > max_pixels or h > max_pixels or w * h > max_pixels) return error.FileLoadError; + if (file.layers.len == 0) return error.FileLoadError; + const sprite_count = @as(u64, file.columns) * file.rows; + for (file.animations) |animation| { + for (animation.frames) |frame| if (frame.sprite_index >= sprite_count) return error.FileLoadError; + } +} + +/// Loads `bytes` as far as this module goes: open, read the data entry, parse and check it. +pub fn loadData(gpa: std.mem.Allocator, bytes: []const u8) Error!std.json.Parsed(File) { + const archive = try Archive.openBytes(bytes); + defer archive.close(); + const content = archive.readData() orelse return error.FileLoadError; + defer freeEntry(content); + return parseData(gpa, content); +} + +const testing = std.testing; + +/// A zip of `entries` (name, contents), built in memory with the same vendored zip. Caller frees. +fn testZip(entries: []const [2][:0]const u8) ![]u8 { + const handle = zip.zip_stream_open(null, 0, zip.ZIP_DEFAULT_COMPRESSION_LEVEL, 'w') orelse return error.ZipCreate; + defer zip.zip_stream_close(handle); + for (entries) |entry| { + if (zip.zip_entry_open(handle, entry[0].ptr) != 0) return error.ZipCreate; + if (zip.zip_entry_write(handle, entry[1].ptr, entry[1].len) != 0) return error.ZipCreate; + if (zip.zip_entry_close(handle) != 0) return error.ZipCreate; + } + var buf: ?*anyopaque = null; + var size: usize = 0; + if (zip.zip_stream_copy(handle, &buf, &size) < 0) return error.ZipCreate; + const b = buf orelse return error.ZipCreate; + defer std.c.free(b); + return testing.allocator.dupe(u8, @as([*]u8, @ptrCast(b))[0..size]); +} + +const good_data = + \\{"version":{"major":0,"minor":1,"patch":0},"columns":2,"rows":1,"column_width":4,"row_height":4, + \\"layers":[{"name":"Layer","visible":true,"collapse":false}],"sprites":[{"origin":[0,0]},{"origin":[0,0]}], + \\"animations":[{"name":"walk","frames":[{"sprite_index":1,"ms":100}]}]} +; + +test "a well-formed archive loads" { + const bytes = try testZip(&.{.{ "fizzydata.json", good_data }}); + defer testing.allocator.free(bytes); + const parsed = try loadData(testing.allocator, bytes); + defer parsed.deinit(); + try testing.expectEqual(@as(u32, 2), parsed.value.columns); + try testing.expectEqualStrings("Layer", parsed.value.layers[0].name); +} + +test "bytes that are not a .pixi are an error, not a crash" { + // Nothing, garbage, text (what a text editor held), and a zip's end record with no zip. + var random: [4096]u8 = undefined; + var prng: std.Random.DefaultPrng = .init(0x9e3779b97f4a7c15); + prng.random().bytes(&random); + const inputs = [_][]const u8{ + "", + &random, + "{\"version\":\"not a pixi\"}\n# notes\nhello world\n", + "PK\x05\x06" ++ "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", + "PK\x03\x04", + }; + for (inputs) |input| try testing.expectError(error.FileLoadError, loadData(testing.allocator, input)); +} + +test "a real .pixi loads, and cut short is an error" { + const real = @embedFile("real.pixi"); + const parsed = try loadData(testing.allocator, real); + parsed.deinit(); + var len: usize = 0; + while (len < real.len) : (len += @max(1, real.len / 512)) + try testing.expectError(error.FileLoadError, loadData(testing.allocator, real[0..len])); +} + +test "a truncated archive is an error" { + const bytes = try testZip(&.{.{ "fizzydata.json", good_data }}); + defer testing.allocator.free(bytes); + // Every cut short of the whole loses the central directory at the end. + var len: usize = 0; + while (len < bytes.len) : (len += 1) + try testing.expectError(error.FileLoadError, loadData(testing.allocator, bytes[0..len])); +} + +test "a corrupt central directory is an error" { + const bytes = try testZip(&.{.{ "fizzydata.json", good_data }}); + defer testing.allocator.free(bytes); + const end = std.mem.lastIndexOf(u8, bytes, "PK\x05\x06").?; + // Point the directory past the end of the archive. + std.mem.writeInt(u32, bytes[end + 16 ..][0..4], 0xfffffff0, .little); + try testing.expectError(error.FileLoadError, loadData(testing.allocator, bytes)); +} + +test "an archive without a data entry is an error" { + const bytes = try testZip(&.{.{ "Layer.png", "not really a png" }}); + defer testing.allocator.free(bytes); + try testing.expectError(error.FileLoadError, loadData(testing.allocator, bytes)); +} + +test "data that is no version of the format is an error" { + const cases = [_][:0]const u8{ + "", + "not json", + "{}", + "[1,2,3]", + // A zero grid, a v2 grid that is not whole tiles, no layers, a frame past the grid, fps 0. + \\{"version":{"major":0,"minor":1,"patch":0},"columns":0,"rows":1,"column_width":4,"row_height":4,"layers":[{"name":"L"}],"sprites":[],"animations":[]} + , + \\{"version":{"major":0,"minor":1,"patch":0},"width":10,"height":8,"tile_width":4,"tile_height":4,"layers":[{"name":"L"}],"sprites":[],"animations":[]} + , + \\{"version":{"major":0,"minor":1,"patch":0},"columns":1,"rows":1,"column_width":4,"row_height":4,"layers":[],"sprites":[],"animations":[]} + , + \\{"version":{"major":0,"minor":1,"patch":0},"columns":1,"rows":1,"column_width":4,"row_height":4,"layers":[{"name":"L"}],"sprites":[],"animations":[{"name":"a","frames":[{"sprite_index":5,"ms":10}]}]} + , + \\{"version":{"major":0,"minor":1,"patch":0},"columns":1,"rows":1,"column_width":4,"row_height":4,"layers":[{"name":"L"}],"sprites":[],"animations":[{"name":"a","frames":[0],"fps":0}]} + , + \\{"version":{"major":0,"minor":1,"patch":0},"columns":65536,"rows":65536,"column_width":65536,"row_height":65536,"layers":[{"name":"L"}],"sprites":[],"animations":[]} + , + }; + for (cases) |data| { + const bytes = try testZip(&.{.{ "fizzydata.json", data }}); + defer testing.allocator.free(bytes); + try testing.expectError(error.FileLoadError, loadData(testing.allocator, bytes)); + } +} + +test "an older version still converts" { + const v1 = + \\{"version":{"major":0,"minor":0,"patch":1},"width":8,"height":4,"tile_width":4,"tile_height":4, + \\"layers":[{"name":"L"}],"sprites":[],"animations":[{"name":"a","start":0,"length":2,"fps":10}]} + ; + const bytes = try testZip(&.{.{ "pixidata.json", v1 }}); + defer testing.allocator.free(bytes); + const parsed = try loadData(testing.allocator, bytes); + defer parsed.deinit(); + try testing.expectEqual(@as(u32, 2), parsed.value.columns); + try testing.expectEqual(@as(u32, 100), parsed.value.animations[0].frames[1].ms); +} diff --git a/src/internal/File.zig b/src/internal/File.zig index 0717e12..5235331 100644 --- a/src/internal/File.zig +++ b/src/internal/File.zig @@ -16,6 +16,7 @@ const Animation = @import("Animation.zig"); const pixi = @import("../pixi.zig"); const plugin = @import("../../plugin.zig"); const runtime = @import("../runtime.zig"); +const fizzy_zip = @import("../fizzy_zip.zig"); const alpha_checkerboard_count: u32 = 8; @@ -491,329 +492,122 @@ fn loadFizzyZip(path: []const u8, file_bytes: ?[]const u8) !?pixi.internal.File if (!isFizzyExtension(std.fs.path.extension(path[0..path.len]))) return error.InvalidExtension; - const null_terminated_path = if (file_bytes == null) - try runtime.allocator().dupeSentinel(u8, path, 0) - else - ""; - defer if (file_bytes == null) runtime.allocator().free(null_terminated_path); - - zip_open: { - const fizzy_file = if (file_bytes) |bytes| - zip.zip_stream_open(bytes.ptr, bytes.len, 0, 'r') - else - zip.zip_open(null_terminated_path.ptr, 0, 'r') orelse break :zip_open; - defer if (file_bytes != null) zip.zip_stream_close(fizzy_file) else zip.zip_close(fizzy_file); - - var buf: ?*anyopaque = null; - var size: usize = 0; - // Try the current entry name first, then fall back to the legacy `pixidata.json` - // so files saved by the pre-rename Pixi versions still load. - if (zip.zip_entry_open(fizzy_file, "fizzydata.json") != 0) { - _ = zip.zip_entry_open(fizzy_file, "pixidata.json"); - } - _ = zip.zip_entry_read(fizzy_file, &buf, &size); - _ = zip.zip_entry_close(fizzy_file); - defer if (buf) |b| { - if (comptime @import("builtin").target.cpu.arch == .wasm32) { - zip.fizzy_zip_free(b); - } else { - std.c.free(b); - } - }; - - const content: []const u8 = if (buf) |b| @as([*]const u8, @ptrCast(b))[0..size] else ""; + // Nothing in the archive is trusted: `fizzy_zip` turns every malformed one into an error. + const archive = if (file_bytes) |bytes| try fizzy_zip.Archive.openBytes(bytes) else open: { + const null_terminated_path = try runtime.allocator().dupeSentinel(u8, path, 0); + defer runtime.allocator().free(null_terminated_path); + break :open try fizzy_zip.Archive.openPath(null_terminated_path); + }; + defer archive.close(); - const options = std.json.ParseOptions{ - .duplicate_field_behavior = .use_first, - .ignore_unknown_fields = true, - }; + const parsed = parsed: { + const content = archive.readData() orelse return error.FileLoadError; + defer fizzy_zip.freeEntry(content); + break :parsed try fizzy_zip.parseData(runtime.allocator(), content); + }; + defer parsed.deinit(); + const ext = parsed.value; - var try_parse: ?std.json.Parsed(pixi.File) = null; - try_parse = std.json.parseFromSlice(pixi.File, runtime.allocator(), content, options) catch null; - - var ext: pixi.File = if (try_parse) |parsed| parsed.value else undefined; - - if (try_parse == null) { - // If we are here, we have tried to load the file but hit an issue because the old animation format - if (std.json.parseFromSlice(pixi.File.FileV3, runtime.allocator(), content, options) catch null) |old_file| { - std.log.info("Loading file v3: {s}", .{path}); - const animations = try runtime.allocator().alloc(pixi.Animation, old_file.value.animations.len); - for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try runtime.allocator().dupe(u8, old_animation.name); - animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len); - for (animation.frames, old_animation.frames) |*frame, old_frame| { - frame.sprite_index = old_frame; - frame.ms = @intFromFloat(1000 / old_animation.fps); - } - } + var internal: pixi.internal.File = .{ + .id = runtime.state().host.allocDocId(), + .path = try runtime.allocator().dupe(u8, path), + .columns = ext.columns, + .rows = ext.rows, + .column_width = ext.column_width, + .row_height = ext.row_height, + .history = pixi.internal.File.History.init(runtime.allocator()), + .buffers = pixi.internal.File.Buffers.init(runtime.allocator()), + }; - ext = .{ - .version = old_file.value.version, - .columns = old_file.value.columns, - .rows = old_file.value.rows, - .column_width = old_file.value.column_width, - .row_height = old_file.value.row_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; - } else if (std.json.parseFromSlice(pixi.File.FileV2, runtime.allocator(), content, options) catch null) |old_file| { - std.log.info("Loading file v2: {s}", .{path}); - const animations = try runtime.allocator().alloc(pixi.Animation, old_file.value.animations.len); - for (animations, old_file.value.animations) |*animation, old_animation| { - animation.name = try runtime.allocator().dupe(u8, old_animation.name); - animation.frames = try runtime.allocator().alloc(Animation.Frame, old_animation.frames.len); - for (animation.frames, old_animation.frames) |*frame, old_frame| { - frame.sprite_index = old_frame; - frame.ms = @intFromFloat(1000 / old_animation.fps); - } - } + //Initialize editor layers and selected sprites + // .ptr: same as new-file init — GPU sync via invalidate / temp_gpu_dirty_rect + updateSubRect. + // .always would re-upload the full texture on every getTexture() (e.g. sprite panel reflection). + internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); + internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); + internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); + errdefer internal.deinit(); + internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - ext = .{ - .version = old_file.value.version, - .columns = @divExact(old_file.value.width, old_file.value.tile_width), - .rows = @divExact(old_file.value.height, old_file.value.tile_height), - .column_width = old_file.value.tile_width, - .row_height = old_file.value.tile_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; - } else if (std.json.parseFromSlice(pixi.File.FileV1, runtime.allocator(), content, options) catch null) |old_file| { - std.log.info("Loading file v1: {s}", .{path}); - const animations = try runtime.allocator().alloc(pixi.Animation, old_file.value.animations.len); - for (animations, 0..) |*animation, i| { - animation.name = try runtime.allocator().dupe(u8, old_file.value.animations[i].name); - animation.frames = try runtime.allocator().alloc(Animation.Frame, old_file.value.animations[i].length); - for (animation.frames, 0..old_file.value.animations[i].length) |*frame, j| { - frame.sprite_index = old_file.value.animations[i].start + j; - frame.ms = @intFromFloat(1000 / old_file.value.animations[i].fps); - } - } + internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); + // Create a layer-sized checkerboard pattern for selection tools + for (0..internal.width() * internal.height()) |i| { + const value = pixi.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); + internal.editor.checkerboard.setValue(i, value); + } - ext = .{ - .version = old_file.value.version, - .columns = @divExact(old_file.value.width, old_file.value.tile_width), - .rows = @divExact(old_file.value.height, old_file.value.tile_height), - .column_width = old_file.value.tile_width, - .row_height = old_file.value.tile_height, - .layers = old_file.value.layers, - .sprites = old_file.value.sprites, - .animations = animations, - }; + var set_layer_index: bool = false; + for (ext.layers) |l| { + const layer_image_name = try std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.layer", .{l.name}, 0); + defer runtime.allocator().free(layer_image_name); + const png_image_name = try std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{l.name}, 0); + defer runtime.allocator().free(png_image_name); + + var new_layer: Layer = if (archive.readEntry(layer_image_name)) |data| layer: { // Read layer file as directly pixels + defer fizzy_zip.freeEntry(data); + const pixel_count = internal.width() * internal.height(); + if (data.len != pixel_count * @sizeOf(dvui.Color.PMA)) return error.FileLoadError; + break :layer try .fromPixelsPMA( + internal.newLayerID(), + l.name, + @as([*]dvui.Color.PMA, @ptrCast(@alignCast(data.ptr)))[0..pixel_count], + internal.width(), + internal.height(), + .ptr, + ); + } else if (archive.readEntry(png_image_name)) |data| layer: { // Read the layer file as PNG file + defer fizzy_zip.freeEntry(data); + var png_layer: Layer = try .fromImageFileBytes(internal.newLayerID(), l.name, data, .ptr); + const s = png_layer.size(); + if (s.w != @as(f32, @floatFromInt(internal.width())) or s.h != @as(f32, @floatFromInt(internal.height()))) { + png_layer.deinit(); + return error.FileLoadError; } - } + break :layer png_layer; + } else continue; - defer if (try_parse) |parsed| parsed.deinit(); + new_layer.visible = l.visible; + new_layer.collapse = l.collapse; - //defer parsed.deinit(); + new_layer.setMaskFromTransparency(true); - var internal: pixi.internal.File = .{ - .id = runtime.state().host.allocDocId(), - .path = try runtime.allocator().dupe(u8, path), - .columns = ext.columns, - .rows = ext.rows, - .column_width = ext.column_width, - .row_height = ext.row_height, - .history = pixi.internal.File.History.init(runtime.allocator()), - .buffers = pixi.internal.File.Buffers.init(runtime.allocator()), + internal.layers.append(runtime.allocator(), new_layer) catch { + new_layer.deinit(); + return error.FileLoadError; }; - //Initialize editor layers and selected sprites - // .ptr: same as new-file init — GPU sync via invalidate / temp_gpu_dirty_rect + updateSubRect. - // .always would re-upload the full texture on every getTexture() (e.g. sprite panel reflection). - internal.editor.temporary_layer = try .init(internal.newLayerID(), "Temporary", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selection_layer = try .init(internal.newLayerID(), "Selection", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.transform_layer = try .init(internal.newLayerID(), "Transform", internal.width(), internal.height(), .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .ptr); - internal.editor.selected_sprites = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.spriteCount()); - - internal.editor.checkerboard = try std.DynamicBitSet.initEmpty(runtime.allocator(), internal.width() * internal.height()); - // Create a layer-sized checkerboard pattern for selection tools - for (0..internal.width() * internal.height()) |i| { - const value = pixi.math.checker(.{ .w = @floatFromInt(internal.width()), .h = @floatFromInt(internal.height()) }, i); - internal.editor.checkerboard.setValue(i, value); - } - - var set_layer_index: bool = false; - for (ext.layers, 0..) |l, i| { - const layer_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.layer", .{l.name}, 0) catch "Memory Allocation Failed"; - defer runtime.allocator().free(layer_image_name); - const png_image_name = std.fmt.allocPrintSentinel(runtime.allocator(), "{s}.png", .{l.name}, 0) catch "Memory Allocation Failed"; - defer runtime.allocator().free(png_image_name); - - var img_buf: ?*anyopaque = null; - var img_len: usize = 0; - - if (zip.zip_entry_open(fizzy_file, layer_image_name.ptr) == 0) { // Read layer file as directly pixels - _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); - const data = img_buf orelse continue; - - var new_layer: Layer = try .fromPixelsPMA( - internal.newLayerID(), - l.name, - @as([*]dvui.Color.PMA, @ptrCast(@constCast(data)))[0..(internal.width() * internal.height())], - internal.width(), - internal.height(), - .ptr, - ); - - new_layer.visible = l.visible; - new_layer.collapse = l.collapse; - - new_layer.setMaskFromTransparency(true); - - internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; - - if (l.visible and !set_layer_index) { - internal.selected_layer_index = i; - set_layer_index = true; - } - } else if (zip.zip_entry_open(fizzy_file, png_image_name.ptr) == 0) { // Read the layer file as PNG file - _ = zip.zip_entry_read(fizzy_file, &img_buf, &img_len); - const data = img_buf orelse continue; - - var new_layer: Layer = try .fromImageFileBytes( - internal.newLayerID(), - l.name, - @as([*]u8, @ptrCast(data))[0..img_len], - .ptr, - ); - - new_layer.visible = l.visible; - new_layer.collapse = l.collapse; - - new_layer.setMaskFromTransparency(true); - - internal.layers.append(runtime.allocator(), new_layer) catch return error.FileLoadError; - - if (l.visible and !set_layer_index) { - internal.selected_layer_index = i; - set_layer_index = true; - } - } - - _ = zip.zip_entry_close(fizzy_file); - } - _ = zip.zip_entry_close(fizzy_file); - - for (0..internal.spriteCount()) |sprite_index| { - if (sprite_index >= ext.sprites.len) { - internal.sprites.append(runtime.allocator(), .{ - .origin = .{ 0, 0 }, - }) catch return error.FileLoadError; - } else { - internal.sprites.append(runtime.allocator(), .{ - .origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] }, - }) catch return error.FileLoadError; - } + // The index among the layers that loaded: a layer with no image entry is skipped. + if (l.visible and !set_layer_index) { + internal.selected_layer_index = internal.layers.len - 1; + set_layer_index = true; } + } + // A document with no layers has nothing to draw on. + if (internal.layers.len == 0) return error.FileLoadError; - for (ext.animations) |animation| { - internal.animations.append(runtime.allocator(), .{ - .id = internal.newAnimationID(), - .name = try runtime.allocator().dupe(u8, animation.name), - .frames = try runtime.allocator().dupe(Animation.Frame, animation.frames), + for (0..internal.spriteCount()) |sprite_index| { + if (sprite_index >= ext.sprites.len) { + internal.sprites.append(runtime.allocator(), .{ + .origin = .{ 0, 0 }, + }) catch return error.FileLoadError; + } else { + internal.sprites.append(runtime.allocator(), .{ + .origin = .{ ext.sprites[sprite_index].origin[0], ext.sprites[sprite_index].origin[1] }, }) catch return error.FileLoadError; } - return internal; - } - // { // Loading TAR experiment - // var file_name_buffer: [std.fs.max_path_bytes]u8 = undefined; - // var link_name_buffer: [std.fs.max_path_bytes]u8 = undefined; - - // if (pixi.fs.read(runtime.allocator(), path) catch null) |file_bytes| { - // std.log.debug("Read file bytes!", .{}); - // var input = std.io.fixedBufferStream(file_bytes); - // var iter = std.tar.iterator(input.reader(), .{ - // .file_name_buffer = &file_name_buffer, - // .link_name_buffer = &link_name_buffer, - // }); - - // var json_content = std.array_list.Managed(u8).init(runtime.allocator()); - // defer json_content.deinit(); - - // while (try iter.next()) |entry| { - // const ext = std.fs.path.extension(entry.name); - // if (std.ascii.eqlIgnoreCase(ext, ".json")) { - // entry.writeAll(json_content.writer()) catch return error.FileLoadError; - // } - // } - - // const options = std.json.ParseOptions{ - // .duplicate_field_behavior = .use_first, - // .ignore_unknown_fields = true, - // }; - - // if (std.json.parseFromSlice(pixi.File, runtime.allocator(), json_content.items, options) catch null) |parsed| { - // defer parsed.deinit(); - - // std.log.debug("Parsed fizzydata.json!", .{}); - - // const ext = parsed.value; - - // var internal: pixi.internal.File = .{ - // .id = runtime.state().host.allocDocId(), - // .path = try runtime.allocator().dupe(u8, path), - // .width = ext.width, - // .height = ext.height, - // .tile_width = ext.tile_width, - // .tile_height = ext.tile_height, - // .history = pixi.internal.File.History.init(runtime.allocator()), - // .buffers = pixi.internal.File.Buffers.init(runtime.allocator()), - // .checkerboard = pixi.image.init( - // ext.tile_width * 2, - // ext.tile_height * 2, - // .{ .r = 0, .g = 0, .b = 0, .a = 0 }, - // .ptr, - // ) catch return error.LayerCreateError, - // .temporary_layer = undefined, - // .selection_layer = undefined, - // .selected_sprites = try std.DynamicBitSet.initEmpty( - // runtime.allocator(), - // @divExact(ext.width, ext.tile_width) * @divExact(ext.height, ext.tile_height), - // ), - // }; - - // internal.temporary_layer = try .init(internal.newID(), "Temporary", internal.width, internal.height, .{ .r = 0, .g = 0, .b = 0, .a = 0 }, .always); - - // for (ext.layers, 0..) |ext_layer, i| { - // const layer_image_name = std.fmt.allocPrintZ(dvui.currentWindow().arena(), "{s}.layer", .{ext_layer.name}) catch "Memory Allocation Failed"; - - // if (ext_layer.visible) { - // internal.selected_layer_index = i; - // } - - // iter = std.tar.iterator(input.reader(), .{ - // .file_name_buffer = &file_name_buffer, - // .link_name_buffer = &link_name_buffer, - // }); - - // while (iter.next() catch null) |entry| { - // std.log.debug("Entry name: {s}", .{entry.name}); - - // if (std.mem.eql(u8, entry.name, layer_image_name)) { - // var layer_content = std.array_list.Managed(u8).init(runtime.allocator()); - // try entry.writeAll(layer_content.writer()); - - // var cond: ?pixi.Layer = pixi.Layer.fromPixels(internal.newID(), runtime.allocator().dupe(u8, ext_layer.name) catch ext_layer.name, layer_content.items, ext.width, ext.height, .ptr) catch null; - - // if (cond) |*new_layer| { - // new_layer.visible = ext_layer.visible; - // new_layer.collapse = ext_layer.collapse; - // internal.layers.append(runtime.allocator(), new_layer.*) catch return error.FileLoadError; - // } else { - // std.log.err("Failed to create layer from pixels", .{}); - // } - // } - // } - // } - - // return internal; - // } - // } - // } - - return error.FileLoadError; + } + + for (ext.animations) |animation| { + const name = try runtime.allocator().dupe(u8, animation.name); + errdefer runtime.allocator().free(name); + const frames = try runtime.allocator().dupe(Animation.Frame, animation.frames); + errdefer runtime.allocator().free(frames); + internal.animations.append(runtime.allocator(), .{ + .id = internal.newAnimationID(), + .name = name, + .frames = frames, + }) catch return error.FileLoadError; + } + return internal; } pub fn isFlatImageExtension(ext: []const u8) bool {