luajitos

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

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 }