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