render.lua (113477B)
1 -- Pure Lua HTML Parser and Renderer 2 -- No external dependencies, just pure Lua! 3 4 local dom_module = require("dom") 5 local style_module = require("style") 6 local document_module = require("document") 7 local lua_engine_module = require("lua_engine") 8 -- local image_parser = require("image_parser") 9 local image_parser = { load_image = function() return nil, "disabled" end } 10 11 local DOM = dom_module.DOM 12 local Element = dom_module.Element 13 local Style = style_module.Style 14 local Document = document_module.Document 15 local LuaEngine = lua_engine_module.LuaEngine 16 17 -- ============================================================================ 18 -- PNG Encoder (Pure Lua) 19 -- ============================================================================ 20 21 local function write_u32_be(value) 22 return string.char( 23 bit.band(bit.rshift(value, 24), 0xFF), 24 bit.band(bit.rshift(value, 16), 0xFF), 25 bit.band(bit.rshift(value, 8), 0xFF), 26 bit.band(value, 0xFF) 27 ) 28 end 29 30 local function crc32(data) 31 local crc = 0xFFFFFFFF 32 for i = 1, #data do 33 local byte = string.byte(data, i) 34 crc = bit.bxor(crc, byte) 35 for _ = 1, 8 do 36 if bit.band(crc, 1) ~= 0 then 37 crc = bit.bxor(bit.rshift(crc, 1), 0xEDB88320) 38 else 39 crc = bit.rshift(crc, 1) 40 end 41 end 42 end 43 return bit.bxor(crc, 0xFFFFFFFF) 44 end 45 46 local function create_png_chunk(chunk_type, data) 47 local length = write_u32_be(#data) 48 local chunk_data = chunk_type .. data 49 local crc = write_u32_be(crc32(chunk_data)) 50 return length .. chunk_data .. crc 51 end 52 53 local function compress_zlib(data) 54 -- Simple DEFLATE with no compression (store only) 55 local compressed = string.char(0x78, 0x01) -- zlib header 56 57 local block_size = 65535 58 local pos = 1 59 local blocks = {} 60 61 while pos <= #data do 62 local remaining = #data - pos + 1 63 local size = math.min(block_size, remaining) 64 local is_final = (pos + size - 1 >= #data) and 1 or 0 65 66 local block = string.char(is_final) -- BFINAL and BTYPE (00 = no compression) 67 block = block .. string.char(bit.band(size, 0xFF), bit.rshift(size, 8)) 68 block = block .. string.char(bit.band(bit.bnot(size), 0xFF), bit.band(bit.rshift(bit.bnot(size), 8), 0xFF)) 69 block = block .. data:sub(pos, pos + size - 1) 70 71 table.insert(blocks, block) 72 pos = pos + size 73 end 74 75 compressed = compressed .. table.concat(blocks) 76 77 -- Adler-32 checksum 78 local s1, s2 = 1, 0 79 for i = 1, #data do 80 s1 = (s1 + string.byte(data, i)) % 65521 81 s2 = (s2 + s1) % 65521 82 end 83 local adler = bit.lshift(s2, 16) + s1 84 compressed = compressed .. write_u32_be(adler) 85 86 return compressed 87 end 88 89 local function save_png(filename, width, height, pixels) 90 local f = io.open(filename, "wb") 91 if not f then 92 error("Could not open file for writing: " .. filename) 93 end 94 95 -- PNG signature 96 f:write(string.char(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A)) 97 98 -- IHDR chunk 99 local ihdr = write_u32_be(width) .. write_u32_be(height) 100 ihdr = ihdr .. string.char(8) -- bit depth 101 ihdr = ihdr .. string.char(2) -- color type (2 = RGB) 102 ihdr = ihdr .. string.char(0) -- compression 103 ihdr = ihdr .. string.char(0) -- filter 104 ihdr = ihdr .. string.char(0) -- interlace 105 f:write(create_png_chunk("IHDR", ihdr)) 106 107 -- IDAT chunk (image data) 108 local raw_data = {} 109 for y = 0, height - 1 do 110 table.insert(raw_data, string.char(0)) -- filter type (0 = none) 111 for x = 0, width - 1 do 112 local idx = y * width + x + 1 113 local pixel = pixels[idx] or {255, 255, 255} 114 table.insert(raw_data, string.char(pixel[1], pixel[2], pixel[3])) 115 end 116 end 117 118 local raw = table.concat(raw_data) 119 local compressed = compress_zlib(raw) 120 f:write(create_png_chunk("IDAT", compressed)) 121 122 -- IEND chunk 123 f:write(create_png_chunk("IEND", "")) 124 125 f:close() 126 end 127 128 -- ============================================================================ 129 -- Simple Bitmap Font (8x8 pixels) 130 -- ============================================================================ 131 132 local function get_char_bitmap(char) 133 -- Simple 8x8 bitmap font for basic ASCII characters 134 -- Returns a table of 8 bytes, each byte represents a row 135 local fonts = { 136 [" "] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, 137 ["!"] = {0x18, 0x18, 0x18, 0x18, 0x00, 0x00, 0x18, 0x00}, 138 [":"] = {0x00, 0x18, 0x18, 0x00, 0x18, 0x18, 0x00, 0x00}, 139 ["."] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x18, 0x18, 0x00}, 140 ["•"] = {0x00, 0x00, 0x00, 0x18, 0x18, 0x00, 0x00, 0x00}, 141 ["0"] = {0x3C, 0x46, 0x4A, 0x52, 0x62, 0x3C, 0x00, 0x00}, 142 ["1"] = {0x18, 0x28, 0x08, 0x08, 0x08, 0x3E, 0x00, 0x00}, 143 ["2"] = {0x3C, 0x42, 0x02, 0x3C, 0x40, 0x7E, 0x00, 0x00}, 144 ["3"] = {0x3C, 0x42, 0x0C, 0x02, 0x42, 0x3C, 0x00, 0x00}, 145 ["a"] = {0x00, 0x00, 0x3C, 0x42, 0x42, 0x46, 0x3A, 0x00}, 146 ["b"] = {0x40, 0x40, 0x5C, 0x62, 0x42, 0x42, 0x7C, 0x00}, 147 ["c"] = {0x00, 0x00, 0x3C, 0x42, 0x40, 0x42, 0x3C, 0x00}, 148 ["d"] = {0x02, 0x02, 0x3A, 0x46, 0x42, 0x42, 0x3E, 0x00}, 149 ["e"] = {0x00, 0x00, 0x3C, 0x42, 0x7E, 0x40, 0x3C, 0x00}, 150 ["f"] = {0x0E, 0x10, 0x10, 0x7C, 0x10, 0x10, 0x10, 0x00}, 151 ["g"] = {0x00, 0x00, 0x3E, 0x42, 0x42, 0x3E, 0x02, 0x3C}, 152 ["h"] = {0x40, 0x40, 0x5C, 0x62, 0x42, 0x42, 0x42, 0x00}, 153 ["i"] = {0x10, 0x00, 0x30, 0x10, 0x10, 0x10, 0x38, 0x00}, 154 ["j"] = {0x04, 0x00, 0x0C, 0x04, 0x04, 0x04, 0x44, 0x38}, 155 ["k"] = {0x40, 0x40, 0x44, 0x48, 0x70, 0x48, 0x44, 0x00}, 156 ["l"] = {0x30, 0x10, 0x10, 0x10, 0x10, 0x10, 0x38, 0x00}, 157 ["m"] = {0x00, 0x00, 0x6C, 0x92, 0x92, 0x92, 0x92, 0x00}, 158 ["n"] = {0x00, 0x00, 0x5C, 0x62, 0x42, 0x42, 0x42, 0x00}, 159 ["o"] = {0x00, 0x00, 0x3C, 0x42, 0x42, 0x42, 0x3C, 0x00}, 160 ["p"] = {0x00, 0x00, 0x5C, 0x62, 0x62, 0x5C, 0x40, 0x40}, 161 ["r"] = {0x00, 0x00, 0x5C, 0x62, 0x40, 0x40, 0x40, 0x00}, 162 ["s"] = {0x00, 0x00, 0x3E, 0x40, 0x3C, 0x02, 0x7C, 0x00}, 163 ["q"] = {0x00, 0x00, 0x3A, 0x46, 0x42, 0x3A, 0x02, 0x02}, 164 ["t"] = {0x10, 0x10, 0x7C, 0x10, 0x10, 0x10, 0x0E, 0x00}, 165 ["u"] = {0x00, 0x00, 0x42, 0x42, 0x42, 0x46, 0x3A, 0x00}, 166 ["v"] = {0x00, 0x00, 0x42, 0x42, 0x42, 0x24, 0x18, 0x00}, 167 ["w"] = {0x00, 0x00, 0x92, 0x92, 0x92, 0x54, 0x28, 0x00}, 168 ["x"] = {0x00, 0x00, 0x42, 0x24, 0x18, 0x24, 0x42, 0x00}, 169 ["y"] = {0x00, 0x00, 0x42, 0x42, 0x42, 0x3E, 0x02, 0x3C}, 170 ["z"] = {0x00, 0x00, 0x7E, 0x04, 0x18, 0x20, 0x7E, 0x00}, 171 ["A"] = {0x18, 0x24, 0x42, 0x7E, 0x42, 0x42, 0x42, 0x00}, 172 ["B"] = {0x7C, 0x42, 0x42, 0x7C, 0x42, 0x42, 0x7C, 0x00}, 173 ["E"] = {0x7E, 0x40, 0x40, 0x7C, 0x40, 0x40, 0x7E, 0x00}, 174 ["H"] = {0x42, 0x42, 0x42, 0x7E, 0x42, 0x42, 0x42, 0x00}, 175 ["I"] = {0x3E, 0x08, 0x08, 0x08, 0x08, 0x08, 0x3E, 0x00}, 176 ["M"] = {0x42, 0x66, 0x5A, 0x42, 0x42, 0x42, 0x42, 0x00}, 177 ["D"] = {0x78, 0x44, 0x42, 0x42, 0x42, 0x44, 0x78, 0x00}, 178 ["O"] = {0x3C, 0x42, 0x42, 0x42, 0x42, 0x42, 0x3C, 0x00}, 179 ["P"] = {0x7C, 0x42, 0x42, 0x7C, 0x40, 0x40, 0x40, 0x00}, 180 ["S"] = {0x3C, 0x42, 0x40, 0x3C, 0x02, 0x42, 0x3C, 0x00}, 181 ["T"] = {0x7F, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x00}, 182 ["U"] = {0x42, 0x42, 0x42, 0x42, 0x42, 0x42, 0x3C, 0x00}, 183 ["W"] = {0x42, 0x42, 0x42, 0x92, 0x92, 0x92, 0x6C, 0x00}, 184 ["Y"] = {0x42, 0x42, 0x42, 0x24, 0x18, 0x08, 0x08, 0x00}, 185 } 186 187 return fonts[char] or {0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} 188 end 189 190 -- ============================================================================ 191 -- Image Buffer 192 -- ============================================================================ 193 194 local ImageBuffer = {} 195 ImageBuffer.__index = ImageBuffer 196 197 function ImageBuffer:new(width, height) 198 local pixels = {} 199 for i = 1, width * height do 200 pixels[i] = {255, 255, 255} -- white background 201 end 202 203 return setmetatable({ 204 width = width, 205 height = height, 206 pixels = pixels 207 }, self) 208 end 209 210 function ImageBuffer:set_pixel(x, y, r, g, b) 211 if x >= 0 and x < self.width and y >= 0 and y < self.height then 212 -- Clamp RGB values to 0-255 range 213 r = math.max(0, math.min(255, math.floor(r))) 214 g = math.max(0, math.min(255, math.floor(g))) 215 b = math.max(0, math.min(255, math.floor(b))) 216 local idx = y * self.width + x + 1 217 self.pixels[idx] = {r, g, b} 218 end 219 end 220 221 function ImageBuffer:draw_char(char, x, y, size, r, g, b) 222 local bitmap = get_char_bitmap(char) 223 local scale = math.floor(size / 8) 224 if scale < 1 then scale = 1 end 225 226 for row = 0, 7 do 227 local byte = bitmap[row + 1] 228 for col = 0, 7 do 229 if bit.band(bit.rshift(byte, 7 - col), 1) == 1 then 230 -- Draw scaled pixel 231 for dy = 0, scale - 1 do 232 for dx = 0, scale - 1 do 233 self:set_pixel(x + col * scale + dx, y + row * scale + dy, r, g, b) 234 end 235 end 236 end 237 end 238 end 239 end 240 241 function ImageBuffer:draw_text(text, x, y, size, r, g, b) 242 local scale = math.floor(size / 8) 243 if scale < 1 then scale = 1 end 244 local char_width = 8 * scale 245 246 local cursor_x = x 247 for i = 1, #text do 248 local char = text:sub(i, i) 249 self:draw_char(char, cursor_x, y, size, r, g, b) 250 cursor_x = cursor_x + char_width 251 end 252 end 253 254 function ImageBuffer:get_text_width(text, size) 255 local scale = math.floor(size / 8) 256 if scale < 1 then scale = 1 end 257 local char_width = 8 * scale 258 return #text * char_width 259 end 260 261 function ImageBuffer:fill_rect(x, y, width, height, r, g, b) 262 for py = y, y + height - 1 do 263 for px = x, x + width - 1 do 264 self:set_pixel(px, py, r, g, b) 265 end 266 end 267 end 268 269 function ImageBuffer:draw_image(image_data, x, y, width, height) 270 -- Draw image at position (x, y) 271 -- image_data can be: 272 -- 1. An Image object (table with getPixel method) 273 -- 2. A C image handle (userdata) - use ImageGetPixel 274 -- 3. A 2D array [y][x] = {r, g, b} (legacy Lua format) 275 276 if type(image_data) == "table" and image_data.getPixel then 277 -- Image library object - use getPixel method 278 local orig_width, orig_height = image_data:getSize() 279 280 for py = 0, height - 1 do 281 local src_y = math.floor(py * orig_height / height) 282 for px = 0, width - 1 do 283 local src_x = math.floor(px * orig_width / width) 284 local pixel = image_data:getPixel(src_x, src_y) 285 if pixel then 286 self:set_pixel(x + px, y + py, pixel.r, pixel.g, pixel.b) 287 end 288 end 289 end 290 elseif type(image_data) == "userdata" and ImageGetPixel then 291 -- C image handle - use ImageGetPixel for each pixel 292 local orig_width = ImageGetWidth and ImageGetWidth(image_data) or width 293 local orig_height = ImageGetHeight and ImageGetHeight(image_data) or height 294 295 for py = 0, height - 1 do 296 local src_y = math.floor(py * orig_height / height) 297 for px = 0, width - 1 do 298 local src_x = math.floor(px * orig_width / width) 299 local r, g, b = ImageGetPixel(image_data, src_x, src_y) 300 if r then 301 self:set_pixel(x + px, y + py, r, g, b) 302 end 303 end 304 end 305 elseif type(image_data) == "table" then 306 -- Legacy Lua pixel table format 307 for py = 1, height do 308 for px = 1, width do 309 if image_data[py] and image_data[py][px] then 310 local pixel = image_data[py][px] 311 self:set_pixel(x + px - 1, y + py - 1, pixel.r, pixel.g, pixel.b) 312 end 313 end 314 end 315 end 316 end 317 318 function ImageBuffer:draw_rect_outline(x, y, width, height, r, g, b) 319 -- Top and bottom edges 320 for px = x, x + width - 1 do 321 self:set_pixel(px, y, r, g, b) 322 self:set_pixel(px, y + height - 1, r, g, b) 323 end 324 -- Left and right edges 325 for py = y, y + height - 1 do 326 self:set_pixel(x, py, r, g, b) 327 self:set_pixel(x + width - 1, py, r, g, b) 328 end 329 end 330 331 function ImageBuffer:save(filename) 332 save_png(filename, self.width, self.height, self.pixels) 333 end 334 335 function ImageBuffer:drawToScreen(gfx) 336 -- Draw the image buffer to a SafeGFX instance 337 -- gfx is expected to have setPixel or fillRect methods 338 if not gfx then 339 error("drawToScreen requires a valid graphics context") 340 end 341 342 -- Draw each pixel 343 for y = 0, self.height - 1 do 344 for x = 0, self.width - 1 do 345 local idx = y * self.width + x + 1 346 local pixel = self.pixels[idx] 347 if pixel then 348 local r, g, b = pixel[1], pixel[2], pixel[3] 349 -- Convert RGB to 24-bit color value (0xRRGGBB) 350 local color = bit.lshift(r, 16) + bit.lshift(g, 8) + b 351 352 -- Use setPixel if available (slower but more compatible) 353 if gfx.setPixel then 354 gfx:setPixel(x, y, color) 355 -- Fallback to fillRect for single pixels 356 elseif gfx.fillRect then 357 gfx:fillRect(x, y, 1, 1, color) 358 end 359 end 360 end 361 end 362 end 363 364 -- ============================================================================ 365 -- HTML Parser 366 -- ============================================================================ 367 368 local HTMLParser = {} 369 HTMLParser.__index = HTMLParser 370 371 function HTMLParser:new() 372 return setmetatable({ 373 dom = DOM:new(), 374 pos = 1, 375 html = "" 376 }, self) 377 end 378 379 function HTMLParser:peek(len) 380 return self.html:sub(self.pos, self.pos + (len or 1) - 1) 381 end 382 383 function HTMLParser:advance(len) 384 self.pos = self.pos + (len or 1) 385 end 386 387 function HTMLParser:skip_whitespace() 388 local _, end_pos = self.html:find("^%s+", self.pos) 389 if end_pos then 390 self.pos = end_pos + 1 391 end 392 end 393 394 function HTMLParser:parse_tag_name() 395 local tag = self.html:match("^([%w]+)", self.pos) 396 if tag then 397 self.pos = self.pos + #tag 398 return tag:lower() 399 end 400 return nil 401 end 402 403 function HTMLParser:parse_attributes() 404 local attrs = {} 405 406 while self.pos <= #self.html do 407 self:skip_whitespace() 408 409 -- Check if we've reached the end of the tag 410 if self:peek(1) == ">" or self:peek(2) == "/>" then 411 break 412 end 413 414 -- Parse attribute name 415 local attr_name = self.html:match("^([%w-]+)", self.pos) 416 if not attr_name then 417 break 418 end 419 self.pos = self.pos + #attr_name 420 421 self:skip_whitespace() 422 423 -- Check for '=' 424 if self:peek(1) ~= "=" then 425 attrs[attr_name] = true 426 goto continue 427 end 428 self:advance(1) 429 430 self:skip_whitespace() 431 432 -- Parse attribute value 433 local quote = self:peek(1) 434 if quote == '"' or quote == "'" then 435 self:advance(1) 436 local value_start = self.pos 437 local value_end = self.html:find(quote, self.pos, true) 438 if value_end then 439 attrs[attr_name] = self.html:sub(value_start, value_end - 1) 440 self.pos = value_end + 1 441 end 442 else 443 -- Unquoted value 444 local value = self.html:match("^([^%s>]+)", self.pos) 445 if value then 446 attrs[attr_name] = value 447 self.pos = self.pos + #value 448 end 449 end 450 451 ::continue:: 452 end 453 454 return attrs 455 end 456 457 function HTMLParser:parse_text() 458 local text = self.html:match("^([^<]+)", self.pos) 459 if text then 460 self.pos = self.pos + #text 461 -- Collapse whitespace (like browsers do): multiple spaces/newlines become single space 462 text = text:gsub("%s+", " ") 463 return text 464 end 465 return nil 466 end 467 468 function HTMLParser:parse_element() 469 self:skip_whitespace() 470 471 -- Check for opening tag 472 if self:peek(1) ~= "<" then 473 return nil 474 end 475 476 self:advance(1) -- skip '<' 477 478 -- Check for closing tag 479 if self:peek(1) == "/" then 480 return nil -- caller should handle closing tags 481 end 482 483 -- Parse tag name 484 local tag = self:parse_tag_name() 485 if not tag then 486 return nil 487 end 488 489 -- Parse attributes 490 local attrs = self:parse_attributes() 491 492 -- Skip to end of opening tag 493 local _, end_pos = self.html:find(">", self.pos) 494 if not end_pos then 495 return nil 496 end 497 self.pos = end_pos + 1 498 499 -- Create element with attributes 500 local elem = Element:new(tag, attrs) 501 502 -- Self-closing tags 503 if tag == "br" or tag == "input" or tag == "img" or tag == "hr" then 504 return elem 505 end 506 507 -- Skip content for script and style tags (but still parse the closing tag) 508 if tag == "script" or tag == "style" then 509 -- Find the closing tag and skip all content 510 local closing_pattern = "</%s*" .. tag .. "%s*>" 511 local _, close_end = self.html:find(closing_pattern, self.pos) 512 if close_end then 513 self.pos = close_end + 1 514 end 515 return elem 516 end 517 518 -- Parse children and text content 519 local last_pos = self.pos 520 521 while self.pos <= #self.html do 522 -- Check for closing tag first 523 if self:peek(2) == "</" then 524 local save_pos = self.pos 525 self:advance(2) 526 local close_tag = self:parse_tag_name() 527 if close_tag == tag then 528 -- Skip '>' 529 local _, close_end = self.html:find(">", self.pos) 530 if close_end then 531 self.pos = close_end + 1 532 end 533 break 534 else 535 -- Not our closing tag, restore position 536 self.pos = save_pos 537 end 538 end 539 540 -- Try to parse child element 541 if self:peek(1) == "<" then 542 local child = self:parse_element() 543 if child then 544 elem:add_child(child) 545 last_pos = self.pos 546 else 547 -- Failed to parse element, skip this character 548 self:advance(1) 549 end 550 else 551 -- Parse text content and create text node 552 local text = self:parse_text() 553 if text and text ~= "" then 554 -- Create a text pseudo-element 555 local text_node = Element:new("text", {}) 556 text_node:set_content(text) 557 elem:add_child(text_node) 558 last_pos = self.pos 559 else 560 -- No progress, advance to avoid infinite loop 561 if self.pos == last_pos then 562 self:advance(1) 563 end 564 end 565 end 566 end 567 568 return elem 569 end 570 571 function HTMLParser:parse(html) 572 self.html = html 573 self.pos = 1 574 575 local body = Element:new("body", {}) 576 577 -- Find body tag content 578 local body_start = html:find("<body") 579 local body_end = html:find("</body>") 580 581 if body_start and body_end then 582 -- Start parsing after <body> 583 local _, body_open_end = html:find(">", body_start) 584 if body_open_end then 585 self.pos = body_open_end + 1 586 587 -- Parse all elements until </body> 588 while self.pos < body_end do 589 self:skip_whitespace() 590 local elem = self:parse_element() 591 if elem then 592 body:add_child(elem) 593 else 594 break 595 end 596 end 597 end 598 end 599 600 self.dom:set_root(body) 601 return self.dom 602 end 603 604 -- ============================================================================ 605 -- HTML Renderer 606 -- ============================================================================ 607 608 local HTMLRenderer = {} 609 HTMLRenderer.__index = HTMLRenderer 610 611 function HTMLRenderer:new(width, height, style_manager, document) 612 return setmetatable({ 613 width = width, 614 height = height, 615 buffer = ImageBuffer:new(width, height), 616 style = style_manager, 617 document = document, -- Document for state (focus, input values) 618 y_offset = 8, -- body margin 619 x_offset = 20, -- current x position for inline elements 620 line_height = 0, -- current line height for inline elements 621 max_x = nil, -- maximum x position for current context (for nested elements with padding) 622 text_align = "left", -- current text alignment context 623 content_start_x = nil, -- start x for line (for alignment calculation) 624 content_width_for_align = nil -- available width for alignment 625 }, self) 626 end 627 628 -- Parse color string to RGB (returns r, g, b, alpha) 629 function HTMLRenderer:parse_color(color_str) 630 if not color_str then return 0, 0, 0, 1 end 631 632 -- Handle rgba() format: rgba(255, 128, 64, 0.5) 633 local r_val, g_val, b_val, a_val = color_str:match("rgba%s*%((%d+)%s*,%s*(%d+)%s*,%s*(%d+)%s*,%s*([%d.]+)%)") 634 if r_val then 635 return tonumber(r_val), tonumber(g_val), tonumber(b_val), tonumber(a_val) 636 end 637 638 -- Handle rgb() format: rgb(255, 128, 64) 639 r_val, g_val, b_val = color_str:match("rgb%s*%((%d+)%s*,%s*(%d+)%s*,%s*(%d+)%)") 640 if r_val then 641 return tonumber(r_val), tonumber(g_val), tonumber(b_val), 1 642 end 643 644 -- Handle hex colors 645 if color_str:match("^#") then 646 local hex = color_str:sub(2) 647 if #hex == 6 then 648 local r = tonumber(hex:sub(1, 2), 16) 649 local g = tonumber(hex:sub(3, 4), 16) 650 local b = tonumber(hex:sub(5, 6), 16) 651 return r, g, b, 1 652 end 653 end 654 655 -- Handle named colors (standard HTML/CSS color names) 656 local named_colors = { 657 aliceblue = {240, 248, 255}, 658 antiquewhite = {250, 235, 215}, 659 aqua = {0, 255, 255}, 660 aquamarine = {127, 255, 212}, 661 azure = {240, 255, 255}, 662 beige = {245, 245, 220}, 663 bisque = {255, 228, 196}, 664 black = {0, 0, 0}, 665 blanchedalmond = {255, 235, 205}, 666 blue = {0, 0, 255}, 667 blueviolet = {138, 43, 226}, 668 brown = {165, 42, 42}, 669 burlywood = {222, 184, 135}, 670 cadetblue = {95, 158, 160}, 671 chartreuse = {127, 255, 0}, 672 chocolate = {210, 105, 30}, 673 coral = {255, 127, 80}, 674 cornflowerblue = {100, 149, 237}, 675 cornsilk = {255, 248, 220}, 676 crimson = {220, 20, 60}, 677 cyan = {0, 255, 255}, 678 darkblue = {0, 0, 139}, 679 darkcyan = {0, 139, 139}, 680 darkgoldenrod = {184, 134, 11}, 681 darkgray = {169, 169, 169}, 682 darkgrey = {169, 169, 169}, 683 darkgreen = {0, 100, 0}, 684 darkkhaki = {189, 183, 107}, 685 darkmagenta = {139, 0, 139}, 686 darkolivegreen = {85, 107, 47}, 687 darkorange = {255, 140, 0}, 688 darkorchid = {153, 50, 204}, 689 darkred = {139, 0, 0}, 690 darksalmon = {233, 150, 122}, 691 darkseagreen = {143, 188, 143}, 692 darkslateblue = {72, 61, 139}, 693 darkslategray = {47, 79, 79}, 694 darkslategrey = {47, 79, 79}, 695 darkturquoise = {0, 206, 209}, 696 darkviolet = {148, 0, 211}, 697 deeppink = {255, 20, 147}, 698 deepskyblue = {0, 191, 255}, 699 dimgray = {105, 105, 105}, 700 dimgrey = {105, 105, 105}, 701 dodgerblue = {30, 144, 255}, 702 firebrick = {178, 34, 34}, 703 floralwhite = {255, 250, 240}, 704 forestgreen = {34, 139, 34}, 705 fuchsia = {255, 0, 255}, 706 gainsboro = {220, 220, 220}, 707 ghostwhite = {248, 248, 255}, 708 gold = {255, 215, 0}, 709 goldenrod = {218, 165, 32}, 710 gray = {128, 128, 128}, 711 grey = {128, 128, 128}, 712 green = {0, 128, 0}, 713 greenyellow = {173, 255, 47}, 714 honeydew = {240, 255, 240}, 715 hotpink = {255, 105, 180}, 716 indianred = {205, 92, 92}, 717 indigo = {75, 0, 130}, 718 ivory = {255, 255, 240}, 719 khaki = {240, 230, 140}, 720 lavender = {230, 230, 250}, 721 lavenderblush = {255, 240, 245}, 722 lawngreen = {124, 252, 0}, 723 lemonchiffon = {255, 250, 205}, 724 lightblue = {173, 216, 230}, 725 lightcoral = {240, 128, 128}, 726 lightcyan = {224, 255, 255}, 727 lightgoldenrodyellow = {250, 250, 210}, 728 lightgray = {211, 211, 211}, 729 lightgrey = {211, 211, 211}, 730 lightgreen = {144, 238, 144}, 731 lightpink = {255, 182, 193}, 732 lightsalmon = {255, 160, 122}, 733 lightseagreen = {32, 178, 170}, 734 lightskyblue = {135, 206, 250}, 735 lightslategray = {119, 136, 153}, 736 lightslategrey = {119, 136, 153}, 737 lightsteelblue = {176, 196, 222}, 738 lightyellow = {255, 255, 224}, 739 lime = {0, 255, 0}, 740 limegreen = {50, 205, 50}, 741 linen = {250, 240, 230}, 742 magenta = {255, 0, 255}, 743 maroon = {128, 0, 0}, 744 mediumaquamarine = {102, 205, 170}, 745 mediumblue = {0, 0, 205}, 746 mediumorchid = {186, 85, 211}, 747 mediumpurple = {147, 112, 219}, 748 mediumseagreen = {60, 179, 113}, 749 mediumslateblue = {123, 104, 238}, 750 mediumspringgreen = {0, 250, 154}, 751 mediumturquoise = {72, 209, 204}, 752 mediumvioletred = {199, 21, 133}, 753 midnightblue = {25, 25, 112}, 754 mintcream = {245, 255, 250}, 755 mistyrose = {255, 228, 225}, 756 moccasin = {255, 228, 181}, 757 navajowhite = {255, 222, 173}, 758 navy = {0, 0, 128}, 759 oldlace = {253, 245, 230}, 760 olive = {128, 128, 0}, 761 olivedrab = {107, 142, 35}, 762 orange = {255, 165, 0}, 763 orangered = {255, 69, 0}, 764 orchid = {218, 112, 214}, 765 palegoldenrod = {238, 232, 170}, 766 palegreen = {152, 251, 152}, 767 paleturquoise = {175, 238, 238}, 768 palevioletred = {219, 112, 147}, 769 papayawhip = {255, 239, 213}, 770 peachpuff = {255, 218, 185}, 771 peru = {205, 133, 63}, 772 pink = {255, 192, 203}, 773 plum = {221, 160, 221}, 774 powderblue = {176, 224, 230}, 775 purple = {128, 0, 128}, 776 rebeccapurple = {102, 51, 153}, 777 red = {255, 0, 0}, 778 rosybrown = {188, 143, 143}, 779 royalblue = {65, 105, 225}, 780 saddlebrown = {139, 69, 19}, 781 salmon = {250, 128, 114}, 782 sandybrown = {244, 164, 96}, 783 seagreen = {46, 139, 87}, 784 seashell = {255, 245, 238}, 785 sienna = {160, 82, 45}, 786 silver = {192, 192, 192}, 787 skyblue = {135, 206, 235}, 788 slateblue = {106, 90, 205}, 789 slategray = {112, 128, 144}, 790 slategrey = {112, 128, 144}, 791 snow = {255, 250, 250}, 792 springgreen = {0, 255, 127}, 793 steelblue = {70, 130, 180}, 794 tan = {210, 180, 140}, 795 teal = {0, 128, 128}, 796 thistle = {216, 191, 216}, 797 tomato = {255, 99, 71}, 798 turquoise = {64, 224, 208}, 799 violet = {238, 130, 238}, 800 wheat = {245, 222, 179}, 801 white = {255, 255, 255}, 802 whitesmoke = {245, 245, 245}, 803 yellow = {255, 255, 0}, 804 yellowgreen = {154, 205, 50}, 805 } 806 807 local color = named_colors[color_str:lower()] 808 if color then 809 return color[1], color[2], color[3], 1 810 end 811 812 -- Default to black 813 return 0, 0, 0, 1 814 end 815 816 -- Apply text-transform to text content 817 function HTMLRenderer:apply_text_transform(text, transform) 818 if not text or not transform then return text end 819 820 if transform == "uppercase" then 821 return text:upper() 822 elseif transform == "lowercase" then 823 return text:lower() 824 elseif transform == "capitalize" then 825 -- Capitalize first letter of each word 826 return text:gsub("(%a)([%w_']*)", function(first, rest) 827 return first:upper() .. rest:lower() 828 end) 829 end 830 831 return text 832 end 833 834 -- Parse size (px or em) 835 function HTMLRenderer:parse_size(size_str, base_size, parent_size) 836 if not size_str then return 16 end 837 838 base_size = base_size or 16 839 parent_size = parent_size or nil 840 841 -- Handle px 842 local px = size_str:match("^([%d.]+)px") 843 if px then return math.floor(tonumber(px)) end 844 845 -- Handle em 846 local em = size_str:match("^([%d.]+)em") 847 if em then return math.floor(tonumber(em) * base_size) end 848 849 -- Handle percentage (requires parent_size) 850 local percent = size_str:match("^([%d.]+)%%") 851 if percent and parent_size then 852 return math.floor(tonumber(percent) * parent_size / 100) 853 end 854 855 -- Handle vw (viewport width) 856 local vw = size_str:match("^([%d.]+)vw") 857 if vw then 858 return math.floor(tonumber(vw) * self.width / 100) 859 end 860 861 -- Handle vh (viewport height) 862 local vh = size_str:match("^([%d.]+)vh") 863 if vh then 864 return math.floor(tonumber(vh) * self.height / 100) 865 end 866 867 -- Handle bare numbers 868 local num = tonumber(size_str) 869 if num then return math.floor(num) end 870 871 return base_size 872 end 873 874 -- Draw text with word wrapping and optional decorations 875 -- Supports CSS properties: white-space, word-wrap/overflow-wrap, word-break 876 function HTMLRenderer:draw_wrapped_text(text, start_x, start_y, max_width, font_size, r, g, b, decoration, base_x, wrap_mode) 877 base_x = base_x or start_x 878 wrap_mode = wrap_mode or "normal" -- normal, nowrap, break-word, break-all 879 880 local current_x = start_x 881 local current_y = start_y 882 local line_started = (current_x > base_x) 883 884 -- Handle nowrap - no wrapping at all 885 if wrap_mode == "nowrap" then 886 self.buffer:draw_text(text, current_x, current_y, font_size, r, g, b) 887 local text_width = self.buffer:get_text_width(text, font_size) 888 889 if decoration == "underline" then 890 local underline_y = current_y + font_size + 1 891 self.buffer:fill_rect(current_x, underline_y, text_width, 1, r, g, b) 892 elseif decoration == "line-through" then 893 local line_y = current_y + math.floor(font_size / 2) 894 self.buffer:fill_rect(current_x, line_y, text_width, 1, r, g, b) 895 elseif decoration == "overline" then 896 self.buffer:fill_rect(current_x, current_y, text_width, 1, r, g, b) 897 end 898 899 self.x_offset = current_x + text_width 900 self.y_offset = current_y 901 self.line_height = math.max(self.line_height, font_size) 902 return text_width 903 end 904 905 -- Handle break-all - break anywhere, even in middle of words 906 if wrap_mode == "break-all" then 907 for i = 1, #text do 908 local char = text:sub(i, i) 909 local char_width = self.buffer:get_text_width(char, font_size) 910 911 -- Check if character fits on current line 912 if current_x + char_width > base_x + max_width and line_started then 913 -- Character doesn't fit, wrap to next line 914 local advance_y = math.max(font_size + 2, self.line_height > 0 and self.line_height or 0) 915 current_y = current_y + advance_y 916 current_x = base_x 917 line_started = false 918 self.line_height = 0 919 end 920 921 -- Draw the character 922 self.buffer:draw_text(char, current_x, current_y, font_size, r, g, b) 923 924 -- Apply text decorations 925 if decoration == "underline" then 926 local underline_y = current_y + font_size + 1 927 self.buffer:fill_rect(current_x, underline_y, char_width, 1, r, g, b) 928 elseif decoration == "line-through" then 929 local line_y = current_y + math.floor(font_size / 2) 930 self.buffer:fill_rect(current_x, line_y, char_width, 1, r, g, b) 931 elseif decoration == "overline" then 932 self.buffer:fill_rect(current_x, current_y, char_width, 1, r, g, b) 933 end 934 935 current_x = current_x + char_width 936 line_started = true 937 end 938 939 self.x_offset = current_x 940 self.y_offset = current_y 941 self.line_height = math.max(self.line_height, font_size) 942 return current_x - start_x 943 end 944 945 -- Normal and break-word modes - split by words 946 local words = {} 947 for word in text:gmatch("%S+") do 948 table.insert(words, word) 949 end 950 951 for i, word in ipairs(words) do 952 local word_width = self.buffer:get_text_width(word, font_size) 953 local space_width = self.buffer:get_text_width(" ", font_size) 954 955 -- Check if we need to add a space before this word 956 local need_space = line_started and i > 1 957 local total_width = word_width + (need_space and space_width or 0) 958 959 -- Check if word fits on current line 960 if current_x + total_width > base_x + max_width and line_started then 961 -- Word doesn't fit - wrap to next line 962 -- Use line_height if it's set (accounts for tall elements like images) 963 local advance_y = math.max(font_size + 2, self.line_height > 0 and self.line_height or 0) 964 current_y = current_y + advance_y 965 current_x = base_x 966 line_started = false 967 need_space = false 968 total_width = word_width 969 -- Reset line_height since we've moved to a new line 970 self.line_height = 0 971 972 if wrap_mode == "break-word" and word_width > max_width then 973 -- Word is too long for a line, break it character by character 974 for j = 1, #word do 975 local char = word:sub(j, j) 976 local char_width = self.buffer:get_text_width(char, font_size) 977 978 if current_x + char_width > base_x + max_width and line_started then 979 local advance_y = math.max(font_size + 2, self.line_height > 0 and self.line_height or 0) 980 current_y = current_y + advance_y 981 current_x = base_x 982 line_started = false 983 self.line_height = 0 984 end 985 986 self.buffer:draw_text(char, current_x, current_y, font_size, r, g, b) 987 988 if decoration == "underline" then 989 local underline_y = current_y + font_size + 1 990 self.buffer:fill_rect(current_x, underline_y, char_width, 1, r, g, b) 991 elseif decoration == "line-through" then 992 local line_y = current_y + math.floor(font_size / 2) 993 self.buffer:fill_rect(current_x, line_y, char_width, 1, r, g, b) 994 elseif decoration == "overline" then 995 self.buffer:fill_rect(current_x, current_y, char_width, 1, r, g, b) 996 end 997 998 current_x = current_x + char_width 999 line_started = true 1000 end 1001 goto continue 1002 end 1003 end 1004 1005 -- Draw space if needed 1006 if need_space then 1007 self.buffer:draw_text(" ", current_x, current_y, font_size, r, g, b) 1008 current_x = current_x + space_width 1009 end 1010 1011 -- Draw the word 1012 self.buffer:draw_text(word, current_x, current_y, font_size, r, g, b) 1013 1014 -- Apply text decorations to this word 1015 if decoration == "underline" then 1016 local underline_y = current_y + font_size + 1 1017 self.buffer:fill_rect(current_x, underline_y, word_width, 1, r, g, b) 1018 elseif decoration == "line-through" then 1019 local line_y = current_y + math.floor(font_size / 2) 1020 self.buffer:fill_rect(current_x, line_y, word_width, 1, r, g, b) 1021 elseif decoration == "overline" then 1022 self.buffer:fill_rect(current_x, current_y, word_width, 1, r, g, b) 1023 end 1024 1025 current_x = current_x + word_width 1026 line_started = true 1027 1028 ::continue:: 1029 end 1030 1031 -- Update renderer state 1032 self.x_offset = current_x 1033 self.y_offset = current_y 1034 self.line_height = math.max(self.line_height, font_size) 1035 1036 return current_x - start_x 1037 end 1038 1039 -- Draw text with optional decorations (underline, line-through) 1040 function HTMLRenderer:draw_decorated_text(text, x, y, font_size, r, g, b, decoration) 1041 -- Draw the text 1042 self.buffer:draw_text(text, x, y, font_size, r, g, b) 1043 1044 -- Calculate text width for decorations 1045 local text_width = self.buffer:get_text_width(text, font_size) 1046 1047 -- Apply text decorations 1048 if decoration == "underline" then 1049 local underline_y = y + font_size + 1 1050 self.buffer:fill_rect(x, underline_y, text_width, 1, r, g, b) 1051 elseif decoration == "line-through" then 1052 local line_y = y + math.floor(font_size / 2) 1053 self.buffer:fill_rect(x, line_y, text_width, 1, r, g, b) 1054 elseif decoration == "overline" then 1055 self.buffer:fill_rect(x, y, text_width, 1, r, g, b) 1056 end 1057 1058 return text_width 1059 end 1060 1061 -- Draw border with optional border-radius 1062 function HTMLRenderer:draw_border(x, y, width, height, border_width, r, g, b, border_radius) 1063 border_radius = border_radius or 0 1064 1065 if border_radius > 0 then 1066 -- Simple rounded corners (just draw at corners, not perfect but functional) 1067 -- Draw top border 1068 self.buffer:fill_rect(x + border_radius, y, width - 2 * border_radius, border_width, r, g, b) 1069 -- Draw bottom border 1070 self.buffer:fill_rect(x + border_radius, y + height - border_width, width - 2 * border_radius, border_width, r, g, b) 1071 -- Draw left border 1072 self.buffer:fill_rect(x, y + border_radius, border_width, height - 2 * border_radius, r, g, b) 1073 -- Draw right border 1074 self.buffer:fill_rect(x + width - border_width, y + border_radius, border_width, height - 2 * border_radius, r, g, b) 1075 1076 -- Draw corner squares (simplified) 1077 self.buffer:fill_rect(x, y, border_radius, border_radius, r, g, b) 1078 self.buffer:fill_rect(x + width - border_radius, y, border_radius, border_radius, r, g, b) 1079 self.buffer:fill_rect(x, y + height - border_radius, border_radius, border_radius, r, g, b) 1080 self.buffer:fill_rect(x + width - border_radius, y + height - border_radius, border_radius, border_radius, r, g, b) 1081 else 1082 -- Regular rectangular border 1083 -- Top 1084 self.buffer:fill_rect(x, y, width, border_width, r, g, b) 1085 -- Bottom 1086 self.buffer:fill_rect(x, y + height - border_width, width, border_width, r, g, b) 1087 -- Left 1088 self.buffer:fill_rect(x, y, border_width, height, r, g, b) 1089 -- Right 1090 self.buffer:fill_rect(x + width - border_width, y, border_width, height, r, g, b) 1091 end 1092 end 1093 1094 function HTMLRenderer:render_element(element, base_x, list_index) 1095 base_x = base_x or 20 1096 local style = self.style:get(element) 1097 1098 -- Skip if display is none or if it's a non-rendered element 1099 if style.display == "none" or element.tag == "title" or element.tag == "head" or 1100 element.tag == "script" or element.tag == "style" then 1101 return 1102 end 1103 1104 local font_size = self:parse_size(style["font-size"], 16) 1105 local margin_top = self:parse_size(style["margin-top"], font_size) 1106 local margin_bottom = self:parse_size(style["margin-bottom"], font_size) 1107 1108 -- Parse padding from shorthand or individual properties 1109 local padding = self:parse_size(style.padding, font_size) or 0 1110 local padding_top = self:parse_size(style["padding-top"], font_size) or padding 1111 local padding_right = self:parse_size(style["padding-right"], font_size) or padding 1112 local padding_bottom = self:parse_size(style["padding-bottom"], font_size) or padding 1113 local padding_left = self:parse_size(style["padding-left"], font_size) or padding 1114 1115 local text_r, text_g, text_b, text_a = self:parse_color(style.color) 1116 local bg_r, bg_g, bg_b, bg_a = self:parse_color(style["background-color"]) 1117 local display = style.display or "block" 1118 local position = style.position or "static" 1119 1120 -- Parse opacity property (overrides alpha from colors) 1121 local opacity = 1 1122 if style.opacity then 1123 opacity = tonumber(style.opacity) or 1 1124 if opacity < 0 then opacity = 0 end 1125 if opacity > 1 then opacity = 1 end 1126 end 1127 1128 -- Apply opacity to colors 1129 text_a = (text_a or 1) * opacity 1130 bg_a = (bg_a or 1) * opacity 1131 1132 -- Parse text-transform property 1133 local text_transform = style["text-transform"] 1134 1135 -- Parse overflow property 1136 local overflow = style["overflow"] or "visible" 1137 1138 -- Parse border properties 1139 local border_width = 0 1140 local border_r, border_g, border_b = 0, 0, 0 1141 local border_radius = 0 1142 if style.border or style["border-width"] then 1143 -- Parse border-width 1144 local border_str = style.border or style["border-width"] or "1px" 1145 border_width = self:parse_size(border_str:match("(%d+px)") or "1px", font_size) 1146 1147 -- Parse border-color 1148 local border_color = style["border-color"] 1149 if style.border and not border_color then 1150 -- Try to extract color from shorthand 1151 border_color = style.border:match("#%x+") or style.border:match("%a+") 1152 end 1153 border_r, border_g, border_b = self:parse_color(border_color or "#000000") 1154 1155 -- Parse border-radius 1156 if style["border-radius"] then 1157 border_radius = self:parse_size(style["border-radius"], font_size) 1158 end 1159 end 1160 1161 -- Handle position: fixed (and absolute) 1162 if position == "fixed" or position == "absolute" then 1163 -- Calculate fixed position 1164 local fixed_x = base_x 1165 local fixed_y = self.y_offset 1166 1167 -- Parse positioning properties 1168 if style.left then 1169 fixed_x = self:parse_size(style.left, font_size, self.width) 1170 elseif style.right then 1171 local right_offset = self:parse_size(style.right, font_size, self.width) 1172 -- Will calculate x after we know width 1173 fixed_x = nil -- Mark as right-aligned 1174 end 1175 1176 if style.top then 1177 fixed_y = self:parse_size(style.top, font_size, self.height) 1178 elseif style.bottom then 1179 local bottom_offset = self:parse_size(style.bottom, font_size, self.height) 1180 -- Will calculate y after we know height 1181 fixed_y = nil -- Mark as bottom-aligned 1182 end 1183 1184 -- Parse width and height 1185 local fixed_width = style.width and self:parse_size(style.width, font_size, self.width) or 100 1186 local fixed_height = style.height and self:parse_size(style.height, font_size, self.height) or 100 1187 1188 -- Apply min/max width constraints 1189 if style["min-width"] then 1190 local min_width = self:parse_size(style["min-width"], font_size, self.width) 1191 fixed_width = math.max(fixed_width, min_width) 1192 end 1193 if style["max-width"] then 1194 local max_width = self:parse_size(style["max-width"], font_size, self.width) 1195 fixed_width = math.min(fixed_width, max_width) 1196 end 1197 1198 -- Apply min/max height constraints 1199 if style["min-height"] then 1200 local min_height = self:parse_size(style["min-height"], font_size, self.height) 1201 fixed_height = math.max(fixed_height, min_height) 1202 end 1203 if style["max-height"] then 1204 local max_height = self:parse_size(style["max-height"], font_size, self.height) 1205 fixed_height = math.min(fixed_height, max_height) 1206 end 1207 1208 -- Handle right positioning 1209 if not fixed_x and style.right then 1210 local right_offset = self:parse_size(style.right, font_size, self.width) 1211 fixed_x = self.width - fixed_width - right_offset 1212 end 1213 1214 -- Handle bottom positioning 1215 if not fixed_y and style.bottom then 1216 local bottom_offset = self:parse_size(style.bottom, font_size, self.height) 1217 fixed_y = self.height - fixed_height - bottom_offset 1218 end 1219 1220 -- Render the fixed element 1221 -- Draw background 1222 if bg_r and bg_g and bg_b then 1223 self.buffer:fill_rect(fixed_x, fixed_y, fixed_width, fixed_height, bg_r, bg_g, bg_b) 1224 end 1225 1226 -- Draw content (simple text rendering for now) 1227 if #element.children > 0 then 1228 for _, child in ipairs(element.children) do 1229 if child.tag == "text" and child.content then 1230 self.buffer:draw_text(child.content, fixed_x + padding_left, fixed_y + padding_top, font_size, text_r, text_g, text_b) 1231 end 1232 end 1233 elseif element.content then 1234 self.buffer:draw_text(element.content, fixed_x + padding_left, fixed_y + padding_top, font_size, text_r, text_g, text_b) 1235 end 1236 1237 -- Set layout for click detection 1238 element:set_layout(fixed_x, fixed_y, fixed_width, fixed_height) 1239 1240 -- Don't affect flow 1241 return 1242 end 1243 1244 -- Handle block vs inline 1245 -- Treat inputs as block even though they're inline-block 1246 -- Text nodes and images are always inline (unless display:block is explicitly set) 1247 local is_block = (display == "block" or display == "list-item" or 1248 (display == "inline-block" and element.tag == "input")) and 1249 element.tag ~= "text" and element.tag ~= "img" 1250 1251 if is_block then 1252 -- Block element - start new line 1253 if self.line_height > 0 then 1254 self.y_offset = self.y_offset + self.line_height 1255 self.line_height = 0 1256 end 1257 self.y_offset = self.y_offset + margin_top 1258 self.x_offset = base_x 1259 1260 -- Handle specific block elements 1261 if element.tag == "ul" then 1262 for _, child in ipairs(element.children) do 1263 if child.tag == "li" then 1264 self:render_element(child, base_x + padding_left, nil) -- nil for bullets 1265 end 1266 -- Skip text nodes (whitespace between li elements) 1267 end 1268 elseif element.tag == "ol" then 1269 local li_index = 0 1270 for _, child in ipairs(element.children) do 1271 if child.tag == "li" then 1272 li_index = li_index + 1 1273 self:render_element(child, base_x + padding_left, li_index) 1274 end 1275 -- Skip text nodes (whitespace between li elements) 1276 end 1277 elseif element.tag == "form" then 1278 for _, child in ipairs(element.children) do 1279 self:render_element(child, base_x) 1280 end 1281 elseif element.tag == "table" then 1282 -- Render table 1283 -- Apply margins 1284 local margin_left = self:parse_size(style["margin-left"], font_size) 1285 local margin_right = self:parse_size(style["margin-right"], font_size) 1286 1287 local table_x = self.x_offset + margin_left 1288 local table_y = self.y_offset 1289 local border_width = 1 1290 1291 -- Parse border if present 1292 if style.border or style["border-width"] then 1293 border_width = self:parse_size(style["border-width"], 1) or 1 1294 end 1295 1296 -- Get border color 1297 local border_r, border_g, border_b = 0, 0, 0 1298 if style["border-color"] then 1299 border_r, border_g, border_b = self:parse_color(style["border-color"]) 1300 end 1301 1302 -- Track table dimensions 1303 local max_width = 0 1304 local row_heights = {} 1305 local col_widths = {} 1306 1307 -- Calculate column widths and row heights 1308 for _, row in ipairs(element.children) do 1309 if row.tag == "tr" then 1310 local col_index = 0 1311 for _, cell in ipairs(row.children) do 1312 if cell.tag == "td" or cell.tag == "th" then 1313 col_index = col_index + 1 1314 1315 -- Check if cell has button or other interactive elements 1316 local cell_width = 0 1317 local has_button = false 1318 1319 for _, child in ipairs(cell.children) do 1320 if child.tag == "button" then 1321 has_button = true 1322 -- Get button style to calculate width 1323 local button_style = self.style:get(child) 1324 local button_width = button_style.width and self:parse_size(button_style.width, font_size, self.width) or 100 1325 1326 -- Get button padding 1327 local button_padding = self:parse_size(button_style.padding, font_size) or 0 1328 1329 -- Get button border width 1330 local button_border_width = 0 1331 if button_style.border or button_style["border-width"] then 1332 local border_str = button_style.border or button_style["border-width"] or "1px" 1333 button_border_width = self:parse_size(border_str:match("(%d+px)") or "1px", font_size) 1334 end 1335 1336 -- Get button text 1337 local button_text = "" 1338 if #child.children > 0 then 1339 for _, btn_child in ipairs(child.children) do 1340 if btn_child.tag == "text" and btn_child.content then 1341 button_text = button_text .. btn_child.content 1342 end 1343 end 1344 else 1345 button_text = child.content or "" 1346 end 1347 1348 local text_width = self.buffer:get_text_width(button_text, font_size) 1349 local btn_total_width = math.max(button_width, text_width + button_padding * 2) 1350 1351 -- Add button border (left + right) 1352 btn_total_width = btn_total_width + button_border_width * 2 1353 1354 -- Add button margin 1355 local button_margin = self:parse_size(button_style.margin, font_size) or 0 1356 btn_total_width = btn_total_width + button_margin * 2 1357 1358 cell_width = math.max(cell_width, btn_total_width + 10) 1359 end 1360 end 1361 1362 if not has_button then 1363 -- Get cell content width (text only) 1364 local cell_text = "" 1365 if #cell.children > 0 and cell.children[1].tag == "text" then 1366 cell_text = cell.children[1].content or "" 1367 else 1368 cell_text = cell.content or "" 1369 end 1370 1371 local text_width = self.buffer:get_text_width(cell_text, font_size) 1372 cell_width = text_width + 10 -- padding 1373 end 1374 1375 col_widths[col_index] = math.max(col_widths[col_index] or 0, cell_width) 1376 end 1377 end 1378 end 1379 end 1380 1381 -- Render table cells 1382 local current_y = table_y 1383 local row_index = 0 1384 1385 for _, row in ipairs(element.children) do 1386 if row.tag == "tr" then 1387 row_index = row_index + 1 1388 local current_x = table_x 1389 local row_height = font_size + 8 1390 1391 -- Calculate row height based on content 1392 for _, cell in ipairs(row.children) do 1393 if cell.tag == "td" or cell.tag == "th" then 1394 for _, child in ipairs(cell.children) do 1395 if child.tag == "button" then 1396 local button_style = self.style:get(child) 1397 local button_height = button_style["min-height"] and self:parse_size(button_style["min-height"], font_size, self.height) or 50 1398 local button_padding = self:parse_size(button_style.padding, font_size) or 0 1399 local button_margin = self:parse_size(button_style.margin, font_size) or 0 1400 1401 -- Get button border width 1402 local button_border_width = 0 1403 if button_style.border or button_style["border-width"] then 1404 local border_str = button_style.border or button_style["border-width"] or "1px" 1405 button_border_width = self:parse_size(border_str:match("(%d+px)") or "1px", font_size) 1406 end 1407 1408 local total_height = button_height + button_margin * 2 + button_border_width * 2 + 10 1409 row_height = math.max(row_height, total_height) 1410 end 1411 end 1412 end 1413 end 1414 1415 local col_index = 0 1416 1417 for _, cell in ipairs(row.children) do 1418 if cell.tag == "td" or cell.tag == "th" then 1419 col_index = col_index + 1 1420 local cell_width = col_widths[col_index] 1421 1422 -- Draw cell background 1423 if cell.tag == "th" then 1424 self.buffer:fill_rect(current_x, current_y, cell_width, row_height, 240, 240, 240) 1425 else 1426 self.buffer:fill_rect(current_x, current_y, cell_width, row_height, 255, 255, 255) 1427 end 1428 1429 -- Draw cell border 1430 if border_width > 0 then 1431 for i = 0, border_width - 1 do 1432 self.buffer:draw_rect_outline( 1433 current_x + i, current_y + i, 1434 cell_width - i * 2, row_height - i * 2, 1435 border_r, border_g, border_b 1436 ) 1437 end 1438 end 1439 1440 -- Render cell contents (text or interactive elements) 1441 -- Save current position 1442 local saved_x = self.x_offset 1443 local saved_y = self.y_offset 1444 local saved_line_height = self.line_height 1445 1446 -- Set position for cell contents 1447 self.x_offset = current_x + 5 1448 self.y_offset = current_y + 4 1449 self.line_height = 0 1450 1451 -- Check if cell has interactive elements 1452 local has_interactive = false 1453 for _, child in ipairs(cell.children) do 1454 if child.tag == "button" or child.tag == "input" or child.tag == "select" or child.tag == "textarea" then 1455 has_interactive = true 1456 break 1457 end 1458 end 1459 1460 if has_interactive then 1461 -- Render children (buttons, inputs, etc.) 1462 for _, child in ipairs(cell.children) do 1463 self:render_element(child, current_x + 5) 1464 end 1465 else 1466 -- Just draw text 1467 local cell_text = "" 1468 if #cell.children > 0 and cell.children[1].tag == "text" then 1469 cell_text = cell.children[1].content or "" 1470 else 1471 cell_text = cell.content or "" 1472 end 1473 1474 if cell_text ~= "" then 1475 local text_r_cell, text_g_cell, text_b_cell = text_r, text_g, text_b 1476 if cell.tag == "th" then 1477 -- Bold effect for headers (draw twice with offset) 1478 self.buffer:draw_text(cell_text, current_x + 5, current_y + 4, font_size, text_r_cell, text_g_cell, text_b_cell) 1479 self.buffer:draw_text(cell_text, current_x + 6, current_y + 4, font_size, text_r_cell, text_g_cell, text_b_cell) 1480 else 1481 self.buffer:draw_text(cell_text, current_x + 5, current_y + 4, font_size, text_r_cell, text_g_cell, text_b_cell) 1482 end 1483 end 1484 end 1485 1486 -- Restore position 1487 self.x_offset = saved_x 1488 self.y_offset = saved_y 1489 self.line_height = saved_line_height 1490 1491 current_x = current_x + cell_width 1492 end 1493 end 1494 1495 max_width = math.max(max_width, current_x - table_x) 1496 current_y = current_y + row_height 1497 end 1498 end 1499 1500 self.y_offset = current_y + margin_bottom 1501 self.x_offset = base_x 1502 elseif element.tag == "li" then 1503 -- Draw bullet or number 1504 if list_index then 1505 -- Numbered list (OL) 1506 local number_text = tostring(list_index) .. "." 1507 self.buffer:draw_text(number_text, base_x - 25, self.y_offset, font_size, text_r, text_g, text_b) 1508 else 1509 -- Bullet list (UL) - use "•" character 1510 self.buffer:draw_text("•", base_x - 10, self.y_offset, font_size, text_r, text_g, text_b) 1511 end 1512 1513 -- Get content from text nodes 1514 local content = "" 1515 if #element.children > 0 then 1516 for _, child in ipairs(element.children) do 1517 if child.tag == "text" and child.content then 1518 content = content .. child.content 1519 end 1520 end 1521 else 1522 content = element.content or "" 1523 end 1524 1525 -- Apply text-transform 1526 content = self:apply_text_transform(content, text_transform) 1527 1528 if content ~= "" then 1529 self.buffer:draw_text(content, base_x, self.y_offset, font_size, text_r, text_g, text_b) 1530 end 1531 self.y_offset = self.y_offset + font_size + 2 1532 elseif element.tag == "br" then 1533 if self.line_height > 0 then 1534 self.y_offset = self.y_offset + self.line_height 1535 self.line_height = 0 1536 end 1537 self.y_offset = self.y_offset + font_size 1538 self.x_offset = base_x 1539 elseif element.tag == "hr" then 1540 -- Render horizontal rule 1541 if self.line_height > 0 then 1542 self.y_offset = self.y_offset + self.line_height 1543 self.line_height = 0 1544 end 1545 self.y_offset = self.y_offset + 10 -- Space before hr 1546 1547 local hr_width = self.buffer.width - base_x - 20 -- Full width minus margins 1548 local hr_height = 1 -- Line thickness 1549 1550 element:set_layout(base_x, self.y_offset, hr_width, hr_height) 1551 1552 -- Draw horizontal line (gray color: 128, 128, 128) 1553 self.buffer:fill_rect(base_x, self.y_offset, hr_width, hr_height, 128, 128, 128) 1554 1555 self.y_offset = self.y_offset + hr_height + 10 -- Space after hr 1556 self.x_offset = base_x 1557 elseif element.tag == "input" then 1558 local input_type = element.attributes.type or "text" 1559 1560 if input_type == "checkbox" then 1561 -- Render checkbox inline 1562 local box_size = font_size + 2 1563 element:set_layout(self.x_offset, self.y_offset, box_size, box_size) 1564 1565 -- Draw checkbox box 1566 self.buffer:draw_rect_outline(self.x_offset, self.y_offset, box_size, box_size, 0, 0, 0) 1567 self.buffer:fill_rect(self.x_offset + 1, self.y_offset + 1, box_size - 2, box_size - 2, 255, 255, 255) 1568 1569 -- Draw checkmark if checked 1570 if self.document and self.document:is_checked(element) then 1571 -- Draw an X checkmark 1572 local cx = self.x_offset + 3 1573 local cy = self.y_offset + 3 1574 local csize = box_size - 6 1575 -- Diagonal lines for checkmark 1576 for i = 0, csize do 1577 self.buffer:set_pixel(cx + i, cy + i, 0, 0, 0) 1578 self.buffer:set_pixel(cx + i + 1, cy + i, 0, 0, 0) 1579 self.buffer:set_pixel(cx + csize - i, cy + i, 0, 0, 0) 1580 self.buffer:set_pixel(cx + csize - i - 1, cy + i, 0, 0, 0) 1581 end 1582 end 1583 1584 self.x_offset = self.x_offset + box_size + 4 1585 self.line_height = math.max(self.line_height, box_size) 1586 1587 elseif input_type == "radio" then 1588 -- Render radio button inline 1589 local radio_size = font_size + 2 1590 element:set_layout(self.x_offset, self.y_offset, radio_size, radio_size) 1591 1592 -- Draw radio circle (outline) 1593 local center_x = self.x_offset + radio_size / 2 1594 local center_y = self.y_offset + radio_size / 2 1595 local radius = radio_size / 2 - 1 1596 1597 -- Draw circle outline 1598 for angle = 0, 360, 5 do 1599 local rad = math.rad(angle) 1600 local x = center_x + radius * math.cos(rad) 1601 local y = center_y + radius * math.sin(rad) 1602 self.buffer:set_pixel(math.floor(x), math.floor(y), 0, 0, 0) 1603 end 1604 1605 -- Fill circle with white 1606 for dy = -radius, radius do 1607 for dx = -radius, radius do 1608 if dx * dx + dy * dy < radius * radius then 1609 self.buffer:set_pixel( 1610 math.floor(center_x + dx), 1611 math.floor(center_y + dy), 1612 255, 255, 255 1613 ) 1614 end 1615 end 1616 end 1617 1618 -- Redraw outline 1619 for angle = 0, 360, 5 do 1620 local rad = math.rad(angle) 1621 local x = center_x + radius * math.cos(rad) 1622 local y = center_y + radius * math.sin(rad) 1623 self.buffer:set_pixel(math.floor(x), math.floor(y), 0, 0, 0) 1624 end 1625 1626 -- Draw filled circle if checked 1627 if self.document and self.document:is_checked(element) then 1628 local inner_radius = radius - 3 1629 for dy = -inner_radius, inner_radius do 1630 for dx = -inner_radius, inner_radius do 1631 if dx * dx + dy * dy < inner_radius * inner_radius then 1632 self.buffer:set_pixel( 1633 math.floor(center_x + dx), 1634 math.floor(center_y + dy), 1635 0, 0, 0 1636 ) 1637 end 1638 end 1639 end 1640 end 1641 1642 self.x_offset = self.x_offset + radio_size + 4 1643 self.line_height = math.max(self.line_height, radio_size) 1644 1645 else 1646 -- Text input (or other input types) - render as block 1647 if self.line_height > 0 then 1648 self.y_offset = self.y_offset + self.line_height 1649 self.line_height = 0 1650 end 1651 self.x_offset = base_x 1652 1653 -- Update element position for click detection 1654 local input_width = 200 1655 local input_height = font_size + 8 1656 element:set_layout(self.x_offset, self.y_offset, input_width, input_height) 1657 1658 self.buffer:draw_rect_outline(self.x_offset, self.y_offset, input_width, input_height, 0, 0, 0) 1659 1660 -- Check if focused 1661 local is_focused = self.document and self.document:is_focused(element) 1662 local text = "" 1663 local text_color_r, text_color_g, text_color_b = 0, 0, 0 1664 1665 if is_focused then 1666 -- Show actual value (black text) 1667 text = self.document:get_input_value(element) or "" 1668 text_color_r, text_color_g, text_color_b = 0, 0, 0 1669 1670 -- Draw cursor (2 pixels wide) 1671 local cursor_pos = self.document:get_cursor_position(element) or 0 1672 local text_before_cursor = text:sub(1, cursor_pos) 1673 local cursor_x = self.x_offset + 4 + self.buffer:get_text_width(text_before_cursor, font_size) 1674 1675 -- Draw cursor line (2 pixels wide for visibility) 1676 for i = 0, font_size do 1677 self.buffer:set_pixel(cursor_x, self.y_offset + 4 + i, 0, 0, 0) 1678 self.buffer:set_pixel(cursor_x + 1, self.y_offset + 4 + i, 0, 0, 0) 1679 end 1680 else 1681 -- Show placeholder (gray) or value 1682 if self.document then 1683 local value = self.document:get_input_value(element) 1684 if value and value ~= "" then 1685 text = value 1686 text_color_r, text_color_g, text_color_b = 0, 0, 0 1687 else 1688 text = element.attributes.placeholder or "" 1689 text_color_r, text_color_g, text_color_b = 100, 100, 100 1690 end 1691 else 1692 -- No document, show placeholder or attribute value 1693 text = element.attributes.value or element.attributes.placeholder or "" 1694 text_color_r, text_color_g, text_color_b = 100, 100, 100 1695 end 1696 end 1697 1698 if text ~= "" then 1699 self.buffer:draw_text(text, self.x_offset + 4, self.y_offset + 4, font_size, text_color_r, text_color_g, text_color_b) 1700 end 1701 1702 self.y_offset = self.y_offset + input_height + 2 1703 self.x_offset = base_x 1704 end 1705 elseif element.tag == "textarea" then 1706 -- Render textarea as block 1707 if self.line_height > 0 then 1708 self.y_offset = self.y_offset + self.line_height 1709 self.line_height = 0 1710 end 1711 self.x_offset = base_x 1712 1713 -- Textarea dimensions (larger than input) 1714 local textarea_width = 300 1715 local textarea_height = font_size * 4 + 8 -- 4 lines tall 1716 element:set_layout(self.x_offset, self.y_offset, textarea_width, textarea_height) 1717 1718 self.buffer:draw_rect_outline(self.x_offset, self.y_offset, textarea_width, textarea_height, 0, 0, 0) 1719 1720 -- Check if focused 1721 local is_focused = self.document and self.document:is_focused(element) 1722 local text = "" 1723 local text_color_r, text_color_g, text_color_b = 0, 0, 0 1724 1725 if is_focused then 1726 -- Show actual value (black text) 1727 text = self.document:get_input_value(element) or "" 1728 text_color_r, text_color_g, text_color_b = 0, 0, 0 1729 1730 -- Draw cursor (2 pixels wide) 1731 local cursor_pos = self.document:get_cursor_position(element) or 0 1732 local text_before_cursor = text:sub(1, cursor_pos) 1733 local cursor_x = self.x_offset + 4 + self.buffer:get_text_width(text_before_cursor, font_size) 1734 1735 -- Draw cursor line (2 pixels wide for visibility) 1736 for i = 0, font_size do 1737 self.buffer:set_pixel(cursor_x, self.y_offset + 4 + i, 0, 0, 0) 1738 self.buffer:set_pixel(cursor_x + 1, self.y_offset + 4 + i, 0, 0, 0) 1739 end 1740 else 1741 -- Show placeholder (gray) or value 1742 if self.document then 1743 local value = self.document:get_input_value(element) 1744 if value and value ~= "" then 1745 text = value 1746 text_color_r, text_color_g, text_color_b = 0, 0, 0 1747 else 1748 text = element.attributes.placeholder or "" 1749 text_color_r, text_color_g, text_color_b = 100, 100, 100 1750 end 1751 else 1752 -- No document, show placeholder or content 1753 text = element.content or element.attributes.placeholder or "" 1754 text_color_r, text_color_g, text_color_b = 100, 100, 100 1755 end 1756 end 1757 1758 if text ~= "" then 1759 self.buffer:draw_text(text, self.x_offset + 4, self.y_offset + 4, font_size, text_color_r, text_color_g, text_color_b) 1760 end 1761 1762 self.y_offset = self.y_offset + textarea_height + 2 1763 self.x_offset = base_x 1764 elseif element.tag == "select" then 1765 -- Render select dropdown 1766 if self.line_height > 0 then 1767 self.y_offset = self.y_offset + self.line_height 1768 self.line_height = 0 1769 end 1770 self.x_offset = base_x 1771 1772 -- Helper to get option text 1773 local function get_option_text(option) 1774 if not option then return "" end 1775 -- Try text child first 1776 if #option.children > 0 and option.children[1].tag == "text" then 1777 return option.children[1].content or "" 1778 end 1779 -- Fall back to content or value attribute 1780 return option.content or option.attributes.value or "" 1781 end 1782 1783 -- Get selected option text 1784 local selected_text = "" 1785 if self.document then 1786 local selected_option = self.document:get_selected_option(element) 1787 if selected_option then 1788 selected_text = get_option_text(selected_option) 1789 else 1790 -- No selection, try to find first option or default selected 1791 for _, child in ipairs(element.children) do 1792 if child.tag == "option" then 1793 if child.attributes.selected then 1794 selected_text = get_option_text(child) 1795 self.document:set_selected_option(element, child) 1796 break 1797 elseif selected_text == "" then 1798 -- Use first option as fallback 1799 selected_text = get_option_text(child) 1800 self.document:set_selected_option(element, child) 1801 end 1802 end 1803 end 1804 end 1805 else 1806 -- No document, find first/selected option 1807 for _, child in ipairs(element.children) do 1808 if child.tag == "option" then 1809 if child.attributes.selected or selected_text == "" then 1810 selected_text = get_option_text(child) 1811 if child.attributes.selected then break end 1812 end 1813 end 1814 end 1815 end 1816 1817 -- Dropdown dimensions 1818 local select_width = 200 1819 local select_height = font_size + 8 1820 element:set_layout(self.x_offset, self.y_offset, select_width, select_height) 1821 1822 -- Draw select box 1823 self.buffer:fill_rect(self.x_offset, self.y_offset, select_width, select_height, 255, 255, 255) 1824 self.buffer:draw_rect_outline(self.x_offset, self.y_offset, select_width, select_height, 0, 0, 0) 1825 1826 -- Draw selected text 1827 if selected_text ~= "" then 1828 self.buffer:draw_text(selected_text, self.x_offset + 4, self.y_offset + 4, font_size, 0, 0, 0) 1829 end 1830 1831 -- Draw dropdown arrow 1832 local arrow_x = self.x_offset + select_width - 20 1833 local arrow_y = self.y_offset + select_height / 2 1834 -- Simple down arrow (triangle) 1835 for i = 0, 5 do 1836 for j = -i, i do 1837 self.buffer:set_pixel(arrow_x + j, math.floor(arrow_y + i), 0, 0, 0) 1838 end 1839 end 1840 1841 self.y_offset = self.y_offset + select_height + 2 1842 self.x_offset = base_x 1843 else 1844 -- Regular block element (h1, p, div, etc) 1845 local start_y = self.y_offset 1846 local content_x = base_x + padding_left 1847 local content_width = 0 1848 local content_height = 0 1849 1850 -- Check if element has explicit height and vertical-align 1851 local explicit_height = style.height and self:parse_size(style.height, font_size, self.height) 1852 1853 -- Apply min/max height constraints 1854 if explicit_height then 1855 if style["min-height"] then 1856 local min_height = self:parse_size(style["min-height"], font_size, self.height) 1857 explicit_height = math.max(explicit_height, min_height) 1858 end 1859 if style["max-height"] then 1860 local max_height = self:parse_size(style["max-height"], font_size, self.height) 1861 explicit_height = math.min(explicit_height, max_height) 1862 end 1863 end 1864 1865 local vertical_align = style["vertical-align"] or "top" 1866 local vertical_offset = 0 1867 1868 -- Apply top padding 1869 self.y_offset = self.y_offset + padding_top 1870 local content_start_y = self.y_offset 1871 1872 if #element.children > 0 then 1873 -- Calculate available width for this element 1874 local available_width 1875 1876 -- Check if element has explicit width 1877 if style.width then 1878 -- Parent width for percentage calculation 1879 local parent_width 1880 if self.max_x then 1881 parent_width = self.max_x - base_x 1882 else 1883 parent_width = self.buffer.width - base_x - 20 1884 end 1885 1886 -- Parse width (supports px, %, vw, vh, em) 1887 local explicit_width = self:parse_size(style.width, font_size, parent_width) 1888 1889 -- Apply min/max width constraints 1890 if style["min-width"] then 1891 local min_width = self:parse_size(style["min-width"], font_size, parent_width) 1892 explicit_width = math.max(explicit_width, min_width) 1893 end 1894 if style["max-width"] then 1895 local max_width = self:parse_size(style["max-width"], font_size, parent_width) 1896 explicit_width = math.min(explicit_width, max_width) 1897 end 1898 1899 available_width = explicit_width - padding_left - padding_right 1900 elseif self.max_x then 1901 -- If there's a constrained width from parent, use it 1902 available_width = self.max_x - base_x - padding_right 1903 else 1904 -- Otherwise use full buffer width 1905 available_width = self.buffer.width - base_x - padding_right - 20 1906 end 1907 1908 -- Set max_x for children (they should not exceed this element's content area) 1909 -- Content area ends at: base_x + padding_left + (available_width - padding_left) = base_x + available_width 1910 local saved_max_x = self.max_x 1911 self.max_x = base_x + available_width 1912 1913 -- Handle text-align 1914 local text_align = style["text-align"] or "left" 1915 local align_offset = 0 1916 1917 if text_align == "center" or text_align == "right" then 1918 -- Calculate total content width 1919 local temp_x = content_x 1920 for _, child in ipairs(element.children) do 1921 if child.tag == "text" and child.content then 1922 local text_width = self.buffer:get_text_width(child.content, font_size) 1923 temp_x = temp_x + text_width 1924 end 1925 end 1926 local total_content_width = temp_x - content_x 1927 1928 if text_align == "center" then 1929 align_offset = math.floor((available_width - total_content_width) / 2) 1930 elseif text_align == "right" then 1931 align_offset = available_width - total_content_width - padding_right 1932 end 1933 end 1934 1935 -- Element with children - render children inline with padding 1936 self.x_offset = content_x + align_offset 1937 for _, child in ipairs(element.children) do 1938 self:render_element(child, content_x + align_offset) 1939 end 1940 1941 -- Advance by line_height to account for the last line 1942 if self.line_height > 0 then 1943 self.y_offset = self.y_offset + self.line_height 1944 self.line_height = 0 1945 end 1946 1947 -- Restore max_x 1948 self.max_x = saved_max_x 1949 1950 -- Calculate actual content height (without padding) 1951 local actual_content_height = self.y_offset - content_start_y 1952 1953 -- Handle vertical-align if explicit height is set 1954 if explicit_height and vertical_align == "middle" then 1955 local available_height = explicit_height - padding_top - padding_bottom 1956 if actual_content_height < available_height then 1957 vertical_offset = math.floor((available_height - actual_content_height) / 2) 1958 end 1959 end 1960 1961 -- Apply bottom padding (or use explicit height) 1962 if explicit_height then 1963 self.y_offset = start_y + explicit_height 1964 else 1965 self.y_offset = self.y_offset + padding_bottom 1966 end 1967 1968 content_height = self.y_offset - start_y 1969 content_width = available_width 1970 1971 -- Draw box-shadow if specified 1972 if style["box-shadow"] then 1973 -- Simple parsing: "2px 2px 4px #000000" or "2px 2px 4px rgba(0,0,0,0.5)" 1974 local shadow_match = style["box-shadow"]:match("([%d]+)px%s+([%d]+)px%s+([%d]+)px%s+(#%x+)") 1975 if shadow_match then 1976 local offset_x, offset_y, blur, color = shadow_match:match("([%d]+)px%s+([%d]+)px%s+([%d]+)px%s+(#%x+)") 1977 if offset_x then 1978 offset_x = tonumber(offset_x) or 2 1979 offset_y = tonumber(offset_y) or 2 1980 blur = tonumber(blur) or 4 1981 local shadow_r, shadow_g, shadow_b = self:parse_color(color or "#808080") 1982 -- Simple shadow - just draw a slightly offset darker rectangle 1983 self.buffer:fill_rect(base_x + offset_x, start_y + offset_y, content_width, content_height, shadow_r, shadow_g, shadow_b) 1984 end 1985 end 1986 end 1987 1988 -- Draw background if specified and not transparent 1989 if style["background-color"] and style["background-color"] ~= "transparent" and bg_r and bg_g and bg_b then 1990 -- Draw background rectangle behind the content (includes padding area) 1991 self.buffer:fill_rect(base_x, start_y, content_width, content_height, bg_r, bg_g, bg_b) 1992 1993 -- Re-render children on top of background 1994 local saved_y = self.y_offset 1995 self.y_offset = start_y + padding_top + vertical_offset 1996 self.x_offset = content_x + align_offset 1997 -- Re-set max_x for children during re-render 1998 self.max_x = base_x + available_width 1999 for _, child in ipairs(element.children) do 2000 self:render_element(child, content_x + align_offset) 2001 end 2002 self.y_offset = saved_y 2003 self.max_x = saved_max_x -- Restore again after re-render 2004 end 2005 2006 -- Draw border if specified 2007 if border_width > 0 then 2008 self:draw_border(base_x, start_y, content_width, content_height, border_width, border_r, border_g, border_b, border_radius) 2009 end 2010 2011 self.x_offset = base_x 2012 elseif element.content and element.content ~= "" then 2013 -- Simple element with just text content 2014 local text_width = self.buffer:get_text_width(element.content, font_size) 2015 content_height = padding_top + font_size + padding_bottom 2016 content_width = padding_left + text_width + padding_right 2017 2018 -- Draw background if specified 2019 if style["background-color"] and style["background-color"] ~= "transparent" and bg_r and bg_g and bg_b then 2020 self.buffer:fill_rect(base_x, start_y, content_width, content_height, bg_r, bg_g, bg_b) 2021 end 2022 2023 -- Draw border if specified 2024 if border_width > 0 then 2025 self:draw_border(base_x, start_y, content_width, content_height, border_width, border_r, border_g, border_b, border_radius) 2026 end 2027 2028 local text_y = self.y_offset 2029 self.buffer:draw_text(element.content, content_x, text_y, font_size, text_r, text_g, text_b) 2030 self.y_offset = self.y_offset + font_size + padding_bottom 2031 self.x_offset = base_x 2032 -- Debug h3 2033 if element.tag == "h3" then 2034 print(string.format("H3 '%s': drew at y=%d, after y_offset=%d", element.content or "", text_y, self.y_offset)) 2035 end 2036 end 2037 end 2038 2039 self.y_offset = self.y_offset + margin_bottom 2040 else 2041 -- Inline element - stay on current line 2042 if element.tag == "text" then 2043 -- Text node - render inline 2044 if element.content and element.content ~= "" then 2045 -- Get parent's style for text nodes 2046 local parent_style = element.parent and self.style:get(element.parent) or style 2047 local parent_r, parent_g, parent_b = self:parse_color(parent_style.color) 2048 local text_decoration = parent_style["text-decoration"] 2049 local parent_text_transform = parent_style["text-transform"] 2050 2051 -- Debug h3 text 2052 if element.parent and element.parent.tag == "h3" then 2053 print(string.format("H3 TEXT '%s': drawing at x=%d, y=%d", element.content:sub(1, 20), self.x_offset, self.y_offset)) 2054 end 2055 2056 -- Apply text-transform from parent 2057 local transformed_content = self:apply_text_transform(element.content, parent_text_transform) 2058 2059 -- Calculate available width for wrapping 2060 local max_width = (self.max_x or self.width) - base_x 2061 2062 -- Determine wrap mode from CSS properties 2063 local wrap_mode = "normal" -- default 2064 2065 -- Check white-space property 2066 local white_space = parent_style["white-space"] 2067 if white_space == "nowrap" or white_space == "pre" then 2068 wrap_mode = "nowrap" 2069 end 2070 2071 -- Check word-break property (takes precedence over word-wrap) 2072 local word_break = parent_style["word-break"] 2073 if word_break == "break-all" then 2074 wrap_mode = "break-all" 2075 elseif word_break == "break-word" then 2076 wrap_mode = "break-word" 2077 end 2078 2079 -- Check word-wrap / overflow-wrap property 2080 local word_wrap = parent_style["word-wrap"] or parent_style["overflow-wrap"] 2081 if word_wrap == "break-word" and wrap_mode == "normal" then 2082 wrap_mode = "break-word" 2083 end 2084 2085 -- Use word wrapping for text 2086 self:draw_wrapped_text(transformed_content, self.x_offset, self.y_offset, max_width, font_size, parent_r, parent_g, parent_b, text_decoration, base_x, wrap_mode) 2087 end 2088 self.line_height = math.max(self.line_height, font_size) 2089 elseif element.tag == "button" then 2090 local padding = self:parse_size(style.padding, font_size) 2091 2092 -- Get button border width 2093 local btn_border_width = 0 2094 if style.border or style["border-width"] then 2095 local border_str = style.border or style["border-width"] or "1px" 2096 btn_border_width = self:parse_size(border_str:match("(%d+px)") or "1px", font_size) 2097 end 2098 2099 -- Get button content from text nodes 2100 local content = "" 2101 if #element.children > 0 then 2102 for _, child in ipairs(element.children) do 2103 if child.tag == "text" and child.content then 2104 content = content .. child.content 2105 end 2106 end 2107 else 2108 content = element.content or "" 2109 end 2110 2111 -- Apply text-transform 2112 content = self:apply_text_transform(content, text_transform) 2113 2114 local text_width = self.buffer:get_text_width(content, font_size) 2115 2116 -- CSS Box Model: width property specifies content width (not including padding/border) 2117 local content_width, content_height 2118 2119 if style.width then 2120 -- Explicit width is content width (CSS standard box model) 2121 content_width = self:parse_size(style.width, font_size, self.width) 2122 else 2123 -- Auto width: size to fit text + padding 2124 content_width = text_width 2125 end 2126 2127 if style.height or style["min-height"] then 2128 content_height = style.height and self:parse_size(style.height, font_size, self.height) or self:parse_size(style["min-height"], font_size, self.height) 2129 else 2130 -- Auto height: size to fit text 2131 content_height = font_size 2132 end 2133 2134 -- Apply min/max constraints to content dimensions 2135 if style["min-width"] then 2136 local min_width = self:parse_size(style["min-width"], font_size, self.width) 2137 content_width = math.max(content_width, min_width) 2138 end 2139 if style["max-width"] then 2140 local max_width = self:parse_size(style["max-width"], font_size, self.width) 2141 content_width = math.min(content_width, max_width) 2142 end 2143 2144 -- Total button size = content + padding + border (CSS box model) 2145 local btn_width = content_width + padding * 2 + btn_border_width * 2 2146 local btn_height = content_height + padding * 2 + btn_border_width * 2 2147 2148 -- Set layout for click detection 2149 element:set_layout(self.x_offset, self.y_offset, btn_width, btn_height) 2150 2151 -- Check if button is pressed 2152 local is_pressed = self.document and self.document:is_pressed(element) 2153 local final_bg_r, final_bg_g, final_bg_b 2154 2155 if is_pressed then 2156 -- Dark grey when pressed 2157 final_bg_r, final_bg_g, final_bg_b = 85, 85, 85 2158 else 2159 final_bg_r, final_bg_g, final_bg_b = bg_r, bg_g, bg_b 2160 end 2161 2162 -- Draw border first (full button size) 2163 if btn_border_width > 0 then 2164 -- Fill the entire button area with border color 2165 self.buffer:fill_rect(self.x_offset, self.y_offset, btn_width, btn_height, border_r, border_g, border_b) 2166 end 2167 2168 -- Draw background (inside border, includes padding area) 2169 if style["background-color"] ~= "transparent" then 2170 self.buffer:fill_rect( 2171 self.x_offset + btn_border_width, 2172 self.y_offset + btn_border_width, 2173 content_width + padding * 2, 2174 content_height + padding * 2, 2175 final_bg_r, final_bg_g, final_bg_b 2176 ) 2177 end 2178 2179 -- Draw text (inside border and padding) 2180 self.buffer:draw_text( 2181 content, 2182 self.x_offset + btn_border_width + padding, 2183 self.y_offset + btn_border_width + padding, 2184 font_size, 2185 text_r, text_g, text_b 2186 ) 2187 2188 self.x_offset = self.x_offset + btn_width + 2 -- Small gap after button 2189 self.line_height = math.max(self.line_height, btn_height) 2190 elseif element.tag == "img" then 2191 -- Render image 2192 local src = element.attributes.src 2193 if src then 2194 -- Debug: check position 2195 print(string.format("IMG: x_offset=%d, y_offset=%d, line_height=%d, base_x=%d", 2196 self.x_offset, self.y_offset, self.line_height, base_x)) 2197 2198 -- Resolve image path relative to base_path 2199 local image_path = src 2200 if self.document and self.document.base_path then 2201 image_path = self.document.base_path .. src 2202 end 2203 2204 -- Parse width and height attributes 2205 local img_width = element.attributes.width and tonumber(element.attributes.width) 2206 local img_height = element.attributes.height and tonumber(element.attributes.height) 2207 2208 -- Load and parse image 2209 local image, err = image_parser.load_image(image_path, img_width, img_height) 2210 2211 if image then 2212 local actual_width = image.width 2213 local actual_height = image.height 2214 2215 -- Check parent's white-space property 2216 local parent_style = element.parent and self.style:get(element.parent) or style 2217 local white_space = parent_style["white-space"] 2218 local should_wrap = (white_space ~= "nowrap" and white_space ~= "pre") 2219 2220 -- Check if we need to wrap to next line (only if wrapping is allowed) 2221 if should_wrap and self.x_offset + actual_width > self.width then 2222 self.y_offset = self.y_offset + self.line_height 2223 self.line_height = 0 2224 self.x_offset = base_x 2225 end 2226 2227 -- Set layout for potential click detection 2228 element:set_layout(self.x_offset, self.y_offset, actual_width, actual_height) 2229 2230 -- Draw the image (use Image object if available, otherwise pixels) 2231 local image_data = image.image or image.pixels 2232 self.buffer:draw_image(image_data, self.x_offset, self.y_offset, actual_width, actual_height) 2233 2234 self.x_offset = self.x_offset + actual_width + 2 2235 self.line_height = math.max(self.line_height, actual_height) 2236 else 2237 -- Image failed to load - draw a placeholder 2238 local placeholder_width = img_width or 100 2239 local placeholder_height = img_height or 100 2240 2241 -- Draw gray box with X 2242 self.buffer:fill_rect(self.x_offset, self.y_offset, placeholder_width, placeholder_height, 200, 200, 200) 2243 self.buffer:draw_rect_outline(self.x_offset, self.y_offset, placeholder_width, placeholder_height, 128, 128, 128) 2244 2245 -- Draw X 2246 for i = 0, math.min(placeholder_width, placeholder_height) - 1 do 2247 self.buffer:set_pixel(self.x_offset + i, self.y_offset + i, 255, 0, 0) 2248 self.buffer:set_pixel(self.x_offset + placeholder_width - 1 - i, self.y_offset + i, 255, 0, 0) 2249 end 2250 2251 element:set_layout(self.x_offset, self.y_offset, placeholder_width, placeholder_height) 2252 self.x_offset = self.x_offset + placeholder_width + 2 2253 self.line_height = math.max(self.line_height, placeholder_height) 2254 end 2255 end 2256 elseif element.tag == "a" then 2257 -- Render anchor link (underlined, blue text) 2258 local link_r, link_g, link_b = 0, 0, 255 -- Blue color for links 2259 2260 -- Get link text content 2261 local content = "" 2262 if #element.children > 0 then 2263 for _, child in ipairs(element.children) do 2264 if child.tag == "text" and child.content then 2265 content = content .. child.content 2266 end 2267 end 2268 else 2269 content = element.content or element.attributes.href or "link" 2270 end 2271 2272 local text_width = self.buffer:get_text_width(content, font_size) 2273 2274 -- Set layout for click detection 2275 element:set_layout(self.x_offset, self.y_offset, text_width, font_size) 2276 2277 -- Draw link text in blue 2278 self.buffer:draw_text(content, self.x_offset, self.y_offset, font_size, link_r, link_g, link_b) 2279 2280 -- Draw underline 2281 local underline_y = self.y_offset + font_size + 1 2282 self.buffer:fill_rect(self.x_offset, underline_y, text_width, 1, link_r, link_g, link_b) 2283 2284 self.x_offset = self.x_offset + text_width + 2 2285 self.line_height = math.max(self.line_height, font_size) 2286 else 2287 -- Regular inline element (span, label, em, strong, etc) 2288 local start_x = self.x_offset 2289 local content_width = 0 2290 local has_padding = (padding_left > 0 or padding_right > 0 or padding_top > 0 or padding_bottom > 0) 2291 2292 -- Apply left padding 2293 self.x_offset = self.x_offset + padding_left 2294 2295 -- First pass: calculate width and render content 2296 if #element.children > 0 then 2297 for _, child in ipairs(element.children) do 2298 self:render_element(child, base_x) 2299 end 2300 content_width = self.x_offset - start_x 2301 elseif element.content and element.content ~= "" then 2302 local text_width = self.buffer:get_text_width(element.content, font_size) 2303 self.buffer:draw_text(element.content, self.x_offset, self.y_offset, font_size, text_r, text_g, text_b) 2304 self.x_offset = self.x_offset + text_width 2305 content_width = padding_left + text_width + padding_right 2306 end 2307 2308 -- Apply right padding 2309 self.x_offset = self.x_offset + padding_right 2310 2311 -- Draw background if specified 2312 if style["background-color"] and style["background-color"] ~= "transparent" and bg_r and bg_g and bg_b then 2313 local total_width = self.x_offset - start_x 2314 local total_height = font_size + padding_top + padding_bottom 2315 2316 -- Draw background rectangle (includes padding) 2317 self.buffer:fill_rect(start_x, self.y_offset - padding_top, total_width, total_height, bg_r, bg_g, bg_b) 2318 2319 -- Re-render content on top 2320 self.x_offset = start_x + padding_left 2321 if #element.children > 0 then 2322 for _, child in ipairs(element.children) do 2323 self:render_element(child, base_x) 2324 end 2325 elseif element.content and element.content ~= "" then 2326 self.buffer:draw_text(element.content, self.x_offset, self.y_offset, font_size, text_r, text_g, text_b) 2327 end 2328 2329 -- Restore position after re-render 2330 self.x_offset = start_x + total_width 2331 end 2332 2333 self.line_height = math.max(self.line_height, font_size + padding_top + padding_bottom) 2334 end 2335 end 2336 end 2337 2338 function HTMLRenderer:render(dom) 2339 if not dom.root then return end 2340 2341 -- Render all children of root 2342 for _, child in ipairs(dom.root.children) do 2343 self:render_element(child) 2344 end 2345 end 2346 2347 function HTMLRenderer:save(filename) 2348 self.buffer:save(filename) 2349 end 2350 2351 function HTMLRenderer:drawToScreen(gfx) 2352 self.buffer:drawToScreen(gfx) 2353 end 2354 2355 -- ============================================================================ 2356 -- Module API 2357 -- ============================================================================ 2358 2359 -- Extract scripts and styles from HTML content 2360 local function extract_resources(html_content, base_path) 2361 base_path = base_path or "input/" 2362 2363 local scripts = {} 2364 local styles = {} 2365 2366 -- Extract inline <script> tags (with or without attributes) 2367 for script_content in html_content:gmatch("<script[^>]*>%s*(.-)%s*</script>") do 2368 table.insert(scripts, { 2369 type = "inline", 2370 code = script_content 2371 }) 2372 end 2373 2374 -- Extract external <script src="..."> tags 2375 for src in html_content:gmatch('<script%s+src="([^"]+)"') do 2376 local script_path = base_path .. src 2377 local file = io.open(script_path, "r") 2378 if file then 2379 local code = file:read("*all") 2380 file:close() 2381 table.insert(scripts, { 2382 type = "external", 2383 src = src, 2384 code = code 2385 }) 2386 else 2387 print("Warning: Could not load script: " .. script_path) 2388 end 2389 end 2390 2391 -- Extract inline <style> tags (with or without attributes) 2392 for style_content in html_content:gmatch("<style[^>]*>%s*(.-)%s*</style>") do 2393 table.insert(styles, { 2394 type = "inline", 2395 css = style_content 2396 }) 2397 end 2398 2399 -- Extract external <link href="..." rel="stylesheet"> tags 2400 for href in html_content:gmatch('<link%s+[^>]*href="([^"]+)"[^>]*>') do 2401 -- Check if it's a stylesheet 2402 local tag = html_content:match('<link%s+[^>]*href="' .. href:gsub("%-", "%%-") .. '"[^>]*>') 2403 if tag and tag:match('rel="stylesheet"') then 2404 local css_path = base_path .. href 2405 local file = io.open(css_path, "r") 2406 if file then 2407 local css = file:read("*all") 2408 file:close() 2409 table.insert(styles, { 2410 type = "external", 2411 href = href, 2412 css = css 2413 }) 2414 else 2415 print("Warning: Could not load stylesheet: " .. css_path) 2416 end 2417 end 2418 end 2419 2420 return scripts, styles 2421 end 2422 2423 -- Open HTML file with all resources (scripts and styles) 2424 local function openAll(html_path) 2425 -- Determine base path 2426 local base_path = html_path:match("^(.*/)") 2427 if not base_path then 2428 base_path = "" 2429 end 2430 2431 -- Read HTML 2432 local html_file = io.open(html_path, "r") 2433 if not html_file then 2434 error("Could not open HTML file: " .. html_path) 2435 end 2436 local html_content = html_file:read("*all") 2437 html_file:close() 2438 2439 -- Extract all resources 2440 local scripts, styles = extract_resources(html_content, base_path) 2441 2442 -- Parse HTML 2443 local parser = HTMLParser:new() 2444 local dom = parser:parse(html_content) 2445 2446 -- Load default styles 2447 local style_manager = Style:new(dom) 2448 2449 -- Load all extracted styles 2450 for _, style in ipairs(styles) do 2451 style_manager:load(style.css) 2452 end 2453 2454 -- Create Lua engine first (without document) 2455 local engine = LuaEngine:new(nil) 2456 2457 -- Create Document with lua_engine 2458 local doc = Document:new(dom, style_manager, engine) 2459 2460 -- Store base path for resolving relative references when saving 2461 doc.base_path = base_path 2462 2463 -- Store external styles for inlining when saving 2464 doc.external_styles = {} 2465 for _, style in ipairs(styles) do 2466 if style.type == "external" then 2467 table.insert(doc.external_styles, style.css) 2468 end 2469 end 2470 2471 -- Now update engine's document reference 2472 engine.document = doc 2473 2474 -- Add render method to Document 2475 function doc:render(output_path, width, height) 2476 width = width or 1200 2477 height = height or 900 2478 2479 local renderer = HTMLRenderer:new(width, height, self.style, self) 2480 renderer:render(self.dom) 2481 2482 -- If output_path is a table with a drawToScreen method, it's a SafeGFX instance 2483 if type(output_path) == "table" and (output_path.setPixel or output_path.fillRect) then 2484 renderer:drawToScreen(output_path) 2485 else 2486 -- Otherwise save to PNG file 2487 renderer:save(output_path) 2488 end 2489 end 2490 2491 -- Override save_dom to use stored base_path 2492 local original_save_dom = doc.save_dom 2493 function doc:save_dom(filename, custom_base_path) 2494 original_save_dom(self, filename, custom_base_path or self.base_path) 2495 end 2496 2497 -- Add all scripts to engine 2498 for i, script in ipairs(scripts) do 2499 local name = script.src or ("inline_script_" .. i) 2500 engine:add_script(script.code, name) 2501 end 2502 2503 -- Run all scripts 2504 engine:run_all_scripts() 2505 2506 -- Execute onload handler if present on body element 2507 local function find_body(element) 2508 if element.tag == "body" then 2509 return element 2510 end 2511 if element.children then 2512 for _, child in ipairs(element.children) do 2513 local body = find_body(child) 2514 if body then return body end 2515 end 2516 end 2517 return nil 2518 end 2519 2520 local body = find_body(dom) 2521 if body and body.attributes and body.attributes.onload then 2522 local success, err = pcall(function() 2523 engine:run_script(body.attributes.onload, "onload_handler") 2524 end) 2525 if not success then 2526 print("Error in onload handler: " .. tostring(err)) 2527 end 2528 end 2529 2530 return doc 2531 end 2532 2533 -- Open an HTML file and return a Document 2534 local function open(html_path, css_path) 2535 -- Read HTML 2536 local html_file = io.open(html_path, "r") 2537 if not html_file then 2538 error("Could not open HTML file: " .. html_path) 2539 end 2540 local html_content = html_file:read("*all") 2541 html_file:close() 2542 2543 -- Parse HTML 2544 local parser = HTMLParser:new() 2545 local dom = parser:parse(html_content) 2546 2547 -- Load styles 2548 local style_manager = Style:new(dom) 2549 2550 -- Extract and load inline <style> tags 2551 for style_content in html_content:gmatch("<style[^>]*>%s*(.-)%s*</style>") do 2552 style_manager:load(style_content) 2553 end 2554 2555 -- Load custom CSS if provided 2556 if css_path then 2557 local css_file = io.open(css_path, "r") 2558 if css_file then 2559 local css_content = css_file:read("*all") 2560 css_file:close() 2561 style_manager:load(css_content) 2562 end 2563 end 2564 2565 -- Create and return Document 2566 local doc = Document:new(dom, style_manager) 2567 2568 -- Add render method to Document 2569 function doc:render(output_path, width, height) 2570 width = width or 1200 2571 height = height or 900 2572 2573 local renderer = HTMLRenderer:new(width, height, self.style, self) 2574 renderer:render(self.dom) 2575 2576 -- If output_path is a table with a drawToScreen method, it's a SafeGFX instance 2577 if type(output_path) == "table" and (output_path.setPixel or output_path.fillRect) then 2578 renderer:drawToScreen(output_path) 2579 else 2580 -- Otherwise save to PNG file 2581 renderer:save(output_path) 2582 end 2583 end 2584 2585 return doc 2586 end 2587 2588 -- Export module 2589 -- Resume state from a state file 2590 local function resumeState(state_name) 2591 local state_file = "states/" .. state_name .. ".lua" 2592 2593 -- Check if file exists 2594 local test_file = io.open(state_file, "r") 2595 if not test_file then 2596 return false, "State file not found: " .. state_file 2597 end 2598 test_file:close() 2599 2600 -- Load state file 2601 local state_fn, err = loadfile(state_file) 2602 if not state_fn then 2603 return false, "Could not load state file: " .. tostring(err) 2604 end 2605 2606 local state = state_fn() 2607 if not state then 2608 return false, "State file did not return a table" 2609 end 2610 2611 -- Reconstruct DOM from state 2612 local dom_module = require("dom") 2613 local Element = dom_module.Element 2614 local DOM = dom_module.DOM 2615 2616 local function reconstruct_element(elem_data) 2617 if not elem_data then return nil end 2618 2619 local elem = Element:new(elem_data.tag, elem_data.attributes or {}) 2620 elem.id = elem_data.id 2621 elem.content = elem_data.content or "" 2622 elem.classes = elem_data.classes or {} 2623 2624 -- Reconstruct children recursively 2625 if elem_data.children then 2626 for _, child_data in ipairs(elem_data.children) do 2627 local child = reconstruct_element(child_data) 2628 elem:add_child(child) 2629 end 2630 end 2631 2632 return elem 2633 end 2634 2635 -- Create DOM 2636 local dom = DOM:new() 2637 if state.dom then 2638 dom.root = reconstruct_element(state.dom) 2639 else 2640 error("State file does not contain DOM tree") 2641 end 2642 2643 -- Reconstruct Style manager 2644 local style_module = require("style") 2645 local Style = style_module.Style 2646 2647 local style_manager = Style:new(dom) 2648 2649 -- Restore style rules 2650 if state.style_rules then 2651 style_manager.rules = {} 2652 for _, rule_data in ipairs(state.style_rules) do 2653 local rule = { 2654 selector = rule_data.selector, 2655 declarations = rule_data.declarations, 2656 specificity = rule_data.specificity, 2657 important_flags = rule_data.important_flags, 2658 } 2659 table.insert(style_manager.rules, rule) 2660 end 2661 end 2662 2663 -- Create Lua engine 2664 local lua_engine_module = require("lua_engine") 2665 local LuaEngine = lua_engine_module.LuaEngine 2666 local engine = LuaEngine:new(nil) 2667 2668 -- Create Document 2669 local document_module = require("document") 2670 local Document = document_module.Document 2671 local doc = Document:new(dom, style_manager, engine) 2672 2673 -- Update engine's document reference 2674 engine.document = doc 2675 2676 -- Restore external styles 2677 if state.external_styles then 2678 doc.external_styles = state.external_styles 2679 end 2680 2681 -- Restore base path if present 2682 if state.base_path then 2683 doc.base_path = state.base_path 2684 end 2685 2686 -- Add render method to Document 2687 function doc:render(output_path, width, height) 2688 width = width or 1200 2689 height = height or 900 2690 2691 local renderer = HTMLRenderer:new(width, height, self.style, self) 2692 renderer:render(self.dom) 2693 2694 -- If output_path is a table with a drawToScreen method, it's a SafeGFX instance 2695 if type(output_path) == "table" and (output_path.setPixel or output_path.fillRect) then 2696 renderer:drawToScreen(output_path) 2697 else 2698 -- Otherwise save to PNG file 2699 renderer:save(output_path) 2700 end 2701 end 2702 2703 -- Override save_dom to use stored base_path 2704 local original_save_dom = doc.save_dom 2705 function doc:save_dom(filename, custom_base_path) 2706 original_save_dom(self, filename, custom_base_path or self.base_path) 2707 end 2708 2709 -- Helper to find element by ID 2710 local function find_by_id(elem, id) 2711 if elem.id == id then return elem end 2712 for _, child in ipairs(elem.children) do 2713 local found = find_by_id(child, id) 2714 if found then return found end 2715 end 2716 return nil 2717 end 2718 2719 -- Restore input values 2720 if state.input_values then 2721 for elem_id, value in pairs(state.input_values) do 2722 local elem = find_by_id(doc.dom.root, elem_id) 2723 if elem then 2724 doc.input_values[elem] = value 2725 end 2726 end 2727 end 2728 2729 -- Restore cursor positions 2730 if state.cursor_positions then 2731 for elem_id, pos in pairs(state.cursor_positions) do 2732 local elem = find_by_id(doc.dom.root, elem_id) 2733 if elem then 2734 doc.cursor_positions[elem] = pos 2735 end 2736 end 2737 end 2738 2739 -- Restore focused element 2740 if state.focused_element_id then 2741 local elem = find_by_id(doc.dom.root, state.focused_element_id) 2742 if elem then 2743 doc.focused_element = elem 2744 end 2745 end 2746 2747 -- Restore checked state for checkboxes and radios 2748 if state.checked_elements then 2749 for elem_id, checked in pairs(state.checked_elements) do 2750 local elem = find_by_id(doc.dom.root, elem_id) 2751 if elem then 2752 doc.checked_elements[elem] = checked 2753 end 2754 end 2755 end 2756 2757 -- Restore selected options for select elements (by index) 2758 if state.selected_options then 2759 for select_id, option_index in pairs(state.selected_options) do 2760 local select_elem = find_by_id(doc.dom.root, select_id) 2761 if select_elem then 2762 -- Find the option at the saved index 2763 local current_index = 0 2764 for _, child in ipairs(select_elem.children) do 2765 if child.tag == "option" then 2766 current_index = current_index + 1 2767 if current_index == option_index then 2768 doc.selected_options[select_elem] = child 2769 break 2770 end 2771 end 2772 end 2773 end 2774 end 2775 end 2776 2777 -- Restore element contents (in case they weren't in DOM) 2778 if state.element_contents then 2779 for elem_id, content in pairs(state.element_contents) do 2780 local elem = find_by_id(doc.dom.root, elem_id) 2781 if elem then 2782 -- Set text child content if exists 2783 if #elem.children > 0 and elem.children[1].tag == "text" then 2784 elem.children[1].content = content 2785 else 2786 elem.content = content 2787 end 2788 end 2789 end 2790 end 2791 2792 -- Restore element styles (in case they weren't in DOM) 2793 if state.element_styles then 2794 for elem_id, styles in pairs(state.element_styles) do 2795 local elem = find_by_id(doc.dom.root, elem_id) 2796 if elem then 2797 if not elem.attributes.style then 2798 elem.attributes.style = {} 2799 end 2800 for prop, val in pairs(styles) do 2801 elem.attributes.style[prop] = val 2802 end 2803 end 2804 end 2805 end 2806 2807 print("State resumed from: " .. state_file) 2808 return doc, nil 2809 end 2810 2811 -- Save state at module level 2812 local function saveState(doc, state_name) 2813 if not doc then 2814 return false, "No document provided" 2815 end 2816 2817 if not doc.saveState then 2818 return false, "Document does not have saveState method" 2819 end 2820 2821 -- Call the document's saveState method 2822 local success, err = pcall(function() 2823 doc:saveState(state_name) 2824 end) 2825 2826 if success then 2827 return true 2828 else 2829 return false, "Failed to save state: " .. tostring(err) 2830 end 2831 end 2832 2833 return { 2834 open = open, 2835 openAll = openAll, 2836 saveState = saveState, 2837 resumeState = resumeState, 2838 HTMLParser = HTMLParser, 2839 HTMLRenderer = HTMLRenderer, 2840 ImageBuffer = ImageBuffer, 2841 }