luajitos

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

Image.lua (49887B)


      1 -- Image.lua - Image Creation and Manipulation Library
      2 -- Requires: "imaging" permission
      3 -- Uses BGRA binary string buffer (matching screen buffer format for fast blitting)
      4 -- Internal format: 4 bytes per pixel, order: B, G, R, A (little-endian 0xAARRGGBB)
      5 
      6 local Image = {}
      7 
      8 -- Global fs override (can be set by app or use sandbox fs)
      9 Image.fsOverride = nil
     10 
     11 -- Permission check removed - if the library is loaded, permission was already granted
     12 local function checkPermission()
     13     return true
     14 end
     15 
     16 -- Helper: Get filesystem to use (fsOverride > sandbox fs > CRamdisk)
     17 local function getFS()
     18     -- 1. Check for explicit override
     19     if Image.fsOverride then
     20         return Image.fsOverride
     21     end
     22 
     23     -- 2. Check for sandbox fs
     24     if fs then
     25         return fs
     26     end
     27 
     28     -- 3. Fall back to CRamdisk (if available)
     29     return nil
     30 end
     31 
     32 -- Helper: Read file using appropriate filesystem
     33 local function readFile(path)
     34     local filesystem = getFS()
     35 
     36     if filesystem then
     37         -- Use SafeFS or custom fs
     38         local content, err = filesystem:read(path)
     39         if not content then
     40             return nil, err or ("Failed to read file: " .. path)
     41         end
     42         return content
     43     else
     44         -- Fall back to CRamdisk
     45         if not CRamdiskExists or not CRamdiskOpen or not CRamdiskRead or not CRamdiskClose then
     46             return nil, "No filesystem available (fs not set and CRamdisk functions not available)"
     47         end
     48 
     49         if not CRamdiskExists(path) then
     50             return nil, "File not found: " .. path
     51         end
     52 
     53         local handle = CRamdiskOpen(path, "r")
     54         if not handle then
     55             return nil, "Failed to open file: " .. path
     56         end
     57 
     58         local content = CRamdiskRead(handle)
     59         CRamdiskClose(handle)
     60 
     61         if not content then
     62             return nil, "Failed to read file: " .. path
     63         end
     64 
     65         return content
     66     end
     67 end
     68 
     69 -- Helper: Write file using appropriate filesystem
     70 local function writeFile(path, data)
     71     local filesystem = getFS()
     72 
     73     if filesystem then
     74         -- Use SafeFS or custom fs
     75         local success, err = filesystem:write(path, data)
     76         if not success then
     77             return nil, err or ("Failed to write file: " .. path)
     78         end
     79         return true
     80     else
     81         -- Fall back to CRamdisk
     82         if not CRamdiskOpen or not CRamdiskWrite or not CRamdiskClose then
     83             return nil, "No filesystem available (fs not set and CRamdisk functions not available)"
     84         end
     85 
     86         local handle = CRamdiskOpen(path, "w")
     87         if not handle then
     88             return nil, "Failed to open file for writing: " .. path
     89         end
     90 
     91         local success = CRamdiskWrite(handle, data)
     92         CRamdiskClose(handle)
     93 
     94         if not success then
     95             return nil, "Failed to write file: " .. path
     96         end
     97 
     98         return true
     99     end
    100 end
    101 
    102 -- Image object
    103 local ImageObj = {}
    104 ImageObj.__index = ImageObj
    105 ImageObj.__metatable = false  -- Prevent metatable access/modification
    106 
    107 -- Create a new image
    108 -- @param width Image width in pixels
    109 -- @param height Image height in pixels
    110 -- @param hasAlpha Optional, default true - whether image uses alpha (always stored as 4 bytes)
    111 -- @return Image object
    112 function Image.new(width, height, hasAlpha)
    113     checkPermission()
    114 
    115     if not width or not height then
    116         error("Image.new requires width and height")
    117     end
    118 
    119     if width <= 0 or width > 4096 then
    120         error("Image width must be between 1 and 4096")
    121     end
    122 
    123     if height <= 0 or height > 4096 then
    124         error("Image height must be between 1 and 4096")
    125     end
    126 
    127     hasAlpha = (hasAlpha == nil) and true or hasAlpha
    128 
    129     local self = setmetatable({}, ImageObj)
    130 
    131     self.width = width
    132     self.height = height
    133     self.hasAlpha = hasAlpha
    134     self.bytesPerPixel = 4  -- Always 4 bytes (BGRA) for screen buffer compatibility
    135 
    136     -- Create binary buffer for pixels
    137     -- Format: BGRA (4 bytes per pixel) matching screen buffer
    138     -- Memory order: B, G, R, A (which is 0xAARRGGBB in little-endian uint32)
    139     local numPixels = width * height
    140 
    141     -- Initialize to transparent black (or opaque black if no alpha)
    142     local pixel
    143     if hasAlpha then
    144         pixel = "\0\0\0\0"  -- BGRA: transparent black (B=0, G=0, R=0, A=0)
    145     else
    146         pixel = "\0\0\0\255"  -- BGRA: opaque black (B=0, G=0, R=0, A=255)
    147     end
    148 
    149     self.buffer = string.rep(pixel, numPixels)
    150 
    151     return self
    152 end
    153 
    154 -- Write a pixel to the image
    155 -- @param x X coordinate (0-based)
    156 -- @param y Y coordinate (0-based)
    157 -- @param color Color in hex format "RRGGBB" or "RRGGBBAA", number (0xRRGGBB or 0xAARRGGBB), or table {r, g, b, a}
    158 function ImageObj:writePixel(x, y, color)
    159     checkPermission()
    160 
    161     if not x or not y or not color then
    162         error("writePixel requires x, y, and color")
    163     end
    164 
    165     if x < 0 or x >= self.width or y < 0 or y >= self.height then
    166         return  -- Silently ignore out of bounds (more efficient for drawing)
    167     end
    168 
    169     -- Parse color to get r, g, b, a values
    170     local r, g, b, a
    171 
    172     if type(color) == "table" then
    173         -- Table format {r, g, b, a}
    174         r = color.r or color[1] or 0
    175         g = color.g or color[2] or 0
    176         b = color.b or color[3] or 0
    177         a = color.a or color[4] or 255
    178     elseif type(color) == "string" then
    179         -- Remove # if present
    180         color = color:gsub("^#", "")
    181 
    182         if #color == 6 then
    183             -- RRGGBB format
    184             r = tonumber(color:sub(1, 2), 16) or 0
    185             g = tonumber(color:sub(3, 4), 16) or 0
    186             b = tonumber(color:sub(5, 6), 16) or 0
    187             a = 255
    188         elseif #color == 8 then
    189             -- RRGGBBAA format
    190             r = tonumber(color:sub(1, 2), 16) or 0
    191             g = tonumber(color:sub(3, 4), 16) or 0
    192             b = tonumber(color:sub(5, 6), 16) or 0
    193             a = tonumber(color:sub(7, 8), 16) or 255
    194         else
    195             r, g, b, a = 0, 0, 0, 255
    196         end
    197     elseif type(color) == "number" then
    198         -- Treat as 0xRRGGBB or 0xAARRGGBB
    199         if color <= 0xFFFFFF then
    200             -- RGB format (0xRRGGBB)
    201             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    202             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    203             b = bit.band(color, 0x0000FF)
    204             a = 255
    205         else
    206             -- ARGB format (0xAARRGGBB)
    207             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    208             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    209             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    210             b = bit.band(color, 0x000000FF)
    211         end
    212     else
    213         r, g, b, a = 0, 0, 0, 255
    214     end
    215 
    216     -- Calculate byte offset in buffer (4 bytes per pixel, BGRA order)
    217     local pixelIndex = y * self.width + x
    218     local byteOffset = pixelIndex * 4 + 1
    219 
    220     -- Write BGRA bytes
    221     local newBytes = string.char(b, g, r, a)
    222 
    223     -- Replace bytes in buffer
    224     self.buffer = self.buffer:sub(1, byteOffset - 1) ..
    225                   newBytes ..
    226                   self.buffer:sub(byteOffset + 4)
    227 end
    228 
    229 -- Read a pixel from the image
    230 -- @param x X coordinate (0-based)
    231 -- @param y Y coordinate (0-based)
    232 -- @return Color string in "RRGGBB" or "RRGGBBAA" format
    233 function ImageObj:readPixel(x, y)
    234     checkPermission()
    235 
    236     if not x or not y then
    237         error("readPixel requires x and y coordinates")
    238     end
    239 
    240     if x < 0 or x >= self.width or y < 0 or y >= self.height then
    241         error("Pixel coordinates out of bounds: (" .. x .. ", " .. y .. ")")
    242     end
    243 
    244     -- Calculate byte offset in buffer (4 bytes per pixel, BGRA order)
    245     local pixelIndex = y * self.width + x
    246     local byteOffset = pixelIndex * 4 + 1
    247 
    248     -- Read BGRA bytes
    249     local b = string.byte(self.buffer, byteOffset)
    250     local g = string.byte(self.buffer, byteOffset + 1)
    251     local r = string.byte(self.buffer, byteOffset + 2)
    252     local a = string.byte(self.buffer, byteOffset + 3)
    253 
    254     -- Format as hex string (RRGGBBAA)
    255     if self.hasAlpha then
    256         return string.format("%02X%02X%02X%02X", r, g, b, a)
    257     else
    258         return string.format("%02X%02X%02X", r, g, b)
    259     end
    260 end
    261 
    262 -- Get pixel as RGBA table
    263 -- @param x X coordinate (0-based)
    264 -- @param y Y coordinate (0-based)
    265 -- @return Table with r, g, b, a fields (0-255)
    266 function ImageObj:getPixel(x, y)
    267     checkPermission()
    268 
    269     if not x or not y then
    270         error("getPixel requires x and y coordinates")
    271     end
    272 
    273     if x < 0 or x >= self.width or y < 0 or y >= self.height then
    274         error("Pixel coordinates out of bounds: (" .. x .. ", " .. y .. ")")
    275     end
    276 
    277     local pixelIndex = y * self.width + x
    278     local byteOffset = pixelIndex * 4 + 1
    279 
    280     -- Read BGRA bytes
    281     local b = string.byte(self.buffer, byteOffset)
    282     local g = string.byte(self.buffer, byteOffset + 1)
    283     local r = string.byte(self.buffer, byteOffset + 2)
    284     local a = string.byte(self.buffer, byteOffset + 3)
    285 
    286     return {
    287         r = r,
    288         g = g,
    289         b = b,
    290         a = a
    291     }
    292 end
    293 
    294 -- Set pixel color using C function for efficiency
    295 -- @param x X coordinate (0-based)
    296 -- @param y Y coordinate (0-based)
    297 -- @param r_or_rgba Red value (0-255) or table {r, g, b, a}
    298 -- @param g Green value (0-255), optional if first arg is table
    299 -- @param b Blue value (0-255), optional if first arg is table
    300 -- @param a Alpha value (0-255), optional, defaults to 255
    301 function ImageObj:setPixel(x, y, r_or_rgba, g, b, a)
    302     checkPermission()
    303 
    304     if x == nil or y == nil or r_or_rgba == nil then
    305         error("setPixel requires x, y, and color")
    306     end
    307 
    308     local r, g_val, b_val, a_val
    309     if type(r_or_rgba) == "table" then
    310         r = r_or_rgba.r or r_or_rgba[1] or 0
    311         g_val = r_or_rgba.g or r_or_rgba[2] or 0
    312         b_val = r_or_rgba.b or r_or_rgba[3] or 0
    313         a_val = r_or_rgba.a or r_or_rgba[4] or 255
    314     else
    315         r = r_or_rgba or 0
    316         g_val = g or 0
    317         b_val = b or 0
    318         a_val = a or 255
    319     end
    320 
    321     -- Use C function for efficiency
    322     if BufferSetPixel then
    323         self.buffer = BufferSetPixel(self.buffer, self.width, self.height, x, y, r, g_val, b_val, a_val)
    324         return
    325     end
    326 
    327     -- Fallback to Lua implementation
    328     if x < 0 or x >= self.width or y < 0 or y >= self.height then
    329         return
    330     end
    331 
    332     local pixelIndex = y * self.width + x
    333     local byteOffset = pixelIndex * 4 + 1
    334     local newBytes = string.char(b_val, g_val, r, a_val)
    335     self.buffer = self.buffer:sub(1, byteOffset - 1) .. newBytes .. self.buffer:sub(byteOffset + 4)
    336 end
    337 
    338 -- Fill a circle with a color (uses C for efficiency)
    339 -- @param cx Center X coordinate
    340 -- @param cy Center Y coordinate
    341 -- @param radius Circle radius
    342 -- @param color Color as number (0xRRGGBB or 0xAARRGGBB) or table {r, g, b, a}
    343 function ImageObj:fillCircle(cx, cy, radius, color)
    344     checkPermission()
    345 
    346     local r, g, b, a = 0, 0, 0, 255
    347     if type(color) == "table" then
    348         r = color.r or color[1] or 0
    349         g = color.g or color[2] or 0
    350         b = color.b or color[3] or 0
    351         a = color.a or color[4] or 255
    352     elseif type(color) == "number" then
    353         if color <= 0xFFFFFF then
    354             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    355             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    356             b = bit.band(color, 0x0000FF)
    357         else
    358             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    359             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    360             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    361             b = bit.band(color, 0x000000FF)
    362         end
    363     end
    364 
    365     -- Use in-place version (no buffer copy, modifies directly)
    366     if BufferFillCircleInplace then
    367         BufferFillCircleInplace(self.buffer, self.width, self.height, cx, cy, radius, r, g, b, a)
    368     elseif BufferFillCircle then
    369         self.buffer = BufferFillCircle(self.buffer, self.width, self.height, cx, cy, radius, r, g, b, a)
    370     else
    371         -- Fallback to Lua
    372         local r2 = radius * radius
    373         for dy = -radius, radius do
    374             for dx = -radius, radius do
    375                 if dx*dx + dy*dy <= r2 then
    376                     self:setPixel(cx + dx, cy + dy, r, g, b, a)
    377                 end
    378             end
    379         end
    380     end
    381 end
    382 
    383 -- Draw a line with variable thickness (uses C for efficiency)
    384 -- @param x1 Start X
    385 -- @param y1 Start Y
    386 -- @param x2 End X
    387 -- @param y2 End Y
    388 -- @param thickness Line thickness in pixels
    389 -- @param color Color as number or table
    390 function ImageObj:drawLine(x1, y1, x2, y2, thickness, color)
    391     checkPermission()
    392 
    393     local r, g, b, a = 0, 0, 0, 255
    394     if type(color) == "table" then
    395         r = color.r or color[1] or 0
    396         g = color.g or color[2] or 0
    397         b = color.b or color[3] or 0
    398         a = color.a or color[4] or 255
    399     elseif type(color) == "number" then
    400         if color <= 0xFFFFFF then
    401             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    402             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    403             b = bit.band(color, 0x0000FF)
    404         else
    405             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    406             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    407             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    408             b = bit.band(color, 0x000000FF)
    409         end
    410     end
    411 
    412     -- Use in-place version (no buffer copy, modifies directly)
    413     if BufferDrawLineInplace then
    414         BufferDrawLineInplace(self.buffer, self.width, self.height, x1, y1, x2, y2, thickness, r, g, b, a)
    415     elseif BufferDrawLine then
    416         self.buffer = BufferDrawLine(self.buffer, self.width, self.height, x1, y1, x2, y2, thickness, r, g, b, a)
    417     else
    418         -- Fallback: just draw circles along the line
    419         local dx = math.abs(x2 - x1)
    420         local dy = math.abs(y2 - y1)
    421         local sx = x1 < x2 and 1 or -1
    422         local sy = y1 < y2 and 1 or -1
    423         local err = dx - dy
    424         local radius = math.floor(thickness / 2)
    425 
    426         while true do
    427             self:fillCircle(x1, y1, radius, {r, g, b, a})
    428             if x1 == x2 and y1 == y2 then break end
    429             local e2 = 2 * err
    430             if e2 > -dy then err = err - dy; x1 = x1 + sx end
    431             if e2 < dx then err = err + dx; y1 = y1 + sy end
    432         end
    433     end
    434 end
    435 
    436 -- Fill a rectangle with a color (uses C for efficiency)
    437 -- @param x Top-left X
    438 -- @param y Top-left Y
    439 -- @param w Width
    440 -- @param h Height
    441 -- @param color Color as number or table
    442 function ImageObj:fillRect(x, y, w, h, color)
    443     checkPermission()
    444 
    445     local r, g, b, a = 0, 0, 0, 255
    446     if type(color) == "table" then
    447         r = color.r or color[1] or 0
    448         g = color.g or color[2] or 0
    449         b = color.b or color[3] or 0
    450         a = color.a or color[4] or 255
    451     elseif type(color) == "number" then
    452         if color <= 0xFFFFFF then
    453             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    454             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    455             b = bit.band(color, 0x0000FF)
    456         else
    457             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    458             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    459             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    460             b = bit.band(color, 0x000000FF)
    461         end
    462     end
    463 
    464     if BufferFillRect then
    465         self.buffer = BufferFillRect(self.buffer, self.width, self.height, x, y, w, h, r, g, b, a)
    466     else
    467         -- Fallback to Lua
    468         for py = y, y + h - 1 do
    469             for px = x, x + w - 1 do
    470                 self:setPixel(px, py, r, g, b, a)
    471             end
    472         end
    473     end
    474 end
    475 
    476 -- Blit a row of BGRA data at the specified y position
    477 -- @param y Y coordinate
    478 -- @param rowData Binary string of BGRA pixels
    479 -- @param startX Starting X position (optional, defaults to 0)
    480 function ImageObj:blitRow(y, rowData, startX)
    481     checkPermission()
    482     startX = startX or 0
    483 
    484     if BufferBlitRow then
    485         self.buffer = BufferBlitRow(self.buffer, self.width, self.height, y, rowData, startX)
    486     else
    487         -- Fallback: copy pixel by pixel
    488         local pixels = #rowData / 4
    489         for i = 0, pixels - 1 do
    490             local offset = i * 4 + 1
    491             local b = rowData:byte(offset)
    492             local g = rowData:byte(offset + 1)
    493             local r = rowData:byte(offset + 2)
    494             local a = rowData:byte(offset + 3)
    495             self:setPixel(startX + i, y, r, g, b, a)
    496         end
    497     end
    498 end
    499 
    500 -- Fill entire image with a color
    501 -- @param color Color in hex format "RRGGBB" or "RRGGBBAA", number, or table {r, g, b, a}
    502 function ImageObj:fill(color)
    503     checkPermission()
    504 
    505     -- Parse color to get r, g, b, a values
    506     local r, g, b, a = 0, 0, 0, 255
    507 
    508     if type(color) == "table" then
    509         r = color.r or color[1] or 0
    510         g = color.g or color[2] or 0
    511         b = color.b or color[3] or 0
    512         a = color.a or color[4] or 255
    513     elseif type(color) == "string" then
    514         color = color:gsub("^#", "")
    515         if #color == 6 then
    516             r = tonumber(color:sub(1, 2), 16) or 0
    517             g = tonumber(color:sub(3, 4), 16) or 0
    518             b = tonumber(color:sub(5, 6), 16) or 0
    519             a = 255
    520         elseif #color == 8 then
    521             r = tonumber(color:sub(1, 2), 16) or 0
    522             g = tonumber(color:sub(3, 4), 16) or 0
    523             b = tonumber(color:sub(5, 6), 16) or 0
    524             a = tonumber(color:sub(7, 8), 16) or 255
    525         end
    526     elseif type(color) == "number" then
    527         if color <= 0xFFFFFF then
    528             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    529             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    530             b = bit.band(color, 0x0000FF)
    531             a = 255
    532         else
    533             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    534             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    535             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    536             b = bit.band(color, 0x000000FF)
    537         end
    538     end
    539 
    540     -- Use C function if available
    541     if BufferFill then
    542         self.buffer = BufferFill(self.buffer, self.width, self.height, r, g, b, a)
    543         return
    544     end
    545 
    546     -- Fallback: Create single pixel in BGRA format and repeat
    547     local pixel = string.char(b, g, r, a)
    548     local numPixels = self.width * self.height
    549     self.buffer = string.rep(pixel, numPixels)
    550 end
    551 
    552 -- Clear image to transparent (or opaque black if no alpha)
    553 function ImageObj:clear()
    554     checkPermission()
    555 
    556     if self.hasAlpha then
    557         self:fill({r=0, g=0, b=0, a=0})
    558     else
    559         self:fill({r=0, g=0, b=0, a=255})
    560     end
    561 end
    562 
    563 -- Get image dimensions
    564 -- @return width, height
    565 function ImageObj:getSize()
    566     return self.width, self.height
    567 end
    568 
    569 -- Get image info
    570 -- @return Table with width, height, hasAlpha, bytesPerPixel
    571 function ImageObj:getInfo()
    572     return {
    573         width = self.width,
    574         height = self.height,
    575         hasAlpha = self.hasAlpha,
    576         bytesPerPixel = self.bytesPerPixel,
    577         bufferSize = #self.buffer
    578     }
    579 end
    580 
    581 -- Draw a filled rectangle
    582 -- @param x X coordinate of top-left corner
    583 -- @param y Y coordinate of top-left corner
    584 -- @param width Rectangle width
    585 -- @param height Rectangle height
    586 -- @param color Fill color
    587 function ImageObj:fillRect(x, y, width, height, color)
    588     checkPermission()
    589 
    590     -- Parse color once
    591     local r, g, b, a = 0, 0, 0, 255
    592     if type(color) == "number" then
    593         if color <= 0xFFFFFF then
    594             r = bit.rshift(bit.band(color, 0xFF0000), 16)
    595             g = bit.rshift(bit.band(color, 0x00FF00), 8)
    596             b = bit.band(color, 0x0000FF)
    597         else
    598             a = bit.rshift(bit.band(color, 0xFF000000), 24)
    599             r = bit.rshift(bit.band(color, 0x00FF0000), 16)
    600             g = bit.rshift(bit.band(color, 0x0000FF00), 8)
    601             b = bit.band(color, 0x000000FF)
    602         end
    603     elseif type(color) == "table" then
    604         r = color.r or color[1] or 0
    605         g = color.g or color[2] or 0
    606         b = color.b or color[3] or 0
    607         a = color.a or color[4] or 255
    608     end
    609 
    610     local pixel = string.char(b, g, r, a)
    611 
    612     -- Clip to image bounds
    613     local x1 = math.max(0, x)
    614     local y1 = math.max(0, y)
    615     local x2 = math.min(self.width, x + width)
    616     local y2 = math.min(self.height, y + height)
    617 
    618     if x1 >= x2 or y1 >= y2 then return end
    619 
    620     local rectWidth = x2 - x1
    621     local rowPixels = string.rep(pixel, rectWidth)
    622 
    623     -- Build new buffer with rectangle
    624     local parts = {}
    625     local imgWidth = self.width
    626 
    627     -- Rows before rectangle
    628     if y1 > 0 then
    629         parts[#parts + 1] = self.buffer:sub(1, y1 * imgWidth * 4)
    630     end
    631 
    632     -- Rectangle rows
    633     for row = y1, y2 - 1 do
    634         local rowStart = row * imgWidth * 4 + 1
    635         -- Pixels before rectangle in this row
    636         if x1 > 0 then
    637             parts[#parts + 1] = self.buffer:sub(rowStart, rowStart + x1 * 4 - 1)
    638         end
    639         -- Rectangle pixels
    640         parts[#parts + 1] = rowPixels
    641         -- Pixels after rectangle in this row
    642         if x2 < imgWidth then
    643             parts[#parts + 1] = self.buffer:sub(rowStart + x2 * 4, rowStart + imgWidth * 4 - 1)
    644         end
    645     end
    646 
    647     -- Rows after rectangle
    648     if y2 < self.height then
    649         parts[#parts + 1] = self.buffer:sub(y2 * imgWidth * 4 + 1)
    650     end
    651 
    652     self.buffer = table.concat(parts)
    653 end
    654 
    655 -- Draw a line (Bresenham's algorithm)
    656 -- @param x1 Start X
    657 -- @param y1 Start Y
    658 -- @param x2 End X
    659 -- @param y2 End Y
    660 -- @param color Line color
    661 function ImageObj:drawLine(x1, y1, x2, y2, color)
    662     checkPermission()
    663 
    664     local dx = math.abs(x2 - x1)
    665     local dy = math.abs(y2 - y1)
    666     local sx = x1 < x2 and 1 or -1
    667     local sy = y1 < y2 and 1 or -1
    668     local err = dx - dy
    669 
    670     while true do
    671         if x1 >= 0 and x1 < self.width and y1 >= 0 and y1 < self.height then
    672             self:writePixel(x1, y1, color)
    673         end
    674 
    675         if x1 == x2 and y1 == y2 then
    676             break
    677         end
    678 
    679         local e2 = 2 * err
    680         if e2 > -dy then
    681             err = err - dy
    682             x1 = x1 + sx
    683         end
    684         if e2 < dx then
    685             err = err + dx
    686             y1 = y1 + sy
    687         end
    688     end
    689 end
    690 
    691 -- Get raw buffer (BGRA format, ready for screen blitting)
    692 -- @return Binary string buffer
    693 function ImageObj:getBuffer()
    694     checkPermission()
    695     return self.buffer
    696 end
    697 
    698 -- Get row of pixels as binary string (for fast blitting)
    699 -- @param y Row index (0-based)
    700 -- @return Binary string of row pixels in BGRA format
    701 function ImageObj:getRow(y)
    702     checkPermission()
    703     if y < 0 or y >= self.height then
    704         return nil
    705     end
    706     local rowStart = y * self.width * 4 + 1
    707     local rowEnd = rowStart + self.width * 4 - 1
    708     return self.buffer:sub(rowStart, rowEnd)
    709 end
    710 
    711 -- Begin batch edit mode - converts buffer to table for faster pixel writes
    712 -- Call endBatch() when done to convert back to string
    713 function ImageObj:beginBatch()
    714     checkPermission()
    715     if self._batchMode then return end  -- Already in batch mode
    716 
    717     -- Convert string buffer to table of bytes for fast random access
    718     -- Process in chunks to avoid "string slice too long" error
    719     self._batchBuffer = {}
    720     local bufLen = #self.buffer
    721     local chunkSize = 4096
    722 
    723     for i = 1, bufLen, chunkSize do
    724         local endIdx = math.min(i + chunkSize - 1, bufLen)
    725         local bytes = {string.byte(self.buffer, i, endIdx)}
    726         for j = 1, #bytes do
    727             self._batchBuffer[i + j - 1] = bytes[j]
    728         end
    729     end
    730     self._batchMode = true
    731 end
    732 
    733 -- End batch edit mode - converts table back to string buffer
    734 function ImageObj:endBatch()
    735     checkPermission()
    736     if not self._batchMode then return end  -- Not in batch mode
    737 
    738     -- Convert table back to string using string.char with multiple args
    739     -- Process in chunks to avoid too many arguments to string.char
    740     local chunks = {}
    741     local chunkSize = 256  -- string.char can handle many args
    742     local bufLen = #self._batchBuffer
    743 
    744     for i = 1, bufLen, chunkSize do
    745         local endIdx = math.min(i + chunkSize - 1, bufLen)
    746         -- Use unpack to pass multiple values to string.char at once
    747         chunks[#chunks + 1] = string.char(unpack(self._batchBuffer, i, endIdx))
    748     end
    749 
    750     self.buffer = table.concat(chunks)
    751     self._batchBuffer = nil
    752     self._batchMode = false
    753 end
    754 
    755 -- Fast pixel write for batch mode (takes RGB values, writes BGRA)
    756 -- @param x X coordinate
    757 -- @param y Y coordinate
    758 -- @param r Red (0-255)
    759 -- @param g Green (0-255)
    760 -- @param b Blue (0-255)
    761 -- @param a Alpha (0-255), optional, defaults to 255
    762 function ImageObj:_batchWritePixel(x, y, r, g, b, a)
    763     if not self._batchMode then return end
    764     if x < 0 or x >= self.width or y < 0 or y >= self.height then return end
    765 
    766     local pixelIndex = y * self.width + x
    767     local byteOffset = pixelIndex * 4 + 1
    768 
    769     -- Write BGRA bytes
    770     self._batchBuffer[byteOffset] = b
    771     self._batchBuffer[byteOffset + 1] = g
    772     self._batchBuffer[byteOffset + 2] = r
    773     self._batchBuffer[byteOffset + 3] = a or 255
    774 end
    775 
    776 -- Convert to native C image for efficient drawing with ImageDraw
    777 -- @return Native image handle (userdata) or nil on error
    778 function ImageObj:toNativeImage()
    779     checkPermission()
    780 
    781     -- Check if ImageCreateFromBuffer is available (fastest path)
    782     -- The buffer is already in BGRA format matching screen buffer
    783     if ImageCreateFromBuffer then
    784         -- Pass the raw buffer directly to C - it's already in the right format
    785         local img = ImageCreateFromBuffer(self.width, self.height, self.buffer, true)
    786         if img then
    787             return img
    788         end
    789         -- Fall through to slower method if this fails
    790     end
    791 
    792     -- Fallback: Check if ImageCreate is available
    793     if not ImageCreate then
    794         return nil, "ImageCreate not available"
    795     end
    796 
    797     -- Create native image
    798     local img = ImageCreate(self.width, self.height)
    799     if not img then
    800         return nil, "Failed to create native image"
    801     end
    802 
    803     -- Copy pixels to native image (slow path - pixel by pixel)
    804     for y = 0, self.height - 1 do
    805         for x = 0, self.width - 1 do
    806             local offset = (y * self.width + x) * 4 + 1
    807             local b = string.byte(self.buffer, offset)
    808             local g = string.byte(self.buffer, offset + 1)
    809             local r = string.byte(self.buffer, offset + 2)
    810             local a = string.byte(self.buffer, offset + 3)
    811 
    812             -- ImageSetPixel expects 0xAARRGGBB format
    813             local color = bit.bor(
    814                 bit.lshift(a, 24),
    815                 bit.lshift(r, 16),
    816                 bit.lshift(g, 8),
    817                 b
    818             )
    819             ImageSetPixel(img, x, y, color)
    820         end
    821     end
    822 
    823     return img
    824 end
    825 
    826 -- Add another image on top using alpha blending
    827 -- @param srcImage Source image to composite on top
    828 -- @param x X position to place source image (default: 0)
    829 -- @param y Y position to place source image (default: 0)
    830 -- @param w Width to scale source image (default: source width)
    831 -- @param h Height to scale source image (default: source height)
    832 -- @param opacity Optional global opacity multiplier 0.0-1.0 (default: 1.0)
    833 function ImageObj:addImage(srcImage, x, y, w, h, opacity)
    834     checkPermission()
    835 
    836     if not srcImage then
    837         error("addImage requires a source image")
    838     end
    839 
    840     x = x or 0
    841     y = y or 0
    842     w = w or srcImage.width
    843     h = h or srcImage.height
    844     opacity = opacity or 1.0
    845 
    846     -- Clamp opacity
    847     if opacity < 0 then opacity = 0 end
    848     if opacity > 1 then opacity = 1 end
    849 
    850     -- Calculate scaling factors
    851     local scaleX = srcImage.width / w
    852     local scaleY = srcImage.height / h
    853 
    854     -- Use batch mode for efficiency
    855     self:beginBatch()
    856 
    857     -- Blend each pixel
    858     for dstY = 0, h - 1 do
    859         for dstX = 0, w - 1 do
    860             -- Calculate destination position
    861             local destX = x + dstX
    862             local destY = y + dstY
    863 
    864             -- Skip if outside destination bounds
    865             if destX >= 0 and destX < self.width and destY >= 0 and destY < self.height then
    866                 -- Calculate source pixel (nearest neighbor sampling)
    867                 local srcX = math.floor(dstX * scaleX)
    868                 local srcY = math.floor(dstY * scaleY)
    869 
    870                 -- Clamp to source bounds
    871                 if srcX >= srcImage.width then srcX = srcImage.width - 1 end
    872                 if srcY >= srcImage.height then srcY = srcImage.height - 1 end
    873 
    874                 -- Get source pixel (BGRA format)
    875                 local srcIndex = (srcY * srcImage.width + srcX) * 4 + 1
    876                 local srcB = string.byte(srcImage.buffer, srcIndex)
    877                 local srcG = string.byte(srcImage.buffer, srcIndex + 1)
    878                 local srcR = string.byte(srcImage.buffer, srcIndex + 2)
    879                 local srcA = string.byte(srcImage.buffer, srcIndex + 3)
    880 
    881                 -- Apply global opacity
    882                 srcA = math.floor(srcA * opacity)
    883 
    884                 -- Get destination pixel
    885                 local destIndex = (destY * self.width + destX) * 4 + 1
    886                 local dstB = self._batchBuffer[destIndex]
    887                 local dstG = self._batchBuffer[destIndex + 1]
    888                 local dstR = self._batchBuffer[destIndex + 2]
    889                 local dstA = self._batchBuffer[destIndex + 3]
    890 
    891                 -- Alpha blending formula: result = src * srcAlpha + dst * (1 - srcAlpha)
    892                 local srcAlpha = srcA / 255
    893                 local invSrcAlpha = 1 - srcAlpha
    894 
    895                 -- Blend colors
    896                 local outR = math.floor(srcR * srcAlpha + dstR * invSrcAlpha)
    897                 local outG = math.floor(srcG * srcAlpha + dstG * invSrcAlpha)
    898                 local outB = math.floor(srcB * srcAlpha + dstB * invSrcAlpha)
    899                 local outA = math.floor(srcA + dstA * invSrcAlpha)
    900 
    901                 -- Clamp values
    902                 outR = math.min(255, math.max(0, outR))
    903                 outG = math.min(255, math.max(0, outG))
    904                 outB = math.min(255, math.max(0, outB))
    905                 outA = math.min(255, math.max(0, outA))
    906 
    907                 -- Write blended pixel (BGRA)
    908                 self._batchBuffer[destIndex] = outB
    909                 self._batchBuffer[destIndex + 1] = outG
    910                 self._batchBuffer[destIndex + 2] = outR
    911                 self._batchBuffer[destIndex + 3] = outA
    912             end
    913         end
    914     end
    915 
    916     self:endBatch()
    917 end
    918 
    919 -- Save image as PNG file
    920 -- @param path Path to save PNG file
    921 -- @param options Optional table with fields: fs (SafeFS instance), compression (boolean)
    922 -- @return true on success, nil and error message on failure
    923 function ImageObj:saveAsPNG(path, options)
    924     checkPermission()
    925 
    926     if not path then
    927         error("saveAsPNG requires a file path")
    928     end
    929 
    930     -- Parse options
    931     options = options or {}
    932     local compression = options.compression or false
    933     local safeFSInstance = options.fs
    934 
    935     -- Build PNG file format (convert from BGRA to RGBA)
    936     local pngData = self:_encodePNG(compression)
    937 
    938     if not pngData then
    939         return nil, "Failed to encode PNG"
    940     end
    941 
    942     -- Use provided SafeFS if specified in options
    943     if safeFSInstance then
    944         local success, err = safeFSInstance:write(path, pngData)
    945         if not success then
    946             return nil, "SafeFS write failed: " .. (err or "unknown error")
    947         end
    948         return true
    949     end
    950 
    951     -- Otherwise use the standard filesystem (fs or CRamdisk)
    952     return writeFile(path, pngData)
    953 end
    954 
    955 -- Save image as BMP file
    956 -- @param path Path to save BMP file
    957 -- @param options Optional table with fields: fs (SafeFS instance)
    958 -- @return true on success, nil and error message on failure
    959 function ImageObj:saveAsBMP(path, options)
    960     checkPermission()
    961 
    962     if not path then
    963         error("saveAsBMP requires a file path")
    964     end
    965 
    966     -- Parse options
    967     options = options or {}
    968     local safeFSInstance = options.fs
    969 
    970     -- Build BMP file format (convert from BGRA to BGR)
    971     local bmpData = self:_encodeBMP()
    972 
    973     if not bmpData then
    974         return nil, "Failed to encode BMP"
    975     end
    976 
    977     -- Use provided SafeFS if specified in options
    978     if safeFSInstance then
    979         local success, err = safeFSInstance:write(path, bmpData)
    980         if not success then
    981             return nil, "SafeFS write failed: " .. (err or "unknown error")
    982         end
    983         return true
    984     end
    985 
    986     -- Otherwise use the standard filesystem (fs or CRamdisk)
    987     return writeFile(path, bmpData)
    988 end
    989 
    990 -- Internal: Encode image as PNG format (converts BGRA buffer to RGBA for PNG)
    991 -- @param compression Use compression (true/false)
    992 -- @return PNG binary data as string
    993 function ImageObj:_encodePNG(compression)
    994     compression = compression or false
    995 
    996     local chunks = {}
    997 
    998     -- PNG signature
    999     local signature = string.char(137, 80, 78, 71, 13, 10, 26, 10)
   1000     chunks[#chunks + 1] = signature
   1001 
   1002     -- IHDR chunk (image header)
   1003     local colorType = self.hasAlpha and 6 or 2  -- 6=RGBA, 2=RGB
   1004     local interlaceMethod = 0
   1005 
   1006     local ihdr = self:_createChunk("IHDR",
   1007         self:_uint32(self.width) ..
   1008         self:_uint32(self.height) ..
   1009         string.char(8) ..          -- Bit depth
   1010         string.char(colorType) ..  -- Color type
   1011         string.char(0) ..          -- Compression method
   1012         string.char(0) ..          -- Filter method
   1013         string.char(interlaceMethod) -- Interlace method
   1014     )
   1015     chunks[#chunks + 1] = ihdr
   1016 
   1017     -- sRGB chunk
   1018     local srgb = self:_createChunk("sRGB", string.char(0))
   1019     chunks[#chunks + 1] = srgb
   1020 
   1021     -- gAMA chunk
   1022     local gama = self:_createChunk("gAMA", self:_uint32(45455))
   1023     chunks[#chunks + 1] = gama
   1024 
   1025     -- IDAT chunk (image data) - convert BGRA to RGBA
   1026     local imageData = self:_createImageDataRGBA()
   1027 
   1028     local compressedData = self:_deflateCompress(imageData, compression)
   1029     local idat = self:_createChunk("IDAT", compressedData)
   1030     chunks[#chunks + 1] = idat
   1031 
   1032     -- IEND chunk (end of file)
   1033     local iend = self:_createChunk("IEND", "")
   1034     chunks[#chunks + 1] = iend
   1035 
   1036     return table.concat(chunks)
   1037 end
   1038 
   1039 -- Internal: Create image data with PNG filtering (converts BGRA to RGBA)
   1040 -- @return Filtered image data in RGBA format
   1041 function ImageObj:_createImageDataRGBA()
   1042     local rows = {}
   1043     local width = self.width
   1044     local hasAlpha = self.hasAlpha
   1045     local buffer = self.buffer
   1046 
   1047     for y = 0, self.height - 1 do
   1048         -- Build row data: filter byte + RGBA pixels
   1049         local rowBytes = {0}  -- Filter type 0 (None)
   1050         local rowStart = y * width * 4 + 1
   1051 
   1052         for x = 0, width - 1 do
   1053             local offset = rowStart + x * 4
   1054             local b = string.byte(buffer, offset)
   1055             local g = string.byte(buffer, offset + 1)
   1056             local r = string.byte(buffer, offset + 2)
   1057             local a = string.byte(buffer, offset + 3)
   1058 
   1059             -- Write RGBA (PNG format)
   1060             if hasAlpha then
   1061                 rowBytes[#rowBytes + 1] = r
   1062                 rowBytes[#rowBytes + 1] = g
   1063                 rowBytes[#rowBytes + 1] = b
   1064                 rowBytes[#rowBytes + 1] = a
   1065             else
   1066                 rowBytes[#rowBytes + 1] = r
   1067                 rowBytes[#rowBytes + 1] = g
   1068                 rowBytes[#rowBytes + 1] = b
   1069             end
   1070         end
   1071 
   1072         rows[#rows + 1] = string.char(unpack(rowBytes))
   1073     end
   1074 
   1075     return table.concat(rows)
   1076 end
   1077 
   1078 -- Internal: Create PNG chunk
   1079 function ImageObj:_createChunk(chunkType, data)
   1080     local length = self:_uint32(#data)
   1081     local typeAndData = chunkType .. data
   1082     local crc = self:_crc32(typeAndData)
   1083 
   1084     return length .. typeAndData .. self:_uint32(crc)
   1085 end
   1086 
   1087 -- Internal: DEFLATE compression
   1088 function ImageObj:_deflateCompress(data, useCompression)
   1089     useCompression = useCompression or false
   1090 
   1091     local result = {}
   1092 
   1093     -- Zlib header (RFC 1950)
   1094     local cmf = 0x78
   1095     local flg = 0x01
   1096 
   1097     result[#result + 1] = string.char(cmf, flg)
   1098 
   1099     -- DEFLATE data (RFC 1951) - uncompressed blocks
   1100     local pos = 1
   1101     while pos <= #data do
   1102         local blockSize = math.min(65535, #data - pos + 1)
   1103         local isLast = (pos + blockSize > #data) and 1 or 0
   1104 
   1105         result[#result + 1] = string.char(isLast)
   1106 
   1107         local len = blockSize
   1108         local nlen = bit.bxor(len, 0xFFFF)
   1109 
   1110         result[#result + 1] = string.char(
   1111             bit.band(len, 0xFF),
   1112             bit.rshift(len, 8),
   1113             bit.band(nlen, 0xFF),
   1114             bit.rshift(nlen, 8)
   1115         )
   1116 
   1117         result[#result + 1] = data:sub(pos, pos + blockSize - 1)
   1118 
   1119         pos = pos + blockSize
   1120     end
   1121 
   1122     -- Adler-32 checksum
   1123     local adler = self:_adler32(data)
   1124     result[#result + 1] = self:_uint32(adler)
   1125 
   1126     return table.concat(result)
   1127 end
   1128 
   1129 -- Internal: Calculate CRC32
   1130 function ImageObj:_crc32(data)
   1131     local crc = 0xFFFFFFFF
   1132 
   1133     for i = 1, #data do
   1134         local byte = string.byte(data, i)
   1135         crc = bit.bxor(crc, byte)
   1136 
   1137         for _ = 1, 8 do
   1138             if bit.band(crc, 1) == 1 then
   1139                 crc = bit.bxor(bit.rshift(crc, 1), 0xEDB88320)
   1140             else
   1141                 crc = bit.rshift(crc, 1)
   1142             end
   1143         end
   1144     end
   1145 
   1146     return bit.bxor(crc, 0xFFFFFFFF)
   1147 end
   1148 
   1149 -- Internal: Calculate Adler-32
   1150 function ImageObj:_adler32(data)
   1151     local s1 = 1
   1152     local s2 = 0
   1153 
   1154     for i = 1, #data do
   1155         local byte = string.byte(data, i)
   1156         s1 = (s1 + byte) % 65521
   1157         s2 = (s2 + s1) % 65521
   1158     end
   1159 
   1160     return bit.bor(bit.lshift(s2, 16), s1)
   1161 end
   1162 
   1163 -- Internal: Encode 32-bit unsigned integer as big-endian
   1164 function ImageObj:_uint32(value)
   1165     return string.char(
   1166         bit.band(bit.rshift(value, 24), 0xFF),
   1167         bit.band(bit.rshift(value, 16), 0xFF),
   1168         bit.band(bit.rshift(value, 8), 0xFF),
   1169         bit.band(value, 0xFF)
   1170     )
   1171 end
   1172 
   1173 -- Internal: Encode 32-bit unsigned integer as little-endian
   1174 function ImageObj:_uint32le(value)
   1175     return string.char(
   1176         bit.band(value, 0xFF),
   1177         bit.band(bit.rshift(value, 8), 0xFF),
   1178         bit.band(bit.rshift(value, 16), 0xFF),
   1179         bit.band(bit.rshift(value, 24), 0xFF)
   1180     )
   1181 end
   1182 
   1183 -- Internal: Encode 16-bit unsigned integer as little-endian
   1184 function ImageObj:_uint16le(value)
   1185     return string.char(
   1186         bit.band(value, 0xFF),
   1187         bit.band(bit.rshift(value, 8), 0xFF)
   1188     )
   1189 end
   1190 
   1191 -- Internal: Encode image as BMP format (BGRA buffer is already close to BMP's BGR format)
   1192 function ImageObj:_encodeBMP()
   1193     local bmpParts = {}
   1194 
   1195     -- BMP uses 3 bytes per pixel (BGR, no alpha)
   1196     local bmpBytesPerPixel = 3
   1197     local rowSize = math.floor((bmpBytesPerPixel * self.width + 3) / 4) * 4
   1198     local pixelDataSize = rowSize * self.height
   1199     local fileSize = 54 + pixelDataSize
   1200 
   1201     -- BMP File Header (14 bytes)
   1202     bmpParts[#bmpParts + 1] = "BM"
   1203     bmpParts[#bmpParts + 1] = self:_uint32le(fileSize)
   1204     bmpParts[#bmpParts + 1] = self:_uint32le(0)
   1205     bmpParts[#bmpParts + 1] = self:_uint32le(54)
   1206 
   1207     -- DIB Header (BITMAPINFOHEADER - 40 bytes)
   1208     bmpParts[#bmpParts + 1] = self:_uint32le(40)
   1209     bmpParts[#bmpParts + 1] = self:_uint32le(self.width)
   1210     bmpParts[#bmpParts + 1] = self:_uint32le(self.height)
   1211     bmpParts[#bmpParts + 1] = self:_uint16le(1)
   1212     bmpParts[#bmpParts + 1] = self:_uint16le(24)  -- 24-bit BMP
   1213     bmpParts[#bmpParts + 1] = self:_uint32le(0)
   1214     bmpParts[#bmpParts + 1] = self:_uint32le(pixelDataSize)
   1215     bmpParts[#bmpParts + 1] = self:_uint32le(2835)
   1216     bmpParts[#bmpParts + 1] = self:_uint32le(2835)
   1217     bmpParts[#bmpParts + 1] = self:_uint32le(0)
   1218     bmpParts[#bmpParts + 1] = self:_uint32le(0)
   1219 
   1220     -- Pixel data (bottom-up, BGR format)
   1221     local paddingLen = rowSize - self.width * bmpBytesPerPixel
   1222     local padding = paddingLen > 0 and string.rep(string.char(0), paddingLen) or ""
   1223     local width = self.width
   1224     local buffer = self.buffer
   1225 
   1226     for y = self.height - 1, 0, -1 do  -- BMP is bottom-up
   1227         local rowBytes = {}
   1228         local rowStart = y * width * 4 + 1
   1229 
   1230         for x = 0, width - 1 do
   1231             local offset = rowStart + x * 4
   1232             -- Buffer is BGRA, BMP wants BGR
   1233             rowBytes[#rowBytes + 1] = string.byte(buffer, offset)      -- B
   1234             rowBytes[#rowBytes + 1] = string.byte(buffer, offset + 1)  -- G
   1235             rowBytes[#rowBytes + 1] = string.byte(buffer, offset + 2)  -- R
   1236             -- Skip alpha
   1237         end
   1238 
   1239         bmpParts[#bmpParts + 1] = string.char(unpack(rowBytes))
   1240         if paddingLen > 0 then
   1241             bmpParts[#bmpParts + 1] = padding
   1242         end
   1243     end
   1244 
   1245     return table.concat(bmpParts)
   1246 end
   1247 
   1248 
   1249 -- Save image as JPEG file
   1250 -- @param path Path to save JPEG file
   1251 -- @param options Optional table with fields: fs (SafeFS instance), quality (1-100, default 95)
   1252 -- @return true on success, nil and error message on failure
   1253 function ImageObj:saveAsJPEG(path, options)
   1254     checkPermission()
   1255 
   1256     if not path then
   1257         error("saveAsJPEG requires a file path")
   1258     end
   1259 
   1260     -- Parse options
   1261     options = options or {}
   1262     local quality = options.quality or 95
   1263     local safeFSInstance = options.fs
   1264 
   1265     -- Clamp quality to valid range
   1266     if quality < 1 then quality = 1 end
   1267     if quality > 100 then quality = 100 end
   1268 
   1269     -- Get BGRA buffer
   1270     local buffer = self:getBuffer()
   1271     if not buffer then
   1272         return nil, "Failed to get image buffer"
   1273     end
   1274 
   1275     -- Use C JPEG encoder
   1276     local jpegEncode = JPEGEncode or _G.JPEGEncode
   1277     if not jpegEncode then
   1278         return nil, "JPEG encoder not available"
   1279     end
   1280 
   1281     local jpegData, err = jpegEncode(buffer, self.width, self.height, quality)
   1282     if not jpegData then
   1283         return nil, err or "JPEG encoding failed"
   1284     end
   1285 
   1286     -- Use provided SafeFS if specified in options
   1287     if safeFSInstance then
   1288         local success, writeErr = safeFSInstance:write(path, jpegData)
   1289         if not success then
   1290             return nil, "SafeFS write failed: " .. (writeErr or "unknown error")
   1291         end
   1292         return true
   1293     end
   1294 
   1295     -- Otherwise use the standard filesystem (fs or CRamdisk)
   1296     return writeFile(path, jpegData)
   1297 end
   1298 
   1299 -- Internal: Load PNG file using existing decoder (converts to BGRA)
   1300 local function loadPNG(path)
   1301     -- Get functions from _G (they may be in sandbox env)
   1302     local pngLoad = PNGLoad or _G.PNGLoad
   1303     local imgGetWidth = ImageGetWidth or _G.ImageGetWidth
   1304     local imgGetHeight = ImageGetHeight or _G.ImageGetHeight
   1305     local imgGetBufferBGRA = ImageGetBufferBGRA or _G.ImageGetBufferBGRA
   1306     local imgDestroy = ImageDestroy or _G.ImageDestroy
   1307 
   1308     if not pngLoad or not imgGetWidth or not imgGetHeight then
   1309         return nil, "PNG decoder functions not available"
   1310     end
   1311 
   1312     local pngData, err = readFile(path)
   1313     if not pngData then
   1314         return nil, err
   1315     end
   1316 
   1317     local imgBuffer = pngLoad(pngData)
   1318     if not imgBuffer then
   1319         return nil, "Failed to decode PNG: " .. path
   1320     end
   1321 
   1322     local width = imgGetWidth(imgBuffer)
   1323     local height = imgGetHeight(imgBuffer)
   1324 
   1325     local self = setmetatable({}, ImageObj)
   1326     self.width = width
   1327     self.height = height
   1328     self.hasAlpha = true
   1329     self.bytesPerPixel = 4
   1330 
   1331     -- Use fast C function to get entire buffer as BGRA string
   1332     if imgGetBufferBGRA then
   1333         local buf, bufErr = imgGetBufferBGRA(imgBuffer)
   1334         if buf then
   1335             self.buffer = buf
   1336         else
   1337             if imgDestroy then imgDestroy(imgBuffer) end
   1338             return nil, bufErr or "Failed to get image buffer"
   1339         end
   1340     else
   1341         -- Fallback: read pixel by pixel (slow)
   1342         local imgGetPixel = ImageGetPixel or _G.ImageGetPixel
   1343         if not imgGetPixel then
   1344             if imgDestroy then imgDestroy(imgBuffer) end
   1345             return nil, "ImageGetPixel not available"
   1346         end
   1347         local rows = {}
   1348         for y = 0, height - 1 do
   1349             local rowBytes = {}
   1350             for x = 0, width - 1 do
   1351                 local r, g, b, a = imgGetPixel(imgBuffer, x, y)
   1352                 local idx = x * 4
   1353                 rowBytes[idx + 1] = b or 0
   1354                 rowBytes[idx + 2] = g or 0
   1355                 rowBytes[idx + 3] = r or 0
   1356                 rowBytes[idx + 4] = a or 255
   1357             end
   1358             rows[#rows + 1] = string.char(unpack(rowBytes))
   1359         end
   1360         self.buffer = table.concat(rows)
   1361     end
   1362 
   1363     if imgDestroy then
   1364         imgDestroy(imgBuffer)
   1365     end
   1366 
   1367     return self
   1368 end
   1369 
   1370 -- Internal: Load JPEG file using existing decoder (converts to BGRA)
   1371 local function loadJPEG(path)
   1372     -- Get functions from _G (they may be in sandbox env)
   1373     local jpegLoad = JPEGLoad or _G.JPEGLoad
   1374     local imgGetWidth = ImageGetWidth or _G.ImageGetWidth
   1375     local imgGetHeight = ImageGetHeight or _G.ImageGetHeight
   1376     local imgGetBufferBGRA = ImageGetBufferBGRA or _G.ImageGetBufferBGRA
   1377     local imgDestroy = ImageDestroy or _G.ImageDestroy
   1378 
   1379     if not jpegLoad or not imgGetWidth or not imgGetHeight then
   1380         return nil, "JPEG decoder functions not available"
   1381     end
   1382 
   1383     local jpegData, err = readFile(path)
   1384     if not jpegData then
   1385         return nil, err
   1386     end
   1387 
   1388     local imgBuffer = jpegLoad(jpegData)
   1389     if not imgBuffer then
   1390         return nil, "Failed to decode JPEG: " .. path
   1391     end
   1392 
   1393     local width = imgGetWidth(imgBuffer)
   1394     local height = imgGetHeight(imgBuffer)
   1395 
   1396     local self = setmetatable({}, ImageObj)
   1397     self.width = width
   1398     self.height = height
   1399     self.hasAlpha = false  -- JPEG doesn't support alpha
   1400     self.bytesPerPixel = 4  -- Still use 4 bytes for screen compatibility
   1401 
   1402     -- Use fast C function to get entire buffer as BGRA string
   1403     if imgGetBufferBGRA then
   1404         local buf, bufErr = imgGetBufferBGRA(imgBuffer)
   1405         if buf then
   1406             self.buffer = buf
   1407         else
   1408             if imgDestroy then imgDestroy(imgBuffer) end
   1409             return nil, bufErr or "Failed to get image buffer"
   1410         end
   1411     else
   1412         -- Fallback: read pixel by pixel (slow)
   1413         local imgGetPixel = ImageGetPixel or _G.ImageGetPixel
   1414         if not imgGetPixel then
   1415             if imgDestroy then imgDestroy(imgBuffer) end
   1416             return nil, "ImageGetPixel not available"
   1417         end
   1418         local rows = {}
   1419         for y = 0, height - 1 do
   1420             local rowBytes = {}
   1421             for x = 0, width - 1 do
   1422                 local r, g, b = imgGetPixel(imgBuffer, x, y)
   1423                 local idx = x * 4
   1424                 rowBytes[idx + 1] = b or 0
   1425                 rowBytes[idx + 2] = g or 0
   1426                 rowBytes[idx + 3] = r or 0
   1427                 rowBytes[idx + 4] = 255
   1428             end
   1429             rows[#rows + 1] = string.char(unpack(rowBytes))
   1430         end
   1431         self.buffer = table.concat(rows)
   1432     end
   1433 
   1434     if imgDestroy then
   1435         imgDestroy(imgBuffer)
   1436     end
   1437 
   1438     return self
   1439 end
   1440 
   1441 -- Internal: Load BMP file (converts to BGRA)
   1442 local function loadBMP(path)
   1443     local bmpData, err = readFile(path)
   1444     if not bmpData then
   1445         return nil, err
   1446     end
   1447 
   1448     if #bmpData < 54 then
   1449         return nil, "Invalid BMP file: too small"
   1450     end
   1451 
   1452     local function readUInt16LE(data, offset)
   1453         local b1 = string.byte(data, offset)
   1454         local b2 = string.byte(data, offset + 1)
   1455         return bit.bor(b1, bit.lshift(b2, 8))
   1456     end
   1457 
   1458     local function readUInt32LE(data, offset)
   1459         local b1 = string.byte(data, offset)
   1460         local b2 = string.byte(data, offset + 1)
   1461         local b3 = string.byte(data, offset + 2)
   1462         local b4 = string.byte(data, offset + 3)
   1463         return bit.bor(b1, bit.lshift(b2, 8), bit.lshift(b3, 16), bit.lshift(b4, 24))
   1464     end
   1465 
   1466     if bmpData:sub(1, 2) ~= "BM" then
   1467         return nil, "Not a valid BMP file"
   1468     end
   1469 
   1470     local pixelDataOffset = readUInt32LE(bmpData, 11)
   1471     local headerSize = readUInt32LE(bmpData, 15)
   1472 
   1473     if headerSize < 40 then
   1474         return nil, "Unsupported BMP format (old header)"
   1475     end
   1476 
   1477     local width = readUInt32LE(bmpData, 19)
   1478     local height = readUInt32LE(bmpData, 23)
   1479     local bitsPerPixel = readUInt16LE(bmpData, 29)
   1480     local compression = readUInt32LE(bmpData, 31)
   1481 
   1482     if compression ~= 0 then
   1483         return nil, "Compressed BMP not supported"
   1484     end
   1485 
   1486     if bitsPerPixel ~= 24 and bitsPerPixel ~= 32 then
   1487         return nil, "Only 24-bit and 32-bit BMP supported"
   1488     end
   1489 
   1490     local self = setmetatable({}, ImageObj)
   1491     self.width = width
   1492     self.height = height
   1493     self.hasAlpha = (bitsPerPixel == 32)
   1494     self.bytesPerPixel = 4  -- Always 4 bytes for screen compatibility
   1495 
   1496     local srcBytesPerPixel = bitsPerPixel / 8
   1497     local rowSize = math.floor((srcBytesPerPixel * width + 3) / 4) * 4
   1498 
   1499     -- Read pixels (BMP is bottom-up, BGR format) and convert to BGRA
   1500     local rows = {}
   1501     for y = 0, height - 1 do
   1502         local rowPixels = {}
   1503         -- BMP rows are stored bottom-to-top
   1504         local srcY = height - 1 - y
   1505         local rowOffset = pixelDataOffset + srcY * rowSize + 1
   1506 
   1507         for x = 0, width - 1 do
   1508             local pixelOffset = rowOffset + x * srcBytesPerPixel
   1509 
   1510             local b = string.byte(bmpData, pixelOffset)
   1511             local g = string.byte(bmpData, pixelOffset + 1)
   1512             local r = string.byte(bmpData, pixelOffset + 2)
   1513             local a = 255
   1514 
   1515             if srcBytesPerPixel == 4 then
   1516                 a = string.byte(bmpData, pixelOffset + 3)
   1517             end
   1518 
   1519             -- Store as BGRA
   1520             rowPixels[#rowPixels + 1] = string.char(b, g, r, a)
   1521         end
   1522         rows[#rows + 1] = table.concat(rowPixels)
   1523     end
   1524 
   1525     self.buffer = table.concat(rows)
   1526 
   1527     return self
   1528 end
   1529 
   1530 -- Open image file (auto-detect format from extension)
   1531 -- @param path Path to image file (.png, .bmp, .jpg, .jpeg)
   1532 -- @return Image object or nil on error
   1533 function Image.open(path)
   1534     checkPermission()
   1535 
   1536     if not path then
   1537         error("Image.open requires a file path")
   1538     end
   1539 
   1540     local extension = path:match("%.([^%.]+)$")
   1541     if not extension then
   1542         return nil, "Cannot determine file format (no extension)"
   1543     end
   1544 
   1545     extension = extension:lower()
   1546 
   1547     if extension == "png" then
   1548         return loadPNG(path)
   1549     elseif extension == "bmp" then
   1550         return loadBMP(path)
   1551     elseif extension == "jpg" or extension == "jpeg" then
   1552         return loadJPEG(path)
   1553     else
   1554         return nil, "Unsupported image format: " .. extension
   1555     end
   1556 end
   1557 
   1558 -- Clone the image
   1559 -- @return New Image object with copied buffer
   1560 function ImageObj:clone()
   1561     checkPermission()
   1562 
   1563     local newImg = setmetatable({}, ImageObj)
   1564     newImg.width = self.width
   1565     newImg.height = self.height
   1566     newImg.hasAlpha = self.hasAlpha
   1567     newImg.bytesPerPixel = self.bytesPerPixel
   1568     newImg.buffer = self.buffer  -- String copy
   1569 
   1570     return newImg
   1571 end
   1572 
   1573 return Image