From 71c44aa81bcc33ee9c7984608fe3897f1e98f85a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 05:56:52 +0000 Subject: [PATCH 01/10] feat: add bitmap (BMP) encoder for 24-bit RGB images Add a library to export Aseprite RGB images as 24-bit BMP files. This enables exporting images in the widely-supported bitmap format. Features: - Encodes Aseprite Image objects to BMP binary string - Supports 24-bit RGB color mode only - Proper BGR byte order conversion - Bottom-to-top row ordering per BMP specification - Automatic row padding to 4-byte boundaries - Standard BMP file and info headers (14 + 40 bytes) Implementation: - src/pkg/bitmap/init.lua: Main BMP encoder with create() API - src/pkg/bitmap/bitmap_test.lua: Comprehensive test suite - src/pkg/asepriteUtil/mock.lua: Reusable Aseprite API mocks for testing The bitmap encoder uses the little-endian pack functions added in PR #34 to generate proper BMP file structures. --- src/pkg/asepriteUtil/mock.lua | 86 +++++++++++++++ src/pkg/bitmap/bitmap_test.lua | 193 +++++++++++++++++++++++++++++++++ src/pkg/bitmap/init.lua | 91 ++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 src/pkg/asepriteUtil/mock.lua create mode 100644 src/pkg/bitmap/bitmap_test.lua create mode 100644 src/pkg/bitmap/init.lua diff --git a/src/pkg/asepriteUtil/mock.lua b/src/pkg/asepriteUtil/mock.lua new file mode 100644 index 0000000..9d5300f --- /dev/null +++ b/src/pkg/asepriteUtil/mock.lua @@ -0,0 +1,86 @@ +---Mock implementations of Aseprite APIs for testing +---@module 'pkg.asepriteUtil.mock' + +---Mock ColorMode enum +---@class ColorMode +local ColorMode = { + RGB = 0, + GRAY = 1, + INDEXED = 2, +} + +---Mock app.pixelColor API +---RGBA format: R=bits[0-7], G=bits[8-15], B=bits[16-23], A=bits[24-31] +local pixelColor = { + ---Constructs a 32-bit RGBA pixel value + ---@param r integer Red component (0-255) + ---@param g integer Green component (0-255) + ---@param b integer Blue component (0-255) + ---@param a? integer Alpha component (0-255), defaults to 255 + ---@return integer + rgba = function (r, g, b, a) + a = a or 255 + return r | (g << 8) | (b << 16) | (a << 24) + end, + + ---Extracts red component from RGBA pixel + ---@param pixel integer + ---@return integer + rgbaR = function (pixel) + return pixel & 0xFF + end, + + ---Extracts green component from RGBA pixel + ---@param pixel integer + ---@return integer + rgbaG = function (pixel) + return (pixel >> 8) & 0xFF + end, + + ---Extracts blue component from RGBA pixel + ---@param pixel integer + ---@return integer + rgbaB = function (pixel) + return (pixel >> 16) & 0xFF + end, + + ---Extracts alpha component from RGBA pixel + ---@param pixel integer + ---@return integer + rgbaA = function (pixel) + return (pixel >> 24) & 0xFF + end, +} + +---Mock app object +local app = { + pixelColor = pixelColor, +} + +---Creates a mock Image object for testing +---@param width integer Image width in pixels +---@param height integer Image height in pixels +---@param pixelData integer[] Array of RGBA pixel values (length = width * height) +---@return table Mock Image object +local function createImage (width, height, pixelData) + return { + width = width, + height = height, + colorMode = ColorMode.RGB, + bytesPerPixel = 4, + rowStride = width * 4, + ---Gets pixel at x,y coordinates + ---@param x integer + ---@param y integer + ---@return integer + getPixel = function (self, x, y) + return pixelData[y * width + x + 1] + end, + } +end + +return { + ColorMode = ColorMode, + app = app, + createImage = createImage, +} diff --git a/src/pkg/bitmap/bitmap_test.lua b/src/pkg/bitmap/bitmap_test.lua new file mode 100644 index 0000000..8c49e77 --- /dev/null +++ b/src/pkg/bitmap/bitmap_test.lua @@ -0,0 +1,193 @@ +local describe = require("lib.test").describe +local expect = require("lib.test").expect +local test = require("lib.test").test + +-- Set up mocks +local mock = require("pkg.asepriteUtil.mock") +_G.ColorMode = mock.ColorMode +_G.app = mock.app + +local bitmap = require("pkg.bitmap") + +describe("bitmap", function () + describe("create", function () + test("creates valid BMP file header", function () + local pixels = { + mock.app.pixelColor.rgba(255, 0, 0), -- Red + } + local image = mock.createImage(1, 1, pixels) + local bmp = bitmap.create(image) + + -- Check BMP signature + expect(bmp:sub(1, 2)):toBe("BM") + + -- File size: 54 (headers) + 4 (1 pixel * 3 bytes + 1 padding) = 58 + local fileSize = string.unpack(" Date: Fri, 14 Nov 2025 06:12:44 +0000 Subject: [PATCH 02/10] refactor: use metatable pattern for bitmap objects Change bitmap.create() to return a table with metatable instead of a plain string. This allows the bitmap object to be converted to a string via tostring() or the __tostring metamethod. Changes: - Replace create() function with bitmap class using metatable - Add tostring() method that generates BMP binary data - Set __tostring metamethod for implicit string conversion - Update all tests to use new API: bitmap(image) - Add test for both explicit and implicit tostring conversion Usage: ```lua local bitmap = require("pkg.bitmap").bitmap local bmp = bitmap(image) -- Explicit conversion local data = bmp:tostring() -- Implicit conversion local data = tostring(bmp) -- Or use in string concatenation local data = "" .. bmp ``` This pattern matches the RIFF chunk implementation and provides more flexibility for future extensions. --- src/pkg/bitmap/bitmap_test.lua | 41 ++++++++++++++++++++++++---------- src/pkg/bitmap/init.lua | 38 ++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 23 deletions(-) diff --git a/src/pkg/bitmap/bitmap_test.lua b/src/pkg/bitmap/bitmap_test.lua index 8c49e77..3f9ed07 100644 --- a/src/pkg/bitmap/bitmap_test.lua +++ b/src/pkg/bitmap/bitmap_test.lua @@ -7,16 +7,16 @@ local mock = require("pkg.asepriteUtil.mock") _G.ColorMode = mock.ColorMode _G.app = mock.app -local bitmap = require("pkg.bitmap") +local bitmap = require("pkg.bitmap").bitmap describe("bitmap", function () - describe("create", function () + describe("bitmap constructor", function () test("creates valid BMP file header", function () local pixels = { mock.app.pixelColor.rgba(255, 0, 0), -- Red } local image = mock.createImage(1, 1, pixels) - local bmp = bitmap.create(image) + local bmp = tostring(bitmap(image)) -- Check BMP signature expect(bmp:sub(1, 2)):toBe("BM") @@ -33,7 +33,7 @@ describe("bitmap", function () test("creates valid bitmap info header", function () local pixels = { mock.app.pixelColor.rgba(0, 0, 0) } local image = mock.createImage(1, 1, pixels) - local bmp = bitmap.create(image) + local bmp = tostring(bitmap(image)) -- Info header starts at byte 15 local headerSize = string.unpack(" Date: Fri, 14 Nov 2025 06:19:23 +0000 Subject: [PATCH 03/10] refactor: separate bitmap class into dedicated file Move bitmap class definition from init.lua to bitmap.lua for better code organization and separation of concerns. Changes: - Create src/pkg/bitmap/bitmap.lua with bitmap class and helper functions - Update init.lua to just require and export the bitmap class - No API changes - tests continue to work without modification This makes the codebase more modular and easier to maintain. --- src/pkg/bitmap/bitmap.lua | 107 ++++++++++++++++++++++++++++++++++++++ src/pkg/bitmap/init.lua | 103 +----------------------------------- 2 files changed, 108 insertions(+), 102 deletions(-) create mode 100644 src/pkg/bitmap/bitmap.lua diff --git a/src/pkg/bitmap/bitmap.lua b/src/pkg/bitmap/bitmap.lua new file mode 100644 index 0000000..00babb7 --- /dev/null +++ b/src/pkg/bitmap/bitmap.lua @@ -0,0 +1,107 @@ +---Bitmap class for encoding 24-bit RGB images to BMP format +---@module 'pkg.bitmap.bitmap' + +local pack = require("pkg.string.pack") + +---Generates BMP file header (14 bytes) +---@param fileSize integer Total file size in bytes +---@return string Binary header data +local function createFileHeader (fileSize) + return "BM" -- Signature + .. pack.u32LE(fileSize) -- File size + .. pack.u32LE(0) -- Reserved + .. pack.u32LE(54) -- Data offset (14 + 40) +end + +---Generates bitmap info header (40 bytes) +---@param width integer Image width in pixels +---@param height integer Image height in pixels +---@return string Binary header data +local function createInfoHeader (width, height) + return pack.u32LE(40) -- Header size + .. pack.i32LE(width) -- Image width + .. pack.i32LE(height) -- Image height + .. pack.u16LE(1) -- Planes (always 1) + .. pack.u16LE(24) -- Bits per pixel + .. pack.u32LE(0) -- Compression (0 = uncompressed) + .. pack.u32LE(0) -- Image size (0 is valid for uncompressed) + .. pack.i32LE(2835) -- X pixels per meter (~72 DPI) + .. pack.i32LE(2835) -- Y pixels per meter (~72 DPI) + .. pack.u32LE(0) -- Colors used (0 = all colors) + .. pack.u32LE(0) -- Important colors (0 = all important) +end + +---Encodes image pixels to BMP format +---Converts RGB to BGR, processes bottom-to-top, adds row padding +---@param image Image Aseprite Image object +---@return string Binary pixel data +local function encodePixels (image) + local width = image.width + local height = image.height + local padding = (4 - (width * 3) % 4) % 4 + local rows = {} + + -- Process rows from bottom to top (BMP stores rows bottom-to-top) + for y = height - 1, 0, -1 do + local row = {} + + -- Process each pixel in the row + for x = 0, width - 1 do + local pixel = image:getPixel(x, y) + + -- Store as BGR (not RGB!) + table.insert(row, pack.u8(app.pixelColor.rgbaB(pixel))) + table.insert(row, pack.u8(app.pixelColor.rgbaG(pixel))) + table.insert(row, pack.u8(app.pixelColor.rgbaR(pixel))) + end + + -- Add padding bytes to align row to 4-byte boundary + for i = 1, padding do + table.insert(row, "\x00") + end + + table.insert(rows, table.concat(row)) + end + + return table.concat(rows) +end + +---@class Bitmap +---@field image Image +---@overload fun(image: Image): Bitmap +local bitmap = {} + +---Converts the bitmap to a binary string +---@return string +function bitmap.tostring (self) + local image = self.image + + -- Calculate sizes + local rowSize = image.width * 3 + ((4 - (image.width * 3) % 4) % 4) + local pixelDataSize = rowSize * image.height + local fileSize = 54 + pixelDataSize -- 14 (file header) + 40 (info header) + pixel data + + -- Generate BMP file + return createFileHeader(fileSize) + .. createInfoHeader(image.width, image.height) + .. encodePixels(image) +end + +setmetatable(bitmap --[[ @as unknown ]], { + ---@param image Image + __call = function (_, image) + -- Validate color mode + if image.colorMode ~= ColorMode.RGB then + error("Only RGB images are supported for BMP export") + end + + local value = { + image = image, + } + setmetatable(value, { __index = bitmap, __tostring = bitmap.tostring }) + + return value + end, +}) + +return bitmap diff --git a/src/pkg/bitmap/init.lua b/src/pkg/bitmap/init.lua index fc93fe9..34b5328 100644 --- a/src/pkg/bitmap/init.lua +++ b/src/pkg/bitmap/init.lua @@ -1,107 +1,6 @@ ---Bitmap (BMP) file format encoder for 24-bit RGB images ---@module 'pkg.bitmap' -local pack = require("pkg.string.pack") - ----Generates BMP file header (14 bytes) ----@param fileSize integer Total file size in bytes ----@return string Binary header data -local function createFileHeader (fileSize) - return "BM" -- Signature - .. pack.u32LE(fileSize) -- File size - .. pack.u32LE(0) -- Reserved - .. pack.u32LE(54) -- Data offset (14 + 40) -end - ----Generates bitmap info header (40 bytes) ----@param width integer Image width in pixels ----@param height integer Image height in pixels ----@return string Binary header data -local function createInfoHeader (width, height) - return pack.u32LE(40) -- Header size - .. pack.i32LE(width) -- Image width - .. pack.i32LE(height) -- Image height - .. pack.u16LE(1) -- Planes (always 1) - .. pack.u16LE(24) -- Bits per pixel - .. pack.u32LE(0) -- Compression (0 = uncompressed) - .. pack.u32LE(0) -- Image size (0 is valid for uncompressed) - .. pack.i32LE(2835) -- X pixels per meter (~72 DPI) - .. pack.i32LE(2835) -- Y pixels per meter (~72 DPI) - .. pack.u32LE(0) -- Colors used (0 = all colors) - .. pack.u32LE(0) -- Important colors (0 = all important) -end - ----Encodes image pixels to BMP format ----Converts RGB to BGR, processes bottom-to-top, adds row padding ----@param image Image Aseprite Image object ----@return string Binary pixel data -local function encodePixels (image) - local width = image.width - local height = image.height - local padding = (4 - (width * 3) % 4) % 4 - local rows = {} - - -- Process rows from bottom to top (BMP stores rows bottom-to-top) - for y = height - 1, 0, -1 do - local row = {} - - -- Process each pixel in the row - for x = 0, width - 1 do - local pixel = image:getPixel(x, y) - - -- Store as BGR (not RGB!) - table.insert(row, pack.u8(app.pixelColor.rgbaB(pixel))) - table.insert(row, pack.u8(app.pixelColor.rgbaG(pixel))) - table.insert(row, pack.u8(app.pixelColor.rgbaR(pixel))) - end - - -- Add padding bytes to align row to 4-byte boundary - for i = 1, padding do - table.insert(row, "\x00") - end - - table.insert(rows, table.concat(row)) - end - - return table.concat(rows) -end - ----@class Bitmap ----@field image Image ----@overload fun(image: Image): Bitmap -local bitmap = {} - ----Converts the bitmap to a binary string ----@return string -function bitmap.tostring (self) - local image = self.image - - -- Calculate sizes - local rowSize = image.width * 3 + ((4 - (image.width * 3) % 4) % 4) - local pixelDataSize = rowSize * image.height - local fileSize = 54 + pixelDataSize -- 14 (file header) + 40 (info header) + pixel data - - -- Generate BMP file - return createFileHeader(fileSize) - .. createInfoHeader(image.width, image.height) - .. encodePixels(image) -end - -setmetatable(bitmap --[[ @as unknown ]], { - ---@param image Image - __call = function (_, image) - -- Validate color mode - if image.colorMode ~= ColorMode.RGB then - error("Only RGB images are supported for BMP export") - end - - local value = { - image = image, - } - setmetatable(value, { __index = bitmap, __tostring = bitmap.tostring }) - - return value - end, -}) +local bitmap = require("pkg.bitmap.bitmap") return { bitmap = bitmap } From 9cf33505269056c294767a791453201ba008c32f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 06:23:31 +0000 Subject: [PATCH 04/10] chore: remove unnecessary @module annotations --- src/pkg/bitmap/bitmap.lua | 1 - src/pkg/bitmap/init.lua | 1 - 2 files changed, 2 deletions(-) diff --git a/src/pkg/bitmap/bitmap.lua b/src/pkg/bitmap/bitmap.lua index 00babb7..661093d 100644 --- a/src/pkg/bitmap/bitmap.lua +++ b/src/pkg/bitmap/bitmap.lua @@ -1,5 +1,4 @@ ---Bitmap class for encoding 24-bit RGB images to BMP format ----@module 'pkg.bitmap.bitmap' local pack = require("pkg.string.pack") diff --git a/src/pkg/bitmap/init.lua b/src/pkg/bitmap/init.lua index 34b5328..cbc09c7 100644 --- a/src/pkg/bitmap/init.lua +++ b/src/pkg/bitmap/init.lua @@ -1,5 +1,4 @@ ---Bitmap (BMP) file format encoder for 24-bit RGB images ----@module 'pkg.bitmap' local bitmap = require("pkg.bitmap.bitmap") From e2479ede06294f51c55af32e706d1fab3505f7c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 06:24:44 +0000 Subject: [PATCH 05/10] chore: remove @module annotation from mock.lua --- src/pkg/asepriteUtil/mock.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/src/pkg/asepriteUtil/mock.lua b/src/pkg/asepriteUtil/mock.lua index 9d5300f..8785119 100644 --- a/src/pkg/asepriteUtil/mock.lua +++ b/src/pkg/asepriteUtil/mock.lua @@ -1,5 +1,4 @@ ---Mock implementations of Aseprite APIs for testing ----@module 'pkg.asepriteUtil.mock' ---Mock ColorMode enum ---@class ColorMode From f56e7fb0049466ec0c11245c9819eff596020418 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 06:29:02 +0000 Subject: [PATCH 06/10] refactor: use string.pack in mock pixelColor.rgba --- src/pkg/asepriteUtil/mock.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/asepriteUtil/mock.lua b/src/pkg/asepriteUtil/mock.lua index 8785119..af3a2cd 100644 --- a/src/pkg/asepriteUtil/mock.lua +++ b/src/pkg/asepriteUtil/mock.lua @@ -19,7 +19,7 @@ local pixelColor = { ---@return integer rgba = function (r, g, b, a) a = a or 255 - return r | (g << 8) | (b << 16) | (a << 24) + return (" Date: Fri, 14 Nov 2025 06:29:39 +0000 Subject: [PATCH 07/10] chore: set pixels per meter to 0 in BMP header --- src/pkg/bitmap/bitmap.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pkg/bitmap/bitmap.lua b/src/pkg/bitmap/bitmap.lua index 661093d..5bd36e8 100644 --- a/src/pkg/bitmap/bitmap.lua +++ b/src/pkg/bitmap/bitmap.lua @@ -24,8 +24,8 @@ local function createInfoHeader (width, height) .. pack.u16LE(24) -- Bits per pixel .. pack.u32LE(0) -- Compression (0 = uncompressed) .. pack.u32LE(0) -- Image size (0 is valid for uncompressed) - .. pack.i32LE(2835) -- X pixels per meter (~72 DPI) - .. pack.i32LE(2835) -- Y pixels per meter (~72 DPI) + .. pack.i32LE(0) -- X pixels per meter (0 = not specified) + .. pack.i32LE(0) -- Y pixels per meter (0 = not specified) .. pack.u32LE(0) -- Colors used (0 = all colors) .. pack.u32LE(0) -- Important colors (0 = all important) end From e19238fb5036c681a8ce54699e5515a20c97d503 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Nov 2025 15:35:15 +0000 Subject: [PATCH 08/10] refactor: restructure bitmap implementation Major refactoring of bitmap module for cleaner architecture: - mock.lua: Use bit operations instead of string.pack for rgba - bitmap.lua: Use table.concat instead of string concatenation - bitmap.lua: Rename class to BitmapFile and store components separately - fileHeader: BMP file header (14 bytes) - bitmapInfoHeader: Bitmap info header (40 bytes) - pixelData: Binary pixel data - bitmap.lua: Export create function instead of __call metamethod - init.lua: Simplify to directly return bitmap module - bitmap_test.lua: Use hard-coded binary string literals instead of helpers - bitmap_test.lua: Add test for BitmapFile structure API change: bitmap(image) -> bitmap.create(image) --- src/pkg/asepriteUtil/mock.lua | 2 +- src/pkg/bitmap/bitmap.lua | 97 +++++++++++--------- src/pkg/bitmap/bitmap_test.lua | 157 +++++++++++++++++---------------- src/pkg/bitmap/init.lua | 4 +- 4 files changed, 138 insertions(+), 122 deletions(-) diff --git a/src/pkg/asepriteUtil/mock.lua b/src/pkg/asepriteUtil/mock.lua index af3a2cd..8785119 100644 --- a/src/pkg/asepriteUtil/mock.lua +++ b/src/pkg/asepriteUtil/mock.lua @@ -19,7 +19,7 @@ local pixelColor = { ---@return integer rgba = function (r, g, b, a) a = a or 255 - return (" Date: Sat, 15 Nov 2025 01:30:32 +0900 Subject: [PATCH 09/10] refactor: remove empty init.lua --- src/pkg/bitmap/bitmap.lua | 117 ------------------------------------- src/pkg/bitmap/init.lua | 118 +++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 119 deletions(-) delete mode 100644 src/pkg/bitmap/bitmap.lua diff --git a/src/pkg/bitmap/bitmap.lua b/src/pkg/bitmap/bitmap.lua deleted file mode 100644 index 02dcc9d..0000000 --- a/src/pkg/bitmap/bitmap.lua +++ /dev/null @@ -1,117 +0,0 @@ ----Bitmap class for encoding 24-bit RGB images to BMP format - -local pack = require("pkg.string.pack") - ----Generates BMP file header (14 bytes) ----@param fileSize integer Total file size in bytes ----@return string Binary header data -local function createFileHeader (fileSize) - return table.concat({ - "BM", -- Signature - pack.u32LE(fileSize), -- File size - pack.u32LE(0), -- Reserved - pack.u32LE(54), -- Data offset (14 + 40) - }) -end - ----Generates bitmap info header (40 bytes) ----@param width integer Image width in pixels ----@param height integer Image height in pixels ----@return string Binary header data -local function createInfoHeader (width, height) - return table.concat({ - pack.u32LE(40), -- Header size - pack.i32LE(width), -- Image width - pack.i32LE(height), -- Image height - pack.u16LE(1), -- Planes (always 1) - pack.u16LE(24), -- Bits per pixel - pack.u32LE(0), -- Compression (0 = uncompressed) - pack.u32LE(0), -- Image size (0 is valid for uncompressed) - pack.i32LE(0), -- X pixels per meter (0 = not specified) - pack.i32LE(0), -- Y pixels per meter (0 = not specified) - pack.u32LE(0), -- Colors used (0 = all colors) - pack.u32LE(0), -- Important colors (0 = all important) - }) -end - ----Encodes image pixels to BMP format ----Converts RGB to BGR, processes bottom-to-top, adds row padding ----@param image Image Aseprite Image object ----@return string Binary pixel data -local function encodePixels (image) - local width = image.width - local height = image.height - local padding = (4 - (width * 3) % 4) % 4 - local rows = {} - - -- Process rows from bottom to top (BMP stores rows bottom-to-top) - for y = height - 1, 0, -1 do - local row = {} - - -- Process each pixel in the row - for x = 0, width - 1 do - local pixel = image:getPixel(x, y) - - -- Store as BGR (not RGB!) - table.insert(row, pack.u8(app.pixelColor.rgbaB(pixel))) - table.insert(row, pack.u8(app.pixelColor.rgbaG(pixel))) - table.insert(row, pack.u8(app.pixelColor.rgbaR(pixel))) - end - - -- Add padding bytes to align row to 4-byte boundary - for i = 1, padding do - table.insert(row, "\x00") - end - - table.insert(rows, table.concat(row)) - end - - return table.concat(rows) -end - ----@class BitmapFile ----@field fileHeader string BMP file header (14 bytes) ----@field bitmapInfoHeader string Bitmap info header (40 bytes) ----@field pixelData string Binary pixel data -local BitmapFile = {} - ----Converts the bitmap file to a binary string ----@return string -function BitmapFile.tostring (self) - return table.concat({ - self.fileHeader, - self.bitmapInfoHeader, - self.pixelData, - }) -end - ----Creates a BitmapFile from an Aseprite Image ----@param image Image Aseprite RGB Image object ----@return BitmapFile -local function createBitmap (image) - -- Validate color mode - if image.colorMode ~= ColorMode.RGB then - error("Only RGB images are supported for BMP export") - end - - -- Calculate sizes - local rowSize = image.width * 3 + ((4 - (image.width * 3) % 4) % 4) - local pixelDataSize = rowSize * image.height - local fileSize = 54 + pixelDataSize -- 14 (file header) + 40 (info header) + pixel data - - -- Generate BMP components - local fileHeader = createFileHeader(fileSize) - local bitmapInfoHeader = createInfoHeader(image.width, image.height) - local pixelData = encodePixels(image) - - local value = { - fileHeader = fileHeader, - bitmapInfoHeader = bitmapInfoHeader, - pixelData = pixelData, - } - setmetatable(value, { __index = BitmapFile, __tostring = BitmapFile.tostring }) - - return value -end - -return { create = createBitmap } diff --git a/src/pkg/bitmap/init.lua b/src/pkg/bitmap/init.lua index 47edc9f..02dcc9d 100644 --- a/src/pkg/bitmap/init.lua +++ b/src/pkg/bitmap/init.lua @@ -1,3 +1,117 @@ ----Bitmap (BMP) file format encoder for 24-bit RGB images +---Bitmap class for encoding 24-bit RGB images to BMP format -return require("pkg.bitmap.bitmap") +local pack = require("pkg.string.pack") + +---Generates BMP file header (14 bytes) +---@param fileSize integer Total file size in bytes +---@return string Binary header data +local function createFileHeader (fileSize) + return table.concat({ + "BM", -- Signature + pack.u32LE(fileSize), -- File size + pack.u32LE(0), -- Reserved + pack.u32LE(54), -- Data offset (14 + 40) + }) +end + +---Generates bitmap info header (40 bytes) +---@param width integer Image width in pixels +---@param height integer Image height in pixels +---@return string Binary header data +local function createInfoHeader (width, height) + return table.concat({ + pack.u32LE(40), -- Header size + pack.i32LE(width), -- Image width + pack.i32LE(height), -- Image height + pack.u16LE(1), -- Planes (always 1) + pack.u16LE(24), -- Bits per pixel + pack.u32LE(0), -- Compression (0 = uncompressed) + pack.u32LE(0), -- Image size (0 is valid for uncompressed) + pack.i32LE(0), -- X pixels per meter (0 = not specified) + pack.i32LE(0), -- Y pixels per meter (0 = not specified) + pack.u32LE(0), -- Colors used (0 = all colors) + pack.u32LE(0), -- Important colors (0 = all important) + }) +end + +---Encodes image pixels to BMP format +---Converts RGB to BGR, processes bottom-to-top, adds row padding +---@param image Image Aseprite Image object +---@return string Binary pixel data +local function encodePixels (image) + local width = image.width + local height = image.height + local padding = (4 - (width * 3) % 4) % 4 + local rows = {} + + -- Process rows from bottom to top (BMP stores rows bottom-to-top) + for y = height - 1, 0, -1 do + local row = {} + + -- Process each pixel in the row + for x = 0, width - 1 do + local pixel = image:getPixel(x, y) + + -- Store as BGR (not RGB!) + table.insert(row, pack.u8(app.pixelColor.rgbaB(pixel))) + table.insert(row, pack.u8(app.pixelColor.rgbaG(pixel))) + table.insert(row, pack.u8(app.pixelColor.rgbaR(pixel))) + end + + -- Add padding bytes to align row to 4-byte boundary + for i = 1, padding do + table.insert(row, "\x00") + end + + table.insert(rows, table.concat(row)) + end + + return table.concat(rows) +end + +---@class BitmapFile +---@field fileHeader string BMP file header (14 bytes) +---@field bitmapInfoHeader string Bitmap info header (40 bytes) +---@field pixelData string Binary pixel data +local BitmapFile = {} + +---Converts the bitmap file to a binary string +---@return string +function BitmapFile.tostring (self) + return table.concat({ + self.fileHeader, + self.bitmapInfoHeader, + self.pixelData, + }) +end + +---Creates a BitmapFile from an Aseprite Image +---@param image Image Aseprite RGB Image object +---@return BitmapFile +local function createBitmap (image) + -- Validate color mode + if image.colorMode ~= ColorMode.RGB then + error("Only RGB images are supported for BMP export") + end + + -- Calculate sizes + local rowSize = image.width * 3 + ((4 - (image.width * 3) % 4) % 4) + local pixelDataSize = rowSize * image.height + local fileSize = 54 + pixelDataSize -- 14 (file header) + 40 (info header) + pixel data + + -- Generate BMP components + local fileHeader = createFileHeader(fileSize) + local bitmapInfoHeader = createInfoHeader(image.width, image.height) + local pixelData = encodePixels(image) + + local value = { + fileHeader = fileHeader, + bitmapInfoHeader = bitmapInfoHeader, + pixelData = pixelData, + } + setmetatable(value, { __index = BitmapFile, __tostring = BitmapFile.tostring }) + + return value +end + +return { create = createBitmap } From 53fbeb564b36943d08d5474308d0a15b23b46bc2 Mon Sep 17 00:00:00 2001 From: Mooncake Sugar Date: Sat, 15 Nov 2025 01:35:51 +0900 Subject: [PATCH 10/10] refactor: change expression --- src/pkg/bitmap/init.lua | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/pkg/bitmap/init.lua b/src/pkg/bitmap/init.lua index 02dcc9d..654ffeb 100644 --- a/src/pkg/bitmap/init.lua +++ b/src/pkg/bitmap/init.lua @@ -1,8 +1,6 @@ ----Bitmap class for encoding 24-bit RGB images to BMP format - local pack = require("pkg.string.pack") ----Generates BMP file header (14 bytes) +---Generates BMP file header ---@param fileSize integer Total file size in bytes ---@return string Binary header data local function createFileHeader (fileSize) @@ -14,11 +12,11 @@ local function createFileHeader (fileSize) }) end ----Generates bitmap info header (40 bytes) +---Generates bitmap info header ---@param width integer Image width in pixels ---@param height integer Image height in pixels ---@return string Binary header data -local function createInfoHeader (width, height) +local function createBitmapInfoHeader (width, height) return table.concat({ pack.u32LE(40), -- Header size pack.i32LE(width), -- Image width @@ -44,24 +42,21 @@ local function encodePixels (image) local padding = (4 - (width * 3) % 4) % 4 local rows = {} - -- Process rows from bottom to top (BMP stores rows bottom-to-top) + -- Process rows from bottom to top for y = height - 1, 0, -1 do local row = {} - -- Process each pixel in the row for x = 0, width - 1 do local pixel = image:getPixel(x, y) - -- Store as BGR (not RGB!) + -- Store as BGR table.insert(row, pack.u8(app.pixelColor.rgbaB(pixel))) table.insert(row, pack.u8(app.pixelColor.rgbaG(pixel))) table.insert(row, pack.u8(app.pixelColor.rgbaR(pixel))) end -- Add padding bytes to align row to 4-byte boundary - for i = 1, padding do - table.insert(row, "\x00") - end + table.insert(row, ("\x00"):rep(padding)) table.insert(rows, table.concat(row)) end @@ -89,7 +84,6 @@ end ---@param image Image Aseprite RGB Image object ---@return BitmapFile local function createBitmap (image) - -- Validate color mode if image.colorMode ~= ColorMode.RGB then error("Only RGB images are supported for BMP export") end @@ -101,7 +95,7 @@ local function createBitmap (image) -- Generate BMP components local fileHeader = createFileHeader(fileSize) - local bitmapInfoHeader = createInfoHeader(image.width, image.height) + local bitmapInfoHeader = createBitmapInfoHeader(image.width, image.height) local pixelData = encodePixels(image) local value = {